From d11afb5ff17096fbb0402f260b052f3876fc47c1 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 19:35:14 +0200 Subject: [PATCH] fix(transcribe): rebuild the transcript from segments so split words are whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whisper.cpp wraps at ~60 chars and can wrap inside a word; the response's text field joins segments with newlines, so chat saw 'nyt' / 't godkännande'. Text is now rebuilt from the raw segments: concatenated when any segment carries a leading space (whisper.cpp), joined by a space when none do (berget's sentence level), whitespace collapsed; the text field is only a fallback. Checked against the first real one-hour transcript with pianissimo's vocabulary as an independent yardstick: 36 spans changed, 31 now consist entirely of words pianissimo also produced, words unknown to pianissimo fell 224 -> 177 and word agreement rose 92.4% -> 93.1%. Five rejoined spans still contain an unknown word (names/loanwords or an unlucky glue) -- a residual risk, small. Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/transcribe/gateway.go | 46 ++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/adapters/transcribe/gateway.go b/internal/adapters/transcribe/gateway.go index e885c71..33cf464 100644 --- a/internal/adapters/transcribe/gateway.go +++ b/internal/adapters/transcribe/gateway.go @@ -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 -// return it; they differ in segment granularity (iguana: whole seconds, berget: -// sub-second) and in the language token, neither of which matters here. +// return it; they differ in segment granularity (iguana: whole seconds and +// 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 { Text string `json:"text"` Duration float64 `json:"duration"` @@ -124,13 +125,21 @@ type verboseJSON struct { // 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 -// 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) { var v verboseJSON if err := json.NewDecoder(r).Decode(&v); err != nil { 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 == "" { return domain.Transcription{}, ErrEmptyTranscript } @@ -145,3 +154,32 @@ func parseVerbose(r io.Reader) (domain.Transcription, error) { } 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), " ") +}