@gotcos/glasses-server 6.92.0 → 6.93.0
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 +30 -1
- package/CHANGELOG.md +57 -29
- package/README.md +58 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// 6.93.0: the I/O half of live Mac window replay (mac-window-voices.ts is the pure half).
|
|
2
|
+
//
|
|
3
|
+
// Reads the durable raw chunks the route already wrote, joins the planned window
|
|
4
|
+
// into one private temp WAV, runs the injected diarizer once with a budget sized
|
|
5
|
+
// from the window's audio length, removes the temp WAV, and crosswalks. When a
|
|
6
|
+
// carried slot needs the current chunk's agreement (QA W1) the chunk is diarized
|
|
7
|
+
// alone as well, after the window run. A window run that times out or whose
|
|
8
|
+
// geometry is refused falls back to the plain per-chunk run (QA W4). Any failure
|
|
9
|
+
// is the normal live fallback (`Ext`); nothing here throws into the chunk route.
|
|
10
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
import { MAC_AUDIO } from './mac-recording.js'
|
|
14
|
+
import { NEMOTRON_TMP_PREFIX, trackNemotronDir, untrackNemotronDir } from './nemotron-cli.js'
|
|
15
|
+
import { pcm16ToWav, readPcm16Mono, type Activity } from './nemotron-segments.js'
|
|
16
|
+
import { liveBudgetForAudioSec, type LiveDiarizeOutcome } from './nemotron-live.js'
|
|
17
|
+
import { chunkShowsVoiceLabel, macLiveVoicesMode, type MacLiveVoicesMode } from './mac-voice-labels.js'
|
|
18
|
+
import {
|
|
19
|
+
crosswalkMacWindow,
|
|
20
|
+
emptyMacWindowVoices,
|
|
21
|
+
planMacWindow,
|
|
22
|
+
validMacWindowVoices,
|
|
23
|
+
type MacWindowPlan,
|
|
24
|
+
type MacWindowResult,
|
|
25
|
+
type MacWindowStats,
|
|
26
|
+
type MacWindowVoiceState,
|
|
27
|
+
} from './mac-window-voices.js'
|
|
28
|
+
|
|
29
|
+
interface LabeledChunk { speaker?: unknown; segments?: unknown }
|
|
30
|
+
|
|
31
|
+
/** Why a capture's window labels are withheld, or null when they may run. */
|
|
32
|
+
export type MacWindowWithheld = 'corrupt_state' | 'earlier_voice_labels'
|
|
33
|
+
|
|
34
|
+
export function macWindowWithheldReason(saved: unknown, chunks: ReadonlyArray<LabeledChunk | null | undefined>): MacWindowWithheld | null {
|
|
35
|
+
if (saved !== undefined) return validMacWindowVoices(saved) ? null : 'corrupt_state'
|
|
36
|
+
return chunks.some(chunkShowsVoiceLabel) ? 'earlier_voice_labels' : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The state this capture may continue from, or null to withhold window labels.
|
|
41
|
+
* Corrupt state stays as it is (never replaced by a fresh start). A capture that
|
|
42
|
+
* already shows Voice labels without window state (switch flipped mid-capture, or
|
|
43
|
+
* an older server) never restarts numbering at Voice 1.
|
|
44
|
+
*/
|
|
45
|
+
export function macWindowStateFor(saved: unknown, chunks: ReadonlyArray<LabeledChunk | null | undefined>): MacWindowVoiceState | null {
|
|
46
|
+
if (macWindowWithheldReason(saved, chunks)) return null
|
|
47
|
+
return saved === undefined ? emptyMacWindowVoices() : saved as MacWindowVoiceState
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Counts (never text): one log line per window run, health totals since start ──
|
|
51
|
+
|
|
52
|
+
const counters = {
|
|
53
|
+
runs: 0,
|
|
54
|
+
fallbackTimeout: 0,
|
|
55
|
+
fallbackGeometry: 0,
|
|
56
|
+
agreementUnavailable: 0,
|
|
57
|
+
carried: 0,
|
|
58
|
+
withheld: 0,
|
|
59
|
+
created: 0,
|
|
60
|
+
confirmed: 0,
|
|
61
|
+
evicted: 0,
|
|
62
|
+
capped: 0,
|
|
63
|
+
disabledCaptures: 0,
|
|
64
|
+
stateWithheld: { corrupt_state: 0, earlier_voice_labels: 0 } as Record<MacWindowWithheld, number>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* QA R3 and W11: the window is switched off for a capture when MAC_WINDOW_BACKOFF_TIMEOUTS of its last
|
|
69
|
+
* MAC_WINDOW_BACKOFF_OF_LAST window runs timed out. After that the capture uses per-chunk runs only (`Ext`) until
|
|
70
|
+
* the server restarts, because a window timeout is exempt from the shared breaker and would otherwise cost its full
|
|
71
|
+
* wait on every chunk, for ever. A rolling count, not a streak: a loaded Mac that alternates a timeout with a
|
|
72
|
+
* run that just fits still pays most of a 6 s cadence on every other chunk, and a streak never trips on it.
|
|
73
|
+
*/
|
|
74
|
+
export const MAC_WINDOW_BACKOFF_TIMEOUTS = 2
|
|
75
|
+
export const MAC_WINDOW_BACKOFF_OF_LAST = 3
|
|
76
|
+
const recentWindowRuns = new Map<string, boolean[]>()
|
|
77
|
+
const windowDisabled = new Set<string>()
|
|
78
|
+
const CAPTURE_MEMORY_MAX = 256
|
|
79
|
+
|
|
80
|
+
function remember<T>(set: Set<T>, value: T): void {
|
|
81
|
+
if (set.size >= CAPTURE_MEMORY_MAX) set.delete(set.values().next().value as T)
|
|
82
|
+
set.add(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True once this capture's window has timed out too often. */
|
|
86
|
+
export function macWindowDisabledFor(sessionId: string): boolean {
|
|
87
|
+
return windowDisabled.has(sessionId)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function noteWindowRun(sessionId: string, timedOut: boolean, log: (line: string) => void): void {
|
|
91
|
+
if (!recentWindowRuns.has(sessionId) && recentWindowRuns.size >= CAPTURE_MEMORY_MAX) recentWindowRuns.delete(recentWindowRuns.keys().next().value as string)
|
|
92
|
+
const recent = [...(recentWindowRuns.get(sessionId) ?? []), timedOut].slice(-MAC_WINDOW_BACKOFF_OF_LAST)
|
|
93
|
+
recentWindowRuns.set(sessionId, recent)
|
|
94
|
+
const timeouts = recent.filter(Boolean).length
|
|
95
|
+
if (timeouts >= MAC_WINDOW_BACKOFF_TIMEOUTS && !windowDisabled.has(sessionId)) {
|
|
96
|
+
remember(windowDisabled, sessionId)
|
|
97
|
+
counters.disabledCaptures++
|
|
98
|
+
log(`[mac-window] capture ${sessionId} window disabled after ${timeouts} of the last ${recent.length} window runs timed out; per-chunk runs only for the rest of this capture (Ext)`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function __resetMacWindowCountersForTests(): void {
|
|
103
|
+
for (const key of Object.keys(counters) as Array<keyof typeof counters>) {
|
|
104
|
+
if (key === 'stateWithheld') counters.stateWithheld = { corrupt_state: 0, earlier_voice_labels: 0 }
|
|
105
|
+
else (counters[key] as number) = 0
|
|
106
|
+
}
|
|
107
|
+
seenWithheld.clear()
|
|
108
|
+
recentWindowRuns.clear()
|
|
109
|
+
windowDisabled.clear()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const seenWithheld = new Set<string>()
|
|
113
|
+
const SEEN_WITHHELD_MAX = 256
|
|
114
|
+
|
|
115
|
+
/** A capture whose window labels are withheld says so once per reason, so `Ext` for a whole meeting is never silent. */
|
|
116
|
+
export function noteMacWindowWithheld(sessionId: string, reason: MacWindowWithheld, log: (line: string) => void = console.log): void {
|
|
117
|
+
const key = `${sessionId}:${reason}`
|
|
118
|
+
if (seenWithheld.has(key)) return
|
|
119
|
+
if (seenWithheld.size >= SEEN_WITHHELD_MAX) seenWithheld.delete(seenWithheld.values().next().value as string)
|
|
120
|
+
seenWithheld.add(key)
|
|
121
|
+
counters.stateWithheld[reason]++
|
|
122
|
+
log(`[mac-window] capture ${sessionId} window labels withheld (${reason}); its chunks stay Ext for the rest of this capture`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface MacLiveVoicesSnapshot {
|
|
126
|
+
/** Which live Voice path is on: window (6.93.0, the default), anchors (6.92.1) or off (COS_MAC_LIVE_VOICES=off). */
|
|
127
|
+
mode: MacLiveVoicesMode
|
|
128
|
+
/** Totals since this server started; present only while the window path is on. */
|
|
129
|
+
window?: {
|
|
130
|
+
runs: number
|
|
131
|
+
fallbacks: { timeout: number; geometry: number }
|
|
132
|
+
agreementUnavailable: number
|
|
133
|
+
carried: number
|
|
134
|
+
withheld: number
|
|
135
|
+
created: number
|
|
136
|
+
confirmed: number
|
|
137
|
+
evicted: number
|
|
138
|
+
capped: number
|
|
139
|
+
/** Captures that stopped using the window after repeated window timeouts (QA R3). */
|
|
140
|
+
disabledCaptures: number
|
|
141
|
+
stateWithheld: Record<MacWindowWithheld, number>
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** For `/api/health` capabilities: the switch state, and the window path's counts when it is on. */
|
|
146
|
+
export function macLiveVoicesSnapshot(env: NodeJS.ProcessEnv = process.env): MacLiveVoicesSnapshot {
|
|
147
|
+
const mode = macLiveVoicesMode(env)
|
|
148
|
+
if (mode !== 'window') return { mode }
|
|
149
|
+
return {
|
|
150
|
+
mode,
|
|
151
|
+
window: {
|
|
152
|
+
runs: counters.runs,
|
|
153
|
+
fallbacks: { timeout: counters.fallbackTimeout, geometry: counters.fallbackGeometry },
|
|
154
|
+
agreementUnavailable: counters.agreementUnavailable,
|
|
155
|
+
carried: counters.carried,
|
|
156
|
+
withheld: counters.withheld,
|
|
157
|
+
created: counters.created,
|
|
158
|
+
confirmed: counters.confirmed,
|
|
159
|
+
evicted: counters.evicted,
|
|
160
|
+
capped: counters.capped,
|
|
161
|
+
disabledCaptures: counters.disabledCaptures,
|
|
162
|
+
stateWithheld: { ...counters.stateWithheld },
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface MacWindowJob {
|
|
168
|
+
diarize: Promise<LiveDiarizeOutcome>
|
|
169
|
+
/** Set once `diarize` settles with a trusted crosswalk; null otherwise. */
|
|
170
|
+
result: () => MacWindowResult | null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const chunkName = (index: number): string => `chunk_${String(index).padStart(4, '0')}.wav`
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Start chunk `chunkIndex`'s window run. `audio` is the chunk's own WAV (already
|
|
177
|
+
* durable at `sessionDir`). Returns null when no window can be planned; the caller
|
|
178
|
+
* then runs the plain per-chunk diarizer.
|
|
179
|
+
* - `diarizeWindow` runs the CLI on the window WAV within `timeoutMs`.
|
|
180
|
+
* - `diarizeChunk` runs it on one durable chunk WAV within the per-chunk budget:
|
|
181
|
+
* the agreement view, and the fallback after a window timeout or refusal.
|
|
182
|
+
*/
|
|
183
|
+
export function startMacWindowDiarize(input: {
|
|
184
|
+
sessionId: string
|
|
185
|
+
sessionDir: string
|
|
186
|
+
chunkIndex: number
|
|
187
|
+
audio: Buffer
|
|
188
|
+
audioSha256: string
|
|
189
|
+
state: MacWindowVoiceState
|
|
190
|
+
diarizeWindow: (wavPath: string, timeoutMs: number) => Promise<LiveDiarizeOutcome>
|
|
191
|
+
diarizeChunk: (wavPath: string) => Promise<LiveDiarizeOutcome>
|
|
192
|
+
tmpRoot?: string
|
|
193
|
+
log?: (line: string) => void
|
|
194
|
+
}): MacWindowJob | null {
|
|
195
|
+
const log = input.log ?? console.log
|
|
196
|
+
const pcmOf = new Map<number, Buffer>()
|
|
197
|
+
const own = readPcm16Mono(input.audio)
|
|
198
|
+
if (!own) return null
|
|
199
|
+
pcmOf.set(input.chunkIndex, own.pcm)
|
|
200
|
+
const plan = planMacWindow(input.chunkIndex, chunk => {
|
|
201
|
+
if (pcmOf.has(chunk)) return pcmOf.get(chunk)!.length / 2
|
|
202
|
+
try {
|
|
203
|
+
const pcm = readPcm16Mono(readFileSync(resolve(input.sessionDir, chunkName(chunk))))
|
|
204
|
+
if (!pcm) return null
|
|
205
|
+
pcmOf.set(chunk, pcm.pcm)
|
|
206
|
+
return pcm.pcm.length / 2
|
|
207
|
+
} catch { return null }
|
|
208
|
+
})
|
|
209
|
+
if (!plan) return null
|
|
210
|
+
const windowSamples = plan.chunks.reduce((sum, c) => sum + c.samples, 0)
|
|
211
|
+
const windowSec = windowSamples / MAC_AUDIO.sampleRate
|
|
212
|
+
let work: string
|
|
213
|
+
let wavPath: string
|
|
214
|
+
try {
|
|
215
|
+
work = mkdtempSync(join(input.tmpRoot ?? tmpdir(), `${NEMOTRON_TMP_PREFIX}window-`))
|
|
216
|
+
trackNemotronDir(work)
|
|
217
|
+
wavPath = join(work, 'window.wav')
|
|
218
|
+
writeFileSync(wavPath, pcm16ToWav(Buffer.concat(plan.chunks.map(c => pcmOf.get(c.chunk)!))), { mode: 0o600 })
|
|
219
|
+
} catch {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
let cleaned = false
|
|
223
|
+
const cleanup = () => {
|
|
224
|
+
if (cleaned) return
|
|
225
|
+
cleaned = true
|
|
226
|
+
untrackNemotronDir(work)
|
|
227
|
+
try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
228
|
+
}
|
|
229
|
+
const chunkPath = resolve(input.sessionDir, chunkName(input.chunkIndex))
|
|
230
|
+
let result: MacWindowResult | null = null
|
|
231
|
+
const cross = (window: Activity, chunkAlone: Activity | null, planned: MacWindowPlan): MacWindowResult | null => {
|
|
232
|
+
try { return crosswalkMacWindow({ state: input.state, window, plan: planned, audioSha256: input.audioSha256, chunkAlone }) } catch { return null }
|
|
233
|
+
}
|
|
234
|
+
const timeoutMs = liveBudgetForAudioSec(windowSec)
|
|
235
|
+
|
|
236
|
+
const fallback = async (why: 'timeout' | 'geometry', ms: number | undefined): Promise<LiveDiarizeOutcome> => {
|
|
237
|
+
cleanup()
|
|
238
|
+
counters[why === 'timeout' ? 'fallbackTimeout' : 'fallbackGeometry']++
|
|
239
|
+
log(`[mac-window] chunk #${input.chunkIndex} window ${windowSec.toFixed(1)}s fallback=${why}${ms !== undefined ? ` after ${ms} ms` : ''} (budget ${timeoutMs} ms) -> per-chunk run, no window state committed`)
|
|
240
|
+
return input.diarizeChunk(chunkPath)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const go = async (): Promise<LiveDiarizeOutcome> => {
|
|
244
|
+
let outcome: LiveDiarizeOutcome
|
|
245
|
+
try { outcome = await input.diarizeWindow(wavPath, timeoutMs) } finally { cleanup() }
|
|
246
|
+
noteWindowRun(input.sessionId, !outcome.ok && outcome.reason === 'timeout', log)
|
|
247
|
+
if (!outcome.ok) return outcome.reason === 'timeout' ? fallback('timeout', outcome.ms) : outcome
|
|
248
|
+
let crossed = cross(outcome.run, null, plan)
|
|
249
|
+
if (!crossed) return fallback('geometry', undefined)
|
|
250
|
+
let alone = 'none'
|
|
251
|
+
if (crossed.stats.carried > 0) {
|
|
252
|
+
// A carried slot is used only if this chunk, diarized alone, agrees with it.
|
|
253
|
+
const solo = existsSync(chunkPath) ? await input.diarizeChunk(chunkPath) : { ok: false as const, reason: 'error' as const }
|
|
254
|
+
alone = solo.ok ? 'ok' : solo.reason
|
|
255
|
+
if (!solo.ok) counters.agreementUnavailable++
|
|
256
|
+
crossed = cross(outcome.run, solo.ok ? solo.run : null, plan)
|
|
257
|
+
if (!crossed) return fallback('geometry', undefined)
|
|
258
|
+
}
|
|
259
|
+
result = crossed
|
|
260
|
+
const s: MacWindowStats = crossed.stats
|
|
261
|
+
counters.runs++
|
|
262
|
+
for (const key of ['carried', 'withheld', 'created', 'confirmed', 'evicted', 'capped'] as const) counters[key] += s[key]
|
|
263
|
+
log(`[mac-window] chunk #${input.chunkIndex} window ${plan.chunks.length} chunks ${windowSec.toFixed(1)}s ${Math.round(outcome.run.wallMs ?? 0)} ms overlap=${s.overlapChunks} carried=${s.carried} withheld=${s.withheld} created=${s.created} confirmed=${s.confirmed} evicted=${s.evicted} capped=${s.capped} tracks=${crossed.state.tracks.length} voices=${crossed.state.voices} alone=${alone}`)
|
|
264
|
+
return { ok: true, run: { ...outcome.run, frames: crossed.chunkActivity.frames, probs: crossed.chunkActivity.probs } }
|
|
265
|
+
}
|
|
266
|
+
const diarize = go().catch((error): LiveDiarizeOutcome => {
|
|
267
|
+
cleanup()
|
|
268
|
+
return { ok: false, reason: 'error', detail: error instanceof Error ? error.message : String(error) }
|
|
269
|
+
})
|
|
270
|
+
return { diarize, result: () => result }
|
|
271
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// 6.93.0: live Mac voice continuity by window replay.
|
|
2
|
+
//
|
|
3
|
+
// Why: the live path ran Nemotron on each 6 s chunk alone, so the model's speaker
|
|
4
|
+
// cache never crossed a chunk boundary and every chunk started over. On the
|
|
5
|
+
// natural 36 s fixture the per-chunk anchors returned no labels at all, while one
|
|
6
|
+
// continuous pass kept two clean voices through all five boundaries.
|
|
7
|
+
//
|
|
8
|
+
// What: for chunk k the route runs the SAME pinned CLI on the last few durable raw
|
|
9
|
+
// chunks (at most MAC_WINDOW_MAX_CHUNKS, at most MAC_WINDOW_MAX_SAMPLES). Inside
|
|
10
|
+
// one window the model's slots are consistent. A slot is carried to a capture
|
|
11
|
+
// track only through frames this capture already attributed (the overlap): one to
|
|
12
|
+
// one, at least MAC_WINDOW_CARRY_MIN_FRAMES of exclusive overlap, at least
|
|
13
|
+
// MAC_WINDOW_CARRY_AGREEMENT of those frames on one track. Anything weaker leaves
|
|
14
|
+
// the slot without a track, so its words stay `Ext`.
|
|
15
|
+
//
|
|
16
|
+
// A carried slot is used only if the current chunk AGREES (QA W1): the same chunk
|
|
17
|
+
// diarized alone (the per-chunk run, an independent view) must put the slot's
|
|
18
|
+
// exclusive frames on one channel (MAC_WINDOW_AGREE_PURITY of the frames it can
|
|
19
|
+
// place, MAC_WINDOW_AGREE_COVERAGE of them placed). A window model that merged two
|
|
20
|
+
// voices which BOTH speak in this chunk is therefore not carried: the slot gets no
|
|
21
|
+
// label and no proof this chunk, and its frames are stored as untracked so the error
|
|
22
|
+
// does not feed the next window. NOT caught (QA R1): a clean hand-over at the chunk
|
|
23
|
+
// boundary, a new voice alone in this chunk that the window model put on an old
|
|
24
|
+
// voice's slot. The chunk alone shows one clean voice and agrees. Catching it needs
|
|
25
|
+
// evidence from more than one chunk. It is a known limit now that the window is on by
|
|
26
|
+
// default: a wrong NUMBER that the saved pass keeps (it never renumbers live labels). It reaches a profile only through an explicit assignment, which is gated (mac-voice-assignment.ts).
|
|
27
|
+
//
|
|
28
|
+
// A track is shown as `Voice N` only after clean proof in TWO distinct original
|
|
29
|
+
// chunks with distinct audio (each: MAC_WINDOW_PROOF_FRAMES exclusive, purity at
|
|
30
|
+
// least MAC_WINDOW_PROOF_PURITY). Numbers only grow; a corrupt state never
|
|
31
|
+
// restarts at Voice 1. A track no recent window frame refers to is evicted (QA W3),
|
|
32
|
+
// so a long meeting cannot spend the MAC_WINDOW_MAX_TRACKS ids; its number stays
|
|
33
|
+
// spent. A voice silent for longer than the window cannot be crosswalked: it reads
|
|
34
|
+
// `Ext` or, after its own two proofs, a NEW number (the saved meeting's final pass
|
|
35
|
+
// numbers what live did not prove). The carry rules do not hand it another voice's number
|
|
36
|
+
// (the clean hand-over above is the exception).
|
|
37
|
+
//
|
|
38
|
+
// A `Voice N` is capture-local acoustic continuity, never a person: similarity 0,
|
|
39
|
+
// never a profile, never enrolled or trained. No audio, embedding or probability
|
|
40
|
+
// matrix is stored: only per-frame track ids for the last few chunks.
|
|
41
|
+
//
|
|
42
|
+
// Switch: COS_MAC_LIVE_VOICES, window by default (see mac-voice-labels.ts). It also
|
|
43
|
+
// needs COS_MAC_VOICE_LABELS not off. `anchors` runs the 6.92.1 per-chunk anchors
|
|
44
|
+
// instead; `off` (also 0, false, no) runs neither, so every live Mac chunk is `Ext`.
|
|
45
|
+
import { MAC_AUDIO } from './mac-recording.js'
|
|
46
|
+
import { NEMOTRON_CHANNELS, NEMOTRON_FRAME_SEC } from './nemotron-cli.js'
|
|
47
|
+
import { ACTIVE_THRESHOLD, type Activity } from './nemotron-segments.js'
|
|
48
|
+
import { macLiveVoicesMode, voiceLabel } from './mac-voice-labels.js'
|
|
49
|
+
|
|
50
|
+
export const MAC_WINDOW_MAX_CHUNKS = 5
|
|
51
|
+
/** One Mac chunk's most audio (30 s at 16 kHz), derived from the capture contract. Bounds the CLI's work per chunk (measured about 0.26 s warm). */
|
|
52
|
+
export const MAC_WINDOW_MAX_SAMPLES = MAC_AUDIO.sampleRate * (MAC_AUDIO.maxChunkMs / 1000)
|
|
53
|
+
/** The CLI's frame hop: NEMOTRON_FRAME_SEC (10 ms) of 16 kHz audio = 160 samples. */
|
|
54
|
+
export const MAC_WINDOW_SAMPLES_PER_FRAME = Math.round(MAC_AUDIO.sampleRate * NEMOTRON_FRAME_SEC)
|
|
55
|
+
/**
|
|
56
|
+
* The CLI's mel front end frames a clip of N samples as 1 + floor((N + FFT - WINDOW) / hop):
|
|
57
|
+
* n_fft 512 minus window 400 = 112 (measured: 36 s -> 3601 frames; a mismatch refuses the window).
|
|
58
|
+
*/
|
|
59
|
+
export const MAC_WINDOW_FFT_SAMPLES = 512
|
|
60
|
+
export const MAC_WINDOW_FRAME_SAMPLES = 400
|
|
61
|
+
export const MAC_WINDOW_FRAME_TAIL_SAMPLES = MAC_WINDOW_FFT_SAMPLES - MAC_WINDOW_FRAME_SAMPLES
|
|
62
|
+
/** Frames the CLI returns for `samples` of audio. The one place the geometry is written down. */
|
|
63
|
+
export function macWindowFrameCount(samples: number): number {
|
|
64
|
+
return 1 + Math.floor((samples + MAC_WINDOW_FRAME_TAIL_SAMPLES) / MAC_WINDOW_SAMPLES_PER_FRAME)
|
|
65
|
+
}
|
|
66
|
+
export const MAC_WINDOW_CARRY_MIN_FRAMES = 100
|
|
67
|
+
export const MAC_WINDOW_CARRY_AGREEMENT = 0.8
|
|
68
|
+
export const MAC_WINDOW_NEW_TRACK_FRAMES = 200
|
|
69
|
+
export const MAC_WINDOW_PROOF_FRAMES = 200
|
|
70
|
+
export const MAC_WINDOW_PROOF_PURITY = 0.9
|
|
71
|
+
export const MAC_WINDOW_PROOFS = 2
|
|
72
|
+
/** Carried-slot agreement with the chunk diarized alone (QA W1): share of the slot's exclusive frames the per-chunk run must place, and the share of those on one channel. */
|
|
73
|
+
export const MAC_WINDOW_AGREE_COVERAGE = 0.5
|
|
74
|
+
export const MAC_WINDOW_AGREE_PURITY = 0.9
|
|
75
|
+
/** Live capture tracks (ids 0..11). A track no recent frame refers to is evicted and its id reused; past this at once, new slots stay `Ext`. */
|
|
76
|
+
export const MAC_WINDOW_MAX_TRACKS = 12
|
|
77
|
+
const MAX_VOICE = 99
|
|
78
|
+
// Per-frame codes stored for recent chunks. Track ids are 0..MAX_TRACKS-1.
|
|
79
|
+
const NONE = -1
|
|
80
|
+
const OVERLAP = -2
|
|
81
|
+
const UNTRACKED = -3
|
|
82
|
+
|
|
83
|
+
export function macWindowVoicesEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
84
|
+
return macLiveVoicesMode(env) === 'window'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MacWindowTrack {
|
|
88
|
+
id: number
|
|
89
|
+
/** The confirmed Voice number, or null while unproven. */
|
|
90
|
+
voice: number | null
|
|
91
|
+
proofs: Array<{ chunk: number; audioSha256: string }>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MacWindowVoiceState {
|
|
95
|
+
version: 1
|
|
96
|
+
kind: 'window'
|
|
97
|
+
/** Every track id is below this (at most MAC_WINDOW_MAX_TRACKS). Ids of evicted tracks are reused, only once no recent frame refers to them. */
|
|
98
|
+
nextTrack: number
|
|
99
|
+
/** Highest Voice number handed out. Never lowered. */
|
|
100
|
+
voices: number
|
|
101
|
+
tracks: MacWindowTrack[]
|
|
102
|
+
/** The newest chunks' per-frame codes (base64 Int8), oldest first, at most MAC_WINDOW_MAX_CHUNKS - 1. */
|
|
103
|
+
recent: Array<{ chunk: number; frames: string }>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function emptyMacWindowVoices(): MacWindowVoiceState {
|
|
107
|
+
return { version: 1, kind: 'window', nextTrack: 0, voices: 0, tracks: [], recent: [] }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isIndex = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0
|
|
111
|
+
const SHA = /^[0-9a-f]{64}$/
|
|
112
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
|
|
113
|
+
|
|
114
|
+
function decodeFrames(frames: string): Int8Array | null {
|
|
115
|
+
if (typeof frames !== 'string' || frames.length > 64_000 || !BASE64.test(frames) || frames.length % 4) return null
|
|
116
|
+
const bytes = Buffer.from(frames, 'base64')
|
|
117
|
+
if (bytes.toString('base64') !== frames) return null
|
|
118
|
+
return new Int8Array(bytes.buffer, bytes.byteOffset, bytes.length)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function encodeFrames(codes: Int8Array): string {
|
|
122
|
+
return Buffer.from(codes.buffer, codes.byteOffset, codes.length).toString('base64')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Corrupt or foreign state is unavailable, never a fresh start. */
|
|
126
|
+
export function validMacWindowVoices(value: unknown): value is MacWindowVoiceState {
|
|
127
|
+
const s = value as MacWindowVoiceState | null
|
|
128
|
+
if (!s || typeof s !== 'object' || s.version !== 1 || s.kind !== 'window') return false
|
|
129
|
+
if (!isIndex(s.nextTrack) || s.nextTrack > MAC_WINDOW_MAX_TRACKS || !isIndex(s.voices) || s.voices > MAX_VOICE) return false
|
|
130
|
+
if (!Array.isArray(s.tracks) || s.tracks.length > s.nextTrack || !Array.isArray(s.recent) || s.recent.length > MAC_WINDOW_MAX_CHUNKS - 1) return false
|
|
131
|
+
const voices = new Set<number>()
|
|
132
|
+
let lastId = -1
|
|
133
|
+
for (let i = 0; i < s.tracks.length; i++) {
|
|
134
|
+
const t = s.tracks[i]
|
|
135
|
+
if (!t || !isIndex(t.id) || t.id <= lastId || t.id >= s.nextTrack || !Array.isArray(t.proofs) || t.proofs.length > MAC_WINDOW_PROOFS) return false
|
|
136
|
+
lastId = t.id
|
|
137
|
+
if (!t.proofs.every(p => p && isIndex(p.chunk) && typeof p.audioSha256 === 'string' && SHA.test(p.audioSha256))) return false
|
|
138
|
+
if (new Set(t.proofs.map(p => p.chunk)).size !== t.proofs.length || new Set(t.proofs.map(p => p.audioSha256)).size !== t.proofs.length) return false
|
|
139
|
+
if (t.voice !== null) {
|
|
140
|
+
if (!isIndex(t.voice) || t.voice < 1 || t.voice > s.voices || voices.has(t.voice) || t.proofs.length < MAC_WINDOW_PROOFS) return false
|
|
141
|
+
voices.add(t.voice)
|
|
142
|
+
} else if (t.proofs.length >= MAC_WINDOW_PROOFS) return false
|
|
143
|
+
}
|
|
144
|
+
const ids = new Set(s.tracks.map(t => t.id))
|
|
145
|
+
let last = -1
|
|
146
|
+
for (const r of s.recent) {
|
|
147
|
+
if (!r || !isIndex(r.chunk) || r.chunk <= last) return false
|
|
148
|
+
const codes = decodeFrames(r.frames)
|
|
149
|
+
// A frame may only name a track that still exists (an evicted id has no frame).
|
|
150
|
+
if (!codes || codes.length === 0 || codes.some(c => c < UNTRACKED || (c >= 0 && !ids.has(c)))) return false
|
|
151
|
+
last = r.chunk
|
|
152
|
+
}
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface MacWindowChunk {
|
|
157
|
+
chunk: number
|
|
158
|
+
/** Samples of this chunk inside the window WAV (the window is these chunks back to back). */
|
|
159
|
+
samples: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface MacWindowPlan {
|
|
163
|
+
chunks: MacWindowChunk[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which durable chunks go in chunk k's window: k and the contiguous run before it,
|
|
168
|
+
* newest first, until MAC_WINDOW_MAX_CHUNKS or MAC_WINDOW_MAX_SAMPLES. A missing
|
|
169
|
+
* chunk ends the run (no gap is ever bridged). `samplesOf` returns null when a
|
|
170
|
+
* chunk has no durable WAV.
|
|
171
|
+
*/
|
|
172
|
+
export function planMacWindow(chunkIndex: number, samplesOf: (chunk: number) => number | null): MacWindowPlan | null {
|
|
173
|
+
if (!isIndex(chunkIndex)) return null
|
|
174
|
+
const own = samplesOf(chunkIndex)
|
|
175
|
+
if (!isIndex(own) || own === 0 || own > MAC_WINDOW_MAX_SAMPLES) return null
|
|
176
|
+
const chunks: MacWindowChunk[] = [{ chunk: chunkIndex, samples: own }]
|
|
177
|
+
let total = own
|
|
178
|
+
for (let c = chunkIndex - 1; c >= 0 && chunks.length < MAC_WINDOW_MAX_CHUNKS; c--) {
|
|
179
|
+
const n = samplesOf(c)
|
|
180
|
+
if (!isIndex(n) || n === 0 || total + n > MAC_WINDOW_MAX_SAMPLES) break
|
|
181
|
+
chunks.unshift({ chunk: c, samples: n })
|
|
182
|
+
total += n
|
|
183
|
+
}
|
|
184
|
+
return { chunks }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface MacWindowStats {
|
|
188
|
+
overlapChunks: number
|
|
189
|
+
/** Slots carried to an existing track through the overlap (before the agreement check). */
|
|
190
|
+
carried: number
|
|
191
|
+
/** Carried slots that speak in this chunk and that the chunk diarized alone did not agree with: no label, no proof, frames stored untracked. (A carried slot silent in this chunk is not counted.) */
|
|
192
|
+
withheld: number
|
|
193
|
+
created: number
|
|
194
|
+
confirmed: number
|
|
195
|
+
/** Tracks dropped because no recent frame refers to them. */
|
|
196
|
+
evicted: number
|
|
197
|
+
/** New tracks refused because every id was in use. */
|
|
198
|
+
capped: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface MacWindowResult {
|
|
202
|
+
/** The state to commit with this chunk (the input is not mutated). */
|
|
203
|
+
state: MacWindowVoiceState
|
|
204
|
+
/** Activity for the current chunk alone, in its own time base (frame 0 = chunk start). */
|
|
205
|
+
chunkActivity: Activity
|
|
206
|
+
/** Confirmed `Voice N` for a window slot, or null (the slot's words stay `Ext`). */
|
|
207
|
+
labelOf: (slot: number) => string | null
|
|
208
|
+
/** Counts for the log line; never text. */
|
|
209
|
+
stats: MacWindowStats
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function activeSlots(a: Activity, f: number): number[] {
|
|
213
|
+
const out: number[] = []
|
|
214
|
+
for (let c = 0; c < NEMOTRON_CHANNELS; c++) if (a.probs[f * NEMOTRON_CHANNELS + c] >= ACTIVE_THRESHOLD) out.push(c)
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Does the chunk diarized alone put this slot's exclusive frames on one channel?
|
|
220
|
+
* `alone` is the per-chunk run's activity in the chunk's own time base. A merged
|
|
221
|
+
* window slot (two voices) splits across channels there; a slot the per-chunk run
|
|
222
|
+
* cannot place is not agreed with. No evidence is no agreement.
|
|
223
|
+
*/
|
|
224
|
+
export function chunkAgreesWithSlot(window: Activity, start: number, length: number, slot: number, alone: Activity | null): boolean {
|
|
225
|
+
if (!alone || alone.probs.length !== alone.frames * NEMOTRON_CHANNELS) return false
|
|
226
|
+
let own = 0
|
|
227
|
+
let covered = 0
|
|
228
|
+
const perChannel = new Map<number, number>()
|
|
229
|
+
for (let i = 0; i < length && start + i < window.frames; i++) {
|
|
230
|
+
const slots = activeSlots(window, start + i)
|
|
231
|
+
if (slots.length !== 1 || slots[0] !== slot) continue
|
|
232
|
+
own++
|
|
233
|
+
if (i >= alone.frames) continue
|
|
234
|
+
const there = activeSlots(alone, i)
|
|
235
|
+
if (there.length !== 1) continue
|
|
236
|
+
covered++
|
|
237
|
+
perChannel.set(there[0], (perChannel.get(there[0]) ?? 0) + 1)
|
|
238
|
+
}
|
|
239
|
+
// A slot with no exclusive frame here (own = 0) places nothing (covered = 0): no agreement.
|
|
240
|
+
if (covered === 0 || covered < own * MAC_WINDOW_AGREE_COVERAGE) return false
|
|
241
|
+
return Math.max(...perChannel.values()) / covered >= MAC_WINDOW_AGREE_PURITY
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Crosswalk one window's activity onto the capture's tracks and return chunk k's
|
|
246
|
+
* labels. Pure. Returns null (no labels) on any geometry it cannot trust.
|
|
247
|
+
* `chunkAlone` is the same chunk diarized by itself (or null when that run is not
|
|
248
|
+
* available): a carried slot is used only if it agrees with it (QA W1).
|
|
249
|
+
*/
|
|
250
|
+
export function crosswalkMacWindow(input: {
|
|
251
|
+
state: MacWindowVoiceState
|
|
252
|
+
window: Activity
|
|
253
|
+
plan: MacWindowPlan
|
|
254
|
+
audioSha256: string
|
|
255
|
+
chunkAlone: Activity | null
|
|
256
|
+
}): MacWindowResult | null {
|
|
257
|
+
const { window, plan, audioSha256 } = input
|
|
258
|
+
if (!validMacWindowVoices(input.state) || !SHA.test(audioSha256) || !plan.chunks.length) return null
|
|
259
|
+
if (!Number.isInteger(window.frames) || window.frames <= 0 || window.probs.length !== window.frames * NEMOTRON_CHANNELS) return null
|
|
260
|
+
for (const v of window.probs) if (!Number.isFinite(v) || v < 0 || v > 1) return null
|
|
261
|
+
const state: MacWindowVoiceState = structuredClone(input.state)
|
|
262
|
+
const current = plan.chunks[plan.chunks.length - 1]
|
|
263
|
+
// Window frame offset of each chunk: its first sample / 160. Chunks are whole frames in practice (6 s); an odd length shifts later offsets by under a frame, which the geometry check below still bounds.
|
|
264
|
+
const offsets = new Map<number, number>()
|
|
265
|
+
let sample = 0
|
|
266
|
+
for (const c of plan.chunks) { offsets.set(c.chunk, Math.floor(sample / MAC_WINDOW_SAMPLES_PER_FRAME)); sample += c.samples }
|
|
267
|
+
if (window.frames !== macWindowFrameCount(sample)) return null
|
|
268
|
+
const start = offsets.get(current.chunk)!
|
|
269
|
+
const length = Math.floor(current.samples / MAC_WINDOW_SAMPLES_PER_FRAME)
|
|
270
|
+
const trackById = (id: number) => state.tracks.find(t => t.id === id)!
|
|
271
|
+
|
|
272
|
+
// 1. Overlap evidence: frames of earlier window chunks this capture attributed.
|
|
273
|
+
const votes = new Map<number, Map<number, number>>()
|
|
274
|
+
const exclusiveInOverlap = new Map<number, number>()
|
|
275
|
+
let overlapChunks = 0
|
|
276
|
+
for (const r of state.recent) {
|
|
277
|
+
if (r.chunk >= current.chunk || !offsets.has(r.chunk)) continue
|
|
278
|
+
const codes = decodeFrames(r.frames)!
|
|
279
|
+
const off = offsets.get(r.chunk)!
|
|
280
|
+
const n = Math.min(codes.length, Math.floor(plan.chunks.find(c => c.chunk === r.chunk)!.samples / MAC_WINDOW_SAMPLES_PER_FRAME))
|
|
281
|
+
overlapChunks++
|
|
282
|
+
for (let i = 0; i < n; i++) {
|
|
283
|
+
const slots = activeSlots(window, off + i)
|
|
284
|
+
if (slots.length !== 1) continue
|
|
285
|
+
const slot = slots[0]
|
|
286
|
+
exclusiveInOverlap.set(slot, (exclusiveInOverlap.get(slot) ?? 0) + 1)
|
|
287
|
+
const track = codes[i]
|
|
288
|
+
if (track < 0) continue
|
|
289
|
+
const row = votes.get(slot) ?? new Map<number, number>()
|
|
290
|
+
row.set(track, (row.get(track) ?? 0) + 1)
|
|
291
|
+
votes.set(slot, row)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// 2. One-to-one carry, strongest first. Two tracks tied on ONE slot cannot reach the 0.8 agreement, so
|
|
295
|
+
// that slot is refused there. Two slots tied for ONE track go to the lower slot index; if that slot is not
|
|
296
|
+
// the one speaking in this chunk, step 2b withholds it (it has no exclusive frames here) and the
|
|
297
|
+
// other slot gets no track either: no label, never the wrong one.
|
|
298
|
+
const candidates: Array<{ slot: number; track: number; count: number }> = []
|
|
299
|
+
for (const [slot, row] of votes) for (const [track, count] of row) candidates.push({ slot, track, count })
|
|
300
|
+
candidates.sort((a, b) => b.count - a.count || a.slot - b.slot || a.track - b.track)
|
|
301
|
+
const slotTrack = new Map<number, number>()
|
|
302
|
+
const usedTracks = new Set<number>()
|
|
303
|
+
for (const c of candidates) {
|
|
304
|
+
if (slotTrack.has(c.slot) || usedTracks.has(c.track)) continue
|
|
305
|
+
if (c.count < MAC_WINDOW_CARRY_MIN_FRAMES || c.count / exclusiveInOverlap.get(c.slot)! < MAC_WINDOW_CARRY_AGREEMENT) continue
|
|
306
|
+
slotTrack.set(c.slot, c.track)
|
|
307
|
+
usedTracks.add(c.track)
|
|
308
|
+
}
|
|
309
|
+
const carried = slotTrack.size
|
|
310
|
+
// 2b. The current chunk must agree (QA W1). A slot whose overlap says "track T"
|
|
311
|
+
// while this chunk's own audio says "two voices here" is not carried: it gets
|
|
312
|
+
// no label and no proof, and its frames below are stored untracked.
|
|
313
|
+
const exclusiveNew = new Map<number, number>()
|
|
314
|
+
const sharedNew = new Map<number, number>()
|
|
315
|
+
for (let i = 0; i < length && start + i < window.frames; i++) {
|
|
316
|
+
const slots = activeSlots(window, start + i)
|
|
317
|
+
if (slots.length === 1) exclusiveNew.set(slots[0], (exclusiveNew.get(slots[0]) ?? 0) + 1)
|
|
318
|
+
else for (const s of slots) sharedNew.set(s, (sharedNew.get(s) ?? 0) + 1)
|
|
319
|
+
}
|
|
320
|
+
let withheld = 0
|
|
321
|
+
for (const slot of [...slotTrack.keys()]) {
|
|
322
|
+
// A carried slot that is silent here (no exclusive frame) has nothing to label or prove this chunk: it
|
|
323
|
+
// simply is not used, and is not a disagreement. A speaking one must be agreed with.
|
|
324
|
+
if ((exclusiveNew.get(slot) ?? 0) === 0) { slotTrack.delete(slot); continue }
|
|
325
|
+
if (chunkAgreesWithSlot(window, start, length, slot, input.chunkAlone)) continue
|
|
326
|
+
slotTrack.delete(slot)
|
|
327
|
+
withheld++
|
|
328
|
+
}
|
|
329
|
+
// 3. New tracks: only a slot with no attributed overlap at all (it may be an
|
|
330
|
+
// existing voice the crosswalk could not prove; then it stays untracked).
|
|
331
|
+
let created = 0
|
|
332
|
+
let capped = 0
|
|
333
|
+
for (const [slot, n] of [...exclusiveNew.entries()].sort((a, b) => a[0] - b[0])) {
|
|
334
|
+
if (slotTrack.has(slot) || votes.has(slot) || n < MAC_WINDOW_NEW_TRACK_FRAMES) continue
|
|
335
|
+
// The lowest id no track holds. An evicted id is free: no recent frame refers to it.
|
|
336
|
+
let id = 0
|
|
337
|
+
while (id < MAC_WINDOW_MAX_TRACKS && state.tracks.some(t => t.id === id)) id++
|
|
338
|
+
if (id >= MAC_WINDOW_MAX_TRACKS) { capped++; continue }
|
|
339
|
+
state.tracks.push({ id, voice: null, proofs: [] })
|
|
340
|
+
state.tracks.sort((a, b) => a.id - b.id)
|
|
341
|
+
state.nextTrack = Math.max(state.nextTrack, id + 1)
|
|
342
|
+
slotTrack.set(slot, id)
|
|
343
|
+
created++
|
|
344
|
+
}
|
|
345
|
+
// 4. Proof for this chunk, then confirmation.
|
|
346
|
+
let confirmed = 0
|
|
347
|
+
for (const [slot, track] of slotTrack) {
|
|
348
|
+
const ex = exclusiveNew.get(slot) ?? 0
|
|
349
|
+
const purity = ex / Math.max(1, ex + (sharedNew.get(slot) ?? 0))
|
|
350
|
+
const t = trackById(track)
|
|
351
|
+
if (ex < MAC_WINDOW_PROOF_FRAMES || purity < MAC_WINDOW_PROOF_PURITY || t.voice !== null) continue
|
|
352
|
+
if (t.proofs.some(p => p.chunk === current.chunk || p.audioSha256 === audioSha256)) continue
|
|
353
|
+
t.proofs.push({ chunk: current.chunk, audioSha256 })
|
|
354
|
+
if (t.proofs.length >= MAC_WINDOW_PROOFS) {
|
|
355
|
+
if (state.voices >= MAX_VOICE) { t.proofs.pop(); continue }
|
|
356
|
+
t.voice = ++state.voices
|
|
357
|
+
confirmed++
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// The labels this chunk may show, taken before any eviction.
|
|
361
|
+
const labelByTrack = new Map<number, number | null>(state.tracks.map(t => [t.id, t.voice]))
|
|
362
|
+
// 5. Remember this chunk's frames for the next window (bounded, no audio).
|
|
363
|
+
const codes = new Int8Array(length)
|
|
364
|
+
for (let i = 0; i < length; i++) {
|
|
365
|
+
const slots = start + i < window.frames ? activeSlots(window, start + i) : []
|
|
366
|
+
codes[i] = slots.length === 0 ? NONE : slots.length > 1 ? OVERLAP : (slotTrack.get(slots[0]) ?? UNTRACKED)
|
|
367
|
+
}
|
|
368
|
+
state.recent = [...state.recent.filter(r => r.chunk !== current.chunk), { chunk: current.chunk, frames: encodeFrames(codes) }]
|
|
369
|
+
.sort((a, b) => a.chunk - b.chunk)
|
|
370
|
+
.slice(-(MAC_WINDOW_MAX_CHUNKS - 1))
|
|
371
|
+
// 6. Evict (QA W3): a track no remembered frame refers to can never be carried
|
|
372
|
+
// again, so it only spends an id. Its Voice number stays spent (`voices` never drops).
|
|
373
|
+
const referenced = new Set<number>()
|
|
374
|
+
for (const r of state.recent) for (const c of decodeFrames(r.frames)!) if (c >= 0) referenced.add(c)
|
|
375
|
+
const before = state.tracks.length
|
|
376
|
+
state.tracks = state.tracks.filter(t => referenced.has(t.id))
|
|
377
|
+
const evicted = before - state.tracks.length
|
|
378
|
+
// The chunk's own activity: the same frames a standalone chunk run would have.
|
|
379
|
+
const chunkFrames = Math.min(window.frames - start, macWindowFrameCount(current.samples))
|
|
380
|
+
const chunkActivity: Activity = {
|
|
381
|
+
frames: chunkFrames,
|
|
382
|
+
probs: window.probs.slice(start * NEMOTRON_CHANNELS, (start + chunkFrames) * NEMOTRON_CHANNELS),
|
|
383
|
+
}
|
|
384
|
+
const labelOf = (slot: number): string | null => {
|
|
385
|
+
const track = slotTrack.get(slot)
|
|
386
|
+
const voice = track === undefined ? null : labelByTrack.get(track)
|
|
387
|
+
return voice ? voiceLabel(voice) : null
|
|
388
|
+
}
|
|
389
|
+
return { state, chunkActivity, labelOf, stats: { overlapChunks, carried, withheld, created, confirmed, evicted, capped } }
|
|
390
|
+
}
|
|
@@ -282,7 +282,7 @@ export function toEngineSentences(rows: unknown): FirefliesSentenceInput[] {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
function g2FromSidecar(doc: Record<string, unknown>, sessionId: string): G2RecordingInput | null {
|
|
285
|
-
if (doc.sourceType === 'mac_system_audio') return null //
|
|
285
|
+
if (doc.sourceType === 'mac_system_audio' || doc.sourceType === 'iphone_microphone' || sessionId.startsWith('phone_')) return null // Neither source enters the G2-only blend engine.
|
|
286
286
|
const startMs = numberOr(doc.startTime, Number.NaN)
|
|
287
287
|
const durationMs = numberOr(doc.durationMs, Number.NaN)
|
|
288
288
|
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) return null
|