Compare commits
4
Commits
365dff5c85
...
d11afb5ff1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11afb5ff1 | ||
|
|
500e11962b | ||
|
|
206fc2f5ca | ||
|
|
5368d5f8c2 |
@@ -110,8 +110,9 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verboseJSON is the subset of the verbose_json response used. Both backends
|
// verboseJSON is the subset of the verbose_json response used. Both backends
|
||||||
// return it; they differ in segment granularity (iguana: whole seconds, berget:
|
// return it; they differ in segment granularity (iguana: whole seconds and
|
||||||
// sub-second) and in the language token, neither of which matters here.
|
// sub-sentence pieces, berget: sub-second sentences), in the language token, and in
|
||||||
|
// whether a segment's text starts with a space.
|
||||||
type verboseJSON struct {
|
type verboseJSON struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
@@ -124,13 +125,21 @@ type verboseJSON struct {
|
|||||||
|
|
||||||
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
|
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
|
||||||
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
|
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
|
||||||
// to the reported duration.
|
// to the reported duration. Text is rebuilt from the segments, not taken from the
|
||||||
|
// response's `text` field: see joinSegments.
|
||||||
func parseVerbose(r io.Reader) (domain.Transcription, error) {
|
func parseVerbose(r io.Reader) (domain.Transcription, error) {
|
||||||
var v verboseJSON
|
var v verboseJSON
|
||||||
if err := json.NewDecoder(r).Decode(&v); err != nil {
|
if err := json.NewDecoder(r).Decode(&v); err != nil {
|
||||||
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
|
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
|
||||||
}
|
}
|
||||||
text := strings.TrimSpace(v.Text)
|
raw := make([]string, 0, len(v.Segments))
|
||||||
|
for _, s := range v.Segments {
|
||||||
|
raw = append(raw, s.Text)
|
||||||
|
}
|
||||||
|
text := joinSegments(raw)
|
||||||
|
if text == "" {
|
||||||
|
text = collapseSpace(v.Text)
|
||||||
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return domain.Transcription{}, ErrEmptyTranscript
|
return domain.Transcription{}, ErrEmptyTranscript
|
||||||
}
|
}
|
||||||
@@ -145,3 +154,32 @@ func parseVerbose(r io.Reader) (domain.Transcription, error) {
|
|||||||
}
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// joinSegments rebuilds the running text from raw (untrimmed) segment texts.
|
||||||
|
//
|
||||||
|
// whisper.cpp wraps its output at ~60 characters and a wrap can land INSIDE a word
|
||||||
|
// ("nyt" | "t godkännande"): a segment that begins with a space starts a new word,
|
||||||
|
// one that does not continues the previous one, and the response's own `text`
|
||||||
|
// field joins them with newlines, which breaks the word. So when any segment
|
||||||
|
// carries a leading space the raw texts are concatenated as they are. Backends
|
||||||
|
// that return whole sentences with no leading space (berget) are joined with a
|
||||||
|
// space instead — concatenating those would glue sentences together.
|
||||||
|
func joinSegments(raw []string) string {
|
||||||
|
leadingSpace := false
|
||||||
|
for _, s := range raw {
|
||||||
|
if strings.HasPrefix(s, " ") {
|
||||||
|
leadingSpace = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sep := " "
|
||||||
|
if leadingSpace {
|
||||||
|
sep = ""
|
||||||
|
}
|
||||||
|
return collapseSpace(strings.Join(raw, sep))
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapseSpace turns every run of whitespace (including newlines) into one space.
|
||||||
|
func collapseSpace(s string) string {
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
|
|||||||
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "God morgon allihopa.\n Vi går igenom läget.", got.Text)
|
assert.Equal(t, "God morgon allihopa. Vi går igenom läget.", got.Text)
|
||||||
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
|
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
|
||||||
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
|
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
|
||||||
assert.Equal(t, []domain.Segment{
|
assert.Equal(t, []domain.Segment{
|
||||||
@@ -172,3 +172,61 @@ func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
|
|||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// whisper.cpp wraps its output at ~60 characters, and a wrap can land INSIDE a word:
|
||||||
|
// the continuation segment has no leading space, the `text` field joins segments
|
||||||
|
// with newlines, and a segment can be a lone ".". Measured on real gateway output
|
||||||
|
// 2026-10-01: 7% of a one-hour recording's segments were such fragments. Joining by
|
||||||
|
// newline hands the chat model broken words ("nyt\nt"), so Text is rebuilt from the
|
||||||
|
// segments: a segment that begins with a space starts a new word, one that does not
|
||||||
|
// continues the previous one.
|
||||||
|
func TestGateway_RejoinsWordsWhisperCppSplitAcrossSegments(t *testing.T) {
|
||||||
|
reply := `{
|
||||||
|
"text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen\n.\n Det kräver ett nyt\nt godkännande från beställaren.\n Framförallt på arbetspl\natsen.\n",
|
||||||
|
"language": "swedish", "duration": 20,
|
||||||
|
"segments": [
|
||||||
|
{"start": 0, "end": 4, "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen"},
|
||||||
|
{"start": 4, "end": 5, "text": "."},
|
||||||
|
{"start": 5, "end": 8, "text": " Det kräver ett nyt"},
|
||||||
|
{"start": 8, "end": 11, "text": "t godkännande från beställaren."},
|
||||||
|
{"start": 11, "end": 14, "text": " Framförallt på arbetspl"},
|
||||||
|
{"start": 14, "end": 16, "text": "atsen."}
|
||||||
|
]}`
|
||||||
|
srv := serve(t, 200, reply, nil)
|
||||||
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
|
||||||
|
|
||||||
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Vi ligger just nu 12 procent över den ursprungliga kalkylen. "+
|
||||||
|
"Det kräver ett nytt godkännande från beställaren. Framförallt på arbetsplatsen.", got.Text)
|
||||||
|
assert.Len(t, got.Segments, 6, "the timed segments are kept as the backend gave them (trimmed)")
|
||||||
|
assert.Equal(t, ".", got.Segments[1].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateway_SentenceLevelSegmentsWithNoLeadingSpaceAreJoinedWithASpace(t *testing.T) {
|
||||||
|
// berget/kb-whisper returns whole sentences with no leading space; rebuilding by
|
||||||
|
// concatenation would glue them ("allihopa!Vi").
|
||||||
|
reply := `{"text": "God morgon allihopa!\nVi går igenom läget.", "language": "sv", "duration": 5,
|
||||||
|
"segments": [
|
||||||
|
{"start": 0.05, "end": 1.2, "text": "God morgon allihopa!"},
|
||||||
|
{"start": 1.6, "end": 4.2, "text": "Vi går igenom läget."}
|
||||||
|
]}`
|
||||||
|
srv := serve(t, 200, reply, nil)
|
||||||
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "berget/kb-whisper", srv.Client())
|
||||||
|
|
||||||
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "God morgon allihopa! Vi går igenom läget.", got.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *testing.T) {
|
||||||
|
srv := serve(t, 200, `{"text": " Hej allihopa.\n Vi börjar. ", "duration": 3, "segments": []}`, nil)
|
||||||
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
||||||
|
|
||||||
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text)
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
|
|||||||
var tooBig *http.MaxBytesError
|
var tooBig *http.MaxBytesError
|
||||||
switch {
|
switch {
|
||||||
case errors.As(err, &rej):
|
case errors.As(err, &rej):
|
||||||
|
// Logged by reason only: the filename and title are private content.
|
||||||
|
a.logger().Info("upload rejected", "reason", string(rej.Reason))
|
||||||
status := http.StatusUnprocessableEntity
|
status := http.StatusUnprocessableEntity
|
||||||
if rej.Reason == uploads.ReasonTooLarge {
|
if rej.Reason == uploads.ReasonTooLarge {
|
||||||
status = http.StatusRequestEntityTooLarge
|
status = http.StatusRequestEntityTooLarge
|
||||||
}
|
}
|
||||||
a.renderUploadsPage(w, r, userID, status, rej.Message)
|
a.renderUploadsPage(w, r, userID, status, rej.Message)
|
||||||
case errors.As(err, &tooBig):
|
case errors.As(err, &tooBig):
|
||||||
|
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
|
||||||
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
||||||
"This file is larger than the upload limit.")
|
"This file is larger than the upload limit.")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -336,3 +337,21 @@ func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
|
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t *testing.T) {
|
||||||
|
// Rejections are synchronous and leave no row, so without a log line an operator
|
||||||
|
// cannot tell "the user never retried" from "the page refused it". The reason is
|
||||||
|
// operational; the filename and title are the user's private content.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
app, _ := newUploadApp(t, uploads.Probe{Duration: time.Minute, HasVideo: true}, 1<<20)
|
||||||
|
app.Log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||||
|
|
||||||
|
rec := do(t, app, uploadReq(t, "Secret project notes", "client-acme-roadmap.mp4", []byte("x")))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||||
|
logged := buf.String()
|
||||||
|
assert.Contains(t, logged, "upload rejected")
|
||||||
|
assert.Contains(t, logged, "reason=no_audio")
|
||||||
|
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
|
||||||
|
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user