@gotcos/glasses-server 6.94.1 → 6.94.2
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 +10 -0
- package/README.md +23 -0
- package/package.json +1 -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/voice-directory.ts +5 -1
- package/server/routes/meeting.ts +32 -5
- package/server/scripts/mac-speaker-repair.ts +324 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 6.94.2 - Scoped profile matches for saved Mac recordings
|
|
2
|
+
|
|
3
|
+
2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses package change required.
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
1
11
|
## 6.94.1 - Mac filing waits for a pipeline that can title a Mac recording
|
|
2
12
|
|
|
3
13
|
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,29 @@ 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
|
+
|
|
702
725
|
### iPhone microphone meetings (6.93.0, off by default)
|
|
703
726
|
|
|
704
727
|
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.
|
|
3
|
+
"version": "6.94.2",
|
|
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": {
|
|
@@ -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
|
+
}
|