@gavel/voice
Audio framing, speech-to-text providers, the bid parser and the decision step that turns speech into a confirm prompt or a bid.
packages/voice turns speech into a structured bid, or into a question for the bidder. It never decides whether a bid is valid: the does that. Its job ends at a decision (auto, confirm or drop) and, for an accepted one, a PlaceBid command the server submits to the room.
G5 PARTIAL Speech accuracy was measured on synthetic audio only (PARTIAL: the first run missed the threshold, the fixes were chosen on the same clips), so spoken bids always go through the . See Voice and the confirm gate and Gate G5.
Responsibilities
Capture and frame audio
src/audio frames PCM (20 ms frames, 100 ms batches), resamples, finds the end of an utterance and queues it. src/browser holds the browser capture state machine, the mute controller (push-to-talk and open mic, with a keepalive every 5 s while muted) and the degraded-mode handling.
Talk to a speech provider
src/stt has one interface (SttProvider) and five implementations: Deepgram (ReconnectingDeepgramStt), Groq (GroqStt), Web Speech (browser only), FakeStt and ReplayStt (cached transcripts). FailoverStt chains providers and selectProvider picks them from the environment.
Parse and decide
src/parser reads the amount and the or speaker from a transcript with rules. src/decide scores the reading, merges it with an optional model reading, applies the policy thresholds and writes the confirm prompt. src/pipeline/pipeline.ts ties segmentation, parsing and deciding together; bid-command.ts maps a decision to an engine PlaceBid.
Limit the stream
src/session.ts enforces the limits of the /audio socket (byte and message rates, stream caps, idle sweep). It holds no socket and no timer, so it is a pure state holder that the server drives.
Where it sits
The dependency rule in .dependency-cruiser.cjs lets voice import only shared. The server imports voice, and the web client is allowed to. The engine and payments are not imported as values; the package ESLint config allows type-only imports of them.
Public interface
Everything is exported from src/index.ts (main and types both point at it). The groups:
| Group | Exports |
|---|---|
| Parsing | parseUtterance, parseUtteranceOutcome, ParseResult, PARSE_FLAGS |
| Deciding | decide, CONFIRM_TTL_MS, Decision, ConfirmPrompt, VOICE_REASONS, buildAudit |
| Pipeline | VoicePipeline, STALE_SEGMENT_MS, RETRACT_ADVISORY_MS, toPlaceBid, resolveConfirm, commandIdFromKey |
| Providers | selectProvider, STT_NAMES, FailoverStt, ReplayStt, SttProvider, SttEvent |
| Wire | encodeBatch, decodeBatch, VoiceFrameError |
| Session | createVoiceSession, AUDIO_ERROR_CODES, AUDIO_IDLE_MS |
| Audit | toAuditRecord, auditRecordSchema, voiceEvidenceSchema |
| Budget | BUDGET_LIMITS_MS, budgetCheck, latencyOf, percentile |
The exported surface is recorded in etc/voice.api.md; a change to it needs the interface label (see Contributing).
Key files
| File | What it holds |
|---|---|
src/stt/select.ts | selectProvider: unset VOICE_STT tries Deepgram then Groq; a named provider is used or startup fails with unknown_provider, missing_key or unavailable. Errors never contain a key |
src/stt/failover.ts | FailoverStt: switches provider on a closed connection, an error or a silent stream. Ambient room noise does not arm the silence timer: only a mean-square level above about -35 dBFS counts as speech |
src/stt/deepgram-reconnect.ts | Reconnecting Deepgram client; the audio of the last seconds is replayed after a switch |
src/decide/config.ts | defaultConfig: auto 0.85, confirm 0.5 |
src/decide/decide.ts | The decision step; CONFIRM_TTL_MS is 8000 |
src/pipeline/pipeline.ts | VoicePipeline; a segment older than STALE_SEGMENT_MS (5000) is treated as stale, RETRACT_ADVISORY_MS is 8000 |
src/pipeline/faults.ts | Speech health: status degraded when no final transcript arrives for 8 s while the level meter shows speech, down after 15 s; transcript sanity checks (2000 characters, character runs) |
src/pipeline/latency.ts | Latency budget: components sum to the total; limits p50 1200 ms and p95 2000 ms with a model, p50 700 ms and p95 2000 ms rules-only |
src/privacy.ts | PRIVACY_NOTICE and PERSISTED_VOICE_KEYS, the only voice fields that are stored |
Thresholds and limits
Prop
Type
Only the auctioneer and console roles may open an audio stream (AUDIO_ROLES); screens and bidders never can.
Invariants
- A spoken bid never skips the engine.
toPlaceBidsetscommandIdwithcommandIdFromKey(a hash of the auction id and the utterance idempotency key), so a replayed utterance maps to the same id and books once, even if two providers heard it. The provider name is not part of the key. - Below the auto threshold, the bidder is asked. With no model reading wired into the running pipeline, every bid scores below 0.85 and reaches a confirm prompt (stated in the package README).
- Only audit fields in
PERSISTED_VOICE_KEYSare persisted. The testtest/privacy.test.tsguards the list against the audit schema. - No key in an error.
SttConfigErrormessages name the provider, never the key. - The held-out text set is frozen.
fixtures/text/HELDOUT.sha256is checked in CI against the base branch.
Tests that guard it
| Area | Test files |
|---|---|
| Provider selection and failover | test/stt/select.test.ts, select-deepgram.test.ts, failover.test.ts, deepgram-reconnect.test.ts, groq-retry.test.ts, groq-timeout.test.ts |
| Decision policy and prompts | test/decide/policy.test.ts, score.test.ts, validate.test.ts, prompts.test.ts, wording.test.ts, audit.test.ts |
| Pipeline | test/pipeline/pipeline.test.ts, faults.test.ts, latency.test.ts, prompt-expiry.test.ts, prompt-identity.test.ts, crosstalk.test.ts |
| Bid command mapping | test/pipeline-bid-command.test.ts, test/pipeline-bounds.test.ts |
| Stream limits | test/session.test.ts, test/session-idle.test.ts |
| Parser corpus | test/corpus.test.ts, test/labels.test.ts |
| Privacy and held-out guard | test/privacy.test.ts, test/heldout-guard.test.ts |
| Evaluation harness | test/eval/gate.test.ts, metrics.test.ts, report.test.ts, cli.test.ts |
| Browser capture and mute | test/browser/capture.test.ts, mute.test.ts, degraded.test.ts, capture-state.test.ts |
Scripts
| Script | What it does |
|---|---|
npm run test -w @gavel/voice | Package unit tests |
npm run eval -w @gavel/voice -- --mode live --provider deepgram | Scores recordings through a real provider; needs a key |
npm run scan:secrets -w @gavel/voice | Scans the package for vendor key patterns (runs in the ci.yml voice job) |
npm run check:heldout -w @gavel/voice | Guards the held-out text set |
npm run size -w @gavel/voice | Bundle size limit |
- packages/voice/README.mdstatus, wiring and the live eval procedure
- packages/voice/src/index.tsthe public surface
- packages/voice/src/decide/config.tsAUTO and CONFIRM thresholds
- packages/voice/src/stt/select.tsprovider selection
- packages/voice/src/session.tsaudio stream limits
- packages/voice/src/pipeline/latency.tslatency budget
- etc/voice.api.mdcommitted API report
Last updated on