Skip to content

fix(stt): count words in the text, not entries in the list, when verifying a speaker sample - #12169

Merged
kodjima33 merged 1 commit into
BasedHardware:mainfrom
abunet:fix/speaker-sample-word-count
Aug 25, 2026
Merged

fix(stt): count words in the text, not entries in the list, when verifying a speaker sample#12169
kodjima33 merged 1 commit into
BasedHardware:mainfrom
abunet:fix/speaker-sample-word-count

Conversation

@abunet

@abunet abunet commented Aug 24, 2026

Copy link
Copy Markdown

verify_and_transcribe_sample decides a sample's fate with len(words), where words is whatever
the pre-recorded transcriber returned. Deepgram emits one entry per word, so entry count and word
count are the same number there and everything works. Parakeet emits one entry per SEGMENT with the
whole utterance inside — a 24.9s sample comes back from its /v2/transcribe as segments: 1, with
no words field at all.

So with STT_PRERECORDED_MODEL=parakeet the count reads 1 and every sample is rejected as
insufficient_words: 1/5 regardless of what it contains. Nothing is ever stored, no person reaches
speech_samples_version >= 3, and routers/listen/speakers.py therefore loads nobody: speaker
identification across conversations cannot work at all on that provider.

The same miscount runs the other way in the multi-speaker guard. speaker_counts adds 1 per entry,
so a single-segment sample has a dominant ratio of exactly 1.0 — the check meant to reject a sample
carrying two voices stops rejecting anything. That one is a false pass, which is the worse of the two.

Both now weigh each entry by the words in its text. For a word-granular provider this is arithmetic
that cannot change: each entry contributes 1, so every count is what it was. The sixteen existing
tests in test_speaker_sample.py pass untouched, which is the evidence that Deepgram behaviour is
unaffected.

Source for the expected values

Measured against a live parakeet built from this repo's own backend/parakeet/Dockerfile.oss, not
inferred: POST /v2/transcribe with a 24.9s 16 kHz mono WAV returns
{"text": ..., "segments": [{...}], "detected_language": ...} — one segment, no words key. The
client at utils/stt/pre_recorded.py:883-902 then builds one list entry per segment, which is
correct for what the API provides.

Verification

  • tests/unit/test_speaker_sample.py — 20 passed. The four new cases fail on this branch's
    merge-base and the sixteen existing ones pass on both sides.
  • End to end on a live stack (parakeet + a pyannote diarizer), tagging a person in one conversation
    and asking for recognition in a second one built from a different half of the same 24.9s recording:
    before, insufficient_words: 1/5 and nothing stored; after, the sample stores, the person reaches
    version 3, an embedding is written, the next session loads the person, and the segment matches.

Failure-Class: none

Context

Found while reproducing #4455 ("Diarization doesn't work through different conversations throughout
the day") on a parakeet-backed deployment. This is not that issue — #4455 describes the user-visible
symptom and has other causes worth their own discussion (the one-sample-per-person cap, and nine
silent exits inside a background task that has already returned 200). This is one mechanism that
produces it, and it is provider-specific, so it is sent on its own.

Product invariants affected

none

Review in cubic

…fying a speaker sample

`verify_and_transcribe_sample` decides a sample's fate with `len(words)`, where `words` is whatever
the pre-recorded transcriber returned. Deepgram emits one entry per word, so entry count and word
count are the same number there and everything works. Parakeet emits one entry per SEGMENT with the
whole utterance inside — a 24.9s sample comes back from its `/v2/transcribe` as `segments: 1`, with
no `words` field at all.

So with `STT_PRERECORDED_MODEL=parakeet` the count reads 1 and every sample is rejected as
`insufficient_words: 1/5` regardless of what it contains. Nothing is ever stored, no person reaches
`speech_samples_version >= 3`, and `routers/listen/speakers.py` therefore loads nobody: speaker
identification across conversations cannot work at all on that provider.

The same miscount runs the other way in the multi-speaker guard. `speaker_counts` adds 1 per entry,
so a single-segment sample has a dominant ratio of exactly 1.0 — the check meant to reject a sample
carrying two voices stops rejecting anything. That one is a false pass, which is the worse of the two.

