// Package transcribe turns a WAV file into a timed Swedish transcript by calling // the LiteLLM gateway's OpenAI-compatible /audio/transcriptions endpoint // (ADR-033). A Gateway is one model alias; a Chain tries a local one and falls // back to a second. package transcribe import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "os" "strings" "git.d-ma.be/mathias/tapir/internal/domain" ) // ErrEmptyTranscript is returned when the endpoint answered 200 but with no text. // An empty transcript is not a success: chat over it could only answer "nothing". var ErrEmptyTranscript = errors.New("transcribe: endpoint returned an empty transcript") // StatusError is a non-2xx answer from the gateway. Nothing may branch on the // body — {"error":"failed to process audio"} has had at least five distinct // causes — so only the status code is exposed for logic; Snippet is for logs. type StatusError struct { Code int Snippet string } // HTTPStatus implements errkind.HTTPStatuser: the code, never the snippet. func (e *StatusError) HTTPStatus() int { return e.Code } func (e *StatusError) Error() string { return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet) } // Gateway transcribes through one model alias on the gateway. baseURL is the // gateway's /v1 root — the in-cluster NodePort, not the public hostname, whose // ingress rejects bodies over ~8-10 MB with 413 (docs/research/audio-length-ceiling). type Gateway struct { baseURL string key string model string client *http.Client } // NewGateway builds a Gateway. The client should carry no overall Timeout: a 90 // minute recording takes minutes, so the deadline belongs to the caller's context. func NewGateway(baseURL, key, model string, client *http.Client) *Gateway { return &Gateway{baseURL: strings.TrimRight(baseURL, "/"), key: key, model: model, client: client} } // Name is the model alias; it is what gets recorded as the serving endpoint. func (g *Gateway) Name() string { return g.model } // Transcribe uploads the WAV at path and returns the normalised transcription. // The body is streamed from disk with a COMPUTED Content-Length: a 90 minute WAV // is ~170 MB, so it is neither buffered in memory nor sent chunked. func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcription, error) { f, err := os.Open(path) if err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err) } defer func() { _ = f.Close() }() info, err := f.Stat() if err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err) } // The language is one request field and a closed set: sv is sent, auto sends nothing so // the engine detects it, and anything else never leaves this process (tapir#61). fields := [][2]string{{"model", g.model}, {"response_format", "verbose_json"}} switch lang := domain.LanguageFrom(ctx); lang { case domain.LangSwedish: fields = append(fields, [2]string{"language", string(lang)}) case domain.LangAuto: default: return domain.Transcription{}, fmt.Errorf("transcribe: language %q is not one tapir sends", lang) } var prefix bytes.Buffer mw := multipart.NewWriter(&prefix) for _, kv := range fields { if err := mw.WriteField(kv[0], kv[1]); err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err) } } // The multipart field is `file`; `audio_file` is rejected as "Invalid request". if _, err := mw.CreateFormFile("file", "audio.wav"); err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err) } suffix := []byte("\r\n--" + mw.Boundary() + "--\r\n") body := io.MultiReader(bytes.NewReader(prefix.Bytes()), f, bytes.NewReader(suffix)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/audio/transcriptions", body) if err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err) } req.ContentLength = int64(prefix.Len()) + info.Size() + int64(len(suffix)) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+g.key) // No "Expect: 100-continue": it trips the proxy for any body over 1 KB. resp, err := g.client.Do(req) if err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))} } t, err := parseVerbose(resp.Body) if err != nil { return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err) } t.Endpoint = g.model return t, nil } // verboseJSON is the subset of the verbose_json response used. Both backends // 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"` Segments []struct { Start float64 `json:"start"` End float64 `json:"end"` Text string `json:"text"` } `json:"segments"` } // 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. 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) } 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 } t := domain.Transcription{Text: text, AudioSeconds: v.Duration} for _, s := range v.Segments { seg := domain.Segment{Start: s.Start, End: s.End, Text: strings.TrimSpace(s.Text)} if v.Duration > 0 { seg.End = min(seg.End, v.Duration) seg.Start = min(seg.Start, seg.End) } t.Segments = append(t.Segments, seg) } 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), " ") }