@gotcos/glasses-server 6.94.4 → 6.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +44 -26
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-speaker-review.ts +182 -17
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Python-compatible numerics for the speaker shadow port (6.95.0).
|
|
2
|
+
//
|
|
3
|
+
// The shadow gates are a byte-for-byte port of the research tool
|
|
4
|
+
// (community-1-fluidaudio-v1/scripts/hybrid.py and round3/propose.py), which ran on
|
|
5
|
+
// CPython 3.15. Four Python behaviours differ from the obvious JavaScript and each one
|
|
6
|
+
// changes a gate or a reason string on real meetings, so each is replicated here:
|
|
7
|
+
//
|
|
8
|
+
// 1. `round(x)` and `round(x, n)` round half to EVEN on the exact binary value.
|
|
9
|
+
// Math.round and toFixed round half up, and `coveredFraction`, `majority` and
|
|
10
|
+
// `seconds` are compared AFTER rounding (0.7996 rounds to 0.8 and passes 0.8).
|
|
11
|
+
// 2. `'%.2f' % x` is correctly rounded half to even as well: 0.125 formats as 0.12,
|
|
12
|
+
// where (0.125).toFixed(2) is 0.13.
|
|
13
|
+
// 3. `sum()` of floats is Neumaier-compensated since CPython 3.12, so a plain loop
|
|
14
|
+
// drifts in the last bits of every cosine distance and norm.
|
|
15
|
+
// 4. String order is by code point, not UTF-16 unit.
|
|
16
|
+
//
|
|
17
|
+
// No I/O and no clock. Verified against CPython 3.15 vectors in speaker-shadow-numeric.test.ts.
|
|
18
|
+
|
|
19
|
+
const view = new DataView(new ArrayBuffer(8))
|
|
20
|
+
|
|
21
|
+
/** x = (neg ? -1 : 1) * mant * 2^exp, exactly. Finite x only. */
|
|
22
|
+
function decompose(x: number): { neg: boolean; mant: bigint; exp: number } {
|
|
23
|
+
view.setFloat64(0, x)
|
|
24
|
+
const hi = view.getUint32(0), lo = view.getUint32(4)
|
|
25
|
+
const neg = hi >>> 31 === 1
|
|
26
|
+
const biased = (hi >>> 20) & 0x7ff
|
|
27
|
+
let mant = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo)
|
|
28
|
+
let exp: number
|
|
29
|
+
if (biased === 0) exp = -1074
|
|
30
|
+
else { mant |= 1n << 52n; exp = biased - 1075 }
|
|
31
|
+
return { neg, mant, exp }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** |x| * 10^digits rounded half to even, as an exact integer. */
|
|
35
|
+
function scaledHalfEven(x: number, digits: number): { neg: boolean; q: bigint } {
|
|
36
|
+
const { neg, mant, exp } = decompose(x)
|
|
37
|
+
let num = mant * 10n ** BigInt(digits)
|
|
38
|
+
let den = 1n
|
|
39
|
+
if (exp >= 0) num <<= BigInt(exp)
|
|
40
|
+
else den <<= BigInt(-exp)
|
|
41
|
+
let q = num / den
|
|
42
|
+
const twice = (num % den) * 2n
|
|
43
|
+
if (twice > den || (twice === den && (q & 1n) === 1n)) q += 1n
|
|
44
|
+
return { neg, q }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decimalString(neg: boolean, q: bigint, digits: number): string {
|
|
48
|
+
let text = q.toString()
|
|
49
|
+
if (digits > 0) {
|
|
50
|
+
text = text.padStart(digits + 1, '0')
|
|
51
|
+
text = `${text.slice(0, -digits)}.${text.slice(-digits)}`
|
|
52
|
+
}
|
|
53
|
+
return neg ? `-${text}` : text
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Python `'%.{digits}f' % x`. */
|
|
57
|
+
export function pyFixed(x: number, digits: number): string {
|
|
58
|
+
if (Number.isNaN(x)) return 'nan'
|
|
59
|
+
if (!Number.isFinite(x)) return x > 0 ? 'inf' : '-inf'
|
|
60
|
+
const { neg, q } = scaledHalfEven(x, digits)
|
|
61
|
+
return decimalString(neg, q, digits)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Python `round(x, digits)` for digits >= 1 (a float). */
|
|
65
|
+
export function pyRound(x: number, digits: number): number {
|
|
66
|
+
if (!Number.isFinite(x)) return x
|
|
67
|
+
const { neg, q } = scaledHalfEven(x, digits)
|
|
68
|
+
return Number.parseFloat(decimalString(neg, q, digits))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Python `round(x)` with no digits (an int, half to even). */
|
|
72
|
+
export function pyRoundInt(x: number): number {
|
|
73
|
+
const floor = Math.floor(x)
|
|
74
|
+
const fraction = x - floor
|
|
75
|
+
if (fraction > 0.5) return floor + 1
|
|
76
|
+
if (fraction < 0.5) return floor
|
|
77
|
+
return floor % 2 === 0 ? floor : floor + 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* CPython (>= 3.12) `sum()` of floats from the int start 0: the first item is added to
|
|
82
|
+
* 0 exactly, then Neumaier's improved Kahan-Babuska compensation, and the compensation is
|
|
83
|
+
* added once at the end only when it is finite and non-zero (Python/bltinmodule.c).
|
|
84
|
+
* Integral JSON values (only exact zeros occur in FluidAudio output) contribute nothing
|
|
85
|
+
* under either of CPython's int and float paths, so treating them as floats is exact.
|
|
86
|
+
*/
|
|
87
|
+
export function pySum(values: ArrayLike<number>): number {
|
|
88
|
+
const n = values.length
|
|
89
|
+
if (n === 0) return 0
|
|
90
|
+
let total = 0 + values[0]
|
|
91
|
+
let compensation = 0
|
|
92
|
+
for (let i = 1; i < n; i++) {
|
|
93
|
+
const x = values[i]
|
|
94
|
+
const t = total + x
|
|
95
|
+
if (Math.abs(total) >= Math.abs(x)) compensation += (total - t) + x
|
|
96
|
+
else compensation += (x - t) + total
|
|
97
|
+
total = t
|
|
98
|
+
}
|
|
99
|
+
if (compensation !== 0 && Number.isFinite(compensation)) total += compensation
|
|
100
|
+
return total
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Python str ordering (by code point). */
|
|
104
|
+
export function codePointCompare(a: string, b: string): number {
|
|
105
|
+
if (a === b) return 0
|
|
106
|
+
const n = Math.min(a.length, b.length)
|
|
107
|
+
for (let i = 0; i < n;) {
|
|
108
|
+
const ca = a.codePointAt(i)!, cb = b.codePointAt(i)!
|
|
109
|
+
if (ca !== cb) return ca < cb ? -1 : 1
|
|
110
|
+
i += ca > 0xffff ? 2 : 1
|
|
111
|
+
}
|
|
112
|
+
return a.length < b.length ? -1 : a.length > b.length ? 1 : 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Python `str.isspace()` characters, which is what `\s` matches in a str pattern. */
|
|
116
|
+
export const PY_SPACE_CLASS = '[\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]'
|
|
117
|
+
/** Python `re.compile(r'Voice\s*\d+').fullmatch` (Unicode `\s` and `\d`). */
|
|
118
|
+
export const PY_VOICE_LABEL = new RegExp(`^Voice${PY_SPACE_CLASS}*\\p{Nd}+$`, 'u')
|
|
119
|
+
|
|
120
|
+
const ND = /^\p{Nd}$/u
|
|
121
|
+
/** Python `int(re.findall(r'\d+', label)[0])`, or null when the label has no digit. */
|
|
122
|
+
export function pyFirstInt(label: string): number | null {
|
|
123
|
+
const run = /\p{Nd}+/u.exec(label)
|
|
124
|
+
if (!run) return null
|
|
125
|
+
let value = 0
|
|
126
|
+
for (const ch of run[0]) {
|
|
127
|
+
const cp = ch.codePointAt(0)!
|
|
128
|
+
// Unicode keeps every decimal digit set as ten contiguous code points from its zero,
|
|
129
|
+
// and adjacent sets (mathematical digits) are whole multiples of ten.
|
|
130
|
+
let zero = cp
|
|
131
|
+
while (zero > 0 && ND.test(String.fromCodePoint(zero - 1))) zero--
|
|
132
|
+
value = value * 10 + ((cp - zero) % 10)
|
|
133
|
+
}
|
|
134
|
+
return value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Python `'%d:%02d:%02d' % (h, m, s) if h else '%d:%02d' % (m, s)` after `int(round(sec))`. */
|
|
138
|
+
export function pyClock(seconds: number): string {
|
|
139
|
+
const sec = pyRoundInt(seconds)
|
|
140
|
+
const h = Math.floor(sec / 3600), m = Math.floor(sec / 60) % 60, s = sec % 60
|
|
141
|
+
const two = (n: number) => String(n).padStart(2, '0')
|
|
142
|
+
return h ? `${h}:${two(m)}:${two(s)}` : `${m}:${two(s)}`
|
|
143
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Bounded read-only clock diagnostics. File dates and timestamps are not alignment proof.
|
|
2
|
+
import { open, readdir, lstat } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { basename, dirname, join } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
export interface ShadowPairSource {
|
|
8
|
+
status: 'time_only' | 'ambiguous' | 'unpaired'
|
|
9
|
+
reason: string
|
|
10
|
+
sessionId?: string
|
|
11
|
+
sourceSha256?: string
|
|
12
|
+
g2?: { startTime: number; chunks: Array<{ speaker: string; elapsed: number; audioDurationMs: number }> }
|
|
13
|
+
}
|
|
14
|
+
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
|
|
15
|
+
function bounds(value: Record<string, unknown>, durationOverride?: number): [number, number] | null {
|
|
16
|
+
if (!finite(value.startTime) || !Array.isArray(value.chunks)) return null
|
|
17
|
+
const duration = durationOverride ?? value.durationMs
|
|
18
|
+
if (!finite(duration) || duration <= 0 || duration > 4 * 3600_000) return null
|
|
19
|
+
return [value.startTime, value.startTime + duration]
|
|
20
|
+
}
|
|
21
|
+
const day = (ms: number) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
|
|
22
|
+
/** Only the current recording directory is searched; absent cross-month pairs remain unpaired. */
|
|
23
|
+
export async function findShadowPairSource(sidecarPath: string, mac: Record<string, unknown>, durationMs?: number): Promise<ShadowPairSource> {
|
|
24
|
+
const target = bounds(mac, durationMs)
|
|
25
|
+
if (!target) return { status: 'unpaired', reason: 'invalid Mac clock or duration' }
|
|
26
|
+
const dir = dirname(sidecarPath)
|
|
27
|
+
// Official sidecars use this Mac's local calendar-day prefix. Include a four-hour
|
|
28
|
+
// lookback for a G2 capture that started before this Mac capture (maximum call length).
|
|
29
|
+
const days = new Set([day(target[0] - 4 * 3600_000), day(target[0]), day(target[1])])
|
|
30
|
+
const all = await readdir(dir)
|
|
31
|
+
const names = all.filter(n => n.endsWith('.g2-chunks.json') && n !== basename(sidecarPath))
|
|
32
|
+
.filter(n => { const prefix = n.match(/^(\d{4}-\d{2}-\d{2})_/); return !prefix || days.has(prefix[1]) }).sort()
|
|
33
|
+
if (names.length > 256) return { status: 'ambiguous', reason: 'day-window discovery file limit exceeded; no pair selected' }
|
|
34
|
+
let bytes = 0, estimated = false
|
|
35
|
+
const candidates: ShadowPairSource[] = []
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const path = join(dir, name)
|
|
38
|
+
let raw: string, value: Record<string, unknown>
|
|
39
|
+
try {
|
|
40
|
+
const stat = await lstat(path)
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'ambiguous', reason: 'unreadable day-window candidate; no pair selected' }
|
|
42
|
+
if (stat.size > 16 * 1024 * 1024 || bytes + stat.size > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery byte limit exceeded; no pair selected' }
|
|
43
|
+
const parts: Buffer[] = []
|
|
44
|
+
let size = 0
|
|
45
|
+
// A candidate can change between lstat and open. Never wait on a FIFO/device
|
|
46
|
+
// substituted in that interval; verify the actual descriptor before reading.
|
|
47
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
48
|
+
try {
|
|
49
|
+
if (!(await handle.stat()).isFile()) throw new Error('candidate is no longer a regular file')
|
|
50
|
+
const buffer = Buffer.alloc(64 * 1024)
|
|
51
|
+
while (true) {
|
|
52
|
+
const { bytesRead } = await handle.read(buffer)
|
|
53
|
+
if (!bytesRead) break
|
|
54
|
+
size += bytesRead
|
|
55
|
+
if (size > 16 * 1024 * 1024 || bytes + size > 128 * 1024 * 1024) throw new Error('candidate grew beyond byte budget')
|
|
56
|
+
parts.push(Buffer.from(buffer.subarray(0, bytesRead)))
|
|
57
|
+
}
|
|
58
|
+
} finally { await handle.close() }
|
|
59
|
+
raw = Buffer.concat(parts).toString('utf8')
|
|
60
|
+
bytes += size
|
|
61
|
+
if (Buffer.byteLength(raw) > 16 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery changed beyond byte limit; no pair selected' }
|
|
62
|
+
const parsed: unknown = JSON.parse(raw)
|
|
63
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid sidecar')
|
|
64
|
+
value = parsed as Record<string, unknown>
|
|
65
|
+
if (typeof value.sessionId !== 'string') throw new Error('missing source identity')
|
|
66
|
+
} catch { return { status: 'ambiguous', reason: 'unreadable or malformed day-window candidate; no pair selected' } }
|
|
67
|
+
if (typeof value.sessionId !== 'string' || !value.sessionId.startsWith('meeting_') || value.finalizationState !== 'complete') continue
|
|
68
|
+
const span = bounds(value)
|
|
69
|
+
if (!span) return { status: 'ambiguous', reason: 'complete G2 candidate lacks valid clock/duration; no pair selected' }
|
|
70
|
+
const overlap = Math.max(0, Math.min(target[1], span[1]) - Math.max(target[0], span[0]))
|
|
71
|
+
const macDuration = target[1] - target[0], g2Duration = span[1] - span[0]
|
|
72
|
+
if (overlap / Math.max(macDuration, g2Duration) < .5 || Math.min(macDuration, g2Duration) / Math.max(macDuration, g2Duration) < .5) continue
|
|
73
|
+
const chunks: NonNullable<ShadowPairSource['g2']>['chunks'] = []
|
|
74
|
+
for (const item of value.chunks as unknown[]) {
|
|
75
|
+
if (!item || typeof item !== 'object') return { status: 'ambiguous', reason: 'invalid G2 chunk; no pair selected' }
|
|
76
|
+
const row = item as Record<string, unknown>
|
|
77
|
+
if (typeof row.speaker !== 'string' || !finite(row.elapsed) || row.elapsed < 0) return { status: 'ambiguous', reason: 'invalid G2 timing or label; no pair selected' }
|
|
78
|
+
const exactDuration = row.audioDurationMs ?? row.durationMs
|
|
79
|
+
if (exactDuration !== undefined && (!finite(exactDuration) || exactDuration <= 0 || (row.audioDurationMs !== undefined && row.durationMs !== undefined && row.audioDurationMs !== row.durationMs))) return { status: 'ambiguous', reason: 'invalid G2 duration; no pair selected' }
|
|
80
|
+
// Historical G2 sidecars lack chunk durations: disclose the fixed-window
|
|
81
|
+
// approximation. This function can NEVER qualify alignment or transfer a name.
|
|
82
|
+
const duration = exactDuration === undefined ? 6000 : exactDuration as number
|
|
83
|
+
estimated ||= exactDuration === undefined
|
|
84
|
+
chunks.push({ speaker: row.speaker, elapsed: row.elapsed, audioDurationMs: duration })
|
|
85
|
+
}
|
|
86
|
+
candidates.push({ status: 'time_only', reason: '', sessionId: value.sessionId, sourceSha256: createHash('sha256').update(raw).digest('hex'), g2: { startTime: span[0], chunks } })
|
|
87
|
+
}
|
|
88
|
+
const scope = `Day-window scan: ${names.length} files, ${bytes} bytes, current directory only.`
|
|
89
|
+
return candidates.length === 1 ? { ...candidates[0], reason: `Clock overlap only; text offset/drift alignment has not been qualified. G2 names are estimates.${estimated ? ' G2 chunk durations use an approximate 6-second window where absent.' : ''} ${scope}` }
|
|
90
|
+
: { status: candidates.length ? 'ambiguous' : 'unpaired', reason: `${candidates.length ? `${candidates.length} overlapping G2 candidates; no pair chosen` : 'no complete overlapping G2 capture'}. ${scope}` }
|
|
91
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** Paired G2/Mac diagnostics only. No remap, identity proof, meeting write or training. */
|
|
2
|
+
import { checkSpeakerName } from './speaker-name.js'
|
|
3
|
+
import { SHADOW_GATES, SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowAccepted, type ShadowSegment, type ShadowTurn, type ShadowVoiceEntry } from './speaker-shadow-decide.js'
|
|
4
|
+
|
|
5
|
+
export const PAIRED_SHADOW_GATES = Object.freeze({ nameAllSpeechFraction: 0.8, nameSupportSeconds: 30, shortMinSeconds: 5, shortMaxSeconds: 15, shortAnchorRatio: 1.1 })
|
|
6
|
+
export type ShadowPairAlignment =
|
|
7
|
+
| { status: 'qualified'; evidenceId: string; offsetMs: number; maxErrorMs: number }
|
|
8
|
+
| { status: 'unpaired' | 'ambiguous' | 'time_only' }
|
|
9
|
+
export interface ShadowG2Chunk { speaker: string; elapsed: number; audioDurationMs?: number; durationMs?: number }
|
|
10
|
+
export interface ShadowG2Recording { startTime: string | number; chunks: readonly ShadowG2Chunk[] }
|
|
11
|
+
/** Raw anchorRatio is required for short nominations. Rounded compatibility values cannot qualify them. */
|
|
12
|
+
export type PairedShadowAcousticEvidence = ShadowVoiceEntry & { anchorRatio?: number | null }
|
|
13
|
+
export interface PairedShadowInput {
|
|
14
|
+
macStartTime: string | number
|
|
15
|
+
totalSamples: number
|
|
16
|
+
segments: readonly ShadowSegment[]
|
|
17
|
+
regularTurns: readonly ShadowTurn[]
|
|
18
|
+
/** Final accepted groups only; rejected/co-located voices must not be added here. */
|
|
19
|
+
acceptedGroups: readonly ShadowAccepted[]
|
|
20
|
+
/** Existing acoustic rows, including rejected short fragments. Never recomputed from names. */
|
|
21
|
+
voiceEvidence: readonly PairedShadowAcousticEvidence[]
|
|
22
|
+
g2?: ShadowG2Recording
|
|
23
|
+
alignment: ShadowPairAlignment
|
|
24
|
+
}
|
|
25
|
+
export interface PairedNameSupport { name: string; seconds: number; fractionOfAllSpeech: number; fractionOfKnownName: number }
|
|
26
|
+
export interface PairedShadowAgreement {
|
|
27
|
+
allSpeechSeconds: number
|
|
28
|
+
knownNameSeconds: number
|
|
29
|
+
unknownOrUnpairedSeconds: number
|
|
30
|
+
conflictingG2Seconds: number
|
|
31
|
+
productionConflictSeconds: number
|
|
32
|
+
nativeUncoveredSeconds: number
|
|
33
|
+
nativeConflictSeconds: number
|
|
34
|
+
wrongClusterSeconds: number
|
|
35
|
+
names: PairedNameSupport[]
|
|
36
|
+
diagnosticTopName: string | null
|
|
37
|
+
diagnosticAllSpeechGate: boolean
|
|
38
|
+
}
|
|
39
|
+
export interface PairedShadowGroup extends PairedShadowAgreement {
|
|
40
|
+
agreementScope: 'accepted_voice_speech'
|
|
41
|
+
wholeCluster: PairedShadowAgreement & { scope: 'whole_native_cluster_speech' }
|
|
42
|
+
cluster: string
|
|
43
|
+
voices: string[]
|
|
44
|
+
candidateName: string | null
|
|
45
|
+
reasons: string[]
|
|
46
|
+
}
|
|
47
|
+
export interface PairedShadowVoice extends PairedShadowAgreement { voice: string; cluster: string | null }
|
|
48
|
+
export interface PairedShadowSpan { startSample: number; endSample: number; cluster: string; candidateName: string }
|
|
49
|
+
export interface PairedShadowShortCandidate { voice: string; cluster: string; candidateName: string; seconds: number }
|
|
50
|
+
export interface PairedShadowReport {
|
|
51
|
+
mode: 'provisional_shadow_only'
|
|
52
|
+
alignmentStatus: ShadowPairAlignment['status']
|
|
53
|
+
candidateNominationAllowed: boolean
|
|
54
|
+
accuracyClaim: false
|
|
55
|
+
humanTruthClaim: false
|
|
56
|
+
warnings: string[]
|
|
57
|
+
groups: PairedShadowGroup[]
|
|
58
|
+
voices: PairedShadowVoice[]
|
|
59
|
+
extCandidates: PairedShadowSpan[]
|
|
60
|
+
shortCandidates: PairedShadowShortCandidate[]
|
|
61
|
+
shortWithheld: Array<{ voice: string; reasons: string[] }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A plausible saved G2 name is still a machine label, never human ground truth. */
|
|
65
|
+
export function isPairedShadowName(label: string): boolean {
|
|
66
|
+
const name = label.trim().replace(/\s+/gu, ' ')
|
|
67
|
+
if (/^(?:ext|unknown|mu|me|speaker(?:[\s_-]*(?:\d+|n))?|voice(?:[\s_-]*(?:\d+|n))?)$/iu.test(name)) return false
|
|
68
|
+
return checkSpeakerName(name).ok
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Row = readonly [number, number, string]
|
|
72
|
+
type Counts = Map<string, number>
|
|
73
|
+
interface Stats { all: number; known: number; unknown: number; conflict: number; production: number; uncovered: number; nativeConflict: number; wrong: number; names: Counts }
|
|
74
|
+
const stats = (): Stats => ({ all: 0, known: 0, unknown: 0, conflict: 0, production: 0, uncovered: 0, nativeConflict: 0, wrong: 0, names: new Map() })
|
|
75
|
+
const add = (map: Counts, key: string, amount: number) => { const n = (map.get(key) ?? 0) + amount; if (n) map.set(key, n); else map.delete(key) }
|
|
76
|
+
const epoch = (value: string | number): number => typeof value === 'number' ? value : Date.parse(value)
|
|
77
|
+
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
78
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
79
|
+
|
|
80
|
+
function accumulate(s: Stats, amount: number, production: Counts, native: Counts, g2: Counts, cluster: string | null, allowUnlabelledProduction = false): void {
|
|
81
|
+
s.all += amount
|
|
82
|
+
const name = g2.size === 1 ? [...g2.keys()][0] : null
|
|
83
|
+
if (g2.size > 1) s.conflict += amount
|
|
84
|
+
else if (name && isPairedShadowName(name)) s.known += amount
|
|
85
|
+
else s.unknown += amount
|
|
86
|
+
if (production.size > 1) s.production += amount
|
|
87
|
+
if (!native.size) s.uncovered += amount
|
|
88
|
+
else if (native.size > 1) s.nativeConflict += amount
|
|
89
|
+
else if (cluster !== null && !native.has(cluster)) s.wrong += amount
|
|
90
|
+
if ((production.size === 1 || (allowUnlabelledProduction && production.size === 0)) && native.size === 1 && cluster !== null && native.has(cluster) && name && g2.size === 1 && isPairedShadowName(name)) add(s.names, name, amount)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agreement(s: Stats): PairedShadowAgreement {
|
|
94
|
+
const names = [...s.names].map(([name, n]) => ({ name, seconds: n / SR, fractionOfAllSpeech: s.all ? n / s.all : 0, fractionOfKnownName: s.known ? n / s.known : 0 }))
|
|
95
|
+
.sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name))
|
|
96
|
+
const best = names[0]
|
|
97
|
+
const unique = !!best && (!names[1] || names[1].seconds !== best.seconds)
|
|
98
|
+
return { allSpeechSeconds: s.all / SR, knownNameSeconds: s.known / SR, unknownOrUnpairedSeconds: s.unknown / SR, conflictingG2Seconds: s.conflict / SR,
|
|
99
|
+
productionConflictSeconds: s.production / SR, nativeUncoveredSeconds: s.uncovered / SR, nativeConflictSeconds: s.nativeConflict / SR, wrongClusterSeconds: s.wrong / SR,
|
|
100
|
+
names, diagnosticTopName: unique ? best.name : null,
|
|
101
|
+
diagnosticAllSpeechGate: unique && best.fractionOfAllSpeech >= PAIRED_SHADOW_GATES.nameAllSpeechFraction && best.seconds >= PAIRED_SHADOW_GATES.nameSupportSeconds }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sweep half-open sample intervals once. Duplicate same-label rows union; different
|
|
106
|
+
* simultaneous labels withhold votes. Every group/voice denominator includes its
|
|
107
|
+
* unknown, uncovered and conflicting time, so named-only percentages cannot qualify it.
|
|
108
|
+
* offsetMs adds to the G2 wall clock. Only caller-qualified alignment enables nominations;
|
|
109
|
+
* timestamp coincidence alone is diagnostic. maxErrorMs erodes G2 vote intervals.
|
|
110
|
+
*/
|
|
111
|
+
export function evaluatePairedSpeakerShadow(input: PairedShadowInput): PairedShadowReport {
|
|
112
|
+
if (!Number.isSafeInteger(input.totalSamples) || input.totalSamples <= 0 || input.totalSamples > 4 * 3600 * SR) throw new Error('invalid paired shadow timeline')
|
|
113
|
+
if (input.segments.length + input.regularTurns.length + (input.g2?.chunks.length ?? 0) > 100_000) throw new Error('paired shadow interval budget exceeded')
|
|
114
|
+
const warnings = new Set<string>()
|
|
115
|
+
let safe = true
|
|
116
|
+
const macStart = epoch(input.macStartTime)
|
|
117
|
+
if (!finite(macStart)) { warnings.add('invalid Mac startTime'); safe = false }
|
|
118
|
+
const align = input.alignment
|
|
119
|
+
let qualified = align.status === 'qualified' && !!align.evidenceId.trim() && finite(align.offsetMs) && finite(align.maxErrorMs) && align.maxErrorMs >= 0
|
|
120
|
+
if (align.status === 'qualified' && !qualified) { warnings.add('invalid qualified alignment evidence'); safe = false }
|
|
121
|
+
if (align.status !== 'qualified') warnings.add(`${align.status}: time alignment is uncertain; diagnostic only`)
|
|
122
|
+
// Time-only still reports wall-clock overlap; it cannot transfer names.
|
|
123
|
+
const offset = qualified && align.status === 'qualified' ? align.offsetMs : 0
|
|
124
|
+
const erosion = qualified && align.status === 'qualified' ? Math.ceil(align.maxErrorMs * SR / 1000) : 0
|
|
125
|
+
const production: Row[] = [], native: Row[] = [], paired: Row[] = []
|
|
126
|
+
const clip = (rows: Row[], lo: number, hi: number, label: string, source: string) => {
|
|
127
|
+
if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi <= lo || typeof label !== 'string' || !label.trim()) { warnings.add(`invalid ${source} interval`); safe = false; return }
|
|
128
|
+
const a = Math.max(0, lo), b = Math.min(input.totalSamples, hi)
|
|
129
|
+
if (a < b) rows.push([a, b, label.trim().replace(/\s+/gu, ' ')])
|
|
130
|
+
}
|
|
131
|
+
for (const [lo, hi, label] of input.segments) clip(production, lo, hi, label, 'production')
|
|
132
|
+
for (const turn of input.regularTurns) clip(native, Math.round(turn.start * SR), Math.round(turn.end * SR), turn.speaker, 'native')
|
|
133
|
+
if (!input.g2) { warnings.add('no paired G2 recording'); qualified = false }
|
|
134
|
+
else {
|
|
135
|
+
const g2Start = epoch(input.g2.startTime)
|
|
136
|
+
if (!finite(g2Start)) { warnings.add('invalid G2 startTime'); safe = false }
|
|
137
|
+
for (const chunk of input.g2.chunks) {
|
|
138
|
+
const duration = chunk.audioDurationMs ?? chunk.durationMs
|
|
139
|
+
if (!finite(chunk.elapsed) || chunk.elapsed < 0 || !finite(duration) || duration <= 0 || (chunk.audioDurationMs !== undefined && chunk.durationMs !== undefined && chunk.audioDurationMs !== chunk.durationMs)) {
|
|
140
|
+
warnings.add('invalid or conflicting G2 elapsed/duration'); safe = false; continue
|
|
141
|
+
}
|
|
142
|
+
const lo = Math.round((g2Start - macStart + offset + chunk.elapsed) * SR / 1000) + erosion
|
|
143
|
+
const hi = Math.round((g2Start - macStart + offset + chunk.elapsed + duration) * SR / 1000) - erosion
|
|
144
|
+
// Entire interval inside alignment uncertainty contributes no name support.
|
|
145
|
+
if (hi <= lo && finite(lo) && finite(hi)) continue
|
|
146
|
+
clip(paired, lo, hi, chunk.speaker, 'G2')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const groups = new Map<string, { voices: string[]; stats: Stats; wholeStats: Stats }>()
|
|
150
|
+
const groupOfVoice = new Map<string, string>()
|
|
151
|
+
for (const group of input.acceptedGroups) {
|
|
152
|
+
const voices = [group.keepLabel, ...group.merge]
|
|
153
|
+
if (!group.cluster || groups.has(group.cluster) || new Set(voices).size !== voices.length || voices.some(v => !isShadowVoiceLabel(v) || groupOfVoice.has(v))) throw new Error('ambiguous accepted paired group')
|
|
154
|
+
groups.set(group.cluster, { voices, stats: stats(), wholeStats: stats() })
|
|
155
|
+
for (const voice of voices) groupOfVoice.set(voice, group.cluster)
|
|
156
|
+
}
|
|
157
|
+
const evidence = new Map<string, PairedShadowAcousticEvidence>()
|
|
158
|
+
for (const row of input.voiceEvidence) {
|
|
159
|
+
if (evidence.has(row.voice)) throw new Error('duplicate paired voice evidence')
|
|
160
|
+
evidence.set(row.voice, row)
|
|
161
|
+
}
|
|
162
|
+
const byVoice = new Map<string, Stats>()
|
|
163
|
+
for (const [, , label] of production) if (isShadowVoiceLabel(label) && !byVoice.has(label)) byVoice.set(label, stats())
|
|
164
|
+
type Event = { at: number; kind: number; label: string; delta: number }
|
|
165
|
+
const events: Event[] = []
|
|
166
|
+
for (const [kind, rows] of [production, native, paired].entries()) for (const [lo, hi, label] of rows) {
|
|
167
|
+
events.push({ at: lo, kind, label, delta: 1 }, { at: hi, kind, label, delta: -1 })
|
|
168
|
+
}
|
|
169
|
+
events.sort((a, b) => a.at - b.at)
|
|
170
|
+
const active = [new Map<string, number>(), new Map<string, number>(), new Map<string, number>()]
|
|
171
|
+
const ext: Array<{ lo: number; hi: number; cluster: string; name: string }> = []
|
|
172
|
+
for (let i = 0; i < events.length;) {
|
|
173
|
+
const at = events[i].at
|
|
174
|
+
while (i < events.length && events[i].at === at) { const event = events[i++]; add(active[event.kind], event.label, event.delta) }
|
|
175
|
+
const end = events[i]?.at
|
|
176
|
+
if (end === undefined || end <= at) continue
|
|
177
|
+
const touched = new Set<string>()
|
|
178
|
+
for (const voice of active[0].keys()) {
|
|
179
|
+
const s = byVoice.get(voice)
|
|
180
|
+
if (s) accumulate(s, end - at, active[0], active[1], active[2], evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null)
|
|
181
|
+
const group = groupOfVoice.get(voice)
|
|
182
|
+
if (group) touched.add(group)
|
|
183
|
+
}
|
|
184
|
+
for (const cluster of touched) accumulate(groups.get(cluster)!.stats, end - at, active[0], active[1], active[2], cluster)
|
|
185
|
+
// Full native-cluster union includes Ext, rejected/named labels and no-ASR time.
|
|
186
|
+
// Concurrent different clusters/production labels remain in the denominator but cannot vote.
|
|
187
|
+
for (const cluster of active[1].keys()) {
|
|
188
|
+
const group = groups.get(cluster)
|
|
189
|
+
if (group) accumulate(group.wholeStats, end - at, active[0], active[1], active[2], cluster, true)
|
|
190
|
+
}
|
|
191
|
+
if (active[0].size === 1 && active[0].has('Ext') && active[1].size === 1 && active[2].size === 1) {
|
|
192
|
+
const cluster = [...active[1].keys()][0], name = [...active[2].keys()][0]
|
|
193
|
+
if (groups.has(cluster) && isPairedShadowName(name)) ext.push({ lo: at, hi: end, cluster, name })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const allow = qualified && safe
|
|
197
|
+
const groupRows: PairedShadowGroup[] = [...groups].map(([cluster, group]) => {
|
|
198
|
+
const result = agreement(group.stats), wholeCluster = { ...agreement(group.wholeStats), scope: 'whole_native_cluster_speech' as const }, reasons: string[] = []
|
|
199
|
+
if (!allow) reasons.push('paired alignment is not qualified')
|
|
200
|
+
if (!result.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL accepted-voice speech and at least 30s support')
|
|
201
|
+
if (!wholeCluster.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL native-cluster speech and at least 30s support')
|
|
202
|
+
if (result.diagnosticTopName !== wholeCluster.diagnosticTopName) reasons.push('accepted voices and whole native cluster disagree on G2 name')
|
|
203
|
+
const both = result.diagnosticAllSpeechGate && wholeCluster.diagnosticAllSpeechGate && result.diagnosticTopName === wholeCluster.diagnosticTopName
|
|
204
|
+
return { ...result, agreementScope: 'accepted_voice_speech', wholeCluster, cluster, voices: group.voices, candidateName: allow && both ? result.diagnosticTopName : null, reasons }
|
|
205
|
+
})
|
|
206
|
+
const named = new Map(groupRows.filter(g => g.candidateName).map(g => [g.cluster, g.candidateName!]))
|
|
207
|
+
const voices = [...byVoice].map(([voice, s]) => ({ ...agreement(s), voice, cluster: evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null }))
|
|
208
|
+
const extCandidates: PairedShadowSpan[] = []
|
|
209
|
+
if (allow) for (const span of ext) if (named.get(span.cluster) === span.name) {
|
|
210
|
+
const previous = extCandidates.at(-1)
|
|
211
|
+
if (previous && previous.endSample === span.lo && previous.cluster === span.cluster && previous.candidateName === span.name) previous.endSample = span.hi
|
|
212
|
+
else extCandidates.push({ startSample: span.lo, endSample: span.hi, cluster: span.cluster, candidateName: span.name })
|
|
213
|
+
}
|
|
214
|
+
const shortCandidates: PairedShadowShortCandidate[] = [], shortWithheld: PairedShadowReport['shortWithheld'] = []
|
|
215
|
+
for (const voice of voices) {
|
|
216
|
+
if (voice.allSpeechSeconds < PAIRED_SHADOW_GATES.shortMinSeconds || voice.allSpeechSeconds > PAIRED_SHADOW_GATES.shortMaxSeconds) continue
|
|
217
|
+
const row = evidence.get(voice.voice), reasons: string[] = []
|
|
218
|
+
if (!allow) reasons.push('paired alignment is not qualified')
|
|
219
|
+
if (!row || !finite(row.seconds) || Math.abs(row.seconds - voice.allSpeechSeconds) > 1 / SR) reasons.push('missing or inconsistent acoustic evidence')
|
|
220
|
+
if (!row || !finite(row.majority) || row.majority < SHADOW_GATES.majority) reasons.push('acoustic majority below 0.8')
|
|
221
|
+
if (!row || !finite(row.coveredFraction) || row.coveredFraction < SHADOW_GATES.minCoveredFraction) reasons.push('acoustic coverage below 0.5')
|
|
222
|
+
if (!row?.outlierPass) reasons.push('acoustic outlier check failed')
|
|
223
|
+
if (!row || !finite(row.anchorRatio) || row.anchorRatio < 0 || row.anchorRatio > PAIRED_SHADOW_GATES.shortAnchorRatio) reasons.push('raw anchor ratio unavailable or above 1.1')
|
|
224
|
+
const name = voice.cluster ? named.get(voice.cluster) : undefined
|
|
225
|
+
const support = voice.names.find(n => n.name === name)
|
|
226
|
+
if (!name || !support || support.fractionOfAllSpeech < PAIRED_SHADOW_GATES.nameAllSpeechFraction || voice.diagnosticTopName !== name) reasons.push('fragment and group lack same-time G2 name agreement')
|
|
227
|
+
if (reasons.length) shortWithheld.push({ voice: voice.voice, reasons })
|
|
228
|
+
else shortCandidates.push({ voice: voice.voice, cluster: voice.cluster!, candidateName: name!, seconds: voice.allSpeechSeconds })
|
|
229
|
+
}
|
|
230
|
+
return { mode: 'provisional_shadow_only', alignmentStatus: align.status, candidateNominationAllowed: allow, accuracyClaim: false, humanTruthClaim: false,
|
|
231
|
+
warnings: [...warnings], groups: groupRows, voices, extCandidates, shortCandidates, shortWithheld }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Readable paired-recording diagnostics. All labels here remain provisional. */
|
|
235
|
+
export function renderPairedSpeakerShadow(report: PairedShadowReport): string {
|
|
236
|
+
const esc = (value: string) => value.replace(/[|\r\n]/gu, ' ').replace(/[*`<>]/gu, '')
|
|
237
|
+
const seconds = (n: number) => `${n.toFixed(1)} s`
|
|
238
|
+
const lines = ['## Paired G2 diagnostics', '',
|
|
239
|
+
`Alignment: **${report.alignmentStatus}**. ${report.candidateNominationAllowed ? 'Provisional shadow nominations are allowed.' : 'Nominations withheld; overlap statistics are diagnostic only.'}`,
|
|
240
|
+
'Saved G2 names are machine labels. This comparison makes no accuracy or human-truth claim.', '',
|
|
241
|
+
'Accepted-voice scope: only the final accepted keep/merge voices; this is not the whole native cluster.', '',
|
|
242
|
+
'| Cluster | All accepted-voice speech | Unique known-name time | Top name support / all speech | Top name support / known-name time | Name nomination |',
|
|
243
|
+
'|---|---:|---:|---|---|---|']
|
|
244
|
+
for (const group of report.groups) {
|
|
245
|
+
const best = group.names[0]
|
|
246
|
+
const support = best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'
|
|
247
|
+
lines.push(`| ${esc(group.cluster)} | ${seconds(group.allSpeechSeconds)} | ${seconds(group.knownNameSeconds)} | ${support}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% | ${group.candidateName ? esc(group.candidateName) + ' (provisional)' : 'Withheld'} |`)
|
|
248
|
+
}
|
|
249
|
+
lines.push('', 'Whole native-cluster scope includes Ext, other source labels and native speech without ASR labels.', '',
|
|
250
|
+
'| Cluster | All native-cluster speech | Unique known-name time | Top name support / all speech | Top name support / known-name time |',
|
|
251
|
+
'|---|---:|---:|---|---|')
|
|
252
|
+
for (const group of report.groups) {
|
|
253
|
+
const whole = group.wholeCluster, best = whole.names[0]
|
|
254
|
+
lines.push(`| ${esc(group.cluster)} | ${seconds(whole.allSpeechSeconds)} | ${seconds(whole.knownNameSeconds)} | ${best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% |`)
|
|
255
|
+
}
|
|
256
|
+
lines.push('', 'A name must pass 80% of ALL speech and 30 seconds in BOTH scopes with the same name. Known-name percentages alone cannot qualify it.', '',
|
|
257
|
+
'| Voice | All speech | Known-name time | Top name / all speech | Production conflict | Native conflict / uncovered |',
|
|
258
|
+
'|---|---:|---:|---|---:|---|')
|
|
259
|
+
for (const voice of report.voices) {
|
|
260
|
+
const best = voice.names[0]
|
|
261
|
+
lines.push(`| ${esc(voice.voice)} | ${seconds(voice.allSpeechSeconds)} | ${seconds(voice.knownNameSeconds)} | ${best ? `${esc(best.name)} ${((best.fractionOfAllSpeech) * 100).toFixed(1)}%` : 'None'} | ${seconds(voice.productionConflictSeconds)} | ${seconds(voice.nativeConflictSeconds)} / ${seconds(voice.nativeUncoveredSeconds)} |`)
|
|
262
|
+
}
|
|
263
|
+
lines.push('', `Ext span nominations: ${report.extCandidates.length}. Short-fragment nominations: ${report.shortCandidates.length}. These lists do not change labels or remaps.`)
|
|
264
|
+
for (const group of report.groups) if (group.reasons.length) lines.push(`- ${esc(group.cluster)}: ${group.reasons.map(esc).join('; ')}.`)
|
|
265
|
+
for (const row of report.shortWithheld) lines.push(`- ${esc(row.voice)} short fragment withheld: ${row.reasons.map(esc).join('; ')}.`)
|
|
266
|
+
for (const warning of report.warnings) lines.push(`- ${esc(warning)}.`)
|
|
267
|
+
return lines.join('\n') + '\n'
|
|
268
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Speaker shadow mode (6.95.0): the readable per-meeting report Miles checks by ear.
|
|
2
|
+
//
|
|
3
|
+
// A port of round3/propose.py `render()`: "N voices would become M", every accepted merge
|
|
4
|
+
// with its seconds, gate values and two or three listen pointers (file, time, text),
|
|
5
|
+
// every rejection with its reasons, and the named labels sharing a cluster as a hint
|
|
6
|
+
// ("this group also holds 65 s labelled ..."). Pure: it returns text, and the runner
|
|
7
|
+
// writes it under <data>/speaker-shadow/reports/. It proposes; it never applies anything.
|
|
8
|
+
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { SHADOW_GATES, type ShadowProposals, type ShadowVoiceEntry } from './speaker-shadow-decide.js'
|
|
11
|
+
import { PY_SPACE_CLASS, pyFixed } from './speaker-shadow-numeric.js'
|
|
12
|
+
|
|
13
|
+
export interface ShadowReportInput {
|
|
14
|
+
title: string
|
|
15
|
+
sessionId: string
|
|
16
|
+
proposals: ShadowProposals
|
|
17
|
+
facts: { minutes: number; originals: number; verifiedAgainstSidecar: number; unhashedOriginals: number; unhashedWithText: number[]; humanProtectedVoices: string[] }
|
|
18
|
+
/** Where the chunk WAVs are, so each pointer names a file Miles can play. */
|
|
19
|
+
audioDir: string
|
|
20
|
+
/** The original chunk text by raw chunk index (the first 140 characters are quoted). */
|
|
21
|
+
chunkText: ReadonlyMap<number, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
|
|
25
|
+
const quote = (text: string | undefined) => Array.from((text ?? '').replace(STRIP, '')).slice(0, 140).join('')
|
|
26
|
+
const chunkFile = (dir: string, index: number) => join(dir, `chunk_${String(index).padStart(4, '0')}.wav`)
|
|
27
|
+
const HEADER = ['| Voice | Speech | Covered | Top cluster, majority | Outlier check | Anchor ratio gate |', '|---|---:|---:|---|---|---|']
|
|
28
|
+
|
|
29
|
+
/** propose.py fmt_voice. */
|
|
30
|
+
export function shadowVoiceRow(m: ShadowVoiceEntry): string {
|
|
31
|
+
const o = m.outlierPass && m.outlierDistance !== null
|
|
32
|
+
? `outlier ${pyFixed(m.outlierDistance, 3)} ≤ p95 ${pyFixed(m.outlierP95!, 3)}`
|
|
33
|
+
: m.outlierDistance !== null ? `outlier ${pyFixed(m.outlierDistance, 3)} > p95 ${pyFixed(m.outlierP95!, 3)}` : `outlier: ${m.outlierNote}`
|
|
34
|
+
const ar = m.anchorRatioPostHoc === null ? '—' : `${pyFixed(m.anchorRatioPostHoc, 2)}x${m.anchorRatioPostHoc > SHADOW_GATES.anchorRatioPostHoc ? ' (above 1.5x)' : ''}`
|
|
35
|
+
return `| ${m.voice} | ${pyFixed(m.seconds, 1)} s | ${pyFixed(100 * m.coveredFraction, 0)}% | ${m.topCluster ?? 'None'} ${pyFixed(m.majority, 2)} | ${o} | ${ar} |`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listenLines(m: ShadowVoiceEntry, input: ShadowReportInput): string[] {
|
|
39
|
+
return m.listen.map(p => ` - ${m.voice}: \`${chunkFile(input.audioDir, p.chunkIndex)}\` ${pyFixed(p.inFileStart, 1)} to ${pyFixed(p.inFileEnd, 1)} s (call time ${p.callTime}) "${quote(input.chunkText.get(p.chunkIndex))}"`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderShadowReport(input: ShadowReportInput): string {
|
|
43
|
+
const P = input.proposals, F = input.facts
|
|
44
|
+
const L: string[] = [
|
|
45
|
+
`# ${input.title}`, '',
|
|
46
|
+
`**${P.productionVoices} voices would become ${P.voicesAfter}.** FluidAudio proposed ${P.proposedMerges} merges; ${P.acceptedMerges} pass every gate, ${P.rejectedMerges} do not. Shadow mode only: nothing was renamed or written.`, '',
|
|
47
|
+
F.unhashedWithText.length
|
|
48
|
+
? `- Meeting: ${pyFixed(F.minutes, 1)} min, ${F.originals} originals (${F.verifiedAgainstSidecar} verified; ${F.unhashedOriginals} unhashed, of which ${F.unhashedWithText.length} have text: check them).`
|
|
49
|
+
: `- Meeting: ${pyFixed(F.minutes, 1)} min, ${F.originals} originals (${F.verifiedAgainstSidecar} verified against the sidecar hashes; ${F.unhashedOriginals} had no hash, all empty-text).`,
|
|
50
|
+
`- FluidAudio found ${P.fluidAudioClusters} clusters. Labels never merged: ${P.frozenOtherLabels.join(', ') || 'none'}.`,
|
|
51
|
+
'- Gates: majority ≥0.8 of covered speech on one cluster (and ≥50% covered), ≥15 s of speech, embedding centroid inside the cluster spread (p95).',
|
|
52
|
+
'- Anchor ratio must be ≤1.5x against the kept voice\'s own spread; missing or degenerate evidence rejects a merge.',
|
|
53
|
+
'- How to check: play two or three pointers for each voice in a row. Same person? The merge is right.',
|
|
54
|
+
...(F.humanProtectedVoices.length ? [`- A person already edited chunks holding ${F.humanProtectedVoices.join(', ')}. Those edits outrank any proposal here.`] : []),
|
|
55
|
+
`- Session \`${input.sessionId}\`.`, '',
|
|
56
|
+
'## Accepted proposals', '',
|
|
57
|
+
]
|
|
58
|
+
if (!P.accepted.length) L.push('None.')
|
|
59
|
+
for (const a of P.accepted) {
|
|
60
|
+
L.push(`### ${a.cluster}: merge ${a.merge.join(', ')} into **${a.keepLabel}**`)
|
|
61
|
+
// Names first: Ext is speech no one was attributed to, not a label anyone gave.
|
|
62
|
+
const named = a.otherLabelsInCluster.filter(o => o.label !== 'Ext'), ext = a.otherLabelsInCluster.find(o => o.label === 'Ext')
|
|
63
|
+
const extText = ext ? `${pyFixed(ext.seconds, 0)} s of Ext speech (no voice label)` : ''
|
|
64
|
+
if (named.length) {
|
|
65
|
+
L.push(`Caution: this group also holds ${named.map(o => `${pyFixed(o.seconds, 0)} s labelled ${o.label}`).join(', ')}. Those labels stay as they are.${ext ? ` It also holds ${extText}.` : ''}`)
|
|
66
|
+
} else if (ext) {
|
|
67
|
+
L.push(`Note: this group also holds ${extText}.`)
|
|
68
|
+
}
|
|
69
|
+
const touched = a.voices.map(m => m.voice).filter(v => F.humanProtectedVoices.includes(v))
|
|
70
|
+
if (touched.length) L.push(`Human edit: ${touched.join(', ')} carries a person's correction on some chunks.`)
|
|
71
|
+
L.push('', ...HEADER, ...a.voices.map(shadowVoiceRow), '', 'Listen:')
|
|
72
|
+
for (const m of a.voices) L.push(...listenLines(m, input))
|
|
73
|
+
L.push('')
|
|
74
|
+
}
|
|
75
|
+
L.push('## Rejected proposals', '')
|
|
76
|
+
const rejected = P.rejected.filter(r => r.wouldJoin)
|
|
77
|
+
if (!rejected.length) L.push('None.')
|
|
78
|
+
for (const r of rejected) {
|
|
79
|
+
L.push(`- **${r.voice}** would have joined ${r.wouldJoin} on ${r.cluster}. Rejected: ${r.reasons.join('; ')}.`)
|
|
80
|
+
L.push('', HEADER[0], HEADER[1], shadowVoiceRow(r), '', ...listenLines(r, input), '')
|
|
81
|
+
}
|
|
82
|
+
L.push('## Untouched voices', '')
|
|
83
|
+
const kept = P.rejected.filter(r => !r.wouldJoin)
|
|
84
|
+
for (const r of kept) {
|
|
85
|
+
const fails = r.eligible ? '' : ` (it also fails: ${r.reasons.filter(x => !x.startsWith('no other')).join('; ')})`
|
|
86
|
+
L.push(`- ${r.voice} (${pyFixed(r.seconds, 1)} s): kept as is; it shares ${r.cluster} with other voices, but none of them passed every gate${fails}.`)
|
|
87
|
+
}
|
|
88
|
+
for (const u of P.untouched) L.push(`- ${u.voice} (${pyFixed(u.seconds, 1)} s): ${u.why}.`)
|
|
89
|
+
if (!kept.length && !P.untouched.length) L.push('None.')
|
|
90
|
+
L.push('')
|
|
91
|
+
return L.join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A meeting the run skipped or failed: one short page, so every finished meeting has a report. */
|
|
95
|
+
export function renderShadowSkipReport(input: { title: string; sessionId: string; status: 'skipped' | 'failed'; reason: string | null; detail: string | null }): string {
|
|
96
|
+
return [`# ${input.title}`, '', `**No proposals: ${input.status}** (\`${input.reason ?? 'unknown'}\`). ${input.detail ?? ''}`.trimEnd(), '', `- Session \`${input.sessionId}\`. Shadow mode only: nothing was renamed or written.`, ''].join('\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The run's own facts, appended by the runner (it alone knows wall time and memory). */
|
|
100
|
+
export function renderShadowRunFacts(input: { rebuildMs: number; nativeMs: number; wallMs: number; peakRssBytes: number | null; osTempDirsLeft: number; sidecarPath: string; pcmSHA256: string; cliTrust: string | null }): string {
|
|
101
|
+
return ['## Run facts', '',
|
|
102
|
+
`- Rebuild ${pyFixed(input.rebuildMs / 1000, 1)} s, diarization ${pyFixed(input.nativeMs / 1000, 1)} s, total ${pyFixed(input.wallMs / 1000, 1)} s. Peak memory ${input.peakRssBytes === null ? 'not sampled' : `${Math.round(input.peakRssBytes / 1048576)} MB`}.`,
|
|
103
|
+
`- fluidaudiocli trust: ${input.cliTrust ?? 'unknown'}. Network denied by sandbox-exec. Empty OS temp dirs left: ${input.osTempDirsLeft}.`,
|
|
104
|
+
`- Sidecar: \`${input.sidecarPath}\`. The timeline WAV was a temporary input and was deleted (PCM SHA ${input.pcmSHA256.slice(0, 12)}…).`, ''].join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The report's place: reports/<YYYY-MM-DD>/<title stem>__<session>.md, named like meeting-store names meetings. */
|
|
108
|
+
export function shadowReportRelativePath(title: string, sessionId: string, day: string): string {
|
|
109
|
+
const stem = title.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^[_-]+|[_-]+$/g, '').replace(/_{2,}/g, '_').slice(0, 72) || 'Mac_recording'
|
|
110
|
+
const safeDay = /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : 'undated'
|
|
111
|
+
// The whole session id: two meetings can share a title and a long id prefix.
|
|
112
|
+
return join('reports', safeDay, `${stem}__${sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96)}.md`)
|
|
113
|
+
}
|