feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39

Merged
mathias merged 299 commits from feat/33-audio-upload-transcript into main 2026-10-03 14:13:37 +00:00
Showing only changes of commit d11afb5ff1 - Show all commits
+42 -4
View File
@@ -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), " ")
}