@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 +24 -0
- package/README.md +75 -0
- package/package.json +2 -1
- package/server/index.ts +13 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +39 -1
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/speaker-shadow-decide.ts +466 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +277 -0
- package/server/lib/speaker-shadow.ts +727 -0
- package/server/lib/voice-directory.ts +5 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/meeting.ts +36 -5
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/scripts/mac-speaker-repair.ts +324 -0
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.
|
|
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
|
+
}
|