@gotcos/glasses-server 6.94.0 → 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 +20 -0
- package/README.md +24 -1
- package/package.json +1 -1
- package/server/lib/g2-ops-handoff.ts +11 -2
- 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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Pure exact-token rendering shared by offline repair and saved Mac finalization.
|
|
2
|
+
type Doc = Record<string, any>
|
|
3
|
+
function refuse(message: string): never { throw new Error(`Mac speaker repair: ${message}`) }
|
|
4
|
+
export interface BoundWord { word: string; before: string; after: string }
|
|
5
|
+
export function boundWords(before: Doc, after: Doc): BoundWord[] {
|
|
6
|
+
if (before.batchApplied !== true || !Array.isArray(before.batchSegments) || !before.batchSegments.length) refuse('v1 requires complete canonical batch speaker words')
|
|
7
|
+
return before.batchSegments.flatMap((segment: Doc, si: number) => {
|
|
8
|
+
if (!Array.isArray(segment.speakerWords) || segment.speakerWords.length !== after.batchSegments[si]?.speakerWords?.length) refuse('incomplete batch speaker words')
|
|
9
|
+
return segment.speakerWords.flatMap((row: Doc, wi: number) => {
|
|
10
|
+
const next = after.batchSegments[si].speakerWords[wi]
|
|
11
|
+
if (typeof row.word !== 'string' || typeof row.speaker !== 'string' || typeof next.speaker !== 'string') refuse('invalid canonical word binding')
|
|
12
|
+
return row.word.match(/\S+/gu)?.map((word: string) => ({ word, before: row.speaker, after: next.speaker })) ?? []
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
/** Exact ordered tokens + exact old labels, no nearest/anchor/substring guessing.
|
|
17
|
+
* Spoken token bytes and original separators remain; only prefixes are replaced
|
|
18
|
+
* or inserted when an existing turn must split at a proven word boundary.
|
|
19
|
+
*/
|
|
20
|
+
export function renderBoundSpeakerLabels(transcript: string, words: BoundWord[], preserveUnlabelled = false): string {
|
|
21
|
+
let pointer = 0
|
|
22
|
+
const result = transcript.split('\n').map(line => {
|
|
23
|
+
const match = /^(\[([^\]\r\n]+)\]:[ \t]*)(.*)$/.exec(line)
|
|
24
|
+
if (!match) { if (line.trim() && !preserveUnlabelled) refuse('unbound nonempty transcript line'); return line }
|
|
25
|
+
const tokens = [...match[3].matchAll(/\S+/gu)]
|
|
26
|
+
if (!tokens.length) return line
|
|
27
|
+
let out = '', previous: string | null = null, consumed = 0
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
const bound = words[pointer++]
|
|
30
|
+
if (!bound || bound.word !== token[0] || bound.before !== match[2]) refuse('transcript words/labels do not exactly match canonical evidence')
|
|
31
|
+
const offset = token.index!
|
|
32
|
+
out += match[3].slice(consumed, offset)
|
|
33
|
+
if (previous === null) out = match[1].replace(`[${match[2]}]`, `[${bound.after}]`) + out
|
|
34
|
+
else if (previous !== bound.after) out += `\n[${bound.after}]: `
|
|
35
|
+
out += token[0]; consumed = offset + token[0].length; previous = bound.after
|
|
36
|
+
}
|
|
37
|
+
return out + match[3].slice(consumed)
|
|
38
|
+
}).join('\n')
|
|
39
|
+
if (pointer !== words.length) refuse('transcript has missing canonical words')
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
/** Omitted/cleaned unaffected lines may stay. Every changed canonical token
|
|
43
|
+
* must still be covered by exactly one full-line, exact-token + old-label join. */
|
|
44
|
+
export function renderSparseBoundSpeakerLabels(transcript: string, words: BoundWord[]): string {
|
|
45
|
+
const covered = new Set<number>()
|
|
46
|
+
let lastEnd = 0
|
|
47
|
+
const out = transcript.split('\n').map(line => {
|
|
48
|
+
const match = /^(\[([^\]\r\n]+)\]:[ \t]*)(.*)$/.exec(line)
|
|
49
|
+
if (!match) return line
|
|
50
|
+
const tokens = match[3].match(/\S+/gu) ?? []
|
|
51
|
+
if (!tokens.length) return line
|
|
52
|
+
const candidates: number[] = []
|
|
53
|
+
for (let start = 0; start <= words.length - tokens.length; start++) {
|
|
54
|
+
if (tokens.every((token, offset) => words[start + offset].word === token && words[start + offset].before === match[2])) candidates.push(start)
|
|
55
|
+
}
|
|
56
|
+
const affects = (start: number) => words.slice(start, start + tokens.length).some(word => word.before !== word.after)
|
|
57
|
+
if (candidates.length !== 1) {
|
|
58
|
+
if (candidates.some(affects)) refuse('changed markdown line has ambiguous canonical binding')
|
|
59
|
+
return line
|
|
60
|
+
}
|
|
61
|
+
const start = candidates[0], end = start + tokens.length
|
|
62
|
+
if (start < lastEnd) refuse('markdown canonical line order disagrees')
|
|
63
|
+
lastEnd = end
|
|
64
|
+
for (let index = start; index < end; index++) covered.add(index)
|
|
65
|
+
return affects(start) ? renderBoundSpeakerLabels(line, words.slice(start, end)) : line
|
|
66
|
+
}).join('\n')
|
|
67
|
+
if (words.some((word, index) => word.before !== word.after && !covered.has(index))) refuse('changed canonical words lack unique complete markdown binding')
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
/** The operations importer writes this literal marker after a character-limited
|
|
71
|
+
* canonical preview. Only an exact ordered prefix may waive omitted tail words. */
|
|
72
|
+
export function renderTruncatedBoundSpeakerLabels(body: string, words: BoundWord[]): string {
|
|
73
|
+
const marker = '... [Transcript truncated for length]'
|
|
74
|
+
const position = body.indexOf(marker)
|
|
75
|
+
if (position < 0 || body.indexOf(marker, position + marker.length) >= 0) refuse('one explicit transcript truncation marker required')
|
|
76
|
+
const shown = body.slice(0, position), suffix = body.slice(position)
|
|
77
|
+
if (/^\[[^\]\r\n]+\]:/m.test(suffix)) refuse('spoken lines follow transcript truncation marker')
|
|
78
|
+
const tokens: Array<{word: string; label: string}> = []
|
|
79
|
+
for (const line of shown.split('\n')) {
|
|
80
|
+
const match = /^\[([^\]\r\n]+)\]:[ \t]*(.*)$/.exec(line)
|
|
81
|
+
if (match) for (const word of match[2].match(/\S+/gu) ?? []) tokens.push({word, label:match[1]})
|
|
82
|
+
}
|
|
83
|
+
if (!tokens.length || tokens.length >= words.length) refuse('truncation marker does not describe a canonical prefix')
|
|
84
|
+
const visible = tokens.map((token, index) => {
|
|
85
|
+
const bound = words[index]
|
|
86
|
+
const partial = index === tokens.length - 1 && bound.word.startsWith(token.word)
|
|
87
|
+
if (bound.before !== token.label || (bound.word !== token.word && !partial)) refuse('truncated preview is not an exact canonical prefix')
|
|
88
|
+
return {...bound, word:token.word}
|
|
89
|
+
})
|
|
90
|
+
return renderBoundSpeakerLabels(shown, visible, true) + suffix
|
|
91
|
+
}
|
|
92
|
+
export function renderMarkdown(markdown: string, words: BoundWord[]): string {
|
|
93
|
+
const headings = [...markdown.matchAll(/^## Transcript[^\n]*\n/gm)]
|
|
94
|
+
if (headings.length !== 1) refuse('exactly one Transcript section required')
|
|
95
|
+
const start = headings[0].index! + headings[0][0].length
|
|
96
|
+
const next = /^## /m.exec(markdown.slice(start)), end = next ? start + next.index : markdown.length
|
|
97
|
+
const body = markdown.slice(start, end)
|
|
98
|
+
let rendered: string
|
|
99
|
+
if (body.includes('... [Transcript truncated for length]')) rendered = renderTruncatedBoundSpeakerLabels(body, words)
|
|
100
|
+
else {
|
|
101
|
+
try { rendered = renderBoundSpeakerLabels(body, words, true) }
|
|
102
|
+
catch { rendered = renderSparseBoundSpeakerLabels(body, words) }
|
|
103
|
+
}
|
|
104
|
+
return markdown.slice(0, start) + rendered + markdown.slice(end)
|
|
105
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Pure preparation/qualification only. The caller verifies WAVs, runs extraction,
|
|
2
|
+
// authenticates these receipts and applies reviewed exact-span candidates.
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
5
|
+
import { hasMacSpeakerProtectedRows } from './mac-speaker-protection.js'
|
|
6
|
+
import {
|
|
7
|
+
createMacSpeakerMemory, matchMacSpeakerProfile, normalizedSpeakerCosine, normalizedSpeakerEmbedding,
|
|
8
|
+
reconcileMacSpeakerObservation, type MacSpeakerObservation, type MacSpeakerProfile,
|
|
9
|
+
} from './mac-speaker-reconcile.js'
|
|
10
|
+
import type { SidecarEntry } from './nemotron-final.js'
|
|
11
|
+
const SR = 16_000
|
|
12
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
13
|
+
const uint = (n: unknown): n is number => Number.isSafeInteger(n) && Number(n) >= 0
|
|
14
|
+
const samplePosition = (value: number): number => Number.isFinite(value) && Math.abs(value - Math.round(value)) <= 1e-6 ? Math.round(value) : Number.NaN
|
|
15
|
+
const hash = (v: unknown): string => createHash('sha256').update(JSON.stringify(v)).digest('hex')
|
|
16
|
+
export const MAC_SPEAKER_WINDOW_VERSION = 'exact-window-v1'
|
|
17
|
+
export interface VerifiedMacRawChunk {
|
|
18
|
+
chunkIndex: number
|
|
19
|
+
audioSHA256: string
|
|
20
|
+
pcmSHA256: string
|
|
21
|
+
sampleRate: 16000
|
|
22
|
+
channels: 1
|
|
23
|
+
format: 'pcm_s16le'
|
|
24
|
+
sampleCount: number
|
|
25
|
+
verified: true
|
|
26
|
+
}
|
|
27
|
+
export interface MacWindowSource extends VerifiedMacRawChunk {
|
|
28
|
+
startSample: number
|
|
29
|
+
endSample: number
|
|
30
|
+
/** Automatic segment span coverage, not independent VAD/purity ground truth. */
|
|
31
|
+
speechSamples: number
|
|
32
|
+
textSHA256: string
|
|
33
|
+
segmentsSHA256: string
|
|
34
|
+
}
|
|
35
|
+
export interface PreparedMacSpeakerWindow {
|
|
36
|
+
id: string
|
|
37
|
+
captureId: string
|
|
38
|
+
originalVoiceHint: string
|
|
39
|
+
startSample: number
|
|
40
|
+
endSample: number
|
|
41
|
+
sampleCount: number
|
|
42
|
+
speechSamples: number
|
|
43
|
+
sources: MacWindowSource[]
|
|
44
|
+
bindingSHA256: string
|
|
45
|
+
}
|
|
46
|
+
export interface MacSpeakerWindowPlan {
|
|
47
|
+
version: typeof MAC_SPEAKER_WINDOW_VERSION
|
|
48
|
+
captureId: string
|
|
49
|
+
/** These families are candidate scheduling hints, never speaker identities. */
|
|
50
|
+
families: { originalVoiceHint: string; windowIds: string[] }[]
|
|
51
|
+
windows: PreparedMacSpeakerWindow[]
|
|
52
|
+
rejected: { chunkIndexes: number[]; reason: string }[]
|
|
53
|
+
thresholds: { windowSamples: number; minimumWindowSamples: number; minimumSpeechSamples: number }
|
|
54
|
+
}
|
|
55
|
+
export interface MacSpeakerWindowInput {
|
|
56
|
+
sidecar: { sessionId: string; correctionRevision?: number; chunkEntries?: SidecarEntry[] }
|
|
57
|
+
rawChunks: VerifiedMacRawChunk[]
|
|
58
|
+
/** Includes ledger-only legacy corrections; unavailable must fail closed. */
|
|
59
|
+
correctionHistory: 'verified-clear' | 'human-corrected' | 'unavailable'
|
|
60
|
+
correctedChunkIndexes?: readonly number[]
|
|
61
|
+
minimumSpeechSamples?: number
|
|
62
|
+
}
|
|
63
|
+
function sourceOf(entry: SidecarEntry, raw: VerifiedMacRawChunk | undefined): { source?: MacWindowSource; hint?: string; reason?: string } {
|
|
64
|
+
const c = entry?.chunk
|
|
65
|
+
if (!c || !raw || !uint(entry.chunkIndex) || raw.chunkIndex !== entry.chunkIndex || raw.verified !== true
|
|
66
|
+
|| raw.sampleRate !== SR || raw.channels !== 1 || raw.format !== 'pcm_s16le' || !uint(raw.sampleCount) || !raw.sampleCount || raw.sampleCount > 480_000
|
|
67
|
+
|| !SHA.test(raw.audioSHA256) || !SHA.test(raw.pcmSHA256) || c.audioSha256 !== raw.audioSHA256) return { reason: 'unverified_raw_binding' }
|
|
68
|
+
if (c.speakerPolicy !== 'high-confidence' || !isVoiceLabel(c.speaker) || typeof c.text !== 'string' || !c.text.trim()) return { reason: 'not_automatic_homogeneous' }
|
|
69
|
+
if (hasMacSpeakerProtectedRows(c)) return { reason: 'human_corrected' }
|
|
70
|
+
const segments = c.segments
|
|
71
|
+
if (!Array.isArray(segments) || !segments.length || segments.length > 1024 || segments.some(s => s?.speaker !== c.speaker)
|
|
72
|
+
|| segments.map(s => s.text).join(' ').trim().split(/\s+/).join(' ') !== c.text.trim().split(/\s+/).join(' ')) return { reason: 'not_automatic_homogeneous' }
|
|
73
|
+
const startSample = samplePosition(Number(c.elapsed) * SR / 1000)
|
|
74
|
+
if (!uint(startSample) || !Number.isSafeInteger(startSample + raw.sampleCount)) return { reason: 'bad_clock' }
|
|
75
|
+
let end = 0, speechSamples = 0
|
|
76
|
+
for (const s of segments) {
|
|
77
|
+
const a = samplePosition(Number(s.startSec) * SR), b = samplePosition(Number(s.endSec) * SR)
|
|
78
|
+
if (!uint(a) || !uint(b) || a < end || b <= a || b > raw.sampleCount || (s as unknown as Record<string, unknown>).overlap === true || (s as unknown as Record<string, unknown>).mixedSpeakers === true) return { reason: 'mixed_or_invalid_segments' }
|
|
79
|
+
speechSamples += b - a; end = b
|
|
80
|
+
}
|
|
81
|
+
return { source: { ...structuredClone(raw), startSample, endSample: startSample + raw.sampleCount, speechSamples,
|
|
82
|
+
textSHA256: hash(c.text), segmentsSHA256: hash(segments) }, hint: c.speaker }
|
|
83
|
+
}
|
|
84
|
+
/** Non-overlapping original chunks only. No cross-label concatenation, gap
|
|
85
|
+
* filling, arbitrary crops, inferred names or automatic enrollment. */
|
|
86
|
+
export function planMacSpeakerWindows(input: MacSpeakerWindowInput): MacSpeakerWindowPlan {
|
|
87
|
+
const captureId = input.sidecar.sessionId
|
|
88
|
+
const minimumSpeechSamples = input.minimumSpeechSamples ?? 320_000
|
|
89
|
+
const plan: MacSpeakerWindowPlan = { version: MAC_SPEAKER_WINDOW_VERSION, captureId, families: [], windows: [], rejected: [], thresholds: { windowSamples: 480_000, minimumWindowSamples: 448_000, minimumSpeechSamples } }
|
|
90
|
+
if (typeof captureId !== 'string' || !captureId || captureId.length > 200 || !uint(minimumSpeechSamples) || minimumSpeechSamples < 32_000 || minimumSpeechSamples > 480_000) throw new Error('mac_window_input')
|
|
91
|
+
const entries = input.sidecar.chunkEntries
|
|
92
|
+
if (!Array.isArray(entries) || entries.length > 65536 || !Array.isArray(input.rawChunks) || input.rawChunks.length > 65536) throw new Error('mac_window_bound')
|
|
93
|
+
const refuse = (indexes: number[], reason: string) => { plan.rejected.push({ chunkIndexes: indexes, reason }) }
|
|
94
|
+
if (input.correctionHistory !== 'verified-clear' || !uint(input.sidecar.correctionRevision ?? 0) || Number(input.sidecar.correctionRevision ?? 0) > 0) {
|
|
95
|
+
refuse(entries.map(e => e.chunkIndex), 'human_history_not_clear'); return plan
|
|
96
|
+
}
|
|
97
|
+
const raw = new Map<number, VerifiedMacRawChunk>()
|
|
98
|
+
const duplicates = new Set<number>()
|
|
99
|
+
for (const r of input.rawChunks) { if (raw.has(r.chunkIndex)) duplicates.add(r.chunkIndex); raw.set(r.chunkIndex, r) }
|
|
100
|
+
const entryCounts = new Map<number, number>()
|
|
101
|
+
for (const e of entries) entryCounts.set(e.chunkIndex, (entryCounts.get(e.chunkIndex) ?? 0) + 1)
|
|
102
|
+
for (const [index, count] of entryCounts) if (count > 1) duplicates.add(index)
|
|
103
|
+
const seenChunks = new Set<number>(), seenAudio = new Set<string>(), seenPCM = new Set<string>()
|
|
104
|
+
const corrected = new Set(input.correctedChunkIndexes ?? [])
|
|
105
|
+
let pending: MacWindowSource[] = [], hint = ''
|
|
106
|
+
let lastSource: MacWindowSource | undefined
|
|
107
|
+
const flush = (reason: string) => { if (pending.length) refuse(pending.map(s => s.chunkIndex), reason); pending = []; hint = '' }
|
|
108
|
+
const finish = () => {
|
|
109
|
+
const first = pending[0], last = pending.at(-1)!
|
|
110
|
+
const sampleCount = pending.reduce((n, s) => n + s.sampleCount, 0)
|
|
111
|
+
const speechSamples = pending.reduce((n, s) => n + s.speechSamples, 0)
|
|
112
|
+
if (speechSamples < minimumSpeechSamples) { flush('insufficient_speech'); return }
|
|
113
|
+
const body = { captureId, originalVoiceHint: hint, startSample: first.startSample, endSample: last.endSample, sampleCount, speechSamples, sources: pending }
|
|
114
|
+
const bindingSHA256 = hash(body)
|
|
115
|
+
plan.windows.push({ ...structuredClone(body), id: `window_${bindingSHA256}`, bindingSHA256 })
|
|
116
|
+
pending = []; hint = ''
|
|
117
|
+
}
|
|
118
|
+
const close = (reason: string) => {
|
|
119
|
+
if (pending.reduce((n, s) => n + s.sampleCount, 0) >= plan.thresholds.minimumWindowSamples) finish()
|
|
120
|
+
else flush(reason)
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const index = entry?.chunkIndex
|
|
124
|
+
if (seenChunks.has(index) || duplicates.has(index)) { close('duplicate_raw_index'); refuse([index], 'duplicate_raw_index'); continue }
|
|
125
|
+
seenChunks.add(index)
|
|
126
|
+
const candidate = sourceOf(entry, raw.get(index))
|
|
127
|
+
if (corrected.has(index) || candidate.reason) { close('interrupted_window'); refuse([index], corrected.has(index) ? 'human_corrected' : candidate.reason!); continue }
|
|
128
|
+
const source = candidate.source!
|
|
129
|
+
if (seenAudio.has(source.audioSHA256) || seenPCM.has(source.pcmSHA256)) { close('duplicate_audio'); refuse([index], 'duplicate_audio'); continue }
|
|
130
|
+
seenAudio.add(source.audioSHA256); seenPCM.add(source.pcmSHA256)
|
|
131
|
+
if (lastSource && source.startSample < lastSource.endSample) { close('clock_overlap'); refuse([index], 'clock_overlap'); continue }
|
|
132
|
+
lastSource = source
|
|
133
|
+
if (pending.length && (source.startSample !== pending.at(-1)!.endSample || index !== pending.at(-1)!.chunkIndex + 1)) close('clock_or_raw_gap')
|
|
134
|
+
if (pending.length && candidate.hint !== hint) close('hint_changed')
|
|
135
|
+
if (pending.reduce((n, s) => n + s.sampleCount, 0) + source.sampleCount > 480_000) close('partial_window')
|
|
136
|
+
if (pending.length >= 240) close('source_count_bound')
|
|
137
|
+
hint = candidate.hint!; pending.push(source)
|
|
138
|
+
if (pending.reduce((n, s) => n + s.sampleCount, 0) === 480_000) finish()
|
|
139
|
+
}
|
|
140
|
+
close('partial_window')
|
|
141
|
+
const families = new Map<string, string[]>()
|
|
142
|
+
for (const w of plan.windows) { const ids = families.get(w.originalVoiceHint) ?? []; ids.push(w.id); families.set(w.originalVoiceHint, ids) }
|
|
143
|
+
plan.families = [...families].map(([originalVoiceHint, windowIds]) => ({ originalVoiceHint, windowIds }))
|
|
144
|
+
return plan
|
|
145
|
+
}
|
|
146
|
+
export interface MacWindowEmbeddingReceipt {
|
|
147
|
+
windowId: string
|
|
148
|
+
windowBindingSHA256: string
|
|
149
|
+
modelSHA256: string
|
|
150
|
+
/** Verified bytes of the exact prepared concatenation, not a crop. */
|
|
151
|
+
audioSHA256: string
|
|
152
|
+
pcmSHA256: string
|
|
153
|
+
sampleCount: number
|
|
154
|
+
embedding: readonly number[]
|
|
155
|
+
/** Sensitivity evidence only, never two additional independent observations. */
|
|
156
|
+
halfEmbeddings?: readonly [readonly number[], readonly number[]]
|
|
157
|
+
}
|
|
158
|
+
export interface QualifiedMacWindowCandidate {
|
|
159
|
+
windowId: string
|
|
160
|
+
startSample: number
|
|
161
|
+
endSample: number
|
|
162
|
+
originalVoiceHint: string
|
|
163
|
+
status: 'qualified' | 'preserve'
|
|
164
|
+
reason: string
|
|
165
|
+
/** Opaque cohort identity. Never a global rename of originalVoiceHint. */
|
|
166
|
+
acousticIdentity?: string
|
|
167
|
+
profileName?: string
|
|
168
|
+
evidence: string[]
|
|
169
|
+
provenance: { algorithm: typeof MAC_SPEAKER_WINDOW_VERSION; modelSHA256: string; profileSHA256?: string; anonymousCosine: .90; anonymousMargin: .06; identityCosine: .88; identityMargin: .05 }
|
|
170
|
+
}
|
|
171
|
+
function preparedWindowValid(w: PreparedMacSpeakerWindow, plan: MacSpeakerWindowPlan): boolean {
|
|
172
|
+
if (!w || w.captureId !== plan.captureId || !isVoiceLabel(w.originalVoiceHint) || !uint(w.startSample) || !uint(w.endSample)
|
|
173
|
+
|| !uint(w.sampleCount) || w.sampleCount < 448_000 || w.sampleCount > 480_000 || w.endSample - w.startSample !== w.sampleCount || !uint(w.speechSamples)
|
|
174
|
+
|| w.speechSamples < plan.thresholds.minimumSpeechSamples || w.speechSamples > w.sampleCount || !Array.isArray(w.sources) || !w.sources.length || w.sources.length > 240) return false
|
|
175
|
+
let samples = 0, speech = 0
|
|
176
|
+
for (let i = 0; i < w.sources.length; i++) {
|
|
177
|
+
const s = w.sources[i], previous = w.sources[i - 1]
|
|
178
|
+
if (!s || s.verified !== true || s.sampleRate !== SR || s.channels !== 1 || s.format !== 'pcm_s16le' || !uint(s.chunkIndex)
|
|
179
|
+
|| !uint(s.startSample) || !uint(s.endSample) || !uint(s.sampleCount) || s.sampleCount <= 0 || s.endSample - s.startSample !== s.sampleCount
|
|
180
|
+
|| !uint(s.speechSamples) || s.speechSamples > s.sampleCount || !SHA.test(s.audioSHA256) || !SHA.test(s.pcmSHA256) || !SHA.test(s.textSHA256) || !SHA.test(s.segmentsSHA256)
|
|
181
|
+
|| (previous && (s.chunkIndex !== previous.chunkIndex + 1 || s.startSample !== previous.endSample))) return false
|
|
182
|
+
samples += s.sampleCount; speech += s.speechSamples
|
|
183
|
+
}
|
|
184
|
+
return samples === w.sampleCount && speech === w.speechSamples && w.sources[0].startSample === w.startSample && w.sources.at(-1)!.endSample === w.endSample
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Cohorts require independent whole windows. Old Voice labels supply no votes.
|
|
188
|
+
* A qualified candidate is a proposal for this exact span, never permission to
|
|
189
|
+
* rename every occurrence of its hint. Frozen profiles are only read. */
|
|
190
|
+
export function qualifyMacSpeakerWindows(plan: MacSpeakerWindowPlan, receipts: readonly MacWindowEmbeddingReceipt[], profiles: readonly MacSpeakerProfile[], modelSHA256: string, dimensions = 192): QualifiedMacWindowCandidate[] {
|
|
191
|
+
if (plan.version !== MAC_SPEAKER_WINDOW_VERSION || plan.thresholds.windowSamples !== 480_000 || plan.thresholds.minimumWindowSamples !== 448_000 || !uint(plan.thresholds.minimumSpeechSamples) || plan.thresholds.minimumSpeechSamples < 32_000 || plan.thresholds.minimumSpeechSamples > 480_000 || !SHA.test(modelSHA256) || !uint(dimensions) || !dimensions || dimensions > 4096 || plan.windows.length > 8192 || receipts.length > 8192) throw new Error('mac_window_qualification_input')
|
|
192
|
+
let memory = createMacSpeakerMemory(plan.captureId, modelSHA256, { dimensions, maxIdentities: 128, maxOriginals: 8192 })
|
|
193
|
+
const byId = new Map<string, MacWindowEmbeddingReceipt>(), duplicate = new Set<string>()
|
|
194
|
+
for (const r of receipts) { if (byId.has(r.windowId)) duplicate.add(r.windowId); byId.set(r.windowId, r) }
|
|
195
|
+
const candidates: QualifiedMacWindowCandidate[] = []
|
|
196
|
+
const cohorts = new Map<number, { index: number; observation: MacSpeakerObservation }[]>()
|
|
197
|
+
const seenSources = new Set<number>(), seenRawPCM = new Set<string>(), seenRawAudio = new Set<string>()
|
|
198
|
+
for (let i = 0; i < plan.windows.length; i++) {
|
|
199
|
+
const w = plan.windows[i], r = byId.get(w.id)
|
|
200
|
+
const provenance: QualifiedMacWindowCandidate['provenance'] = { algorithm: MAC_SPEAKER_WINDOW_VERSION, modelSHA256, anonymousCosine: .90 as const, anonymousMargin: .06 as const, identityCosine: .88 as const, identityMargin: .05 as const }
|
|
201
|
+
const candidate: QualifiedMacWindowCandidate = { windowId: w.id, startSample: w.startSample, endSample: w.endSample, originalVoiceHint: w.originalVoiceHint, status: 'preserve', reason: 'missing_embedding', evidence: [], provenance }
|
|
202
|
+
candidates.push(candidate)
|
|
203
|
+
const { id: _id, bindingSHA256, ...body } = w
|
|
204
|
+
if (!r) continue
|
|
205
|
+
if (!preparedWindowValid(w, plan) || duplicate.has(w.id) || r.modelSHA256 !== modelSHA256 || r.windowBindingSHA256 !== bindingSHA256 || hash(body) !== bindingSHA256 || w.id !== `window_${bindingSHA256}`
|
|
206
|
+
|| w.captureId !== plan.captureId || !uint(w.sampleCount) || w.sampleCount < 448_000 || w.sampleCount > 480_000 || w.endSample - w.startSample !== w.sampleCount || r.sampleCount !== w.sampleCount
|
|
207
|
+
|| !SHA.test(r.audioSHA256) || !SHA.test(r.pcmSHA256) || !normalizedSpeakerEmbedding(r.embedding, dimensions)
|
|
208
|
+
|| w.sources.some(s => seenSources.has(s.chunkIndex) || seenRawPCM.has(s.pcmSHA256) || seenRawAudio.has(s.audioSHA256))) { candidate.reason = 'receipt_or_lineage_conflict'; continue }
|
|
209
|
+
for (const source of w.sources) { seenSources.add(source.chunkIndex); seenRawPCM.add(source.pcmSHA256); seenRawAudio.add(source.audioSHA256) }
|
|
210
|
+
if (r.halfEmbeddings && (r.halfEmbeddings.length !== 2 || r.halfEmbeddings.some(v => !normalizedSpeakerEmbedding(v, dimensions))
|
|
211
|
+
|| normalizedSpeakerCosine(r.halfEmbeddings[0], r.halfEmbeddings[1])! < .90
|
|
212
|
+
|| r.halfEmbeddings.some(v => normalizedSpeakerCosine(v, r.embedding)! < .90))) { candidate.reason = 'incoherent_halves'; continue }
|
|
213
|
+
const observation: MacSpeakerObservation = { captureId: plan.captureId, modelSHA256,
|
|
214
|
+
original: { chunkIndex: i, audioSHA256: r.audioSHA256, pcmSHA256: r.pcmSHA256, startSample: w.startSample, sampleCount: w.sampleCount },
|
|
215
|
+
startSample: w.startSample, endSample: w.endSample, exclusiveSamples: w.speechSamples, purity: 1, overlapSamples: 0, mixedSpeakers: false, embedding: r.embedding }
|
|
216
|
+
const next = reconcileMacSpeakerObservation(memory, observation)
|
|
217
|
+
memory = next.state; candidate.reason = next.decision.reason; candidate.evidence = next.decision.evidence
|
|
218
|
+
if (next.decision.identityId !== undefined) {
|
|
219
|
+
const group = cohorts.get(next.decision.identityId) ?? []; group.push({ index: i, observation }); cohorts.set(next.decision.identityId, group)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [identity, group] of cohorts) {
|
|
223
|
+
if (memory.identities[identity - 1]?.voice === null || group.length < 2) continue
|
|
224
|
+
if (group.some(a => group.some(b => normalizedSpeakerCosine(a.observation.embedding, b.observation.embedding)! < .90))) {
|
|
225
|
+
for (const item of group) candidates[item.index].reason = 'inconsistent_complete_link'
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const acousticIdentity = `acoustic_${hash(group.map(g => plan.windows[g.index].bindingSHA256).sort())}`
|
|
229
|
+
const proposal = matchMacSpeakerProfile(memory, group.map(g => g.observation), profiles)
|
|
230
|
+
const evidence = group.map(g => plan.windows[g.index].bindingSHA256)
|
|
231
|
+
for (const item of group) {
|
|
232
|
+
const c = candidates[item.index]
|
|
233
|
+
c.status = 'qualified'; c.reason = proposal.reason === 'matched_profile' ? 'qualified_profile' : 'qualified_anonymous'
|
|
234
|
+
c.acousticIdentity = acousticIdentity; c.evidence = evidence
|
|
235
|
+
if (proposal.reason === 'matched_profile') { c.profileName = proposal.label!; c.provenance.profileSHA256 = proposal.provenance.profileSHA256 }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return candidates
|
|
239
|
+
}
|
|
@@ -276,6 +276,9 @@ export interface AttendeeLine {
|
|
|
276
276
|
*/
|
|
277
277
|
isOwner: boolean
|
|
278
278
|
confirmedByHuman: boolean
|
|
279
|
+
/** Qualified exact-span machine evidence; raw capture scores stay unchanged. */
|
|
280
|
+
machineQualified?: boolean
|
|
281
|
+
machineQualifiedSegments?: number
|
|
279
282
|
}
|
|
280
283
|
|
|
281
284
|
/**
|
|
@@ -406,8 +409,12 @@ export function renderProvenance(
|
|
|
406
409
|
const clauses: string[] = []
|
|
407
410
|
const owner = named.filter(v => v.isOwner).map(q)
|
|
408
411
|
const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
|
|
409
|
-
const
|
|
412
|
+
const machine = named.filter(v => !v.isOwner && !v.confirmedByHuman && v.machineQualified).map(q)
|
|
413
|
+
const combined = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && (v.machineQualifiedSegments ?? 0) > 0).map(q)
|
|
414
|
+
const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && !(v.machineQualifiedSegments ?? 0)).map(q)
|
|
410
415
|
if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
|
|
416
|
+
if (machine.length) clauses.push(`${machine.join(', ')} by qualified independent long-window profile evidence (not human confirmation)`)
|
|
417
|
+
if (combined.length) clauses.push(`${combined.join(', ')} by capture matches plus qualified independent long-window profile evidence (not human confirmation)`)
|
|
411
418
|
if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
|
|
412
419
|
if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
|
|
413
420
|
const lines: string[] = [
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
//
|
|
23
23
|
// Everything here is pure over a chunk array so it can be tested by execution.
|
|
24
24
|
|
|
25
|
+
import type { QualifiedMachinePosition } from './mac-speaker-machine-evidence.js'
|
|
25
26
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
26
27
|
|
|
27
28
|
/** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
|
|
@@ -152,6 +153,10 @@ export interface VoiceReview {
|
|
|
152
153
|
isOwner: boolean
|
|
153
154
|
/** A human confirmed this label for this meeting; the floor is waived. */
|
|
154
155
|
confirmedByHuman: boolean
|
|
156
|
+
/** Independent long-window profile evidence, never a human confirmation. */
|
|
157
|
+
machineQualified: boolean
|
|
158
|
+
machineQualifiedSegments: number
|
|
159
|
+
assertionSource: 'owner' | 'human' | 'qualified-profile' | 'capture-and-qualified-profile' | 'capture-match' | null
|
|
155
160
|
reliability: Reliability
|
|
156
161
|
/**
|
|
157
162
|
* Whether `label` may be shown to a human AS A NAME.
|
|
@@ -588,6 +593,9 @@ export function reviewMeetingSpeakers(
|
|
|
588
593
|
*/
|
|
589
594
|
confirmed?: Set<string>
|
|
590
595
|
confirmedChunks?: Map<number, string>
|
|
596
|
+
/** Reader-validated exact compacted positions from current bound raw audio. */
|
|
597
|
+
machineEvidenceStatus?: 'absent' | 'qualified' | 'invalid'
|
|
598
|
+
machineQualifiedPositions?: ReadonlyMap<number, QualifiedMachinePosition>
|
|
591
599
|
/**
|
|
592
600
|
* The sidecar's `batchSegments`, when the HQ pass has run. Their word
|
|
593
601
|
* timings give real voiced time; without them speaking time falls back to
|
|
@@ -646,6 +654,24 @@ export function reviewMeetingSpeakers(
|
|
|
646
654
|
const voices: VoiceReview[] = labels.map(label => {
|
|
647
655
|
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
648
656
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
657
|
+
const machineQualifiedSegments = chunks.reduce((n, c, i) => {
|
|
658
|
+
const proof = options.machineQualifiedPositions?.get(i)
|
|
659
|
+
return n + Number(c.speaker === label && !!proof && proof.label === label
|
|
660
|
+
&& (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex)
|
|
661
|
+
&& proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05)
|
|
662
|
+
}, 0)
|
|
663
|
+
const machineQualified = own.length > 0 && machineQualifiedSegments === own.length
|
|
664
|
+
// Repaired records require support for every occurrence. A legacy mean
|
|
665
|
+
// from one positive row cannot vouch for repaired zero-score positions.
|
|
666
|
+
const scopedMachineRecord = (options.machineEvidenceStatus !== undefined && options.machineEvidenceStatus !== 'absent') || (options.machineQualifiedPositions?.size ?? 0) > 0
|
|
667
|
+
const unsupportedScopedSegments = scopedMachineRecord ? chunks.reduce((n, c, i) => {
|
|
668
|
+
if (c.speaker !== label) return n
|
|
669
|
+
const proof = options.machineQualifiedPositions?.get(i)
|
|
670
|
+
const machine = proof?.label === label && (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex) && proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05
|
|
671
|
+
const human = confirmed.has(label) || options.confirmedChunks?.get(i) === label
|
|
672
|
+
const capture = typeof c.similarity === 'number' && Number.isFinite(c.similarity) && c.similarity >= ASSERT_MIN_SIMILARITY
|
|
673
|
+
return n + Number(!machine && !human && !capture)
|
|
674
|
+
}, 0) : 0
|
|
649
675
|
const runs = speakerRuns(sequence, label)
|
|
650
676
|
const unattributed = isUnattributed(label)
|
|
651
677
|
const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
|
|
@@ -674,7 +700,7 @@ export function reviewMeetingSpeakers(
|
|
|
674
700
|
? 'unattributed'
|
|
675
701
|
: thrashesWith.length > 0
|
|
676
702
|
? 'unreliable'
|
|
677
|
-
: (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
703
|
+
: machineQualified || (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
678
704
|
|
|
679
705
|
// What stops this label being presented as a name. Collected as reasons
|
|
680
706
|
// rather than a bare boolean: "2 segments" and "similarity 0.58" are
|
|
@@ -688,6 +714,11 @@ export function reviewMeetingSpeakers(
|
|
|
688
714
|
// below 0.65" under a name they personally confirmed would reasonably
|
|
689
715
|
// conclude the confirmation had not taken. `thrashesWith` still renders,
|
|
690
716
|
// so a genuinely mixed row is still visibly mixed.
|
|
717
|
+
} else if (unsupportedScopedSegments) {
|
|
718
|
+
// System-audio repairs do not identify the wearer by device ownership.
|
|
719
|
+
// This gate also precedes the legacy owner exemption.
|
|
720
|
+
assertionBlockers.push(`${unsupportedScopedSegments} segment${unsupportedScopedSegments === 1 ? '' : 's'} lack current scoped speaker evidence`)
|
|
721
|
+
if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
691
722
|
} else if (label === owner) {
|
|
692
723
|
// The wearer is exempt. Their identity is established by wearing the
|
|
693
724
|
// device, not by cosine — and the owner is verified at exactly this floor
|
|
@@ -696,6 +727,10 @@ export function reviewMeetingSpeakers(
|
|
|
696
727
|
// row read "Unidentified voice" in 4 of 9 meetings, including one with 285
|
|
697
728
|
// of their own segments. `thrashesWith` still renders, so a mixed row is
|
|
698
729
|
// still visible — the name is asserted, the caveat is not hidden.
|
|
730
|
+
} else if (machineQualified) {
|
|
731
|
+
// Exact positions passed the independent .88/margin/long-window gate.
|
|
732
|
+
// Keep raw similarities unchanged and retain the mixed-row warning.
|
|
733
|
+
if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
699
734
|
} else {
|
|
700
735
|
if (own.length < ASSERT_MIN_SEGMENTS) {
|
|
701
736
|
assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
|
|
@@ -719,6 +754,9 @@ export function reviewMeetingSpeakers(
|
|
|
719
754
|
isOwner: label === owner,
|
|
720
755
|
/** True when a human vouched for this label in this meeting. */
|
|
721
756
|
confirmedByHuman: confirmed.has(label),
|
|
757
|
+
machineQualified: !unattributed && machineQualified,
|
|
758
|
+
machineQualifiedSegments: unattributed ? 0 : machineQualifiedSegments,
|
|
759
|
+
assertionSource: assertionBlockers.length ? null : confirmed.has(label) ? 'human' : label === owner ? 'owner' : machineQualified ? 'qualified-profile' : machineQualifiedSegments > 0 ? 'capture-and-qualified-profile' : 'capture-match',
|
|
722
760
|
reliability,
|
|
723
761
|
nameAsserted: assertionBlockers.length === 0,
|
|
724
762
|
assertionBlockers,
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
nemotronCliPath,
|
|
41
41
|
prepareNemotronModelsDir,
|
|
42
42
|
} from './diarizer-backend.js'
|
|
43
|
+
import { readCorrections } from './meeting-corrections.js'
|
|
43
44
|
import { CONFIDENT_SIMILARITY, isUnattributed } from './meeting-speaker-review.js'
|
|
44
45
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
45
46
|
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
@@ -370,6 +371,24 @@ function liveSpeakerAt(chunk: SidecarChunk, t: number, owner?: string | null): s
|
|
|
370
371
|
return best && typeof best.speaker === 'string' ? guardOwnerName(best.speaker, best.similarity, chunk, owner) : null
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/** Existing strict Mac names are local immutable claims, never a name source
|
|
375
|
+
* for a global anonymous channel. Exact token/word labels win over chunk names.
|
|
376
|
+
* An unplaced named segment withholds anonymous refinement for the whole chunk. */
|
|
377
|
+
function protectedMacName(chunk: SidecarChunk, t: number, exact?: string): { name: string | null; hold: boolean } {
|
|
378
|
+
if (chunk.speakerPolicy !== 'high-confidence') return { name: null, hold: false }
|
|
379
|
+
if (typeof exact === 'string' && isName(exact)) return { name: exact, hold: true }
|
|
380
|
+
const named = (chunk.segments ?? []).filter(s => isName(s.speaker))
|
|
381
|
+
for (const segment of named) {
|
|
382
|
+
if (!Number.isFinite(segment.startSec) || !Number.isFinite(segment.endSec)
|
|
383
|
+
|| Number(segment.startSec) < 0 || Number(segment.endSec) <= Number(segment.startSec)) return { name: null, hold: true }
|
|
384
|
+
if (t >= Number(segment.startSec) && t < Number(segment.endSec)) return { name: segment.speaker, hold: true }
|
|
385
|
+
}
|
|
386
|
+
// A segment-less legacy chunk has only its head claim. Explicit anonymous
|
|
387
|
+
// turns, however, must never inherit a real name from the majority/head label.
|
|
388
|
+
if (isName(chunk.speaker) && !chunk.segments?.length) return { name: chunk.speaker, hold: true }
|
|
389
|
+
return { name: null, hold: false }
|
|
390
|
+
}
|
|
391
|
+
|
|
373
392
|
export interface RelabelStats {
|
|
374
393
|
total: number
|
|
375
394
|
/** Words whose label changed. */
|
|
@@ -418,13 +437,15 @@ export function relabelBatchSegments(
|
|
|
418
437
|
const end = batchWordToTimeline(segment, Number(word.end), timeline)
|
|
419
438
|
// Existing live anonymous turns are established capture evidence. A
|
|
420
439
|
// meeting-wide channel may refine unknown speech, never overwrite them.
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
440
|
+
const liveCandidate = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
|
|
441
|
+
const protectedName = start ? protectedMacName(start.window.chunk, start.inChunk, word.speaker) : { name: null, hold: false }
|
|
442
|
+
const live = start?.window.chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
|
|
443
|
+
let name: string | null = protectedName.name ?? (isVoiceLabel(live) ? live : null)
|
|
444
|
+
if (name === null && !protectedName.hold && start) {
|
|
424
445
|
const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
|
|
425
446
|
if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
426
447
|
}
|
|
427
|
-
if (isVoiceLabel(live)) stats.kept++
|
|
448
|
+
if (isVoiceLabel(live) || protectedName.name !== null) stats.kept++
|
|
428
449
|
if (name === null) {
|
|
429
450
|
stats.kept++
|
|
430
451
|
name = live
|
|
@@ -479,20 +500,34 @@ export function relabelStreamingChunks(
|
|
|
479
500
|
if (typeof chunk.text !== 'string' || !chunk.text.trim()) continue
|
|
480
501
|
const tokens = splitTokens(chunk.text)
|
|
481
502
|
const times = streamingTokenTimes(chunk, tokens, window, a)
|
|
482
|
-
const
|
|
503
|
+
const originalBase = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
|
|
504
|
+
const base = chunk.speakerPolicy === 'high-confidence' && chunk.segments?.length && isName(originalBase) ? 'Ext' : originalBase
|
|
483
505
|
const labels: string[] = []
|
|
484
506
|
const liveTokens = (chunk.segments ?? []).flatMap(segment => splitTokens(String(segment.text ?? '')).map(token => ({ token, speaker: segment.speaker })))
|
|
485
507
|
const exactLive = liveTokens.length === tokens.length && liveTokens.every((part, i) => part.token === tokens[i]) ? liveTokens : null
|
|
508
|
+
// Without token alignment or a clock, preserve the original named chunk
|
|
509
|
+
// rather than spreading a nearby named turn across its unmatched text.
|
|
510
|
+
if (chunk.speakerPolicy === 'high-confidence' && chunk.segments?.some(s => isName(s.speaker)) && !exactLive && !times) continue
|
|
486
511
|
let carry: string | null = null
|
|
487
512
|
for (let i = 0; i < tokens.length; i++) {
|
|
488
513
|
const established = exactLive?.[i].speaker
|
|
489
|
-
if (!isWordToken(tokens[i]) || !times) {
|
|
514
|
+
if (!isWordToken(tokens[i]) || !times) {
|
|
515
|
+
const protectedName = !times && established === undefined && chunk.segments?.length ? { name: null, hold: true } : protectedMacName(chunk, times?.[i]?.start ?? 0, established)
|
|
516
|
+
const local = liveSpeakerAt(chunk, times?.[i]?.start ?? 0, owner)
|
|
517
|
+
const safeLocal = chunk.speakerPolicy === 'high-confidence' && isName(local) ? protectedName.name : local
|
|
518
|
+
labels.push(protectedName.name ?? (chunk.speakerPolicy === 'high-confidence' && established !== undefined ? established : isVoiceLabel(established) ? established! : carry ?? safeLocal ?? base))
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
490
521
|
stats.total++
|
|
491
522
|
const span = times[i]
|
|
492
523
|
const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
|
|
493
524
|
let name: string
|
|
494
|
-
const
|
|
495
|
-
|
|
525
|
+
const liveCandidate = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
|
|
526
|
+
const protectedName = protectedMacName(chunk, span.start, established)
|
|
527
|
+
const live = chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
|
|
528
|
+
if (protectedName.name !== null) { name = protectedName.name; stats.kept++ }
|
|
529
|
+
else if (protectedName.hold) { name = live ?? base; stats.kept++ }
|
|
530
|
+
else if (isVoiceLabel(live)) { name = live!; stats.kept++ }
|
|
496
531
|
else if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
497
532
|
else { name = live ?? base; stats.kept++ }
|
|
498
533
|
labels.push(name)
|
|
@@ -787,6 +822,14 @@ function distinctSpeakers(transcript: string): number {
|
|
|
787
822
|
return names.size
|
|
788
823
|
}
|
|
789
824
|
|
|
825
|
+
/** Legacy corrections may have a ledger without a sidecar revision. Unknown
|
|
826
|
+
* or incomplete recorded history is held, never treated as automatic evidence. */
|
|
827
|
+
function hasHumanCorrectionHistory(sidecar: Record<string, unknown>): boolean {
|
|
828
|
+
if (typeof sidecar.sessionId !== 'string') return false
|
|
829
|
+
const corrections = readCorrections(sidecar.sessionId)
|
|
830
|
+
return corrections.rows.length > 0 || corrections.unusable > 0
|
|
831
|
+
}
|
|
832
|
+
|
|
790
833
|
async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome> {
|
|
791
834
|
const started = (options.now ?? Date.now)()
|
|
792
835
|
const env = options.env ?? process.env
|
|
@@ -801,6 +844,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
801
844
|
const sidecar = JSON.parse(sidecarText) as Record<string, any>
|
|
802
845
|
const owner = options.owner !== undefined ? options.owner : ownerLabelOrNull()
|
|
803
846
|
if (Number(sidecar.correctionRevision ?? 0) > 0) return { status: 'skipped', reason: 'human_corrected' }
|
|
847
|
+
if (hasHumanCorrectionHistory(sidecar)) return { status: 'skipped', reason: 'human_corrected' }
|
|
804
848
|
const markdown = readFileSync(options.meetingPath, 'utf8')
|
|
805
849
|
const transcript = readTranscriptSection(markdown)
|
|
806
850
|
if (transcript === null) return { status: 'skipped', reason: 'no_transcript' }
|
|
@@ -1025,6 +1069,9 @@ function commitFinalPass(
|
|
|
1025
1069
|
return { status: 'skipped', reason: 'changed_during_pass', detail: sidecarNow !== sidecarText ? 'sidecar changed' : 'markdown changed' }
|
|
1026
1070
|
}
|
|
1027
1071
|
|
|
1072
|
+
// A legacy confirmation can land only in its ledger while native work runs.
|
|
1073
|
+
if (hasHumanCorrectionHistory(nextSidecar)) return { status: 'skipped', reason: 'changed_during_pass', detail: 'correction history changed' }
|
|
1074
|
+
|
|
1028
1075
|
// Sidecar first, markdown second, as the store does: the markdown is the
|
|
1029
1076
|
// visible commit. If the markdown write fails, put the sidecar back, and say
|
|
1030
1077
|
// so loudly if even that fails (the two files then disagree).
|
|
@@ -1056,8 +1103,8 @@ function commitFinalPass(
|
|
|
1056
1103
|
// ── 6.90.0: unnamed, meeting-stable voices for Mac recordings ───────────────────
|
|
1057
1104
|
//
|
|
1058
1105
|
// The named pass above maps a channel to a name from chunks the voiceprint was
|
|
1059
|
-
// sure of.
|
|
1060
|
-
//
|
|
1106
|
+
// sure of. Strict Mac recordings never borrow names across a global channel;
|
|
1107
|
+
// rare existing names are preserved locally while other words stay anonymous.
|
|
1061
1108
|
// This pass keeps the channels and drops the names: each channel that holds the
|
|
1062
1109
|
// floor in enough chunks becomes `Voice 1`, `Voice 2`, ... in order of first
|
|
1063
1110
|
// speech. No profile is read or written anywhere below; `owner` is never passed,
|
|
@@ -1070,9 +1117,9 @@ export const VOICE_MIN_SUPPORT = FINAL_MIN_SUPPORT
|
|
|
1070
1117
|
|
|
1071
1118
|
/**
|
|
1072
1119
|
* Unnamed mode applies to a Mac recording, with the switch on, when every chunk
|
|
1073
|
-
* that carries words is the Mac strict policy
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1120
|
+
* that carries words is the Mac strict policy. Rare existing names are protected
|
|
1121
|
+
* per span by the relabellers; one match must not disable anonymous refinement
|
|
1122
|
+
* for the remainder of an otherwise strict recording.
|
|
1076
1123
|
*/
|
|
1077
1124
|
export function unnamedVoicesEligible(
|
|
1078
1125
|
sidecar: Record<string, unknown>,
|
|
@@ -1090,7 +1137,7 @@ export function unnamedVoicesEligible(
|
|
|
1090
1137
|
// strict; anything else (anonymous, a malformed marker) still refuses.
|
|
1091
1138
|
const silentOk = entries.filter(entry => !hasText(entry.chunk))
|
|
1092
1139
|
.every(entry => entry.chunk?.speakerPolicy === undefined || entry.chunk.speakerPolicy === 'high-confidence')
|
|
1093
|
-
return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence'
|
|
1140
|
+
return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence')
|
|
1094
1141
|
}
|
|
1095
1142
|
|
|
1096
1143
|
export interface VoiceChannels {
|
|
@@ -1360,6 +1407,9 @@ async function unnamedVoicePass(input: UnnamedInput): Promise<FinalPassOutcome>
|
|
|
1360
1407
|
for (const chunk of [entry.chunk, chunks[i]]) {
|
|
1361
1408
|
const original = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
|
|
1362
1409
|
if (final) chunk.finalSegments = final.segments
|
|
1410
|
+
// A pre-existing strict name is immutable even when a legacy chunk's
|
|
1411
|
+
// segments disagree. The anonymous pass never asserts a different name.
|
|
1412
|
+
if (chunk.speakerPolicy === 'high-confidence' && isName(chunk.speaker)) continue
|
|
1363
1413
|
chunk.speaker = label
|
|
1364
1414
|
if (label !== original) chunk.originalSpeaker = original
|
|
1365
1415
|
else delete chunk.originalSpeaker
|