Both now weigh each entry by the words in its text. For a word-granular provider this is arithmetic
that cannot change: each entry contributes 1, so every count is what it was. The sixteen existing
tests in test_speaker_sample.py pass untouched, which is the evidence that Deepgram behaviour is
unaffected.

## Source for the expected values

Measured against a live parakeet built from this repo's own `backend/parakeet/Dockerfile.oss`, not
inferred: `POST /v2/transcribe` with a 24.9s 16 kHz mono WAV returns
`{"text": ..., "segments": [{...}], "detected_language": ...}` — one segment, no `words` key. The
client at `utils/stt/pre_recorded.py:883-902` then builds one list entry per segment, which is
correct for what the API provides.

## Verification

- `tests/unit/test_speaker_sample.py` — 20 passed. The four new cases fail on this branch's
  merge-base and the sixteen existing ones pass on both sides.
- End to end on a live stack (parakeet + a pyannote diarizer), tagging a person in one conversation
  and asking for recognition in a second one built from a different half of the same 24.9s recording:
  before, `insufficient_words: 1/5` and nothing stored; after, the sample stores, the person reaches
  version 3, an embedding is written, the next session loads the person, and the segment matches.

Failure-Class: none
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @abunet — sharp catch, and the diagnosis is even more right than the PR description claims.

Verified on the head:

  • backend/utils/speaker_sample.py — the old len(words) measured list entries, not words, and _words_in() weighing each entry by len((entry.get('text') or '').split()) is the correct unit for both granularities. The or '' guard degrades a missing/empty text to zero weight instead of crashing. For a word-granular provider each entry contributes exactly 1, so Deepgram-style counts are arithmetically unchanged — the 16 pre-existing tests passing untouched is exactly that evidence.
  • One amplification worth recording: get_prerecorded_service() currently routes pre-recorded bytes only to modulate-velma-2 or parakeet, and both build one entry per utterance/segment (modulate at utils/stt/pre_recorded.py:601-620, parakeet at :872-894). So the multi-speaker guard was effectively inert on every reachable provider — the false-pass direction you call out — not just under STT_PRERECORDED_MODEL=parakeet.
  • backend/tests/unit/test_speaker_sample.py — the four new cases are well aimed: single-segment acceptance, word-vs-segment equivalence on the same utterance, the 4-word floor still refusing, and the 0.90 word-weighted ratio that entry-counting would have rejected at 0.50. Ran the file locally: 20/20 pass.

Also checked that the changed counts inside insufficient_words: N/5 and multi_speaker: ratio=X are safe: both production call sites (utils/speaker_identification.py:503, utils/speaker_sample_migration.py:105) only log the reason and skip; nothing parses it.

Leaving the formal approval to a human maintainer; code-wise this looks ready to merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added positive-signal Good PR — positive signal, not a formal approval backend Backend Task (python) labels Aug 25, 2026

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confidence 5/5: clear root cause (word vs segment granularity miscount for parakeet), scoped diff, 4 new regression tests, CI green. Verified not already fixed on main.

@kodjima33
kodjima33 merged commit b6bcc6d into BasedHardware:main Aug 25, 2026
41 of 42 checks passed
abunet added a commit to abunet/omi that referenced this pull request Aug 25, 2026
…nd it was ours to drop

`fix/replay-harness-storage-fake-kwargs` (BasedHardware#12188) and the speaker-sample word count (BasedHardware#12169)
were both merged upstream, so both files return to byte-identical with theirs — verified,
`git diff upstream/main` is empty for each. The single conflict was our own marked workaround
in replay_harness_phase0a/apps.py against their now-merged fix: took theirs, and the comment
that said "once that is merged this file goes back to being byte-identical to theirs, and this
comment with it" went with it.

ADR-0030 audit, now including the Dart surface — the gap the +12 merge left, where
`getTimeZone` came through unchanged while upstream updated its FCM sibling. The nine
`check_oss_*` guards are 0, and our port markers are intact (118 `_object_store()`,
261 `_store()`, 152 `_signed_url`, 58 `get_auth_provider`, 53 `_vector_store()`,
48 `get_document_store`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants