@gotcos/glasses-server 6.94.1 → 6.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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 (post-hoc) |', '|---|---:|---:|---|---|---|']
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" is an UNVALIDATED extra check (fragment vs the kept voice\'s own spread); it decides nothing. Above 1.5x is worth a listen.',
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
+ }
@@ -0,0 +1,210 @@
1
+ // Speaker shadow mode (6.95.0): the whole-call timeline and the production segments.
2
+ //
3
+ // A port of round3/propose.py `build_timeline` and its segment loop. READ ONLY: it reads
4
+ // the finalized sidecar and the retained chunk WAVs and returns buffers; it never writes.
5
+ //
6
+ // Placement policy (the canary's): each original chunk starts at floor(elapsed_ms*16+.5),
7
+ // rows sort by (start, raw index), an original's tail that runs into the next one's start
8
+ // is trimmed, and gaps stay zero. Every original with an `audioSha256` must still match
9
+ // it, and each file is read twice with the second read required to hash the same as the
10
+ // first, which is what the tool's "rebuild twice, same PCM SHA" check proves.
11
+
12
+ import { createHash } from 'node:crypto'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { basename } from 'node:path'
15
+ import { hasMacSpeakerProtectedRows } from './mac-speaker-protection.js'
16
+ import { SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowSegment, type ShadowSegmentSource } from './speaker-shadow-decide.js'
17
+ import { PY_SPACE_CLASS, pyRoundInt } from './speaker-shadow-numeric.js'
18
+
19
+ /** A meeting longer than this is not reconstructed (the timeline buffer is 115 MB per hour). */
20
+ export const SHADOW_MAX_TIMELINE_SECONDS = 4 * 60 * 60
21
+
22
+ export class ShadowInputError extends Error {
23
+ constructor(readonly reason: 'bad_sidecar' | 'audio_missing' | 'audio_format' | 'audio_drift' | 'too_long', message: string) {
24
+ super(message)
25
+ this.name = 'ShadowInputError'
26
+ }
27
+ }
28
+
29
+ export interface ShadowChunkEntry { chunkIndex: number; chunk: Record<string, unknown> }
30
+ export interface ShadowTimelineRow { rawChunkIndex: number; startSample: number; sampleCount: number; usedSampleCount: number }
31
+ export interface ShadowTimeline {
32
+ rows: ShadowTimelineRow[]
33
+ pcm: Buffer
34
+ total: number
35
+ verified: number
36
+ unhashed: number
37
+ unhashedWithText: number[]
38
+ pcmSHA256: string
39
+ }
40
+
41
+ const sha256 = (data: Buffer) => createHash('sha256').update(data).digest('hex')
42
+ const PY_STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
43
+
44
+ /** Validated entries in sidecar order, or a bad_sidecar error. */
45
+ export function shadowEntries(sidecar: Record<string, unknown>): ShadowChunkEntry[] {
46
+ const raw = sidecar.chunkEntries
47
+ if (!Array.isArray(raw) || !raw.length) throw new ShadowInputError('bad_sidecar', 'the sidecar has no chunkEntries')
48
+ const seen = new Set<number>()
49
+ return raw.map(item => {
50
+ const e = item as { chunkIndex?: unknown; chunk?: unknown }
51
+ if (!e || typeof e !== 'object' || !Number.isInteger(e.chunkIndex) || (e.chunkIndex as number) < 0 || (e.chunkIndex as number) > 99_999) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no valid chunkIndex')
52
+ if (!e.chunk || typeof e.chunk !== 'object' || Array.isArray(e.chunk)) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no chunk')
53
+ const chunk = e.chunk as Record<string, unknown>
54
+ if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) throw new ShadowInputError('bad_sidecar', 'a chunk has no elapsed clock')
55
+ if (chunk.segments !== undefined && !Array.isArray(chunk.segments)) throw new ShadowInputError('bad_sidecar', 'a chunk has malformed segments')
56
+ if (seen.has(e.chunkIndex as number)) throw new ShadowInputError('bad_sidecar', 'duplicate chunkIndex')
57
+ seen.add(e.chunkIndex as number)
58
+ return { chunkIndex: e.chunkIndex as number, chunk }
59
+ })
60
+ }
61
+
62
+ /** propose.py labels_of: what a later correction would change. Hashed, so a run can refuse labels that moved after finalize. */
63
+ export function shadowLabelsHash(entries: readonly ShadowChunkEntry[]): string {
64
+ const projection = entries.map(e => [
65
+ e.chunkIndex,
66
+ e.chunk.speaker ?? null,
67
+ ((e.chunk.segments as unknown[] | undefined) ?? []).map(s => {
68
+ const seg = (s ?? {}) as Record<string, unknown>
69
+ return [seg.speaker ?? null, seg.startSec ?? null, seg.endSec ?? null]
70
+ }),
71
+ ])
72
+ return createHash('sha256').update(JSON.stringify(projection)).digest('hex')
73
+ }
74
+
75
+ /** Python `wave` read of a 16 kHz mono 16-bit PCM file: the declared frames and their bytes. */
76
+ export function readPcm16Wav(wav: Buffer): { sampleCount: number; pcm: Buffer } {
77
+ if (wav.length < 12 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') throw new ShadowInputError('audio_format', 'not a RIFF/WAVE file')
78
+ let offset = 12, fmtOk = false
79
+ while (offset + 8 <= wav.length) {
80
+ const id = wav.toString('ascii', offset, offset + 4)
81
+ const size = wav.readUInt32LE(offset + 4)
82
+ const body = offset + 8
83
+ if (id === 'fmt ') {
84
+ if (body + 16 > wav.length) throw new ShadowInputError('audio_format', 'truncated fmt chunk')
85
+ const format = wav.readUInt16LE(body), channels = wav.readUInt16LE(body + 2), rate = wav.readUInt32LE(body + 4), bits = wav.readUInt16LE(body + 14)
86
+ if (format !== 1 || channels !== 1 || rate !== SHADOW_SAMPLE_RATE || bits !== 16) throw new ShadowInputError('audio_format', `expected 16 kHz mono PCM16, got format ${format}, ${channels} ch, ${rate} Hz, ${bits} bit`)
87
+ fmtOk = true
88
+ } else if (id === 'data') {
89
+ if (!fmtOk) throw new ShadowInputError('audio_format', 'data before fmt')
90
+ const sampleCount = Math.floor(size / 2)
91
+ if (body + sampleCount * 2 > wav.length) throw new ShadowInputError('audio_format', 'truncated data chunk')
92
+ return { sampleCount, pcm: wav.subarray(body, body + sampleCount * 2) }
93
+ }
94
+ offset = body + size + (size % 2)
95
+ }
96
+ throw new ShadowInputError('audio_format', 'no data chunk')
97
+ }
98
+
99
+ /** The 44-byte header Python's wave writer emits, then the PCM. */
100
+ export function pcm16WavFile(pcm: Buffer): Buffer {
101
+ const header = Buffer.alloc(44)
102
+ header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
103
+ header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(SHADOW_SAMPLE_RATE, 24)
104
+ header.writeUInt32LE(SHADOW_SAMPLE_RATE * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40)
105
+ return Buffer.concat([header, pcm])
106
+ }
107
+
108
+ async function readOriginal(path: string): Promise<Buffer> {
109
+ try { return await readFile(path) } catch (error) {
110
+ const code = (error as NodeJS.ErrnoException)?.code
111
+ throw new ShadowInputError(code === 'ENOENT' ? 'audio_missing' : 'audio_format', `original ${path.split('/').pop()}: ${code ?? 'unreadable'}`)
112
+ }
113
+ }
114
+
115
+ /** Every original verified, placed and copied into one PCM buffer. Async reads, one file at a time. */
116
+ export async function buildShadowTimeline(entries: readonly ShadowChunkEntry[], originalPath: (chunkIndex: number) => string): Promise<ShadowTimeline> {
117
+ const SR = SHADOW_SAMPLE_RATE
118
+ let verified = 0
119
+ const unhashed: number[] = [], unhashedWithText: number[] = []
120
+ const firstHash = new Map<number, string>()
121
+ const rows: Array<ShadowTimelineRow & { path: string }> = []
122
+ for (const e of entries) {
123
+ const path = originalPath(e.chunkIndex)
124
+ const bytes = await readOriginal(path)
125
+ const digest = sha256(bytes)
126
+ const want = e.chunk.audioSha256
127
+ if (typeof want === 'string' && want) {
128
+ if (digest !== want) throw new ShadowInputError('audio_drift', `original ${e.chunkIndex} no longer matches the sidecar audioSha256`)
129
+ verified++
130
+ } else {
131
+ unhashed.push(e.chunkIndex)
132
+ const text = typeof e.chunk.text === 'string' ? e.chunk.text : ''
133
+ if (text.replace(PY_STRIP, '')) unhashedWithText.push(e.chunkIndex)
134
+ }
135
+ const { sampleCount } = readPcm16Wav(bytes)
136
+ firstHash.set(e.chunkIndex, digest)
137
+ rows.push({ rawChunkIndex: e.chunkIndex, startSample: Math.floor((e.chunk.elapsed as number) * 16 + 0.5), sampleCount, usedSampleCount: 0, path })
138
+ }
139
+ rows.sort((a, b) => (a.startSample - b.startSample) || (a.rawChunkIndex - b.rawChunkIndex))
140
+ if (rows[0].startSample < 0) throw new ShadowInputError('bad_sidecar', 'a chunk starts before the recording')
141
+ const total = Math.max(...rows.map(x => x.startSample + x.sampleCount))
142
+ if (total > SHADOW_MAX_TIMELINE_SECONDS * SR) throw new ShadowInputError('too_long', `the meeting is longer than ${SHADOW_MAX_TIMELINE_SECONDS / 3600} h`)
143
+ const pcm = Buffer.alloc(total * 2)
144
+ for (let i = 0; i < rows.length; i++) {
145
+ const x = rows[i]
146
+ const bytes = await readOriginal(x.path)
147
+ if (sha256(bytes) !== firstHash.get(x.rawChunkIndex)) throw new ShadowInputError('audio_drift', `original ${x.rawChunkIndex} changed while the timeline was built`)
148
+ const raw = readPcm16Wav(bytes).pcm
149
+ const use = i + 1 < rows.length ? Math.min(x.sampleCount, Math.max(0, rows[i + 1].startSample - x.startSample)) : x.sampleCount
150
+ if (!(use > 0)) throw new ShadowInputError('bad_sidecar', `original ${x.rawChunkIndex} is fully shadowed by the next one`)
151
+ x.usedSampleCount = use
152
+ raw.copy(pcm, x.startSample * 2, 0, use * 2)
153
+ }
154
+ return {
155
+ rows: rows.map(({ path: _path, ...row }) => row),
156
+ pcm, total, verified, unhashed: unhashed.length, unhashedWithText, pcmSHA256: sha256(pcm),
157
+ }
158
+ }
159
+
160
+ /** propose.py's segment loop: production labels on the timeline, plus where each came from. */
161
+ export function shadowProductionSegments(entries: readonly ShadowChunkEntry[], rows: readonly ShadowTimelineRow[], total: number): { segs: ShadowSegment[]; sources: ShadowSegmentSource[] } {
162
+ const SR = SHADOW_SAMPLE_RATE
163
+ const placed = new Map(rows.map(x => [x.rawChunkIndex, x]))
164
+ // Python's isinstance(x, (int, float)) is true for a JSON boolean, which counts as 0 or 1.
165
+ const num = (x: unknown): number | null => typeof x === 'number' ? x : typeof x === 'boolean' ? Number(x) : null
166
+ const segs: ShadowSegment[] = [], sources: ShadowSegmentSource[] = []
167
+ for (const e of entries) {
168
+ const x = placed.get(e.chunkIndex)!
169
+ const off = x.startSample, end = Math.min(total, off + x.usedSampleCount)
170
+ for (const raw of (e.chunk.segments as unknown[] | undefined) ?? []) {
171
+ const s = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
172
+ const a = num(s.startSec), b = num(s.endSec), label = s.speaker
173
+ if (a === null || b === null || !label) continue
174
+ if (typeof label !== 'string') throw new ShadowInputError('bad_sidecar', 'a segment speaker is not a string')
175
+ const lo = off + pyRoundInt(a * SR), hi = Math.min(end, off + pyRoundInt(b * SR))
176
+ if (lo < hi) {
177
+ segs.push([lo, hi, label])
178
+ sources.push({ chunkIndex: e.chunkIndex, start: a, end: Math.min(b, x.usedSampleCount / SR), abs: lo / SR })
179
+ }
180
+ }
181
+ }
182
+ return { segs, sources }
183
+ }
184
+
185
+ /** Voice labels that appear on any chunk a human touched. A hint in the record; it never changes a gate. */
186
+ export function shadowHumanProtectedVoices(entries: readonly ShadowChunkEntry[]): string[] {
187
+ const out = new Set<string>()
188
+ for (const e of entries) {
189
+ if (!hasMacSpeakerProtectedRows(e.chunk)) continue
190
+ const labels = [e.chunk.speaker, ...((e.chunk.segments as unknown[] | undefined) ?? []).map(s => (s as Record<string, unknown> | null)?.speaker)]
191
+ for (const label of labels) if (typeof label === 'string' && isShadowVoiceLabel(label)) out.add(label)
192
+ }
193
+ return [...out].sort()
194
+ }
195
+
196
+ /** The meeting's title (the saved markdown's `# ` line), else the sidecar's file name. Read only. */
197
+ export async function readShadowMeetingTitle(sidecarPath: string): Promise<string> {
198
+ const fallback = basename(sidecarPath).replace(/\.g2-chunks\.json$/, '')
199
+ try {
200
+ const text = await readFile(sidecarPath.replace(/\.g2-chunks\.json$/, '.md'), 'utf8')
201
+ const line = text.split('\n', 40).find(l => l.startsWith('# '))
202
+ const title = line?.slice(2).trim()
203
+ return title ? title.slice(0, 160) : fallback
204
+ } catch { return fallback }
205
+ }
206
+
207
+ /** The meeting day for the report folder: the sidecar name's YYYY-MM-DD prefix, else null. */
208
+ export function shadowMeetingDay(sidecarPath: string): string | null {
209
+ return /^(\d{4}-\d{2}-\d{2})/.exec(basename(sidecarPath))?.[1] ?? null
210
+ }