fix(transcribe): rebuild the transcript from segments so split words are whole
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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), " ")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user