@gotcos/glasses-server 6.92.0 → 6.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -1
- package/CHANGELOG.md +72 -29
- package/README.md +70 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +7 -0
- package/server/lib/g2-ops-handoff.ts +204 -2
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/__fixtures__/g2-filing-golden-6.93.0.json +62 -0
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +231 -20
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Anonymous, derived projection only; no identity/profile/training or recording imports.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
5
|
+
const TOKEN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
6
|
+
const MAC = /^mac_[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
7
|
+
const matches = (value: unknown, pattern: RegExp): value is string => typeof value === 'string' && pattern.test(value)
|
|
8
|
+
const MAX_CHUNKS = 4096
|
|
9
|
+
const MAX_WORDS = 8192
|
|
10
|
+
const MAX_GENERATIONS = 100
|
|
11
|
+
const MAX_TOTAL_SEGMENTS = 8192
|
|
12
|
+
const MAX_STATE_BYTES = 4 * 1024 * 1024
|
|
13
|
+
const uint = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
|
|
14
|
+
const fail = (reason: string): never => { throw new Error(`mac_projection_${reason}`) }
|
|
15
|
+
const keys = (value: object, names: string[]): boolean => Object.keys(value).length === names.length && names.every(k => Object.hasOwn(value, k))
|
|
16
|
+
export interface ConfirmationProof {
|
|
17
|
+
originalChunkIndex: number; audioSHA256: string; pcmSHA256: string
|
|
18
|
+
chunkStartSample: number; chunkSampleCount: number
|
|
19
|
+
startSample: number; endSample: number; exclusiveSamples: number; purity: number
|
|
20
|
+
}
|
|
21
|
+
interface Slot { slot: number; proofs: ConfirmationProof[]; label?: string }
|
|
22
|
+
export interface SpeakerSegment {
|
|
23
|
+
wordStart: number; wordEnd: number; startSample: number; endSample: number
|
|
24
|
+
speaker: string; similarity: 0
|
|
25
|
+
}
|
|
26
|
+
export interface ChunkProjection {
|
|
27
|
+
chunkIndex: number; generation: string; audioSHA256: string; canonicalBinding: string
|
|
28
|
+
wordCount: number; startSample: number; endSample: number; coveredThroughSample: number
|
|
29
|
+
changedAtRevision: number; segments: SpeakerSegment[]
|
|
30
|
+
}
|
|
31
|
+
export interface ProjectionState {
|
|
32
|
+
version: 1; captureId: string; generation: string | null; generations: string[]
|
|
33
|
+
speakerRevision: number; labelHighWater: number; slots: Slot[]; chunks: ChunkProjection[]
|
|
34
|
+
}
|
|
35
|
+
export interface ProjectionRead {
|
|
36
|
+
version: 1; captureId: string; generation: string | null; speakerRevision: number
|
|
37
|
+
reset: boolean; chunks: ChunkProjection[]
|
|
38
|
+
}
|
|
39
|
+
export function canonicalTextBinding(text: string, words: readonly { text: string; startSample: number; endSample: number }[]): string {
|
|
40
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > 128_000 || !Array.isArray(words) || words.length > MAX_WORDS) fail('canonical')
|
|
41
|
+
let wordBytes = 0
|
|
42
|
+
for (let i = 0; i < words.length; i++) {
|
|
43
|
+
const w = words[i]
|
|
44
|
+
if (!Object.hasOwn(words, i) || !w || typeof w.text !== 'string' || !uint(w.startSample) || !uint(w.endSample) || w.endSample < w.startSample) fail('canonical_word')
|
|
45
|
+
wordBytes += Buffer.byteLength(w.text)
|
|
46
|
+
if (wordBytes > 128_000) fail('canonical_word_bound')
|
|
47
|
+
}
|
|
48
|
+
return createHash('sha256').update(JSON.stringify({ text, words })).digest('hex')
|
|
49
|
+
}
|
|
50
|
+
export function newProjectionState(captureId: string): ProjectionState {
|
|
51
|
+
if (!matches(captureId, MAC)) fail('capture')
|
|
52
|
+
return { version: 1, captureId, generation: null, generations: [], speakerRevision: 0, labelHighWater: 0, slots: [], chunks: [] }
|
|
53
|
+
}
|
|
54
|
+
function dense<T>(value: T[], max: number): boolean {
|
|
55
|
+
if (!Array.isArray(value) || value.length > max || Object.keys(value).length !== value.length) return false
|
|
56
|
+
for (let i = 0; i < value.length; i++) if (!Object.hasOwn(value, i)) return false
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
function proofValid(p: ConfirmationProof): boolean {
|
|
60
|
+
return !!p && keys(p, ['originalChunkIndex', 'audioSHA256', 'pcmSHA256', 'chunkStartSample', 'chunkSampleCount', 'startSample', 'endSample', 'exclusiveSamples', 'purity']) && uint(p.originalChunkIndex) && matches(p.audioSHA256, SHA) && matches(p.pcmSHA256, SHA)
|
|
61
|
+
&& uint(p.chunkStartSample) && uint(p.chunkSampleCount) && p.chunkSampleCount > 0 && p.chunkSampleCount <= 480_000
|
|
62
|
+
&& uint(p.startSample) && uint(p.endSample) && p.startSample >= p.chunkStartSample
|
|
63
|
+
&& p.endSample > p.startSample && p.endSample <= p.chunkStartSample + p.chunkSampleCount
|
|
64
|
+
&& uint(p.exclusiveSamples) && p.exclusiveSamples >= 32_000 && p.exclusiveSamples <= p.endSample - p.startSample
|
|
65
|
+
&& Number.isFinite(p.purity) && p.purity >= 0.9 && p.purity <= 1
|
|
66
|
+
}
|
|
67
|
+
function chunkValid(c: ChunkProjection, state: ProjectionState): boolean {
|
|
68
|
+
if (!c || !keys(c, ['chunkIndex', 'generation', 'audioSHA256', 'canonicalBinding', 'wordCount', 'startSample', 'endSample', 'coveredThroughSample', 'changedAtRevision', 'segments']) || !uint(c.chunkIndex) || !state.generations.includes(c.generation) || !matches(c.audioSHA256, SHA) || !matches(c.canonicalBinding, SHA)
|
|
69
|
+
|| !uint(c.wordCount) || c.wordCount > MAX_WORDS || !uint(c.startSample) || !uint(c.endSample) || c.endSample < c.startSample
|
|
70
|
+
|| !uint(c.coveredThroughSample) || c.coveredThroughSample < c.startSample || c.coveredThroughSample > c.endSample
|
|
71
|
+
|| !uint(c.changedAtRevision) || c.changedAtRevision < 1 || c.changedAtRevision > state.speakerRevision || !dense(c.segments, MAX_WORDS)) return false
|
|
72
|
+
let word = 0; let sample = c.startSample
|
|
73
|
+
for (const segment of c.segments) {
|
|
74
|
+
if (!segment || !keys(segment, ['wordStart', 'wordEnd', 'startSample', 'endSample', 'speaker', 'similarity']) || segment.wordStart !== word || !uint(segment.wordEnd) || segment.wordEnd <= word || segment.wordEnd > c.wordCount
|
|
75
|
+
|| !uint(segment.startSample) || !uint(segment.endSample) || segment.startSample < sample || segment.endSample < segment.startSample
|
|
76
|
+
|| segment.endSample > c.endSample || (segment.speaker !== 'Ext' && segment.endSample > c.coveredThroughSample) || segment.similarity !== 0
|
|
77
|
+
|| (segment.speaker !== 'Ext' && !matches(segment.speaker, /^Voice [1-9]\d?$/))
|
|
78
|
+
|| (segment.speaker !== 'Ext' && Number(segment.speaker.slice(6)) > state.labelHighWater)) return false
|
|
79
|
+
word = segment.wordEnd; sample = segment.endSample
|
|
80
|
+
}
|
|
81
|
+
// Empty segments is an explicit clear: clients fall back to immutable canonical labels.
|
|
82
|
+
return c.segments.length === 0 || word === c.wordCount
|
|
83
|
+
}
|
|
84
|
+
/** Strict restore: missing/corrupt high-water or partial arrays never reset numbering. */
|
|
85
|
+
export function validateProjectionState(state: ProjectionState): void {
|
|
86
|
+
if (!state || !keys(state, ['version', 'captureId', 'generation', 'generations', 'speakerRevision', 'labelHighWater', 'slots', 'chunks']) || state.version !== 1 || !matches(state.captureId, MAC) || !uint(state.speakerRevision) || !uint(state.labelHighWater) || state.labelHighWater > 99
|
|
87
|
+
|| !dense(state.generations, MAX_GENERATIONS) || new Set(state.generations).size !== state.generations.length
|
|
88
|
+
|| state.generations.some(g => typeof g !== 'string' || !matches(g, TOKEN))
|
|
89
|
+
|| (state.generation !== null && !state.generations.includes(state.generation)) || !dense(state.slots, 8) || !dense(state.chunks, MAX_CHUNKS)
|
|
90
|
+
|| new Set(state.slots.map(s => s?.slot)).size !== state.slots.length || new Set(state.chunks.map(c => c?.chunkIndex)).size !== state.chunks.length) fail('corrupt_state')
|
|
91
|
+
const labels = new Set<string>()
|
|
92
|
+
for (const slot of state.slots) {
|
|
93
|
+
if (!slot || !keys(slot, slot.label === undefined ? ['slot', 'proofs'] : ['slot', 'proofs', 'label']) || !uint(slot.slot) || slot.slot > 7 || !dense(slot.proofs, 3) || slot.proofs.some(p => !proofValid(p))
|
|
94
|
+
|| new Set(slot.proofs.map(p => p.originalChunkIndex)).size !== slot.proofs.length
|
|
95
|
+
|| new Set(slot.proofs.map(p => p.pcmSHA256)).size !== slot.proofs.length
|
|
96
|
+
|| (slot.label !== undefined && (slot.proofs.length !== 3 || !matches(slot.label, /^Voice [1-9]\d?$/)
|
|
97
|
+
|| Number(slot.label.slice(6)) > state.labelHighWater || labels.has(slot.label)))) fail('corrupt_slot')
|
|
98
|
+
if (slot.label) labels.add(slot.label)
|
|
99
|
+
}
|
|
100
|
+
const proofs = state.slots.flatMap(slot => slot.proofs)
|
|
101
|
+
for (let i = 0; i < proofs.length; i++) for (let j = i + 1; j < proofs.length; j++) {
|
|
102
|
+
const a = proofs[i], b = proofs[j]
|
|
103
|
+
if (a.originalChunkIndex === b.originalChunkIndex && (a.audioSHA256 !== b.audioSHA256 || a.pcmSHA256 !== b.pcmSHA256
|
|
104
|
+
|| a.chunkStartSample !== b.chunkStartSample || a.chunkSampleCount !== b.chunkSampleCount)) fail('corrupt_provenance')
|
|
105
|
+
if (a.originalChunkIndex !== b.originalChunkIndex && Math.max(a.chunkStartSample, b.chunkStartSample)
|
|
106
|
+
< Math.min(a.chunkStartSample + a.chunkSampleCount, b.chunkStartSample + b.chunkSampleCount)) fail('corrupt_overlap')
|
|
107
|
+
const samePCM = a.pcmSHA256 === b.pcmSHA256
|
|
108
|
+
const startA = samePCM ? a.startSample - a.chunkStartSample : a.startSample
|
|
109
|
+
const startB = samePCM ? b.startSample - b.chunkStartSample : b.startSample
|
|
110
|
+
if ((samePCM || a.originalChunkIndex === b.originalChunkIndex) && Math.max(startA, startB)
|
|
111
|
+
< Math.min(startA + a.endSample - a.startSample, startB + b.endSample - b.startSample)) fail('corrupt_repeated_support')
|
|
112
|
+
}
|
|
113
|
+
let totalSegments = 0
|
|
114
|
+
for (const c of state.chunks) {
|
|
115
|
+
if (!c || !Array.isArray(c.segments)) fail('corrupt_chunk')
|
|
116
|
+
totalSegments += c.segments.length
|
|
117
|
+
if (totalSegments > MAX_TOTAL_SEGMENTS || !chunkValid(c, state)) fail('corrupt_chunk_or_bound')
|
|
118
|
+
if (c.generation !== state.generation && c.segments.some(segment => labels.has(segment.speaker))) fail('corrupt_reused_label')
|
|
119
|
+
}
|
|
120
|
+
if (Buffer.byteLength(JSON.stringify(state)) > MAX_STATE_BYTES) fail('state_bound')
|
|
121
|
+
}
|
|
122
|
+
/** All mutation is staged and synchronously atomically persisted before publication. */
|
|
123
|
+
export class MacSpeakerProjection {
|
|
124
|
+
private state: ProjectionState
|
|
125
|
+
private admittedGeneration: string | null = null
|
|
126
|
+
private pending?: ProjectionState
|
|
127
|
+
private committing = false
|
|
128
|
+
constructor(state: ProjectionState, private readonly persist: (state: Readonly<ProjectionState>) => void) {
|
|
129
|
+
validateProjectionState(state); this.state = structuredClone(state)
|
|
130
|
+
}
|
|
131
|
+
snapshot(): ProjectionState { return structuredClone(this.state) }
|
|
132
|
+
private commit(edit: (state: ProjectionState) => void): void {
|
|
133
|
+
if (this.committing) fail('reentrant_persist')
|
|
134
|
+
const next = structuredClone(this.state); edit(next)
|
|
135
|
+
if (next.speakerRevision >= Number.MAX_SAFE_INTEGER) fail('revision_exhausted')
|
|
136
|
+
next.speakerRevision++
|
|
137
|
+
validateProjectionState(next)
|
|
138
|
+
if (this.pending && JSON.stringify(this.pending) !== JSON.stringify(next)) fail('uncommitted')
|
|
139
|
+
this.pending = next
|
|
140
|
+
this.committing = true
|
|
141
|
+
try {
|
|
142
|
+
const accepted: unknown = this.persist(Object.freeze(structuredClone(next)))
|
|
143
|
+
if (accepted && typeof (accepted as { then?: unknown }).then === 'function') fail('async_persist')
|
|
144
|
+
this.state = next
|
|
145
|
+
this.pending = undefined
|
|
146
|
+
} finally { this.committing = false }
|
|
147
|
+
}
|
|
148
|
+
beginGeneration(generation: string): void {
|
|
149
|
+
if (!matches(generation, TOKEN) || this.state.generations.includes(generation) || this.state.generations.length >= MAX_GENERATIONS) fail('generation')
|
|
150
|
+
this.commit(s => { s.generation = generation; s.generations.push(generation); s.slots = [] })
|
|
151
|
+
this.admittedGeneration = generation
|
|
152
|
+
}
|
|
153
|
+
endGeneration(generation: string): void {
|
|
154
|
+
this.assertGeneration(generation)
|
|
155
|
+
this.commit(s => { s.generation = null; s.slots = [] })
|
|
156
|
+
this.admittedGeneration = null
|
|
157
|
+
}
|
|
158
|
+
private assertGeneration(generation: string): void { if (this.state.generation !== generation || this.admittedGeneration !== generation) fail('stale_generation') }
|
|
159
|
+
observe(generation: string, slotIndex: number, proof: ConfirmationProof): string | null {
|
|
160
|
+
this.assertGeneration(generation)
|
|
161
|
+
if (!uint(slotIndex) || slotIndex > 7 || !proofValid(proof)) fail('proof')
|
|
162
|
+
for (const slot of this.state.slots) for (const p of slot.proofs) {
|
|
163
|
+
if (p.originalChunkIndex === proof.originalChunkIndex && (p.audioSHA256 !== proof.audioSHA256 || p.pcmSHA256 !== proof.pcmSHA256
|
|
164
|
+
|| p.chunkStartSample !== proof.chunkStartSample || p.chunkSampleCount !== proof.chunkSampleCount)) fail('proof_conflict')
|
|
165
|
+
if (p.originalChunkIndex !== proof.originalChunkIndex && Math.max(p.chunkStartSample, proof.chunkStartSample)
|
|
166
|
+
< Math.min(p.chunkStartSample + p.chunkSampleCount, proof.chunkStartSample + proof.chunkSampleCount)) fail('overlapping_originals')
|
|
167
|
+
}
|
|
168
|
+
const existing = this.state.slots.find(s => s.slot === slotIndex)
|
|
169
|
+
if (existing?.proofs.some(p => p.originalChunkIndex === proof.originalChunkIndex || p.pcmSHA256 === proof.pcmSHA256)) return existing.label ?? null
|
|
170
|
+
// Repeated canonical PCM/overlapping support cannot confirm another slot either.
|
|
171
|
+
for (const slot of this.state.slots) for (const p of slot.proofs) {
|
|
172
|
+
const samePCM = p.pcmSHA256 === proof.pcmSHA256
|
|
173
|
+
const a = samePCM ? p.startSample - p.chunkStartSample : p.startSample
|
|
174
|
+
const b = samePCM ? proof.startSample - proof.chunkStartSample : proof.startSample
|
|
175
|
+
if ((samePCM || p.originalChunkIndex === proof.originalChunkIndex) && Math.max(a, b) < Math.min(a + p.endSample - p.startSample, b + proof.endSample - proof.startSample)) return existing?.label ?? null
|
|
176
|
+
}
|
|
177
|
+
if (existing?.proofs.length === 3) return existing.label ?? null
|
|
178
|
+
this.commit(s => {
|
|
179
|
+
let slot = s.slots.find(item => item.slot === slotIndex)
|
|
180
|
+
if (!slot) { slot = { slot: slotIndex, proofs: [] }; s.slots.push(slot) }
|
|
181
|
+
slot.proofs.push(structuredClone(proof))
|
|
182
|
+
if (slot.proofs.length === 3 && s.labelHighWater < 99) slot.label = `Voice ${++s.labelHighWater}`
|
|
183
|
+
})
|
|
184
|
+
return this.state.slots.find(s => s.slot === slotIndex)?.label ?? null
|
|
185
|
+
}
|
|
186
|
+
/** Whole-chunk replacement; [] explicitly clears prior segments. Text/word anchors never change. */
|
|
187
|
+
replaceChunk(generation: string, chunk: Omit<ChunkProjection, 'generation' | 'changedAtRevision'>): void {
|
|
188
|
+
this.assertGeneration(generation)
|
|
189
|
+
const old = this.state.chunks.find(c => c.chunkIndex === chunk.chunkIndex)
|
|
190
|
+
if (old && chunk.coveredThroughSample < old.coveredThroughSample) fail('coverage_regression')
|
|
191
|
+
if (old && (old.generation !== generation || old.audioSHA256 !== chunk.audioSHA256 || old.canonicalBinding !== chunk.canonicalBinding
|
|
192
|
+
|| old.wordCount !== chunk.wordCount || old.startSample !== chunk.startSample || old.endSample !== chunk.endSample)) fail('immutable_binding')
|
|
193
|
+
if (old) {
|
|
194
|
+
const { generation: _generation, changedAtRevision: _revision, ...previous } = old
|
|
195
|
+
if (Object.keys(previous).every(k => JSON.stringify(previous[k as keyof typeof previous]) === JSON.stringify(chunk[k as keyof typeof chunk]))) return
|
|
196
|
+
}
|
|
197
|
+
if (!old && this.state.chunks.length >= MAX_CHUNKS) fail('chunk_bound')
|
|
198
|
+
const validLabels = new Set(this.state.slots.map(s => s.label).filter(Boolean))
|
|
199
|
+
if (!dense(chunk.segments, MAX_WORDS) || chunk.segments.some(s => s.speaker !== 'Ext' && !validLabels.has(s.speaker))) fail('unconfirmed_label')
|
|
200
|
+
this.commit(s => {
|
|
201
|
+
const replacement: ChunkProjection = { ...structuredClone(chunk), generation, changedAtRevision: s.speakerRevision + 1 }
|
|
202
|
+
const i = s.chunks.findIndex(c => c.chunkIndex === chunk.chunkIndex)
|
|
203
|
+
if (i < 0) s.chunks.push(replacement); else s.chunks[i] = replacement
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
read(cursor?: { generation: string | null; speakerRevision: number }): ProjectionRead {
|
|
207
|
+
const reset = !cursor || cursor.generation !== this.state.generation || !uint(cursor.speakerRevision) || cursor.speakerRevision > this.state.speakerRevision
|
|
208
|
+
return { version: 1, captureId: this.state.captureId, generation: this.state.generation, speakerRevision: this.state.speakerRevision,
|
|
209
|
+
reset, chunks: structuredClone(this.state.chunks.filter(c => reset || c.changedAtRevision > cursor!.speakerRevision)) }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Isolated protocol/state machine only. No discovery, subprocess, models or live imports.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
export const STREAM_MAX_SAMPLES = 96_000
|
|
5
|
+
export const STREAM_MAX_PCM_BYTES = STREAM_MAX_SAMPLES * 2
|
|
6
|
+
export type StreamOp = 'open' | 'append' | 'finish' | 'abort'
|
|
7
|
+
export interface StreamRequest {
|
|
8
|
+
version: 1; op: StreamOp; captureId: string; generation: string; requestId: string
|
|
9
|
+
startSample?: number; firstChunkIndex?: number; originalChunkIndex?: number; sampleCount?: number
|
|
10
|
+
audioSHA256?: string; pcmSHA256?: string; pcmS16LEBase64?: string
|
|
11
|
+
}
|
|
12
|
+
export interface StreamReceipt {
|
|
13
|
+
version: 1; op: StreamOp; captureId: string; generation: string; requestId: string
|
|
14
|
+
state: 'open' | 'finished' | 'aborted'; consumedThroughSample: number; consumedThroughChunkIndex: number
|
|
15
|
+
frameStart: number; frameCount: number; numSpeakers: 8; probabilities: number[]
|
|
16
|
+
}
|
|
17
|
+
export interface StreamDependencies {
|
|
18
|
+
exchange(request: Readonly<StreamRequest>, signal: AbortSignal): Promise<unknown>
|
|
19
|
+
// Synchronous, atomic durable acceptance. A throw leaves one staged receipt for exact retry.
|
|
20
|
+
// Actual filesystem persistence and cancellation-aware CAS remain future integration work.
|
|
21
|
+
accept(request: Readonly<StreamRequest>, receipt: Readonly<StreamReceipt>): void
|
|
22
|
+
deadlineMs?: number
|
|
23
|
+
}
|
|
24
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
25
|
+
const CAPTURE = /^mac_[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
26
|
+
const TOKEN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
27
|
+
const matches = (value: unknown, pattern: RegExp): value is string => typeof value === 'string' && pattern.test(value)
|
|
28
|
+
const ENVELOPE = ['version', 'op', 'captureId', 'generation', 'requestId']
|
|
29
|
+
const fields = (op: StreamOp): string[] => [...ENVELOPE, ...(op === 'open' ? ['startSample', 'firstChunkIndex'] : op === 'append' ? ['originalChunkIndex', 'startSample', 'sampleCount', 'audioSHA256', 'pcmSHA256', 'pcmS16LEBase64'] : [])]
|
|
30
|
+
const requestKey = (r: StreamRequest): string => JSON.stringify(Object.fromEntries(fields(r.op).map(k => [k, r[k as keyof StreamRequest]])))
|
|
31
|
+
const integer = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
|
|
32
|
+
const fail = (reason: string): never => { throw new Error(`mac_stream_${reason}`) }
|
|
33
|
+
export function validateStreamRequest(request: StreamRequest): void {
|
|
34
|
+
if (!request || typeof request !== 'object' || Array.isArray(request) || request.version !== 1 || !['open', 'append', 'finish', 'abort'].includes(request.op)
|
|
35
|
+
|| !matches(request.captureId, CAPTURE) || !matches(request.generation, TOKEN) || !matches(request.requestId, TOKEN)) fail('request')
|
|
36
|
+
if (Object.keys(request).length !== fields(request.op).length || fields(request.op).some(k => !Object.hasOwn(request, k))) fail('fields')
|
|
37
|
+
if (request.op === 'open') {
|
|
38
|
+
if (!integer(request.startSample) || request.startSample % 160 !== 0 || request.startSample > 1_382_400_000 || !integer(request.firstChunkIndex) || request.firstChunkIndex >= 10_000_000) fail('start_sample')
|
|
39
|
+
} else if (request.op === 'append') {
|
|
40
|
+
if (!integer(request.originalChunkIndex) || !integer(request.startSample) || !integer(request.sampleCount)
|
|
41
|
+
|| request.sampleCount < 1 || request.sampleCount > STREAM_MAX_SAMPLES
|
|
42
|
+
|| !Number.isSafeInteger(request.startSample + request.sampleCount)
|
|
43
|
+
|| !matches(request.audioSHA256, SHA) || !matches(request.pcmSHA256, SHA)) fail('append_metadata')
|
|
44
|
+
const encoded = request.pcmS16LEBase64
|
|
45
|
+
if (typeof encoded !== 'string' || encoded.length > Math.ceil(STREAM_MAX_PCM_BYTES / 3) * 4
|
|
46
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) fail('pcm')
|
|
47
|
+
const pcm = Buffer.from(encoded!, 'base64')
|
|
48
|
+
if (pcm.length !== request.sampleCount! * 2 || pcm.toString('base64') !== encoded
|
|
49
|
+
|| createHash('sha256').update(pcm).digest('hex') !== request.pcmSHA256) fail('pcm_hash')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
interface Pending {
|
|
53
|
+
request: StreamRequest; resolve(value: StreamReceipt): void; reject(error: unknown): void
|
|
54
|
+
}
|
|
55
|
+
/** One capture generation, one exchange plus one queued request. Never resumable after failure. */
|
|
56
|
+
export class MacStreamDiarizer {
|
|
57
|
+
private state: 'new' | 'open' | 'finished' | 'aborted' | 'failed' = 'new'
|
|
58
|
+
private scope?: { captureId: string; generation: string }
|
|
59
|
+
private watermark = 0
|
|
60
|
+
private initialSample = 0
|
|
61
|
+
private nextIndex = 0
|
|
62
|
+
private nextFrame = 0
|
|
63
|
+
private last?: { key: string; receipt: StreamReceipt }
|
|
64
|
+
private terminal?: { key: string; receipt: StreamReceipt }
|
|
65
|
+
private staged?: { key: string; request: StreamRequest; receipt: StreamReceipt }
|
|
66
|
+
private active = false
|
|
67
|
+
private queue?: Pending
|
|
68
|
+
private controller?: AbortController
|
|
69
|
+
private fence = 0
|
|
70
|
+
private usedRequestIds = new Set<string>()
|
|
71
|
+
private usedPCMHashes = new Set<string>()
|
|
72
|
+
constructor(private readonly deps: StreamDependencies) {
|
|
73
|
+
if (!Number.isSafeInteger(deps.deadlineMs ?? 1000) || (deps.deadlineMs ?? 1000) < 1 || (deps.deadlineMs ?? 1000) > 30_000) fail('deadline')
|
|
74
|
+
}
|
|
75
|
+
get status(): string { return this.state }
|
|
76
|
+
private invalidate(): void {
|
|
77
|
+
this.fence++; this.state = 'failed'; this.controller?.abort()
|
|
78
|
+
this.staged = undefined
|
|
79
|
+
this.queue?.reject(new Error('mac_stream_cancelled')); this.queue = undefined
|
|
80
|
+
}
|
|
81
|
+
/** Owner cancellation is immediate and fences an already-running/late transport callback. */
|
|
82
|
+
cancel(): void { this.invalidate() }
|
|
83
|
+
request(request: StreamRequest): Promise<StreamReceipt> {
|
|
84
|
+
try { validateStreamRequest(request) } catch (error) { return Promise.reject(error) }
|
|
85
|
+
const copy = { ...request }
|
|
86
|
+
if (this.scope && (copy.captureId !== this.scope.captureId || copy.generation !== this.scope.generation)) return Promise.reject(new Error('mac_stream_scope'))
|
|
87
|
+
if (this.active) {
|
|
88
|
+
if (copy.op === 'abort') { this.invalidate(); return Promise.reject(new Error('mac_stream_cancelled')) }
|
|
89
|
+
if (this.queue) { this.invalidate(); return Promise.reject(new Error('mac_stream_queue_overflow')) }
|
|
90
|
+
return new Promise((resolve, reject) => { this.queue = { request: copy, resolve, reject } })
|
|
91
|
+
}
|
|
92
|
+
return this.dispatch(copy)
|
|
93
|
+
}
|
|
94
|
+
private async dispatch(request: StreamRequest): Promise<StreamReceipt> {
|
|
95
|
+
this.active = true
|
|
96
|
+
try { return await this.run(request) } finally {
|
|
97
|
+
this.active = false
|
|
98
|
+
const queued = this.queue; this.queue = undefined
|
|
99
|
+
if (queued) void this.dispatch(queued.request).then(queued.resolve, queued.reject)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
private async run(request: StreamRequest): Promise<StreamReceipt> {
|
|
103
|
+
const key = requestKey(request)
|
|
104
|
+
if (this.state === 'failed') fail('generation_ended')
|
|
105
|
+
if (this.scope && (request.captureId !== this.scope.captureId || request.generation !== this.scope.generation)) fail('scope')
|
|
106
|
+
if (this.terminal) {
|
|
107
|
+
if (this.terminal.key === key) return structuredClone(this.terminal.receipt)
|
|
108
|
+
fail('terminal')
|
|
109
|
+
}
|
|
110
|
+
if (this.last?.key === key) return structuredClone(this.last.receipt)
|
|
111
|
+
if (this.staged && this.staged.key !== key) fail('uncommitted')
|
|
112
|
+
if (!this.staged) {
|
|
113
|
+
if (this.usedRequestIds.has(request.requestId)) fail('request_conflict_or_evicted')
|
|
114
|
+
if (request.op === 'append' && (this.usedPCMHashes.has(request.pcmSHA256!) || this.usedPCMHashes.size >= 4096)) fail('repeated_pcm_or_bound')
|
|
115
|
+
if (request.op === 'open') {
|
|
116
|
+
if (this.state !== 'new') fail('already_open')
|
|
117
|
+
this.scope = { captureId: request.captureId, generation: request.generation }
|
|
118
|
+
} else {
|
|
119
|
+
if (this.state !== 'open') fail('not_open')
|
|
120
|
+
if (request.op === 'append' && (request.startSample !== this.watermark
|
|
121
|
+
|| request.originalChunkIndex !== this.nextIndex)) fail('sequence')
|
|
122
|
+
}
|
|
123
|
+
const fence = this.fence
|
|
124
|
+
const controller = this.controller = new AbortController()
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
126
|
+
try {
|
|
127
|
+
const raw = await Promise.race([
|
|
128
|
+
this.deps.exchange(Object.freeze({ ...request }), controller.signal),
|
|
129
|
+
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('mac_stream_timeout')), this.deps.deadlineMs ?? 1000) }),
|
|
130
|
+
new Promise<never>((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('mac_stream_cancelled')), { once: true })),
|
|
131
|
+
])
|
|
132
|
+
if (fence !== this.fence || controller.signal.aborted) fail('cancelled')
|
|
133
|
+
const receipt = this.validateReceipt(request, raw)
|
|
134
|
+
this.staged = { key, request, receipt }
|
|
135
|
+
} catch (error) { this.invalidate(); throw error } finally { if (timer) clearTimeout(timer); this.controller = undefined }
|
|
136
|
+
}
|
|
137
|
+
const staged = this.staged!
|
|
138
|
+
// No await between the cancellation fence, atomic durable acceptance and local publication.
|
|
139
|
+
const fence = this.fence
|
|
140
|
+
const accepted: unknown = this.deps.accept(Object.freeze({ ...staged.request }), Object.freeze(structuredClone(staged.receipt)))
|
|
141
|
+
if (accepted && typeof (accepted as { then?: unknown }).then === 'function') { this.invalidate(); fail('async_accept') }
|
|
142
|
+
if (fence !== this.fence) fail('cancelled')
|
|
143
|
+
const receipt = staged.receipt
|
|
144
|
+
this.scope = { captureId: request.captureId, generation: request.generation }
|
|
145
|
+
this.watermark = receipt.consumedThroughSample
|
|
146
|
+
this.nextFrame = receipt.frameStart + receipt.frameCount
|
|
147
|
+
this.usedRequestIds.add(request.requestId)
|
|
148
|
+
if (request.op === 'append') this.usedPCMHashes.add(request.pcmSHA256!)
|
|
149
|
+
if (request.op === 'open') { this.nextIndex = request.firstChunkIndex!; this.initialSample = request.startSample! }
|
|
150
|
+
if (request.op === 'append') this.nextIndex = request.originalChunkIndex! + 1
|
|
151
|
+
this.state = receipt.state
|
|
152
|
+
this.last = { key, receipt: structuredClone(receipt) }
|
|
153
|
+
if (request.op === 'finish' || request.op === 'abort') this.terminal = this.last
|
|
154
|
+
this.staged = undefined
|
|
155
|
+
return structuredClone(receipt)
|
|
156
|
+
}
|
|
157
|
+
private validateReceipt(request: StreamRequest, raw: unknown): StreamReceipt {
|
|
158
|
+
if (!raw || typeof raw !== 'object') fail('receipt')
|
|
159
|
+
const r = raw as StreamReceipt
|
|
160
|
+
const expectedEnd = request.op === 'open' ? request.startSample! : request.op === 'append' ? request.startSample! + request.sampleCount! : this.watermark
|
|
161
|
+
const expectedFrame = request.op === 'open' ? request.startSample! / 160 : this.nextFrame
|
|
162
|
+
const expectedIndex = request.op === 'open' ? request.firstChunkIndex! - 1 : request.op === 'append' ? request.originalChunkIndex! : this.nextIndex - 1
|
|
163
|
+
const expectedState = request.op === 'finish' ? 'finished' : request.op === 'abort' ? 'aborted' : 'open'
|
|
164
|
+
if (r.version !== 1 || r.op !== request.op || r.captureId !== request.captureId || r.generation !== request.generation
|
|
165
|
+
|| r.requestId !== request.requestId || r.state !== expectedState || r.consumedThroughChunkIndex !== expectedIndex || r.consumedThroughSample !== expectedEnd
|
|
166
|
+
|| r.frameStart !== expectedFrame || !integer(r.frameCount) || r.frameCount > 900 || r.numSpeakers !== 8
|
|
167
|
+
|| !Array.isArray(r.probabilities) || r.probabilities.length !== r.frameCount * 8
|
|
168
|
+
|| (request.op === 'open' || request.op === 'abort') && r.frameCount !== 0
|
|
169
|
+
|| (r.frameStart + r.frameCount) * 160 > expectedEnd + (request.op === 'finish' ? 160 : 0)) fail('receipt_shape')
|
|
170
|
+
if (request.op === 'finish') {
|
|
171
|
+
const initial = this.initialSample
|
|
172
|
+
const count = expectedEnd - initial
|
|
173
|
+
const exactFrames = count > 0 ? 1 + Math.floor((count + 512 - 400) / 160) : 0
|
|
174
|
+
if (r.frameStart + r.frameCount !== initial / 160 + exactFrames) fail('finish_coverage')
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < r.probabilities.length; i++) {
|
|
177
|
+
const p = r.probabilities[i]
|
|
178
|
+
if (!Object.hasOwn(r.probabilities, i) || !Number.isFinite(p) || p < 0 || p > 1) fail('probability')
|
|
179
|
+
}
|
|
180
|
+
// Copy only protocol fields: ignored native instrumentation/extra payload cannot grow retained state.
|
|
181
|
+
return { version: 1, op: r.op, captureId: r.captureId, generation: r.generation, requestId: r.requestId, state: r.state,
|
|
182
|
+
consumedThroughSample: r.consumedThroughSample, consumedThroughChunkIndex: r.consumedThroughChunkIndex,
|
|
183
|
+
frameStart: r.frameStart, frameCount: r.frameCount, numSpeakers: 8, probabilities: r.probabilities.slice() }
|
|
184
|
+
}
|
|
185
|
+
}
|