@gotcos/glasses-server 6.60.0 → 6.61.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/CHANGELOG.md +51 -0
- package/README.md +64 -0
- package/managed-runtime-contract.json +4 -1
- package/package.json +3 -1
- package/server/index.ts +11 -0
- package/server/lib/diarizer-backend.ts +163 -0
- package/server/lib/meeting-relabel-enrolment.ts +44 -7
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-cli.ts +256 -0
- package/server/lib/nemotron-final.ts +1005 -0
- package/server/lib/nemotron-live.ts +736 -0
- package/server/lib/nemotron-segments.ts +358 -0
- package/server/lib/speaker-embeddings.ts +9 -5
- package/server/lib/voiceprint-floor.ts +12 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/meeting.ts +55 -2
- package/server/routes/transcribe-stream.ts +75 -6
package/.env.example
CHANGED
|
@@ -159,6 +159,14 @@ BIND_HOST=0.0.0.0
|
|
|
159
159
|
# changing committed transcription or meeting audio.
|
|
160
160
|
# COS_WHISPER_PREVIEW_MODEL=auto # auto | small.en | turbo | off
|
|
161
161
|
|
|
162
|
+
# Who spoke when (6.61.0). Nemotron 3 Diarization separates the voices in each
|
|
163
|
+
# chunk and at save; the voiceprint names them. It needs FluidAudio's
|
|
164
|
+
# fluidaudiocli and its models ON THIS MAC: the server always passes --models
|
|
165
|
+
# and never lets the CLI download. Missing either, labels stay on the voiceprint.
|
|
166
|
+
# COS_DIARIZER=nemotron # nemotron | embedding (voiceprint only, the 6.60 labels)
|
|
167
|
+
# COS_NEMOTRON_CLI=~/.cos-glasses/bin/fluidaudiocli
|
|
168
|
+
# COS_NEMOTRON_MODELS=~/.cos-glasses/models/nemotron-3-diarization # holds Nemotron3Diarizer_fast32.mlmodelc + learnable_sil_emb.bin
|
|
169
|
+
|
|
162
170
|
# Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
|
|
163
171
|
# first run creates a private venv and downloads the model. Local mode fails
|
|
164
172
|
# closed: it never sends text to OpenAI. Set openai_primary only when cloud TTS
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
## 6.61.1
|
|
2
|
+
|
|
3
|
+
### Fixes 6.61.0 labelling other people as the wearer: a split track is named only by a confident voiceprint, never by the glasses' default label
|
|
4
|
+
|
|
5
|
+
- Miles, 2026-10-02 10:19, in a live meeting on 6.61.0: "MU is being assigned to loudest speaker vs my actual voice signature ... I saw Jeremy Sokolic voice showing up as MU." In that session (`meeting_1790953219564_yhwyxs`, read from a copy), 40 turns read MU. 25 were genuine (voiceprint at 0.65 or more, Even Hub `self`). 11 had similarity 0 in two-track chunks where Even Hub heard mostly someone else ("Okay.", "I can send a message. Yeah.", "to have as we play into calendar."): a short second track took the client's label, and the client's label is the glasses' default wearer label (`MU`), not evidence of who spoke. 2 more ("Of course. All right." at 0.612, and 0.625) were a track's own voiceprint check returning the wearer from the full search, which accepts any profile from 0.55, below the 0.65 at which the voiceprint verifies the wearer.
|
|
6
|
+
- **The client's label never names a track.** In a split chunk, a short track that is not the dominant one is `Ext`. The client's label is still what 6.60.0 used it for: a whole chunk no voiceprint could run on (under 2 s, or no voiceprint model). A one-track chunk is labelled exactly as 6.60.0 labelled the chunk.
|
|
7
|
+
- **The wearer needs owner-verify confidence in a split chunk.** A track named the wearer (`owner_speaker_label`) by its own voiceprint check, or a short dominant track that would take the whole chunk's voiceprint, keeps the wearer's name only at `OWNER_VERIFY_SIMILARITY` (0.65, now one constant shared with the voiceprint's owner verify); between 0.55 and 0.65 it is `Ext`. Any other name keeps the search threshold, as before. The two 0.665 and 0.716 turns where Even Hub heard mostly someone else are above the floor and stay, as reviewed.
|
|
8
|
+
- **The final pass applies the same rule.** Its channel map was already built only from chunks at 0.65 or more. Its per-chunk fallback (the live turn a word sat in, else the chunk's label) now treats a wearer name in a split chunk below 0.65 as `Ext`, so a 6.61.0 turn like these is never carried into a final transcript.
|
|
9
|
+
- **Logged and counted.** Every chunk where a would-be wearer name was refused logs one line, `[diarizer] owner-guard chunk #N refused=K client_label=X weak_owner=Y`; the chunk's `diarizer.ownerGuard` lists the reasons, and `readiness.diarizer.ownerGuard` counts them since start.
|
|
10
|
+
- **Replayed on the field session (read-only copies of its 195 Nemotron chunks and their audio, the real CLI and the real voiceprint):** all 28 genuine wearer turns (0.65 or more, Even Hub `self`) stay MU; 0 of the 11 similarity-0 MU turns and 0 of the 2 weak ones (0.612, 0.625) are MU any more; the 2 borderline turns stay MU; the guard refused 13 client labels and 2 weak wearer matches; no chunk fell back.
|
|
11
|
+
- Tests: the field chunks rebuilt from their records ("Okay." with Even Hub self 19/121, "I can send a message. Yeah." at self 3/125 with Kyle keeping his own track, "Of course. All right." at 0.612), the wearer staying the wearer in a one-track chunk (0.703) and a split one (0.716), a weak wearer match refused on both paths (the whole-chunk voiceprint a short dominant turn takes, and a track's own check), the client label never naming a track, the owner-guard log line and count, the route passing `owner_speaker_label`, and the final pass refusing a 6.61.0 similarity-0 MU turn while keeping a confident one. The mutation gate adds a case for each guard and retargets the three 6.61.0 cases the guard replaced: baseline 169 tests in 11 files, 104 of 104 killed. Full suite 5,723 passed, 2 skipped.
|
|
12
|
+
- No Install hooks and no COS Control change.
|
|
13
|
+
|
|
14
|
+
## 6.61.0
|
|
15
|
+
|
|
16
|
+
### Nemotron is the default diarizer, for real: it splits two-voice chunks on the HUD during the call and in the final transcript, and the voiceprint names each voice
|
|
17
|
+
|
|
18
|
+
- Miles, 2026-10-02 07:10: "I want nemotron to become the default as it's more accurate. We should see it labeling in the HUD (During the call) and in the final transcript." This folds in the unpublished 6.60.1, which made `COS_DIARIZER` default to `nemotron` but never ran Nemotron: every live chunk was still labelled by the whole-chunk voiceprint, and its `[diarizer] tracks=nemotron` log line was false. That line is gone; every `[diarizer]` line now says what ran.
|
|
19
|
+
- **What it does, and what it does not claim.** Nemotron separates the voices inside a chunk and across a meeting; it names nobody. The voiceprint still decides every name, with its thresholds unchanged. What changes is that a chunk holding two voices is no longer given one label, and a stretch the voiceprint could not name can take the name of the voice Nemotron heard there. This release does not claim the labels are more accurate: the replay below measures how rarely Nemotron disagrees with labels the voiceprint was already sure of, which is stability, not accuracy (see the disclosure there).
|
|
20
|
+
- The evidence it builds on is the read-only canary of 2026-10-01 (`operations/personal/wk40_2026/nemotron3_canary/`): FluidAudio v0.17.4, preset `fast32`, about 155x realtime warm; on chunks the voiceprint is sure of, Nemotron's dominant channel maps back to the same name in 29 of 29 conclusive meetings (mean 0.959); 45.6% of chunks hold two voices the voiceprint labels as one. The canary's alignment rules are reused as written: chunkEntries joined to `chunk_NNNN.wav` by chunkIndex with `audioSha256` checked, each WAV at its real `elapsed` with only the overlapping tail trimmed, the raw 10 ms probabilities scored at 0.5, 8 channels.
|
|
21
|
+
- **Live, on the HUD.** Each chunk runs `fluidaudiocli nemotron3-diarize` on the chunk WAV the server has just written, started before the voiceprint and Whisper so it runs in their shadow, on the Neural Engine (`--compute-units ane`, so the GPU stays Whisper's; its probabilities matched `all` frame for frame at 0.5 on real chunks) at a lower CPU priority. A channel active for at least 0.5 s is a track. Whisper's word clocks place the words on the tracks when the ASR gives them (the iPhone path); the live whisper-server path returns compact JSON without clocks (its verbose JSON can crash the native server), so there the text is laid over the frames where Nemotron hears speech in proportion to word length, a turn change moves to the nearest phrase end within two words, and a one-word flicker inside another voice's turn is absorbed. The voiceprint names each track: one track takes the chunk's voiceprint; with two or more, a track with at least 2 s of its own audio (the same floor as a whole chunk) is named by `identifySpeaker` on that audio, its calibration rows tagged `nemotron-track`; a shorter one takes the chunk's voiceprint when it is the dominant voice, otherwise the client's label when that is a real name different from the dominant one, otherwise `Ext`. A name is never invented.
|
|
22
|
+
- **The chunk response (the contract with COS Glasses 6.9.567).** `speaker` stays and is now the dominant voice's name (by voiced time among the tracks that carry the words; two channels can carry one person, and a louder voice with no words in the chunk is not who said it), `text` is unchanged, and an optional `segments: [{ speaker, text, startSec, endSec, similarity }]` lists the turns in time order; their texts joined with one space equal `text`. A replayed upload of a canonical chunk returns the same segments, and the `transcript_chunk` display event carries them. The segments are stored on the chunk, with `diarizer: { engine, tracks, timing, ms }` and, when the dominant name differs from the whole-chunk voiceprint, `voiceprintSpeaker` and `voiceprintSimilarity`. The saved sidecar's `speakers` counts segment speakers too. The whole-chunk voiceprint still runs first and still banks the chunk embedding, auto-enrols and saves training and Ext audio exactly as before. When boot recovery strips a hallucination from a stored chunk, its turns are refitted to the new text, or dropped when the words no longer line up, so they always join to the text.
|
|
23
|
+
- **The fallback-experience rule.** Live never stalls or loses a chunk; every fallback keeps today's voiceprint label and gives no `segments`.
|
|
24
|
+
- The model is warmed in the background at server start (a silent second; the first Neural Engine compile took 15 to 35 s); until then chunks are `warming`. A warm-up tries three times (5 s and 30 s apart); a temp directory it cannot make is a failed attempt with a reason, never a warm-up stuck on `warming`.
|
|
25
|
+
- Each run has a 1.5 s budget and is killed with SIGKILL at the deadline. At most two run at once; a chunk that finds both busy is `busy`, never queued.
|
|
26
|
+
- **The breaker.** Three failed runs in a row (budget kills or crashes) stop spawning: every chunk takes the voiceprint at once, with no 1.5 s wait, and the server logs `[diarizer] breaker open: ...`. After a rest of 2 minutes one fresh warm-up decides; each further trip doubles the rest (2, 4, 8, 16, 32, then 60 minutes at most), and a clean chunk resets it. A warm-up that gives up rests on the same schedule; a missing CLI or missing models are re-checked every 2 minutes without spawning anything.
|
|
27
|
+
- A missing CLI or missing models (any install without FluidAudio) is logged once at warm-up, and chunks carry no per-chunk `diarizer` stamp. Run failures (timeout, crash, unreadable output, busy) are logged per chunk with their reason; state reasons (`warming`, `cooling`, `warmup_failed`) are counted per chunk and logged once per change of state.
|
|
28
|
+
- **The final transcript.** When a meeting is saved, the finalization job runs one Nemotron pass over the whole meeting after the HQ batch is persisted, while the raw chunk audio still exists, and before the job reaches the ops handoff. One run over the timeline keeps the channels consistent for the whole call. A channel takes a name only from chunks the voiceprint was sure of (similarity at least 0.65, the review panel's own floor, a real name, one-sided by Even Hub, or by Nemotron itself when a chunk has no Even Hub histogram), with at least 3 such chunks and 80% of them agreeing; several channels may map to one person, and a channel two people share stays unmapped. Following the canary's retro plan, a room with more than four identities or all eight channels in use (a channel counts with half a second of speech, not a blip) gets no meeting-wide map. Every word then takes its channel's name, or keeps its per-chunk name (the live Nemotron turn it sat in, else its existing label). The timeline is built asynchronously in steps, so a long meeting does not freeze live uploads; a timeline far longer than the audio it holds (more than 1.5 times, plus 10 minutes) is a bad clock and the pass is skipped as `too_long` before anything is allocated.
|
|
29
|
+
- **Only labels change.** The saved transcript is relabelled in place, word by word, against the relabelled speaker words (a 12-word window, then a three-word anchor past anything cleaning removed). A line splits where its speaker changes and is never merged with its neighbour, so a pass that changes no label reproduces the transcript byte for byte, writes nothing and moves no revision (`no_change`). Nothing is written unless the result has exactly the same words in the same order. Each changed batch word keeps `originalSpeaker`, a changed streaming chunk keeps `originalSpeaker` and gains `finalSegments`, and the sidecar keeps the whole pre-relabel transcript under `diarization.originalTranscript` with the channel map and its evidence. If the markdown write fails the sidecar is put back byte for byte; if even that fails the pass reports `sidecar_restore_failed` and says so in the log.
|
|
30
|
+
- **A person's edit always wins.** A meeting with a human correction (`correctionRevision` above 0) is never relabelled. The pass re-reads both files just before writing; if a relabel, a deattribution or a summary landed during its CLI run, it stands down (`changed_during_pass`) and writes nothing. Relabel and deattribute answer 409 `meeting_finalizing` (retryable) while a finalization job exists for the meeting, as held naming already did. A second pass over an applied meeting is a no-op. If the pass fails in any way the meeting stays exactly as saved, and the job goes on.
|
|
31
|
+
- **Voice profiles stay safe.** Since a chunk's label can now be its dominant turn's name, naming a voice no longer enrols a chunk whose own whole-chunk voiceprint named a third person, or a chunk Nemotron split into more than one turn; the response says how many it refused (`otherVoiceSkipped`, present only when it happened). A wrong existing label is still how a new person first appears (the Milo case).
|
|
32
|
+
- **When COS sync sees it.** The relabel completes before the meeting is visible as final: the job is still in `batch_pending` when it runs, and `sync_meetings.py --g2-only --g2-import-file` runs from the ops handoff only after it returns. Sync reads speakers from the markdown transcript and, for a batch meeting, from `batchSegments[].speakerWords`, both already relabelled; `batchTranscript` is kept equal to the canonical text. No sync change is needed. With early sync on, the claim made at save is marked HQ pending and the final import replaces its transcript, as it already did for HQ.
|
|
33
|
+
- **Offline by construction.** The server always passes `--models <dir>`; that path in FluidAudio has no network call, while the CLI run without it downloads from Hugging Face and deletes a cache whose weights marker it does not recognise. The directory is `COS_NEMOTRON_MODELS`, else `~/.cos-glasses/models/nemotron-3-diarization`, which the warm-up links to FluidAudio's own cache (`~/Library/Application Support/FluidAudio/Models/nemotron-3-diarization`, bundle `monolithic/v2`). An incomplete explicit directory is never replaced by another copy. With no complete local copy the CLI is never run. Probed 2026-10-02 under `sandbox-exec` with all network denied: the CLI ran from the linked directory and from the cache. The CLI has no version command, so the warm-up logs its sha256 once and whether it is the verified v0.17.4 build (`readiness.diarizer.cliKnown`).
|
|
34
|
+
- **The CLI gets nothing it does not need.** It runs with PATH, HOME, TMPDIR, USER, LOGNAME and the locale only: no API token, provider key or OpenAI key reaches it (verified warm with only those on 2026-10-02).
|
|
35
|
+
- **Nothing outlives the server.** Runs and temp directories in flight are tracked. `gracefulShutdown` and the process exit hook kill every CLI run and delete every temp directory (the final pass's holds the whole meeting's audio). The next start sweeps `cos-nemotron-*` directories older than a minute and kills CLI runs of ours that were orphaned.
|
|
36
|
+
- **Honest health, codes only.** `/api/health` is public (no token) and the server listens on the LAN, so `readiness.diarizer` carries codes and counts only: never a speaker name, a path, a meeting title or raw error text (QA blocker; the channel map and raw errors now stay in `server.log`). It reports `requested`; `active`, which is `nemotron` only while the model is warm, the breaker is closed and most of the last five chunks really were labelled by Nemotron (otherwise `warming` or `embedding`), with the `fallback` reason; `recent`; `breaker` (open, trips, next retry); the preset, budget and lane; the warm-up; `cliKnown`; the per-chunk outcome counts by reason and the last chunk; and the final passes as `{ status, reasonCode, mode, crowded, channels, mappedChannels, wordsRelabeled, wordsTotal, excludedChunks, ms, at }` with counts keyed by status and code from a fixed set. A Nemotron fallback never degrades `readiness.status`. Every final pass logs what it applied or why it left the meeting alone, with any chunks left off the timeline (missing, hash mismatch, unreadable); a pass the save path declines because the batch sidecar was not written is counted and logged too (`metadata_not_persisted`).
|
|
37
|
+
- **Settings.** `COS_DIARIZER` (default `nemotron`; `embedding`, `eres2net` or `voiceprint` turns Nemotron off entirely: no warm-up, no run, the 6.60 labels), `COS_NEMOTRON_CLI` (default `~/.cos-glasses/bin/fluidaudiocli`) and `COS_NEMOTRON_MODELS`, all in `managed-runtime-contract.json` and `.env.example`. To turn it off, put `COS_DIARIZER=embedding` in `~/.cos-glasses/.env`.
|
|
38
|
+
- **After updating.** No Install hooks and no COS Control change. The first start compiles the model for the Neural Engine in the background (about 30 s on the M3 Ultra); chunks use the voiceprint until `readiness.diarizer.active` reads `nemotron`. No LLM is called anywhere in transcription.
|
|
39
|
+
- **Real-data replay (read-only, 2026-10-02).** Clones of three meetings from the canary list, `COS_DATA_DIR` pointed at a scratch copy so not even the calibration log touched the store, the live path chunk by chunk exactly as the route runs it (Nemotron started first, the whole-chunk voiceprint, Whisper large-v3 through a private whisper-server with the live server's arguments, then the track names), then the final pass on a sidecar carrying those live turns. The live replay ran on `dacd8be`-era code, with the mutation gate alongside part of it, so the times are a busy-Mac upper bound.
|
|
40
|
+
- **Disclosure: what the agreement numbers measure.** They measure how rarely Nemotron disagrees with a label the voiceprint was already sure of, not whether a label is right. The live path gives a one-track chunk the stored voiceprint label by construction, so its agreement is high by design on those chunks; the final pass maps channels from the very chunks it is then scored on. Nobody listened to the changed turns. The independent check available here is the last number in each final line: final word labels against the per-track voiceprint names of the live turns.
|
|
41
|
+
- Silas and Miles, Thu 10:01 (505 chunks with text): Nemotron labelled 502 (3 `no_speech`); 114 chunks (22.7%) gained a second speaker, 1.31 segments per chunk; on 270 high-confidence chunks the dominant name matched the stored label 270 times. Final: channels {0: Silas, 1: MU}, 2,201 of 8,484 words relabelled (988 Ext to Silas, 455 Ext to MU, 609 turn splits); majority agreement 1.000 on 132 one-sided and 241 high-confidence chunks; final words against the voiceprint-named live tracks 0.983 (4,589 words).
|
|
42
|
+
- Ryan and Miles, Thu 16:03 (853): 852 labelled (1 `no_speech`); 289 (33.9%) gained a second speaker, 1.49 segments per chunk; dominant agreement 0.995 on 659. Final: {0: Ryan, 1: MU, 2: Ryan}, 4,432 of 19,140 words relabelled; majority agreement 0.993 one-sided (274), 0.974 high-confidence (650); against live tracks 0.974 (12,969 words).
|
|
43
|
+
- Five people, Wed 07:31 (753): 747 labelled (6 `no_speech`); 175 (23.4%) gained a second speaker, 1.32 per chunk; dominant agreement 0.995 on 365. Final: crowded (5 identities), so no meeting-wide map; the live turns relabelled 2,977 of 12,226 words; majority agreement 0.995 one-sided (183), 0.980 high-confidence (346); against live tracks 0.999.
|
|
44
|
+
- Latency with Whisper load, all 2,111 chunks: Nemotron's own run p50 140 to 141 ms, p95 149 to 157 ms, max 196, 302 and 680 ms; what the chunk waited beyond Whisper (the end of Nemotron plus naming the tracks) p50 0 ms, p95 89 to 114 ms, max 129, 142 and 213 ms; no chunk timed out or found the lane busy. The final pass took 19 s, 30 s and 28 s for 52, 86 and 80 minutes of audio.
|
|
45
|
+
- The final pass re-run on the QA-fixed code, on the stored sidecars without live turns: 10:01 applied ({0: Silas, 1: MU}, 1,935 of 8,484 words, one-sided 1.000, high-confidence 0.996); 16:03 applied ({0: Ryan, 1: MU, 2: Ryan}, 4,234 of 19,140, 0.993 and 0.974); 07:31 crowded with no live turns to apply, so `no_change`: nothing written.
|
|
46
|
+
- The rule that the chunk `speaker` is the voice carrying the words came after the replay; it changes none of the 2,101 replayed chunks' names (every replayed `speaker` carried words).
|
|
47
|
+
- The replay found a real bug before release: the first final pass regenerated the transcript from the relabelled words and cleaned it again, which changed which URL-only lines cleaning dropped, so the word check refused every batch meeting. Fixed by relabelling in place.
|
|
48
|
+
- **QA round 1** (the Skeptic and the Ghost Hunter, on `dacd8be`). Fixed: the public health leak (blocker); the final pass overwriting a correction or summary made during its run; rename enrolment able to take a chunk the voiceprint gave to someone else; the CLI inheriting the server's tokens; health reading `nemotron` while every chunk fell back; no breaker on repeated budget kills; `no_change` that almost never fired because lines were merged; no cap on a timeline stretched by a bad `elapsed`; temp audio and CLI runs left behind by an exit; a warm-up that could stick on `warming`; recovery leaving turns that no longer joined to the text; untestable guards (a seam for the writer and the relabeller now makes the word check reachable); missing telemetry; constants defined twice. 28 of the new regression tests fail on `dacd8be` and pass here.
|
|
49
|
+
- Tests: the backend choice and the models directory (offline, linking, an incomplete override); the CLI run (the arguments, the minimal environment, the frame check both ways, exit codes, SIGKILL at the budget, a missing binary, children and temp dirs killed and removed, the startup sweep); word-to-track mapping with clocks and estimated, phrase snapping, flickers, tracks, exclusive audio; naming (one track, two long tracks, a short dominant and a short other with the client label, a repeated or unknown client label, an unnamed track, word clocks over estimates, the voice that carries the words, punctuation-only tokens and merged turns joining to the text exactly, turns refitted after recovery); the runtime (a cold model, busy under a 300-chunk backlog, the breaker and its backoff, the real defaults pinned, health while chunks fall back, a warm-up whose temp dir cannot be made, the warm-up failure and identity lines, outcomes counted and logged, a missing CLI never stamped); the route (segments in the response and the HUD event with every legacy field intact, stored on the chunk, replayed, a fallback answering like 6.60, the voiceprint by choice leaving the record as 6.60 wrote it; recovery refitting turns); the final pass (consistent tracks with original labels and line breaks kept, a crashed or timed-out CLI changing no byte, words cleaning changed or removed kept exactly with labels in step, a relabel or summary during the run surviving, `no_change` writing nothing, `too_long`, a failed markdown write restoring the sidecar and a failed restore reported, the word check reached through the seam, the log lines, the timeout formula pinned, blips not crowding a room, an exit mid-pass leaving no temp audio, a human correction, the voiceprint by choice, a streaming meeting, a crowded room, the timeline's hash check, trim and refusal of a non-regular file, the map's floors, a declined pass counted); the save path (the pass runs on the saved files while the audio exists and before the job leaves `batch_pending`; a failed or throwing pass leaves the meeting byte-identical; relabel and deattribute refused while finalizing and taken after); rename enrolment refusing another voice or a two-voice chunk; unauthenticated `/api/health` after a final pass carrying no name, path or raw error; `readiness.diarizer`; the runtime contract keys. The suite runs with `COS_DIARIZER=embedding` so no test can spawn the real CLI.
|
|
50
|
+
- `node server/scripts/nemotron-mutation-gate.mjs` (repository only, not in the package) runs from a green baseline, matches each target exactly once, and each mutant must fail the test named for it: baseline 160 tests in 11 files, 96 of 96 killed. In QA round 1 it caught a mutant that dropped unmatched words and hid behind a line the pass left whole; the test now inserts a word into a line the pass splits. `server/scripts/nemotron-replay.ts` is the replay above (repository only).
|
|
51
|
+
|
|
1
52
|
## 6.60.0
|
|
2
53
|
|
|
3
54
|
### A question that starts while you are away waits up to 10 minutes for the glasses you are wearing
|
package/README.md
CHANGED
|
@@ -472,6 +472,9 @@ past the installed hook's wait; `0` turns the away hold off),
|
|
|
472
472
|
`COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (6.60.0: default 1800; the away hold applies
|
|
473
473
|
only when a `hold=1` client reported its wearer present within it; under 30 means the 30 s
|
|
474
474
|
live window),
|
|
475
|
+
`COS_DIARIZER` (6.61.0: default `nemotron`; `embedding` keeps the voiceprint only),
|
|
476
|
+
`COS_NEMOTRON_CLI` and `COS_NEMOTRON_MODELS` (6.61.0: the FluidAudio CLI and its
|
|
477
|
+
local model directory; see Speaker diarization),
|
|
475
478
|
`COS_MEDIA_ROOT` (optional image/video store location; default
|
|
476
479
|
`~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
|
|
477
480
|
resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
|
|
@@ -578,6 +581,67 @@ report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
|
|
|
578
581
|
voiceprints rather than re-enrolling, which would split the same voice across two
|
|
579
582
|
profiles.
|
|
580
583
|
|
|
584
|
+
### Who spoke when: Nemotron (6.61.0, the default)
|
|
585
|
+
|
|
586
|
+
The voiceprint names a person but gives a chunk one label, and about 45% of
|
|
587
|
+
meeting chunks hold two voices. Since 6.61.0 the server separates the voices
|
|
588
|
+
with [Nemotron 3 Diarization](https://huggingface.co/FluidInference/nemotron-3-diarization-coreml)
|
|
589
|
+
(FluidAudio's `fluidaudiocli`, preset `fast32`, Neural Engine) and lets the
|
|
590
|
+
voiceprint name each one. Nemotron never names anyone itself, and the
|
|
591
|
+
voiceprint's thresholds are unchanged: what changes is that a two-voice chunk
|
|
592
|
+
is split into turns instead of carrying one label.
|
|
593
|
+
|
|
594
|
+
- **Live, on the HUD.** Each chunk runs Nemotron in parallel with Whisper, inside
|
|
595
|
+
a 1.5 s budget, at most two at once. The chunk response keeps `speaker` (now
|
|
596
|
+
the dominant voice's name) and `text`, and adds
|
|
597
|
+
`segments: [{ speaker, text, startSec, endSec, similarity }]` in time order;
|
|
598
|
+
their texts joined with one space equal `text`. A track with at least 2 s of
|
|
599
|
+
its own audio is named by the voiceprint on that audio; a shorter one takes the
|
|
600
|
+
chunk's voiceprint when it is the dominant voice, otherwise it is `Ext`. The
|
|
601
|
+
glasses' default wearer label never names a track, and in a split chunk the
|
|
602
|
+
wearer is named only at owner-verify confidence (0.65; since 6.61.1). The
|
|
603
|
+
segments are stored on the chunk, so the saved meeting keeps them.
|
|
604
|
+
- **The final transcript.** When a meeting is saved, one Nemotron pass over the
|
|
605
|
+
whole meeting maps each channel to a name from the chunks the voiceprint was
|
|
606
|
+
sure of (similarity 0.65 or more, one-sided), and relabels the transcript word
|
|
607
|
+
by word before the meeting reaches COS sync. A channel two people share stays
|
|
608
|
+
unmapped and keeps the per-chunk names. Only labels change: lines split where
|
|
609
|
+
the speaker changes and are never merged, nothing is written unless every
|
|
610
|
+
word survives in order, changed words keep `originalSpeaker`, and the sidecar
|
|
611
|
+
keeps the pre-relabel transcript under `diarization.originalTranscript`. A
|
|
612
|
+
meeting with a human speaker correction is never relabelled; an edit made
|
|
613
|
+
while the pass runs makes it stand down, and relabel or deattribute answer
|
|
614
|
+
409 `meeting_finalizing` until the meeting has finished finalizing.
|
|
615
|
+
- **Never in the way.** Until the model is warm (the first Neural Engine compile
|
|
616
|
+
takes 15 to 35 s, started in the background at server start), and on any
|
|
617
|
+
timeout, crash, busy lane, missing CLI or missing model, a chunk keeps the
|
|
618
|
+
voiceprint label exactly as before, and the server logs the reason. Three
|
|
619
|
+
failed runs in a row open a breaker: no CLI is spawned and every chunk takes
|
|
620
|
+
the voiceprint at once, for 2 minutes, doubling per trip up to an hour, then
|
|
621
|
+
one fresh warm-up decides. A failed final pass leaves the meeting exactly as
|
|
622
|
+
saved. The CLI runs with a minimal environment (no tokens or keys), and no run
|
|
623
|
+
or temp file outlives the server. No model call is ever made.
|
|
624
|
+
|
|
625
|
+
**Offline.** The server always passes `--models <dir>` to the CLI, a directory
|
|
626
|
+
holding `Nemotron3Diarizer_fast32.mlmodelc` and `learnable_sil_emb.bin`, and that
|
|
627
|
+
path in FluidAudio never downloads. Without `--models` the CLI fetches from
|
|
628
|
+
Hugging Face, so the server never runs it that way. The directory is
|
|
629
|
+
`COS_NEMOTRON_MODELS`, else `~/.cos-glasses/models/nemotron-3-diarization`, which
|
|
630
|
+
the server links to FluidAudio's own cache
|
|
631
|
+
(`~/Library/Application Support/FluidAudio/Models/nemotron-3-diarization`, left
|
|
632
|
+
there by running the CLI once by hand). The CLI is `COS_NEMOTRON_CLI`, else
|
|
633
|
+
`~/.cos-glasses/bin/fluidaudiocli`. Missing either, the server stays on the
|
|
634
|
+
voiceprint and says so.
|
|
635
|
+
|
|
636
|
+
`COS_DIARIZER=embedding` (or `voiceprint`, `eres2net`) turns Nemotron off: no
|
|
637
|
+
warm-up, no run, the 6.60 labels. `/api/health` `readiness.diarizer` reports
|
|
638
|
+
`active` (`nemotron` only while warm, the breaker is closed and most recent
|
|
639
|
+
chunks really were labelled by Nemotron; otherwise `warming` or `embedding`),
|
|
640
|
+
the `fallback` reason, the recent outcomes, the breaker, the per-chunk counts,
|
|
641
|
+
the warm-up and the final passes. `/api/health` is public, so this carries codes
|
|
642
|
+
and counts only, never a speaker name, path or error text; those stay in
|
|
643
|
+
`server.log`. A Nemotron fallback never degrades `readiness.status`.
|
|
644
|
+
|
|
581
645
|
## HQ dictation
|
|
582
646
|
|
|
583
647
|
Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S",
|
|
40
40
|
"COS_LENS_GIST_ENGINE",
|
|
41
41
|
"COS_LENS_GIST_MODEL",
|
|
42
|
-
"COS_LENS_GIST_DAILY_CAP"
|
|
42
|
+
"COS_LENS_GIST_DAILY_CAP",
|
|
43
|
+
"COS_DIARIZER",
|
|
44
|
+
"COS_NEMOTRON_CLI",
|
|
45
|
+
"COS_NEMOTRON_MODELS"
|
|
43
46
|
],
|
|
44
47
|
"maintenance": {
|
|
45
48
|
"scope": "cross_boot",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.61.1",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
47
47
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
49
|
+
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
50
|
+
"!server/scripts/nemotron-replay.ts",
|
|
49
51
|
"!server/scripts/mutation-gate.mjs",
|
|
50
52
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
51
53
|
"!server/lib/__fixtures__/**",
|
package/server/index.ts
CHANGED
|
@@ -116,6 +116,8 @@ import { startLocalTtsServer, stopLocalTtsServer } from './lib/tts-local.js'
|
|
|
116
116
|
import { initSileroVAD } from './lib/vad-silero.js'
|
|
117
117
|
import { initSessionCache } from './lib/session-cache-writer.js'
|
|
118
118
|
import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
|
|
119
|
+
import { startNemotronWarmup } from './lib/nemotron-live.js'
|
|
120
|
+
import { killNemotronChildren } from './lib/nemotron-cli.js'
|
|
119
121
|
import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
|
|
120
122
|
import { getMediaStore } from './lib/media-store.js'
|
|
121
123
|
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
@@ -1000,6 +1002,12 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
1000
1002
|
console.error('[live-cues] shutdown termination failed:', error)
|
|
1001
1003
|
}
|
|
1002
1004
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
1005
|
+
// 6.61.0: no Nemotron run may outlive the server, and no temp directory may
|
|
1006
|
+
// keep a meeting's audio (the module's exit hook repeats this for any exit).
|
|
1007
|
+
try {
|
|
1008
|
+
const nemotron = killNemotronChildren()
|
|
1009
|
+
if (nemotron.killed || nemotron.removed) console.log(`[diarizer] shutdown: killed ${nemotron.killed} Nemotron run(s), removed ${nemotron.removed} temp dir(s)`)
|
|
1010
|
+
} catch { /* best effort */ }
|
|
1003
1011
|
stopCodexModelCatalogRefresh()
|
|
1004
1012
|
stopWhisperServer()
|
|
1005
1013
|
await stopWhisperPreviewServer({ final: true })
|
|
@@ -1162,6 +1170,9 @@ listenRequiredServers(listeners).then(() => {
|
|
|
1162
1170
|
// Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
|
|
1163
1171
|
const vadOk = initSileroVAD()
|
|
1164
1172
|
console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
|
|
1173
|
+
// 6.61.0: warm Nemotron in the background (the first Neural Engine compile
|
|
1174
|
+
// takes 15-30 s). Chunks use the voiceprint until it is warm; never awaited.
|
|
1175
|
+
startNemotronWarmup()
|
|
1165
1176
|
}
|
|
1166
1177
|
|
|
1167
1178
|
// Durable recovery starts with the admitted runtime by default. A literal
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Which model assigns who-spoke-when.
|
|
2
|
+
//
|
|
3
|
+
// Voiceprints (ERes2Net) name a person. They do not separate two people inside
|
|
4
|
+
// one chunk. Nemotron 3 Diarization does that, then the voiceprint names the
|
|
5
|
+
// track. COS_DIARIZER selects the track model. The default is Nemotron. If its
|
|
6
|
+
// CLI or its local models are missing, the server stays on the voiceprint path
|
|
7
|
+
// and says so.
|
|
8
|
+
//
|
|
9
|
+
// OFFLINE BY CONSTRUCTION. The FluidAudio CLI downloads its models from Hugging
|
|
10
|
+
// Face whenever it is run WITHOUT `--models`, and it deletes and re-downloads a
|
|
11
|
+
// cache whose weights marker it does not recognise. A meeting must never wait on
|
|
12
|
+
// that, so the server always passes `--models <dir>`, a local directory holding
|
|
13
|
+
// the compiled bundle and the silence embedding. That code path in FluidAudio
|
|
14
|
+
// (Nemotron3Models.load(directory:)) has no network call at all. When no complete
|
|
15
|
+
// local directory exists the server does not run the CLI.
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process'
|
|
18
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
export type DiarizerMode = 'nemotron' | 'embedding'
|
|
23
|
+
|
|
24
|
+
/** Why Nemotron cannot run at all on this install. Per-chunk reasons live in nemotron-live.ts. */
|
|
25
|
+
export type DiarizerStaticFallback = 'cli_missing' | 'models_missing'
|
|
26
|
+
|
|
27
|
+
export interface DiarizerChoice {
|
|
28
|
+
requested: DiarizerMode
|
|
29
|
+
active: DiarizerMode
|
|
30
|
+
/** Why Nemotron was not used. Null when the request is what is running. */
|
|
31
|
+
fallback: DiarizerStaticFallback | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The canary's preset: 32-frame chunks, about 155x realtime warm on an M3 Ultra. */
|
|
35
|
+
export const NEMOTRON_VARIANT = 'fast32'
|
|
36
|
+
/** The FluidAudio v0.17.4 cache keeps the compiled preset under this subdirectory. */
|
|
37
|
+
export const NEMOTRON_BUNDLE = 'monolithic/v2'
|
|
38
|
+
export const NEMOTRON_MODEL_DIRNAME = `Nemotron3Diarizer_${NEMOTRON_VARIANT}.mlmodelc`
|
|
39
|
+
export const NEMOTRON_SILENCE_FILE = 'learnable_sil_emb.bin'
|
|
40
|
+
|
|
41
|
+
const EMBEDDING_ALIASES = new Set(['embedding', 'eres2net', 'voiceprint', 'legacy'])
|
|
42
|
+
|
|
43
|
+
export function requestedDiarizer(env: NodeJS.ProcessEnv = process.env): DiarizerMode {
|
|
44
|
+
const raw = (env.COS_DIARIZER ?? 'nemotron').trim().toLowerCase()
|
|
45
|
+
return EMBEDDING_ALIASES.has(raw) ? 'embedding' : 'nemotron'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** First existing binary. Never returns a path that is not on disk. */
|
|
49
|
+
export function nemotronCliPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string | null {
|
|
50
|
+
if (typeof env.COS_NEMOTRON_CLI === 'string') {
|
|
51
|
+
return env.COS_NEMOTRON_CLI.length > 0 && existsSync(env.COS_NEMOTRON_CLI) ? env.COS_NEMOTRON_CLI : null
|
|
52
|
+
}
|
|
53
|
+
const installed = join(home, '.cos-glasses', 'bin', 'fluidaudiocli')
|
|
54
|
+
if (existsSync(installed)) return installed
|
|
55
|
+
// Health reads this on every request; on an install with no CLI that was a
|
|
56
|
+
// synchronous `which` per request (QA 6.61.0 N5). Cached for a minute per PATH.
|
|
57
|
+
const key = env.PATH ?? ''
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
if (whichCache && whichCache.key === key && now - whichCache.at < WHICH_CACHE_MS) {
|
|
60
|
+
return whichCache.value && existsSync(whichCache.value) ? whichCache.value : null
|
|
61
|
+
}
|
|
62
|
+
let value: string | null = null
|
|
63
|
+
try {
|
|
64
|
+
const found = execFileSync('which', ['fluidaudiocli'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000 }).trim()
|
|
65
|
+
value = found && existsSync(found) ? found : null
|
|
66
|
+
} catch {
|
|
67
|
+
value = null
|
|
68
|
+
}
|
|
69
|
+
whichCache = { key, at: now, value }
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const WHICH_CACHE_MS = 60_000
|
|
74
|
+
let whichCache: { key: string; at: number; value: string | null } | null = null
|
|
75
|
+
|
|
76
|
+
/** The directory passed to `--models`. COS_NEMOTRON_MODELS overrides it. */
|
|
77
|
+
export function nemotronModelsDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
78
|
+
const override = env.COS_NEMOTRON_MODELS?.trim()
|
|
79
|
+
return override ? override : join(home, '.cos-glasses', 'models', 'nemotron-3-diarization')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Where FluidAudio's own downloader leaves the models (`fluidaudiocli` run once without `--models`). */
|
|
83
|
+
export function fluidAudioCacheDir(home: string = homedir()): string {
|
|
84
|
+
return join(home, 'Library', 'Application Support', 'FluidAudio', 'Models', 'nemotron-3-diarization')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isFile(path: string): boolean {
|
|
88
|
+
try { return statSync(path).isFile() } catch { return false }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A compiled bundle is complete once its coremldata.bin is on disk (FluidAudio's own test). */
|
|
92
|
+
export function modelsDirComplete(dir: string): boolean {
|
|
93
|
+
return isFile(join(dir, NEMOTRON_MODEL_DIRNAME, 'coremldata.bin')) && isFile(join(dir, NEMOTRON_SILENCE_FILE))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cacheComplete(home: string): boolean {
|
|
97
|
+
const cache = fluidAudioCacheDir(home)
|
|
98
|
+
return isFile(join(cache, NEMOTRON_BUNDLE, NEMOTRON_MODEL_DIRNAME, 'coremldata.bin'))
|
|
99
|
+
&& isFile(join(cache, NEMOTRON_SILENCE_FILE))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NemotronModelSource {
|
|
103
|
+
dir: string
|
|
104
|
+
/** env: COS_NEMOTRON_MODELS. cos: ~/.cos-glasses/models already complete.
|
|
105
|
+
* fluidaudio-cache: complete only after prepareNemotronModelsDir links it. */
|
|
106
|
+
source: 'env' | 'cos' | 'fluidaudio-cache'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read-only: where the models are, or null. Never creates anything (health calls this). */
|
|
110
|
+
export function nemotronModelSource(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): NemotronModelSource | null {
|
|
111
|
+
const dir = nemotronModelsDir(env, home)
|
|
112
|
+
if (env.COS_NEMOTRON_MODELS?.trim()) {
|
|
113
|
+
// An explicit override is the answer, complete or not. Falling through to a
|
|
114
|
+
// different copy would run weights the operator did not choose.
|
|
115
|
+
return modelsDirComplete(dir) ? { dir, source: 'env' } : null
|
|
116
|
+
}
|
|
117
|
+
if (modelsDirComplete(dir)) return { dir, source: 'cos' }
|
|
118
|
+
if (cacheComplete(home)) return { dir, source: 'fluidaudio-cache' }
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Make `--models` point at a complete local copy, or return null.
|
|
124
|
+
*
|
|
125
|
+
* FluidAudio keeps the bundle one level below the silence embedding, while
|
|
126
|
+
* `--models` wants both in one directory, so the COS directory holds two symlinks
|
|
127
|
+
* into the FluidAudio cache. Called by the warm-up, never by a health read.
|
|
128
|
+
*/
|
|
129
|
+
export function prepareNemotronModelsDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string | null {
|
|
130
|
+
const found = nemotronModelSource(env, home)
|
|
131
|
+
if (!found) return null
|
|
132
|
+
if (found.source !== 'fluidaudio-cache') return found.dir
|
|
133
|
+
const cache = fluidAudioCacheDir(home)
|
|
134
|
+
const links: Array<[string, string]> = [
|
|
135
|
+
[join(found.dir, NEMOTRON_MODEL_DIRNAME), join(cache, NEMOTRON_BUNDLE, NEMOTRON_MODEL_DIRNAME)],
|
|
136
|
+
[join(found.dir, NEMOTRON_SILENCE_FILE), join(cache, NEMOTRON_SILENCE_FILE)],
|
|
137
|
+
]
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(found.dir, { recursive: true, mode: 0o700 })
|
|
140
|
+
try { chmodSync(found.dir, 0o700) } catch { /* best effort */ }
|
|
141
|
+
for (const [link, target] of links) {
|
|
142
|
+
let current: string | null = null
|
|
143
|
+
try {
|
|
144
|
+
if (lstatSync(link).isSymbolicLink()) current = readlinkSync(link)
|
|
145
|
+
else continue // a real file or directory someone placed there wins
|
|
146
|
+
} catch { /* absent */ }
|
|
147
|
+
if (current === target) continue
|
|
148
|
+
if (current !== null) unlinkSync(link)
|
|
149
|
+
symlinkSync(target, link)
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
return modelsDirComplete(found.dir) ? found.dir : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function activeDiarizer(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): DiarizerChoice {
|
|
158
|
+
const requested = requestedDiarizer(env)
|
|
159
|
+
if (requested === 'embedding') return { requested, active: 'embedding', fallback: null }
|
|
160
|
+
if (!nemotronCliPath(env, home)) return { requested, active: 'embedding', fallback: 'cli_missing' }
|
|
161
|
+
if (!nemotronModelSource(env, home)) return { requested, active: 'embedding', fallback: 'models_missing' }
|
|
162
|
+
return { requested, active: 'nemotron', fallback: null }
|
|
163
|
+
}
|
|
@@ -96,6 +96,10 @@ export interface EnrolmentReport {
|
|
|
96
96
|
/** Candidates rejected as a different voice. `clusterSkipped === attempted`
|
|
97
97
|
* means the bucket had no dominant voice at all and nothing was written. */
|
|
98
98
|
clusterSkipped: number
|
|
99
|
+
/** 6.61.0: candidates refused before clustering because the chunk is not one
|
|
100
|
+
* voice: its own capture-time voiceprint named a third person, or Nemotron
|
|
101
|
+
* split it into more than one turn. */
|
|
102
|
+
otherVoiceSkipped?: number
|
|
99
103
|
skipped: EnrolmentSkipReason
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -230,16 +234,35 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
|
|
|
230
234
|
if (read.missing) return { ...IDLE, skipped: missingReason(sidecar) }
|
|
231
235
|
|
|
232
236
|
const wanted = new Set(rawIndices)
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
237
|
+
const found = read.rows.filter(r => wanted.has(r.i) && r.embedding && r.embedding.length > 0)
|
|
238
|
+
if (found.length === 0) return { ...IDLE, skipped: 'no_embeddings' }
|
|
235
239
|
|
|
236
|
-
|
|
240
|
+
// 6.61.0 (QA W3). Since Nemotron, a chunk's sidecar `speaker` is the dominant
|
|
241
|
+
// TURN's name, while the embedding row holds the WHOLE chunk's voice and the
|
|
242
|
+
// label the voiceprint gave that whole chunk. A rename of the turn's label must
|
|
243
|
+
// not teach the profile a chunk whose own voiceprint named someone else, or a
|
|
244
|
+
// chunk Nemotron heard as more than one voice: either would enrol the wrong
|
|
245
|
+
// person. `from` stays allowed (a wrong label is how a new person first shows up).
|
|
246
|
+
const chunksByIndex = new Map<number, Record<string, unknown>>()
|
|
247
|
+
for (const entry of Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries as Array<Record<string, unknown>> : []) {
|
|
248
|
+
if (typeof entry?.chunkIndex === 'number' && entry.chunk && typeof entry.chunk === 'object') {
|
|
249
|
+
chunksByIndex.set(entry.chunkIndex, entry.chunk as Record<string, unknown>)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const candidates = found.filter(r => !isAnotherVoice(r.speaker, input.from, to) && !isMultiVoiceChunk(chunksByIndex.get(r.i)))
|
|
253
|
+
// Reported only when it happened, so the response is the 6.60 shape otherwise.
|
|
254
|
+
const otherVoice = found.length - candidates.length > 0 ? { otherVoiceSkipped: found.length - candidates.length } : {}
|
|
255
|
+
if (candidates.length === 0) {
|
|
256
|
+
return { enrolled: 0, attempted: found.length, created: false, clusterSkipped: 0, ...otherVoice, skipped: null }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const attempted = found.length
|
|
237
260
|
const cluster = dominantCoherentCluster(candidates.map(r => r.embedding))
|
|
238
|
-
const clusterSkipped =
|
|
261
|
+
const clusterSkipped = candidates.length - cluster.members.length
|
|
239
262
|
// Every candidate disagreed with every other one. There is no voice here to
|
|
240
263
|
// learn, only a bucket of strangers sharing one placeholder label.
|
|
241
264
|
if (cluster.members.length === 0) {
|
|
242
|
-
return { enrolled: 0, attempted, created: false, clusterSkipped, skipped: null }
|
|
265
|
+
return { enrolled: 0, attempted, created: false, clusterSkipped, ...otherVoice, skipped: null }
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
// Diversity trims the coherent group to a bounded, spread-out sample. Those
|
|
@@ -261,12 +284,26 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
|
|
|
261
284
|
}
|
|
262
285
|
} catch {
|
|
263
286
|
return {
|
|
264
|
-
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped,
|
|
287
|
+
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped, ...otherVoice,
|
|
265
288
|
skipped: 'store_unavailable',
|
|
266
289
|
}
|
|
267
290
|
}
|
|
268
291
|
|
|
269
292
|
return {
|
|
270
|
-
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped, skipped: null,
|
|
293
|
+
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped, ...otherVoice, skipped: null,
|
|
271
294
|
}
|
|
272
295
|
}
|
|
296
|
+
|
|
297
|
+
/** The row's own capture-time voiceprint named a real person other than the two in this rename. */
|
|
298
|
+
export function isAnotherVoice(rowSpeaker: unknown, from: string, to: string): boolean {
|
|
299
|
+
if (typeof rowSpeaker !== 'string' || !rowSpeaker.trim()) return false
|
|
300
|
+
if (isPlaceholderLabel(rowSpeaker)) return false
|
|
301
|
+
return rowSpeaker !== from && rowSpeaker !== to
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Nemotron heard more than one turn in this chunk, live or in the final pass. */
|
|
305
|
+
export function isMultiVoiceChunk(chunk: Record<string, unknown> | undefined): boolean {
|
|
306
|
+
if (!chunk) return false
|
|
307
|
+
const turns = (value: unknown): number => Array.isArray(value) ? value.length : 0
|
|
308
|
+
return turns(chunk.segments) > 1 || turns(chunk.finalSegments) > 1
|
|
309
|
+
}
|
|
@@ -379,7 +379,9 @@ export class MeetingStore {
|
|
|
379
379
|
canonicalProvider: canonicalProvider(input.chunks),
|
|
380
380
|
providers,
|
|
381
381
|
providerCandidates: input.providerCandidates ?? {},
|
|
382
|
-
|
|
382
|
+
// 6.61.0: a chunk can hold two named voices; its segments count too.
|
|
383
|
+
speakers: [...new Set(input.chunks.flatMap(chunk => [chunk.speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)])
|
|
384
|
+
.filter(speaker => speaker && speaker !== 'Ext'))],
|
|
383
385
|
chunks: input.chunks,
|
|
384
386
|
chunkEntries: input.chunkEntries ?? input.chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk })),
|
|
385
387
|
transferIntegrity: input.transferIntegrity ?? null,
|