feat(audio): provider-neutral speech-to-text seam - #144
Merged
Conversation
Transcribe ahead of the loop so every provider can drive an audio-fed agent, and a long recording uploads once instead of re-sending each turn.
A dropped part silently changes the question: the model answers from the caption alone, indistinguishable from success in the response and the logs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
pkg/audio, a provider-neutral speech-to-text seam, and closes a silent-failure hole in how all three LLM adapters handlehistory.MediaPart.Of the three providers here, only Gemini accepts audio in a chat message: Anthropic has no audio content block, and the vendored OpenAI Chat Completions client cannot express one (
ChatMessagePartTypehas onlytextandimage_url). Transcribing ahead of the loop rather than inside the message makes the capability portable — what reaches the model is ordinary text, so every provider can drive an audio-fed agent.It is also the cheaper shape. History is re-sent on every LLM call in a session, so audio carried as a message part is re-uploaded on every subsequent turn: a one-hour recording transcribed once costs one upload, while the same recording as message parts is billed again on each turn of the conversation about it.
Changes
pkg/audio— new stdlib-only leaf packageTranscriberinterface plusClip,Transcript,Segment,Optionsvalue types.ErrTooLarge→ re-cut into shorter chunks,ErrUnsupportedFormat→ re-encode,ErrNoAudio→ caller bug. Route witherrors.Is, not message matching.Transcript.Segmentsis nil when the backend emits no timing — a normal result, not a failure, so callers needing timestamps must check rather than assume.Extstrips MIME parameters and matches case-insensitively: browsers'MediaRecorderreportsaudio/webm;codecs=opus, and some emit thevideo/webmspelling for an audio-only recording. Rejecting either would fail a clip the backend decodes fine.[]byte, notio.Reader: the OpenAI client assembles its multipart body into abytes.Bufferandgenai.Blobneeds[]byte, so a Reader would be a streaming API that does not stream.Two implementations
openai.NewTranscriber— populatesSegments,Language,Duration. Requestsverbose_jsononly for whisper models, since thegpt-4o-transcribefamily rejects that format outright rather than degrading; asking for it everywhere would fail every request instead of merely losing timings. Matched as a substring, because compatible endpoints name the same weights differently (Systran/faster-whisper-large-v3). Oversized clips are rejected against the 25 MB limit before the upload. HonoursWithBaseURL, so a self-hosted transcription server is a supported target.gemini.NewTranscriber— no dedicated transcription endpoint exists, so it constrains the generation API with a system instruction; without one the model opens with a preamble or summarizes instead of transcribing, and both corrupt a transcript appended verbatim.Segmentsis always nil, stated on the type rather than discovered at run time.agent.ErrUnrenderablePart— media parts no longer vanish silentlyAll three adapters converted
MediaPartwith aswitchthat fell through for anything unexpected, and one documented the omission as deliberate. Dropping a part does not degrade the call — it silently changes what the question was. The model receives the caption alone and answers it fluently, and nothing distinguishes that from success: not the response, not the logs, not a schema check, because a well-formed answer is exactly what success looks like.Four shapes now fail:
URLnorData,userrole — previously ignored wholesale by every adapter (OpenAI behind an explicit role guard, Anthropic and Gemini by rendering media only under theiruserbranch).Empty text parts are still skipped; they carry nothing to lose.
isRetryabletreats the sentinel as terminal — the same bytes fail identically on every attempt.Type of Change
Breaking: a message carrying a malformed or unsupported media part now fails where it previously went through silently. That is the intent, and the reason for the minor bump to v0.41.0.
Blast radius was checked before the change rather than assumed:
tools.Result.Partshas zero consumers inpkg/agent(declared but never wired), no internal path builds a non-user message with Parts, andexamples/media_chat/main.go:371usesRole: "user". Nothing in-tree breaks.Testing
gofmt -l .clean ·go vetok ·go buildok ·golangci-lint0 issues ·go test ./...24 packages ok, 0 failures ·go test -race ./...clean ·go mod tidyproduces no dependency changes.New coverage:
pkg/audio— MIME parsing with codec parameters and thevideo/webmspelling, clip validation per sentinel, sentinel distinctness, single-package-prefix error messages.verbose_jsonselected only for whisper, oversized clip rejected without contacting the API (asserted via a server that records whether it was called).transcriptFromResponseextracted as a pure function so its order-sensitive checks are testable without a client: clean stop, safety block, truncation, no-candidate.Notes
One issue found and deliberately not fixed here:
ErrLLMAuth's doc states auth failures are deterministic, butisRetryable(pkg/agent/retry.go:62) never excluded it, so auth failures are retried 3× today. Changing retry semantics for auth deserves its own decision rather than riding along in an audio release.