@gotcos/glasses-server 6.94.1 → 6.95.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/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## 6.95.0 - Speaker merge shadow mode (opt-in, log only)
2
+
3
+ 2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses change required.
4
+
5
+ - **Why:** Mac recordings split one person into many anonymous `Voice N` labels (45 on one 60-minute call). The research canary (`community-1-fluidaudio-v1`, rounds 1 to 3) found a hybrid that fixes this safely on the reference call: production labels stay the base, and FluidAudio's offline Community-1 diarizer only proposes merges behind four gates. On the Oct 27 reference call (11 human-confirmed people) it cut fragmentation from 27 to 18 with no mixed people and the error proxy from 33% to 3%. Before any write path exists, the proposals have to be scored against Miles's own corrections, so this release only computes and logs them.
6
+ - **What:** with shadow mode on, every Mac recording whose finalization completes is queued. When no recording, save, finalization, one-shot transcription or dictation is live, a niced child process rebuilds the whole-call timeline from the retained chunk WAVs (each verified against the sidecar's `audioSha256`), runs the Parakeet install's `fluidaudiocli process --mode offline --overlapping-segments --export-embeddings` under `sandbox-exec` with network denied, applies the research gates (a port of `hybrid.decide()`), and appends one record per meeting to `<data>/speaker-shadow/proposals.jsonl`: accepted and rejected proposals with reasons, gate values, the post-hoc anchor ratio (reported, never decisive), co-occurring named labels as hints, and listen pointers by chunk and time (no transcript text). A readable report per finished meeting lands in `<data>/speaker-shadow/reports/<YYYY-MM-DD>/<title>__<session>.md`: "N voices would become M", each merge with its gate values and listen pointers (chunk file, time and words), rejections with reasons, and name hints ("this group also holds 65 s labelled ..."). `GET /api/speaker-shadow/proposals` and `/status` read them (token required); while it is on, `/api/health` reports `speaker_shadow` (absent when off, so the health shape is unchanged by default).
7
+ - **Log only:** nothing writes a meeting file, label, name, profile, embedding cache or correction ledger. Pinned by a data-home snapshot test (every file byte-identical after a run), an import allowlist and pinned write-call counts for every shadow module, and mutation-tested.
8
+ - **Exact port:** the TypeScript gates reproduce the Python tool's Driving decisions exactly (45 voices to 27, 29 proposed, 18 accepted, 11 rejected, every reason string and unrounded outlier distance bit for bit), and a real-CLI run of the TypeScript pipeline on the research copies reproduces the timeline PCM and WAV SHA-256 and the full round-3 report with zero differences. Python's half-to-even rounding and %-formatting, CPython 3.12+ compensated `sum()`, dict order and code-point string order are replicated and tested against CPython 3.15 vectors.
9
+ - **Switch, per Mac:** `POST /api/speaker-shadow/settings {"enabled":true}` (token required, no restart) saves it in `<data>/speaker-shadow/settings.json`; `COS_SPEAKER_SHADOW=1|0` pins it instead (409 `env_forced`); otherwise it is off. The package default is off for everyone, so installing this version changes nothing until a Mac turns it on.
10
+ - **How it stops:** the switch (default off; off creates nothing; off mid-run stops the run), a daily cap (12), a breaker (3 failures pause 1 hour), a 180 s wall clock and an RSS cap (3 GB) on the whole process group, and live work, which defers it or stops a run mid-way and requeues the meeting. No LLM; no network at run time.
11
+ - **Models:** never downloaded by the server. The pinned `FluidInference/speaker-diarization-coreml@df2625ac` files must be present (`models_missing` is logged otherwise); every file is hashed before every run and linked read-only into the CLI's private HOME.
12
+ - **Known limits:** a recording's first run with a cold CoreML cache leaves up to four empty `NSIRD_fluidaudiocli_*` folders in the per-user temp dir (counted in the record, not removed). Recovered (orphan) Mac captures are not queued. The gates were fixed on one truth-labeled call; scoring the logged proposals is the next step.
13
+ - **Switches:** the per-Mac setting and `COS_SPEAKER_SHADOW` (default off), `COS_SPEAKER_SHADOW_DAILY_RUNS`, `COS_SPEAKER_SHADOW_TIMEOUT_MS` (may only lower 180 s), `COS_SPEAKER_SHADOW_RSS_MB`, `COS_SPEAKER_SHADOW_MODELS`. No default-on change.
14
+
15
+ ## 6.94.2 - Scoped profile matches for saved Mac recordings
16
+
17
+ 2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses package change required.
18
+
19
+ - Saved Mac recordings can match existing profiles using two independent, coherent long audio windows. Names apply only to supported spans at the unchanged .88 identity floor and .05 separation margin. This does not enroll or train profiles.
20
+ - Durable repair evidence distinguishes machine matches from human confirmations and protects words, timestamps, existing corrections, retained audio and interrupted-save recovery. The review no longer treats one scored occurrence as proof for every repaired occurrence.
21
+ - Existing named spans no longer prevent anonymous refinement of the remainder of a strict Mac recording. Historical recordings require explicit validated repair; upgrading does not silently rewrite them.
22
+ - Known limitation: live numbering still loses continuity across long gaps, and some anonymous groups contain mixed voices. This release does not claim full-call deduplication, an attendee count, or recognition of every speaker.
23
+ - Qualification: 9,704 tests passed, 14 skipped, zero failed; typecheck passed. Isolated natural-recording runs preserved all speech and timestamps, accepted exact supported spans, and withheld incoherent or insufficient matches.
24
+
1
25
  ## 6.94.1 - Mac filing waits for a pipeline that can title a Mac recording
2
26
 
3
27
  2026-10-27. Pairs with COS operations `sync_meetings.py` on branch `claude/mac-library-cos` (the `mac_title: 1` capability). Works with COS Control 0.5.351; Control needs no change.
package/README.md CHANGED
@@ -699,6 +699,81 @@ flag a Mac rename is text only. `GET /api/health` reports `capabilities.macLiveV
699
699
  The saved meeting is relabeled by the whole-meeting pass. Live numbers are kept;
700
700
  voices the live path did not prove are numbered above them at save.
701
701
 
702
+ ### Saved Mac profile reconciliation (6.94.2)
703
+
704
+ After transcription and diarization, eligible Mac recordings receive a bounded
705
+ local voice-profile pass. It checks up to twelve 28–30 second windows across the
706
+ call against the existing profile cohort. Two independent windows must agree;
707
+ each needs profile similarity of at least .88, a .05 margin over alternatives,
708
+ and coherent full/half-window voice evidence. Only the supported audio spans
709
+ receive a name. Other occurrences of the same live Voice number remain unchanged.
710
+
711
+ This pass does not train profiles, infer identities from attendees, or change the
712
+ transcript words and timestamps. Existing human corrections are protected. A
713
+ durable journal preserves recovery across interrupted writes; retained audio is
714
+ not released over an unfinished repair. Unsupported or ambiguous evidence leaves
715
+ the existing labels in place. The pass uses one bounded CPU worker and can skip
716
+ when its budget or prerequisites are unavailable.
717
+
718
+ The speaker review distinguishes qualified machine matches from human
719
+ confirmations. A pre-existing named span no longer prevents anonymous refinement
720
+ of the rest of a strict Mac recording. Live voice numbering still has the window
721
+ limits above: this release does not promise meeting-wide speaker deduplication
722
+ or an attendee count. Older recordings require an explicit, validated repair;
723
+ they are not silently rewritten on upgrade.
724
+
725
+ ### Speaker merge shadow mode (6.95.0, off by default, log only)
726
+
727
+ Mac recordings can split one person across many `Voice N` labels. Shadow mode asks
728
+ FluidAudio's offline Community-1 diarizer which of those labels belong together and
729
+ **logs** the answer for every finished Mac recording, with a readable report per meeting,
730
+ so the proposals can be checked by ear and against later human corrections. It changes
731
+ nothing: no meeting file, label, name, voice profile, embedding cache or correction ledger
732
+ is written.
733
+
734
+ - **On, for this Mac only** (no restart; the token is `COS_API_TOKEN` in `~/.cos-glasses/.env`):
735
+
736
+ ```sh
737
+ curl -s -X POST http://127.0.0.1:3141/api/speaker-shadow/settings \
738
+ -H "X-Cos-Token: $(sed -n 's/^COS_API_TOKEN=//p' ~/.cos-glasses/.env)" \
739
+ -H 'Content-Type: application/json' -d '{"enabled":true}'
740
+ ```
741
+
742
+ **Off:** the same with `{"enabled":false}`. The setting lives in
743
+ `~/.cos-glasses/data/speaker-shadow/settings.json`. `COS_SPEAKER_SHADOW=1` or `=0` in the
744
+ environment pins it instead (the POST then answers `409 env_forced`). With neither, it is
745
+ off, and off does nothing at all: no queue, no process, no file. Meetings already finished
746
+ when it is turned on are not queued; turning it off mid-run stops the run.
747
+ - **Needs:** the Parakeet install's verified `fluidaudiocli` 0.17.4, `sandbox-exec`, and the
748
+ pinned models from `FluidInference/speaker-diarization-coreml` at revision `df2625ac` in
749
+ `~/.cos-glasses/data/speaker-shadow/models/speaker-diarization/` (or
750
+ `COS_SPEAKER_SHADOW_MODELS`). The server never downloads them: missing or mismatched
751
+ models are logged as `models_missing` or `models_mismatch`. `GET /api/speaker-shadow/status`
752
+ shows `readiness` (models present, CLI installed and how it is trusted, sandbox). The models
753
+ are CC-BY-4.0 (scoped); see the repo's NOTICE.md.
754
+ - **When:** after a Mac recording's finalization is complete. A live recording, save,
755
+ batch finalization, one-shot transcription or dictation defers it, and one that starts
756
+ mid-run stops the run and puts the meeting back in the queue.
757
+ - **How it stops:** the switch, a daily cap (`COS_SPEAKER_SHADOW_DAILY_RUNS`, default 12), a
758
+ breaker (3 failures in a row pause it for 1 hour), a 180 s hard wall clock
759
+ (`COS_SPEAKER_SHADOW_TIMEOUT_MS` may only lower it) and an RSS cap on the whole run
760
+ (`COS_SPEAKER_SHADOW_RSS_MB`, default 3072). It runs in a niced child process, never on the
761
+ server's event loop. No LLM, and the CLI runs under `sandbox-exec` with network denied.
762
+ - **Gates** (the research tool's, unchanged): at least 0.8 of a voice's covered speech on one
763
+ cluster with at least half of it covered, at least 15 s of speech, and the voice's
764
+ embedding centroid inside the cluster's 95th-percentile spread (leave-fragment-out). Named
765
+ labels and `Ext` are never merged into or out of. The anchor ratio is reported for
766
+ scoring and decides nothing.
767
+ - **Read:** one report per finished Mac meeting in
768
+ `~/.cos-glasses/data/speaker-shadow/reports/<YYYY-MM-DD>/<title>__<session>.md`: "N voices
769
+ would become M", each accepted merge with its seconds, gate values and listen pointers
770
+ (chunk file, time, the chunk's words), rejections with reasons, and named labels sharing a
771
+ group as a hint only ("this group also holds 65 s labelled ..."). A meeting with no
772
+ proposals gets a one-line page saying why. The records are in `proposals.jsonl` beside it
773
+ (rotated past 8 MB) and `GET /api/speaker-shadow/proposals?limit=10&sessionId=mac_<uuid>`
774
+ (token required). While it is on, `/api/health` carries `speaker_shadow` (codes and counts
775
+ only); when off the key is absent.
776
+
702
777
  ### iPhone microphone meetings (6.93.0, off by default)
703
778
 
704
779
  With `COS_IPHONE_MICROPHONE_MEETINGS=1`, HQ set to Parakeet Ultra and Parakeet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.94.1",
3
+ "version": "6.95.0",
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": {
@@ -58,6 +58,7 @@
58
58
  "!server/scripts/routines-mutation-gate.mjs",
59
59
  "!server/scripts/routines-canary.ts",
60
60
  "!server/scripts/stt-mutation-gate.mjs",
61
+ "!server/scripts/speaker-shadow-mutation-gate.mjs",
61
62
  "!server/scripts/dictation-stt-mutation-gate.mjs",
62
63
  "!server/scripts/pairing-mutation-gate.mjs",
63
64
  "!server/scripts/away-hold-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -74,7 +74,7 @@ import { ForkJobLedger, fileForkJobPersistence } from './lib/fork-job-ledger.js'
74
74
  import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './lib/native-head.js'
75
75
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
76
76
  import { displayRouter } from './routes/display.js'
77
- import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
77
+ import { transcribeStreamRouter, getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './routes/transcribe-stream.js'
78
78
  import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
79
79
  import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
80
80
  import { meetingsRouter } from './routes/meetings.js'
@@ -110,6 +110,8 @@ import { voiceRouter } from './routes/voice.js'
110
110
  import { glossaryRouter } from './routes/glossary.js'
111
111
  import { homeAreaRouter } from './routes/home-area.js'
112
112
  import { createSttSettingsRouter } from './routes/stt-settings.js'
113
+ import { createSpeakerShadowRouter } from './routes/speaker-shadow.js'
114
+ import { configureSpeakerShadow, speakerShadowLeaseActive } from './lib/speaker-shadow.js'
113
115
  import { setRecordingLiveProbe, startPreviewWarmLoop, stopPreviewWarmLoop } from './lib/stt-runtime.js'
114
116
  import { findPinnedCli, killParakeetChildren, sweepParakeetStaging, sweepStaleParakeetTmp } from './lib/parakeet.js'
115
117
  import { handoffsRouter } from './routes/handoffs.js'
@@ -1229,6 +1231,8 @@ app.use('/api', voiceRouter)
1229
1231
  app.use('/api', glossaryRouter)
1230
1232
  app.use('/api', homeAreaRouter)
1231
1233
  app.use('/api', createSttSettingsRouter({ recordingLive: () => getActiveTranscriptionSessionCount() > 0 }))
1234
+ // 6.95.0: speaker merge shadow mode, read only (GET proposals and status).
1235
+ app.use('/api', createSpeakerShadowRouter())
1232
1236
  app.use('/api', handoffsRouter)
1233
1237
  app.use('/api', recoveryRouter)
1234
1238
  app.use('/api', promptEditRouter)
@@ -1541,6 +1545,14 @@ listenRequiredServers(listeners).then(() => {
1541
1545
  // defer while a capture is live or admissions are closed.
1542
1546
  startMeetingMergeScheduler()
1543
1547
 
1548
+ // 6.95.0: speaker merge shadow mode (off unless COS_SPEAKER_SHADOW=1; log only). Live
1549
+ // work is a recording that is still live, or any capture or dictation lease; a drain
1550
+ // or shutdown also holds it. Until this runs, the runner never starts a diarization.
1551
+ configureSpeakerShadow({
1552
+ captureActive: () => getTranscriptionSessionLiveness().live > 0 || speakerShadowLeaseActive(),
1553
+ admissionsOpen: () => !gracefulShutdownStarted && maintenanceAdmissionsOpen(),
1554
+ })
1555
+
1544
1556
  // AFTER the imports sweep, not before: the sweep removes a killed write's
1545
1557
  // debris, and re-driving a pending apply into a half-written decision is
1546
1558
  // exactly what the sweep exists to prevent. A pending action is one a
@@ -0,0 +1,111 @@
1
+ /** Read-only bridge from a labels-only repair receipt to scoped machine claims.
2
+ * Capture similarities, human confirmations and profile training are untouched.
3
+ * Persisted bytes are trusted only after current speech/raw-map/receipt checks.
4
+ */
5
+ import {
6
+ hashMacSpeakerDocument, hashMacSpeakerMachineBinding, validateMacSpeakerTargetEvidence,
7
+ type MacSpeakerMachineEvidence, type MacSpeakerRepairPlan,
8
+ } from './mac-speaker-repair-projection.js'
9
+ import { checkSpeakerName } from './speaker-name.js'
10
+ type Doc = Record<string, any>
11
+ const SHA = /^[a-f0-9]{64}$/
12
+ const uint = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
13
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
14
+ function refuse(reason: string): never { throw new Error(reason) }
15
+ export interface QualifiedMachinePosition {
16
+ label: string
17
+ rawChunkIndex: number
18
+ evidenceId: string
19
+ receiptHash: string
20
+ modelSHA256: string
21
+ profileSHA256: string
22
+ algorithmVersion: string
23
+ independentWindows: number
24
+ minimumSimilarity: number
25
+ minimumMargin: number
26
+ }
27
+ export interface MacMachineEvidenceRead {
28
+ status: 'absent' | 'qualified' | 'invalid'
29
+ reason?: string
30
+ /** Compacted positions, obtained by the strict raw join below. Never guess
31
+ * positions from raw indexes, text similarity or repeated phrases. */
32
+ positions: Map<number, QualifiedMachinePosition>
33
+ }
34
+ export function readMacSpeakerMachineEvidence(value: unknown): MacMachineEvidenceRead {
35
+ const positions = new Map<number, QualifiedMachinePosition>()
36
+ const doc = value as Doc | null
37
+ const evidence = doc?.speakerRepair?.machineEvidence as MacSpeakerMachineEvidence | undefined
38
+ if (!evidence) return { status: 'absent', positions }
39
+ try {
40
+ if (!doc || doc.sourceType !== 'mac_system_audio' || typeof doc.sessionId !== 'string' || !doc.sessionId.startsWith('mac_')
41
+ || doc.finalizationState !== 'complete' || doc.claimPending === true || doc.active === true || doc.isActive === true || doc.isMerged || doc.mergedFrom || doc.sourceSessionIds) refuse('source_not_closed_original')
42
+ // Deliberately global: any later human revision invalidates all machine
43
+ // claims until requalification. Real ledger confirmations still prevail.
44
+ if (evidence.version !== 1 || !uint(evidence.correctionRevision) || evidence.correctionRevision < 1 || doc.correctionRevision !== evidence.correctionRevision) refuse('revision_drift')
45
+ if (!SHA.test(evidence.documentBinding) || hashMacSpeakerMachineBinding(doc) !== evidence.documentBinding) refuse('speech_binding_mismatch')
46
+ const receipt = evidence.receipt
47
+ if (!receipt || receipt.version !== 1 || !SHA.test(receipt.receiptHash) || receipt.id !== doc.speakerRepair.id || receipt.evidenceHash !== doc.speakerRepair.evidenceHash
48
+ || !SHA.test(receipt.evidenceHash) || !Array.isArray(receipt.assignments) || !receipt.assignments.length || receipt.assignments.length > 65536
49
+ || !Array.isArray(receipt.patches) || receipt.patches.length > 1_000_000) refuse('receipt_invalid')
50
+ const { receiptHash, ...unsigned } = receipt
51
+ if (hashMacSpeakerDocument(unsigned) !== receiptHash) refuse('receipt_invalid')
52
+ if (!Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.chunks.length > 65536 || doc.chunkEntries.length > 65536) refuse('raw_map_ambiguous')
53
+ const byRaw = new Map<number, { position: number; chunk: Doc }>()
54
+ let position = 0, previous = -1
55
+ for (const entry of doc.chunkEntries) {
56
+ if (!entry || !uint(entry.chunkIndex) || entry.chunkIndex <= previous) refuse('raw_map_ambiguous')
57
+ previous = entry.chunkIndex
58
+ if (typeof entry.chunk?.text !== 'string' || !entry.chunk.text.trim()) continue
59
+ const chunk = doc.chunks[position]
60
+ if (!chunk || ['text', 'speaker', 'elapsed', 'audioDurationMs', 'audioSha256', 'segments', 'finalSegments', 'words'].some(key => !equal(chunk[key], entry.chunk[key]))) refuse('raw_map_ambiguous')
61
+ byRaw.set(entry.chunkIndex, { position, chunk }); position++
62
+ }
63
+ if (position !== doc.chunks.length) refuse('raw_map_ambiguous')
64
+ const source = structuredClone(doc)
65
+ // Reconstruct only the original label surfaces from the validated receipt.
66
+ // Lifecycle/prose/profile metadata are never inverse-applied by this reader.
67
+ const paths = new Set<string>()
68
+ for (const patch of [...receipt.patches].reverse()) {
69
+ if (!Array.isArray(patch.path) || !patch.path.length || patch.path.length > 8 || patch.path.some(k => !['string', 'number'].includes(typeof k) || ['__proto__', 'prototype', 'constructor'].includes(String(k)))) refuse('receipt_invalid')
70
+ const rootSpeakers = patch.path.length === 1 && patch.path[0] === 'speakers'
71
+ if (!rootSpeakers && (!['chunks', 'chunkEntries', 'batchSegments'].includes(String(patch.path[0])) || patch.path.at(-1) !== 'speaker')) refuse('receipt_invalid')
72
+ const key = JSON.stringify(patch.path)
73
+ if (paths.has(key)) refuse('receipt_invalid')
74
+ paths.add(key)
75
+ let row = source
76
+ for (const part of patch.path.slice(0, -1)) {
77
+ if (!row || typeof row !== 'object' || !Object.hasOwn(row, part)) refuse('receipt_invalid')
78
+ row = row[part]
79
+ }
80
+ const last = patch.path.at(-1)!
81
+ if (!row || !equal(row[last], patch.after)) refuse('receipt_label_drift')
82
+ if (patch.before === undefined) delete row[last]
83
+ else row[last] = structuredClone(patch.before)
84
+ }
85
+ if (!evidence.rawAudioSampleCounts || typeof evidence.rawAudioSampleCounts !== 'object') refuse('receipt_invalid')
86
+ const plan: MacSpeakerRepairPlan = { id: receipt.id, evidenceHash: receipt.evidenceHash, expectedDocumentHash: receipt.beforeHash,
87
+ protectedRawChunkIndices: [], rawAudioSampleCounts: evidence.rawAudioSampleCounts, assignments: receipt.assignments, withheldBatchWords: receipt.withheldBatchWords }
88
+ const seen = new Set<number>()
89
+ for (const assignment of receipt.assignments) {
90
+ if (!uint(assignment.rawChunkIndex) || seen.has(assignment.rawChunkIndex)) refuse('receipt_invalid')
91
+ seen.add(assignment.rawChunkIndex)
92
+ const proof = assignment.targetEvidence
93
+ if (!proof) continue // Anonymous recurrence cannot authorize a name.
94
+ if (!checkSpeakerName(assignment.to).ok || !proof.windows?.length || proof.windows.length > 128) refuse('proof_rejected')
95
+ try { validateMacSpeakerTargetEvidence(source, plan, assignment) } catch { refuse('proof_rejected') }
96
+ const current = byRaw.get(assignment.rawChunkIndex)
97
+ if (!current || current.chunk.speaker !== assignment.to || !SHA.test(current.chunk.audioSha256)) refuse('receipt_label_drift')
98
+ const restored = source.chunkEntries.find((entry: Doc) => entry.chunkIndex === assignment.rawChunkIndex)?.chunk
99
+ if (restored?.speaker !== assignment.from) refuse('receipt_label_drift')
100
+ positions.set(current.position, {
101
+ label: assignment.to, rawChunkIndex: assignment.rawChunkIndex, evidenceId: assignment.evidenceId, receiptHash,
102
+ modelSHA256: proof.modelSha256, profileSHA256: proof.profileSha256, algorithmVersion: proof.algorithmVersion,
103
+ independentWindows: proof.windows.length, minimumSimilarity: Math.min(...proof.windows.map(w => w.similarity)),
104
+ minimumMargin: Math.min(...proof.windows.map(w => w.similarity - w.runnerUpSimilarity)),
105
+ })
106
+ }
107
+ return { status: 'qualified', positions }
108
+ } catch (error) {
109
+ return { status: 'invalid', positions: new Map(), reason: error instanceof Error ? error.message : 'proof_rejected' }
110
+ }
111
+ }
@@ -0,0 +1,41 @@
1
+ /** Isolated, killable read-only extraction. Native model work never runs on the
2
+ * server event loop and never reaches recognition/enrollment/calibration APIs. */
3
+ import { spawn } from 'node:child_process'
4
+ import { createRequire } from 'node:module'
5
+ const require = createRequire(import.meta.url)
6
+ const worker = `
7
+ const fs = require('node:fs');
8
+ const sherpa = require(process.argv[1]);
9
+ const model = process.argv[2];
10
+ const extractor = new sherpa.SpeakerEmbeddingExtractor({model, numThreads:1, provider:'cpu'});
11
+ let size=0, parts=[];
12
+ process.stdin.on('data', b=>{size+=b.length;if(size>960000)process.exit(2);parts.push(b)});
13
+ process.stdin.on('end',()=>{
14
+ const pcm=Buffer.concat(parts); if(pcm.length<896000||pcm.length%2)process.exit(3);
15
+ const embed=(b)=>{const samples=new Float32Array(b.length/2);for(let i=0;i<samples.length;i++)samples[i]=b.readInt16LE(i*2)/32768;
16
+ const stream=extractor.createStream();stream.acceptWaveform({samples,sampleRate:16000});stream.inputFinished();
17
+ if(!extractor.isReady(stream))throw Error('not ready');return Array.from(extractor.compute(stream));};
18
+ const half=Math.floor(pcm.length/4)*2; process.stdout.write(JSON.stringify([embed(pcm),embed(pcm.subarray(0,half)),embed(pcm.subarray(half))]));
19
+ });`
20
+ export async function extractQualifiedMacWindow(pcm: Buffer, modelPath: string, timeoutMs: number, deps: { spawn?: typeof spawn; modulePath?: string } = {}): Promise<readonly number[][] | null> {
21
+ if (pcm.length < 896000 || pcm.length > 960000 || pcm.length % 2 || timeoutMs <= 0) return null
22
+ let modulePath: string
23
+ try { modulePath = deps.modulePath ?? require.resolve('sherpa-onnx-node') } catch { return null }
24
+ return new Promise(resolve => {
25
+ const child = (deps.spawn ?? spawn)(process.execPath, ['-e', worker, modulePath, modelPath], { stdio: ['pipe', 'pipe', 'pipe'] })
26
+ const output: Buffer[] = []; let size = 0, settled = false, aborted = false
27
+ const finish = (result: readonly number[][] | null) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result) }
28
+ const abort = () => { aborted = true; child.kill('SIGKILL') }
29
+ // Keep ownership until close confirms native termination. Releasing on
30
+ // kill() alone permits a second native job while the first still exits.
31
+ const timer = setTimeout(abort, timeoutMs)
32
+ child.stdout.on('data', b => { size += b.length; if (size > 131072) abort(); else if (!aborted) output.push(Buffer.from(b)) })
33
+ // Drain diagnostics without retaining native stderr or logging private input.
34
+ child.stderr.on('data', () => {})
35
+ child.on('error', () => { aborted = true; if (!child.pid) finish(null); else child.kill('SIGKILL') }); child.stdin.on('error', () => {})
36
+ child.on('close', code => {
37
+ try { const rows = JSON.parse(Buffer.concat(output).toString()); finish(!aborted && code === 0 && Array.isArray(rows) && rows.length === 3 ? rows : null) } catch { finish(null) }
38
+ })
39
+ child.stdin.end(pcm)
40
+ })
41
+ }
@@ -0,0 +1,257 @@
1
+ /** Qualified, named, exact-window finalization only. No anonymous merging,
2
+ * enrollment, guessed participant count, model-channel naming or lower floor.
3
+ * A durable attempt and two-file before/after journal make retries fail closed. */
4
+ import { createHash } from 'node:crypto'
5
+ import { existsSync, lstatSync, readFileSync } from 'node:fs'
6
+ import { readFile, stat } from 'node:fs/promises'
7
+ import { join, resolve } from 'node:path'
8
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
9
+ import { MAC_CAPTURE_ID } from './mac-recording.js'
10
+ import { dataPath } from './data-dir.js'
11
+ import { readPcm16Mono, wavHeader } from './nemotron-segments.js'
12
+ import { planMacSpeakerWindows, qualifyMacSpeakerWindows, type VerifiedMacRawChunk, type MacWindowEmbeddingReceipt } from './mac-speaker-windows.js'
13
+ import { normalizedSpeakerCosine, type MacSpeakerProfile } from './mac-speaker-reconcile.js'
14
+ import { hashMacSpeakerDocument, hashMacSpeakerMachineBinding, projectMacSpeakerRepair, type MacProfileMatchEvidence, type MacSpeakerRepairAssignment, type MacSpeakerWordBinding } from './mac-speaker-repair-projection.js'
15
+ import { boundWords, renderBoundSpeakerLabels, renderMarkdown } from './mac-speaker-repair-render.js'
16
+ import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
17
+ import { extractQualifiedMacWindow } from './mac-speaker-profile-extractor.js'
18
+ type Doc = Record<string, any>
19
+ const sha = (v: string | Buffer) => createHash('sha256').update(v).digest('hex')
20
+ const yieldLoop = () => new Promise<void>(r => setImmediate(r))
21
+ const MAX_WINDOWS = 12, MAX_RAW = 2048, MAX_MS = 90_000
22
+ export const MAX_PROFILE_BYTES = 32 * 1024 * 1024
23
+ function fail(reason: string): never { throw new Error(`qualified Mac profile pass: ${reason}`) }
24
+ export interface QualifiedMacProfileInput { sidecarPath: string; meetingPath: string; audioDir: string; resumeOnly?: boolean }
25
+ export interface QualifiedMacProfileDeps {
26
+ /** Test seams never invoke native inference. Production freezes the complete cohort. */
27
+ runtime?: () => Promise<{ modelPath: string; profilesPath: string } | null>
28
+ extract?: (pcm: Buffer, modelPath: string, timeoutMs: number) => Promise<readonly number[][] | null>
29
+ ledgerPath?: (sessionId: string) => string
30
+ now?: () => number
31
+ beforeCommit?: () => void
32
+ afterWrite?: (index: number) => void
33
+ }
34
+ export interface QualifiedMacProfileOutcome { status: 'applied' | 'skipped'; reason: string; assignments?: number }
35
+ interface Image { path: string; before: string; after: string }
36
+ interface Journal { version: 1; status: 'attempted' | 'prepared' | 'complete'; sessionId: string; sourceSidecarHash: string; imageBinding?: string; rawBindings?: Array<{ name: string; sha256: string }>; reason?: string; images?: Image[]; ledgerPath: string; ledgerHash: string | null; profilePath: string; profileHash: string; modelPath: string; modelHash: string }
37
+ const active = new Set<string>()
38
+ let extractionBusy = false
39
+ function regularRead(path: string, max = 12 * 1024 * 1024): string {
40
+ const s = lstatSync(path)
41
+ if (!s.isFile() || s.isSymbolicLink() || s.size > max) fail('unsafe or oversized file')
42
+ return readFileSync(path, 'utf8')
43
+ }
44
+ function ledgerHash(path: string): string | null {
45
+ try { return sha(regularRead(path, 4 * 1024 * 1024)) } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e }
46
+ }
47
+ function ledgerClear(path: string): boolean {
48
+ const digest = ledgerHash(path)
49
+ if (digest === null) return true
50
+ // Every historical human action (including intent/revert) protects the capture.
51
+ return regularRead(path).trim() === ''
52
+ }
53
+
54
+ function strictPcm(wav: Buffer) {
55
+ if (wav.length < 44 || wav.readUInt32LE(4) !== wav.length - 8 || wav.toString('ascii', 12, 16) !== 'fmt ' || wav.readUInt32LE(16) !== 16 || wav.readUInt16LE(20) !== 1 || wav.readUInt16LE(22) !== 1 || wav.readUInt32LE(24) !== 16000 || wav.readUInt32LE(28) !== 32000 || wav.readUInt16LE(32) !== 2 || wav.readUInt16LE(34) !== 16 || wav.toString('ascii', 36, 40) !== 'data' || wav.readUInt32LE(40) !== wav.length - 44 || (wav.length - 44) % 2) return null
56
+ return readPcm16Mono(wav)
57
+ }
58
+ async function runtimeDefault() {
59
+ const { speakerModelState } = await import('./speaker-embeddings.js')
60
+ const model = speakerModelState()
61
+ return model.state === 'active' && model.path ? { modelPath: model.path, profilesPath: dataPath('voice-profiles.json') } : null
62
+ }
63
+ function recover(journalPath: string, journal: Journal, input: QualifiedMacProfileInput, deps: QualifiedMacProfileDeps): QualifiedMacProfileOutcome {
64
+ if (journal.version !== 1 || !MAC_CAPTURE_ID.test(journal.sessionId) || journal.images?.length !== 2
65
+ || journal.images[0].path !== resolve(input.meetingPath) || journal.images[1].path !== resolve(input.sidecarPath)
66
+ || ledgerHash(journal.ledgerPath) !== journal.ledgerHash) fail('journal or human history drift')
67
+ if (journal.status !== 'complete' && (sha(regularRead(journal.profilePath, MAX_PROFILE_BYTES)) !== journal.profileHash || sha(readFileSync(journal.modelPath)) !== journal.modelHash)) fail('journal profile/model drift')
68
+ const images = journal.images
69
+ if (journal.imageBinding !== hashMacSpeakerDocument(images) || sha(images[1].before) !== journal.sourceSidecarHash) fail('journal image integrity drift')
70
+ const beforeDoc = JSON.parse(images[1].before), afterDoc = JSON.parse(images[1].after)
71
+ const proofRead = readMacSpeakerMachineEvidence({ ...afterDoc, finalizationState: 'complete' })
72
+ if (beforeDoc.sessionId !== journal.sessionId || afterDoc.sessionId !== journal.sessionId || proofRead.status !== 'qualified' || !proofRead.positions.size || !afterDoc.speakerRepair?.machineEvidence?.receipt) fail('journal proof rejected')
73
+ const expectedSequence = boundWords(beforeDoc, afterDoc)
74
+ if (renderBoundSpeakerLabels(beforeDoc.batchTranscript, expectedSequence) !== afterDoc.batchTranscript || renderMarkdown(images[0].before, expectedSequence) !== images[0].after) fail('journal word invariant drift')
75
+ if (journal.status !== 'complete') {
76
+ if (!Array.isArray(journal.rawBindings) || !journal.rawBindings.length || journal.rawBindings.length > MAX_RAW) fail('journal raw bindings invalid')
77
+ for (const raw of journal.rawBindings) { if (!/^chunk_\d{4,}\.wav$/.test(raw.name) || sha(readFileSync(join(input.audioDir, raw.name))) !== raw.sha256) fail('journal raw drift') }
78
+ }
79
+ const current = images.map(image => regularRead(image.path))
80
+ if (journal.status !== 'complete' && current.some((bytes, i) => bytes !== images[i].before && bytes !== images[i].after)) fail('journal precondition drift')
81
+ // Completed transactions never reapply over a later change; the exact speech
82
+ // binding/revision guard in readers separately invalidates later stale claims.
83
+ if (journal.status === 'complete') {
84
+ if (current.every((bytes, i) => bytes === images[i].after)) return { status: 'applied', reason: 'already_applied' }
85
+ const saved = JSON.parse(images[1].after), currentDoc = JSON.parse(current[1])
86
+ if (hashMacSpeakerMachineBinding(currentDoc) === hashMacSpeakerMachineBinding(saved) && currentDoc.correctionRevision === saved.correctionRevision && readMacSpeakerMachineEvidence(currentDoc).status === 'qualified') return { status: 'applied', reason: 'already_applied' }
87
+ fail('completed journal changed')
88
+ }
89
+ for (let i = 0; i < images.length; i++) {
90
+ if (ledgerHash(journal.ledgerPath) !== journal.ledgerHash) fail('human history changed')
91
+ for (let j = 0; j < images.length; j++) {
92
+ const bytes = regularRead(images[j].path)
93
+ if (bytes !== (j < i ? images[j].after : current[j])) fail('transaction changed during commit')
94
+ }
95
+ if (current[i] !== images[i].after) durableAtomicWriteFileSync(images[i].path, images[i].after)
96
+ deps.afterWrite?.(i)
97
+ }
98
+ if (images.some(image => regularRead(image.path) !== image.after)) fail('postimage changed')
99
+ journal.status = 'complete'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
100
+ return { status: 'applied', reason: 'qualified_profile' }
101
+ }
102
+ /** Six deterministic adjacent pairs spread across the entire call. Scheduling
103
+ * hints never add identity votes; each pair still needs acoustic qualification. */
104
+ export function selectQualifiedProfileWindows<T>(windows: readonly T[]): T[] {
105
+ if (windows.length <= MAX_WINDOWS) return [...windows]
106
+ const indexes = new Set<number>()
107
+ for (let i = 0; i < MAX_WINDOWS / 2; i++) { const index = Math.floor(i * (windows.length - 2) / (MAX_WINDOWS / 2 - 1)); indexes.add(index); indexes.add(index + 1) }
108
+ return [...indexes].sort((a, b) => a - b).map(i => windows[i])
109
+ }
110
+ /** Called twice by the finalizer: resumeOnly BEFORE any ASR retry, and ordinary
111
+ * mode AFTER ASR+diarization, before raw deletion or downstream copies/sync. */
112
+ export async function runQualifiedMacProfilePass(input: QualifiedMacProfileInput, deps: QualifiedMacProfileDeps = {}): Promise<QualifiedMacProfileOutcome> {
113
+ const key = resolve(input.sidecarPath), journalPath = `${key}.speaker-profile-pass.json`
114
+ if (active.has(key)) return { status: 'skipped', reason: 'active' }
115
+ active.add(key)
116
+ let ownsExtraction = false
117
+ try {
118
+ if (existsSync(journalPath)) {
119
+ const prior = JSON.parse(regularRead(journalPath, 64 * 1024 * 1024)) as Journal
120
+ if (prior.status === 'prepared' || prior.status === 'complete') return recover(journalPath, prior, input, deps)
121
+ if (prior.version !== 1 || prior.status !== 'attempted') fail('invalid attempt journal')
122
+ return { status: 'skipped', reason: prior.reason ?? 'previous_attempt' }
123
+ }
124
+ if (input.resumeOnly) return { status: 'skipped', reason: 'no_transaction' }
125
+ const now = deps.now ?? Date.now, deadline = now() + MAX_MS
126
+ const budget = () => { if (now() >= deadline) fail('budget_exhausted') }
127
+ const beforeSidecar = regularRead(key), source = JSON.parse(beforeSidecar) as Doc
128
+ if (source.sourceType !== 'mac_system_audio' || !MAC_CAPTURE_ID.test(source.sessionId) || !['batch_pending', 'ops_pending', 'complete'].includes(source.finalizationState)
129
+ || source.claimPending || source.active || source.isActive || source.status === 'recording' || source.isMerged || source.mergedFrom || source.sourceSessionIds || source.supersededBy || source.superseded || source.speakerRepair
130
+ || (source.correctionRevision ?? 0) !== 0 || source.batchApplied !== true || !Array.isArray(source.batchSegments) || !Array.isArray(source.chunkEntries) || source.chunkEntries.length > MAX_RAW) return { status: 'skipped', reason: 'source_ineligible' }
131
+ const ledgerPath = deps.ledgerPath?.(source.sessionId) ?? dataPath('meeting-corrections', `${source.sessionId}.jsonl`)
132
+ if (!ledgerClear(ledgerPath)) return { status: 'skipped', reason: 'human_corrected' }
133
+ const ledger = ledgerHash(ledgerPath), beforeMarkdown = regularRead(input.meetingPath, 4 * 1024 * 1024)
134
+ const runtime = await (deps.runtime ?? runtimeDefault)()
135
+ if (!runtime) return { status: 'skipped', reason: 'model_unavailable' }
136
+ const profileBytes = regularRead(runtime.profilesPath, MAX_PROFILE_BYTES), profileSHA256 = sha(profileBytes)
137
+ const cohort = JSON.parse(profileBytes).profiles
138
+ if (!Array.isArray(cohort) || !cohort.length || cohort.length > 1024) return { status: 'skipped', reason: 'profile_cohort_invalid' }
139
+ if ((await stat(runtime.modelPath)).size > 128 * 1024 * 1024) return { status: 'skipped', reason: 'model_oversized' }
140
+ const modelBytes = await readFile(runtime.modelPath); budget()
141
+ if (modelBytes.length > 128 * 1024 * 1024) return { status: 'skipped', reason: 'model_oversized' }
142
+ if (cohort.some(p => !p || typeof p.name !== 'string' || !Array.isArray(p.embeddings) || !p.embeddings.length || p.embeddings.length > 128 || p.embeddings.some((r: unknown) => !Array.isArray(r) || r.length !== 192 || r.some(v => !Number.isFinite(v)))) || cohort.reduce((n, p) => n + p.embeddings.length, 0) > 8192) return { status: 'skipped', reason: 'profile_cohort_invalid' }
143
+ const modelSHA256 = sha(modelBytes)
144
+ const profiles: MacSpeakerProfile[] = cohort.map(p => ({ name: p.name, embeddings: p.embeddings, profileSHA256 }))
145
+ // One bounded native profile job server-wide. A concurrent capture keeps
146
+ // its saved labels rather than multiplying native processes/CPU load.
147
+ if (extractionBusy) return { status: 'skipped', reason: 'profile_lane_busy' }
148
+ extractionBusy = true; ownsExtraction = true
149
+ const journal: Journal = { version: 1, status: 'attempted', sessionId: source.sessionId, sourceSidecarHash: sha(beforeSidecar), ledgerPath, ledgerHash: ledger, profilePath: resolve(runtime.profilesPath), profileHash: profileSHA256, modelPath: resolve(runtime.modelPath), modelHash: modelSHA256 }
150
+ durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
151
+ try {
152
+ const raw: VerifiedMacRawChunk[] = [], counts: Record<string, number> = {}
153
+ for (const entry of source.chunkEntries) {
154
+ budget()
155
+ if (!Number.isSafeInteger(entry.chunkIndex) || entry.chunkIndex < 0) fail('invalid raw index')
156
+ const path = join(input.audioDir, `chunk_${String(entry.chunkIndex).padStart(4, '0')}.wav`)
157
+ const info = await stat(path)
158
+ if (!info.isFile() || info.size > 960044) fail('invalid raw file')
159
+ const wav = await readFile(path), audioSHA256 = sha(wav), pcm = strictPcm(wav)
160
+ if (!pcm || pcm.sampleRate !== 16000 || wav.length > 960044 || (entry.chunk.audioSha256 !== undefined && audioSHA256 !== entry.chunk.audioSha256)) fail('raw hash/format drift')
161
+ const sampleCount = pcm.pcm.length / 2
162
+ counts[entry.chunkIndex] = sampleCount
163
+ raw.push({ chunkIndex: entry.chunkIndex, audioSHA256, pcmSHA256: sha(pcm.pcm), sampleRate: 16000, channels: 1, format: 'pcm_s16le', sampleCount, verified: true })
164
+ if (raw.length % 4 === 0) await yieldLoop()
165
+ }
166
+ const plan = planMacSpeakerWindows({ sidecar: source as any, rawChunks: raw, correctionHistory: 'verified-clear' })
167
+ plan.windows = selectQualifiedProfileWindows(plan.windows)
168
+ if (plan.windows.length < 2) fail('insufficient_long_windows')
169
+ const receipts: MacWindowEmbeddingReceipt[] = []
170
+ for (const window of plan.windows) {
171
+ budget(); await yieldLoop()
172
+ const buffers: Buffer[] = []
173
+ for (const s of window.sources) {
174
+ const wav = await readFile(join(input.audioDir, `chunk_${String(s.chunkIndex).padStart(4, '0')}.wav`)), pcm = strictPcm(wav)
175
+ if (sha(wav) !== s.audioSHA256 || !pcm || sha(pcm.pcm) !== s.pcmSHA256) fail('raw changed before extraction')
176
+ buffers.push(pcm.pcm)
177
+ }
178
+ const pcm = Buffer.concat(buffers), wav = Buffer.concat([wavHeader(pcm.length), pcm])
179
+ const vectors = await (deps.extract ?? extractQualifiedMacWindow)(pcm, runtime.modelPath, Math.min(15_000, deadline - now()))
180
+ budget()
181
+ if (!vectors || vectors.length !== 3) continue
182
+ receipts.push({ windowId: window.id, windowBindingSHA256: window.bindingSHA256, modelSHA256, audioSHA256: sha(wav), pcmSHA256: sha(pcm), sampleCount: pcm.length / 2, embedding: vectors[0], halfEmbeddings: [vectors[1], vectors[2]] })
183
+ }
184
+ const qualified = qualifyMacSpeakerWindows(plan, receipts, profiles, modelSHA256)
185
+ const byWindow = new Map(plan.windows.map(w => [w.id, w])), byReceipt = new Map(receipts.map(r => [r.windowId, r]))
186
+ const assignments: MacSpeakerRepairAssignment[] = []
187
+ const groups = new Map<string, typeof qualified>()
188
+ for (const c of qualified) if (c.status === 'qualified' && c.profileName) { const k = `${c.acousticIdentity}:${c.profileName}`; groups.set(k, [...(groups.get(k) ?? []), c]) }
189
+ for (const group of groups.values()) {
190
+ if (group.length < 2) continue
191
+ const profile = profiles.find(p => p.name === group[0].profileName)!
192
+ // Same raw-average centroid used by the production recognizer. The
193
+ // qualifier already rejected corrupt/mismatched rows and profile ties.
194
+ const rows = profile.embeddings, dimensions = rows[0].length
195
+ const centroid = Array.from({ length: dimensions }, (_, i) => rows.reduce((n, r) => n + r[i] / rows.length, 0))
196
+ const centroids = profiles.map(p => ({ name: p.name, vector: Array.from({ length: dimensions }, (_, i) => p.embeddings.reduce((n, r) => n + r[i] / p.embeddings.length, 0)) }))
197
+ const proof: MacProfileMatchEvidence = { kind: 'profile-match', profileName: profile.name, profileSha256: profileSHA256, modelSha256: modelSHA256, algorithmVersion: 'qualified-long-window-profile-v1',
198
+ crossWindowCosines: group.map(a => group.map(b => normalizedSpeakerCosine(byReceipt.get(a.windowId)!.embedding, byReceipt.get(b.windowId)!.embedding)!)),
199
+ windows: group.map(c => { const w = byWindow.get(c.windowId)!, r = byReceipt.get(c.windowId)!; return { id: w.id, captureStartSample: w.startSample, captureEndSample: w.endSample, pcmSha256: r.pcmSHA256,
200
+ similarity: normalizedSpeakerCosine(r.embedding, centroid)!, runnerUpSimilarity: Math.max(-1, ...centroids.filter(p => p.name !== profile.name).map(p => normalizedSpeakerCosine(r.embedding, p.vector)!)),
201
+ rawChunks: w.sources.map(s => ({ rawChunkIndex: s.chunkIndex, audioSha256: s.audioSHA256, sampleCount: s.sampleCount })) } }) }
202
+ for (const c of group) for (const s of byWindow.get(c.windowId)!.sources) assignments.push({ rawChunkIndex: s.chunkIndex, from: c.originalVoiceHint, to: profile.name, evidenceId: c.acousticIdentity!, targetEvidence: proof, batchWords: [] })
203
+ }
204
+ if (!assignments.length) fail('no_qualified_profile')
205
+ const selected = new Map(assignments.map(a => [a.rawChunkIndex, a])), withheldBatchWords: Array<MacSpeakerWordBinding & { reason: 'outside-qualified-span' }> = []
206
+ source.batchSegments.forEach((segment: Doc, segmentIndex: number) => {
207
+ const spans: { raw: number; start: number; end: number }[] = []; let end = 0
208
+ if (!Number.isSafeInteger(segment.startChunkIdx) || !Number.isSafeInteger(segment.endChunkIdx) || segment.endChunkIdx - segment.startChunkIdx > MAX_RAW) fail('invalid batch range')
209
+ for (let raw = segment.startChunkIdx; raw <= segment.endChunkIdx; raw++) { if (!counts[raw]) fail('missing batch raw clock'); spans.push({ raw, start: end, end: end + counts[raw] / 16000 }); end += counts[raw] / 16000 }
210
+ segment.speakerWords.forEach((word: Doc, wordIndex: number) => {
211
+ const overlaps = spans.filter(s => word.end === word.start ? word.start >= s.start && word.start < s.end : word.start < s.end && word.end > s.start)
212
+ if (!overlaps.some(s => selected.has(s.raw))) return
213
+ const binding = { segmentIndex, wordIndex, word: word.word, from: word.speaker, start: word.start, end: word.end }
214
+ if (overlaps.some(s => !selected.has(s.raw))) withheldBatchWords.push({ ...binding, reason: 'outside-qualified-span' })
215
+ else if (new Set(overlaps.map(s => selected.get(s.raw)!.to)).size === 1) selected.get(overlaps[0].raw)!.batchWords.push(binding)
216
+ else fail('crossed identities')
217
+ })
218
+ })
219
+ budget()
220
+ const staging = { ...source, finalizationState: 'complete' }, repairPlan = { id: `profile_${sha(beforeSidecar).slice(0, 24)}`, evidenceHash: hashMacSpeakerDocument(receipts), expectedDocumentHash: hashMacSpeakerDocument(staging), protectedRawChunkIndices: [], rawAudioSampleCounts: counts, assignments, withheldBatchWords }
221
+ const projected = projectMacSpeakerRepair(staging, repairPlan), after = projected.doc, sequence = boundWords(staging, after)
222
+ after.batchTranscript = renderBoundSpeakerLabels(source.batchTranscript, sequence)
223
+ const afterMarkdown = renderMarkdown(beforeMarkdown, sequence)
224
+ after.finalizationState = source.finalizationState
225
+ after.correctionRevision = 1; after.lifecycleRevision = (source.lifecycleRevision ?? 0) + 1
226
+ after.speakerRepair = { version: 1, id: repairPlan.id, evidenceHash: repairPlan.evidenceHash, machineEvidence: { version: 1, correctionRevision: 1, documentBinding: hashMacSpeakerMachineBinding(after), receipt: projected.receipt, rawAudioSampleCounts: counts } }
227
+ after.speakerProfilePass = { version: 1, status: 'applied', windows: receipts.length, assignments: assignments.length }
228
+ deps.beforeCommit?.(); budget()
229
+ for (const c of assignments) {
230
+ const rawBytes = await readFile(join(input.audioDir, `chunk_${String(c.rawChunkIndex).padStart(4, '0')}.wav`))
231
+ if (sha(rawBytes) !== raw.find(r => r.chunkIndex === c.rawChunkIndex)!.audioSHA256) fail('raw changed before commit')
232
+ }
233
+ const currentModelHash = sha(await readFile(runtime.modelPath))
234
+ if (regularRead(key) !== beforeSidecar || regularRead(input.meetingPath) !== beforeMarkdown || ledgerHash(ledgerPath) !== ledger || sha(regularRead(runtime.profilesPath, MAX_PROFILE_BYTES)) !== profileSHA256 || currentModelHash !== modelSHA256) fail('source/profile/model/history drift')
235
+ journal.images = [{ path: resolve(input.meetingPath), before: beforeMarkdown, after: afterMarkdown }, { path: key, before: beforeSidecar, after: JSON.stringify(after, null, 2) + '\n' }]
236
+ journal.imageBinding = hashMacSpeakerDocument(journal.images)
237
+ journal.rawBindings = assignments.map(a => ({ name: `chunk_${String(a.rawChunkIndex).padStart(4, '0')}.wav`, sha256: raw.find(r => r.chunkIndex === a.rawChunkIndex)!.audioSHA256 }))
238
+ journal.status = 'prepared'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
239
+ const outcome = recover(journalPath, journal, input, deps)
240
+ return { ...outcome, assignments: assignments.length }
241
+ } catch (error) {
242
+ // Prepared transactions must be retried before ASR, never reported as an
243
+ // ordinary no-match that permits downstream filing of mixed generations.
244
+ if (journal.status === 'prepared') throw error
245
+ journal.reason = error instanceof Error ? error.message : 'unqualified'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
246
+ return { status: 'skipped', reason: journal.reason }
247
+ }
248
+ } catch (error) {
249
+ // Recovery failures must block ASR/copying; ordinary unavailable resources
250
+ // leave the already saved recording alone.
251
+ if (existsSync(journalPath)) {
252
+ const saved = JSON.parse(regularRead(journalPath, 64 * 1024 * 1024)) as Journal
253
+ if (saved.status === 'prepared' || saved.status === 'complete') throw error
254
+ }
255
+ return { status: 'skipped', reason: error instanceof Error ? error.message : 'unavailable' }
256
+ } finally { if (ownsExtraction) extractionBusy = false; active.delete(key) }
257
+ }
@@ -0,0 +1,22 @@
1
+ /** One conservative predicate for automatic acoustic support and label writes.
2
+ * Human markers protect even still-anonymous labels and unlabeled ASR words. */
3
+ export function isMacSpeakerHumanProtected(value: unknown): boolean {
4
+ if (!value || typeof value !== 'object') return false
5
+ const row = value as Record<string, unknown>
6
+ if (['speakerCorrectionBatchId', 'speakerCorrectionId', 'manualSpeaker', 'speakerManuallyAssigned', 'confirmedByHuman', 'manuallyAssigned', 'manuallyCorrected'].some(key => !!row[key])) return true
7
+ return ['speakerOrigin', 'speakerSource', 'speakerPolicy', 'speakerProvenance', 'source'].some(key =>
8
+ typeof row[key] === 'string' && /(?:manual|human|correction|confirmed)/i.test(row[key] as string))
9
+ }
10
+ /** Inspect every stored chunk representation before using any of its audio as
11
+ * positive evidence. Malformed declared surfaces also fail closed. */
12
+ export function hasMacSpeakerProtectedRows(value: unknown): boolean {
13
+ if (!value || typeof value !== 'object') return true
14
+ const chunk = value as Record<string, unknown>
15
+ if (isMacSpeakerHumanProtected(chunk)) return true
16
+ for (const key of ['segments', 'finalSegments', 'words']) {
17
+ if (chunk[key] === undefined) continue
18
+ if (!Array.isArray(chunk[key])) return true
19
+ if ((chunk[key] as unknown[]).some(row => !row || typeof row !== 'object' || isMacSpeakerHumanProtected(row))) return true
20
+ }
21
+ return false
22
+ }