@gotcos/glasses-server 6.94.2 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +52 -0
- package/package.json +2 -1
- package/server/index.ts +13 -1
- package/server/lib/speaker-shadow-decide.ts +466 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -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 +277 -0
- package/server/lib/speaker-shadow.ts +727 -0
- package/server/routes/health.ts +5 -0
- package/server/routes/meeting.ts +4 -0
- package/server/routes/speaker-shadow.ts +79 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Speaker shadow mode (6.95.0): the pinned FluidAudio Community-1 diarizer models.
|
|
2
|
+
//
|
|
3
|
+
// NEVER DOWNLOADED BY THE SERVER. FluidAudio's CLI fetches models from Hugging Face
|
|
4
|
+
// whenever its cache folder is missing or its revision marker is unknown, so the server
|
|
5
|
+
// runs the CLI only after every pinned file below is present and hashes to its pin, and
|
|
6
|
+
// always under sandbox-exec with network denied (a CLI that tried anyway fails closed).
|
|
7
|
+
// The models must already be on the Mac, in `<data>/speaker-shadow/models/speaker-diarization`
|
|
8
|
+
// (or COS_SPEAKER_SHADOW_MODELS): the Hugging Face repo files at the pinned revision.
|
|
9
|
+
// Anything else is skipped as `models_missing` or `models_mismatch` and logged.
|
|
10
|
+
//
|
|
11
|
+
// LAYOUT. The CLI reads `$HOME/Library/Application Support/FluidAudio/Models/speaker-diarization`.
|
|
12
|
+
// The server gives it a private HOME whose model folder holds only symlinks to the
|
|
13
|
+
// verified files plus the revision marker. The sandbox lets the CLI write in that HOME
|
|
14
|
+
// (CoreML keeps its compiled cache there) but not through a symlink, because the sandbox
|
|
15
|
+
// matches resolved paths: the verified model bytes are read-only to the CLI.
|
|
16
|
+
//
|
|
17
|
+
// License: CC-BY-4.0 scoped (NOTICE.md in the repo at this revision). Attribution is
|
|
18
|
+
// required before these models ship inside a build; the server ships none of them.
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { createReadStream, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { pipeline } from 'node:stream/promises'
|
|
24
|
+
import { Transform } from 'node:stream'
|
|
25
|
+
import { dataPath } from './data-dir.js'
|
|
26
|
+
|
|
27
|
+
export interface ShadowPinnedFile { path: string; size: number; sha256: string }
|
|
28
|
+
|
|
29
|
+
/** FluidInference/speaker-diarization-coreml, the revision FluidAudio 0.17.4 pins (ModelNames.swift Repo.diarizer). */
|
|
30
|
+
export const SHADOW_MODEL_REPO = 'FluidInference/speaker-diarization-coreml'
|
|
31
|
+
export const SHADOW_MODEL_REVISION = 'df2625ac79a7ac6b65ad868fee6d80f320da4232'
|
|
32
|
+
/** FluidAudio's local folder name for that repo (not the repo name: the round-1 canary lost a run to this). */
|
|
33
|
+
export const SHADOW_MODEL_FOLDER = 'speaker-diarization'
|
|
34
|
+
/** What FluidAudio writes after a download; without it the CLI deletes the folder and re-downloads. */
|
|
35
|
+
export const SHADOW_REVISION_MARKER = { path: '.fluidaudio-revision', content: `${SHADOW_MODEL_REVISION}\n`, sha256: '1f6ae38637c86e0998be2d231a1431a5a3c37010e9337b682e635cd5c147ef7a' } as const
|
|
36
|
+
|
|
37
|
+
// From the canary's model-manifest.json and the repo's file listing at the pinned revision.
|
|
38
|
+
export const SHADOW_MODEL_FILES: readonly ShadowPinnedFile[] = [
|
|
39
|
+
{ path: 'Embedding.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '8d6706436639b53830b4dbe8aaf9c9a843f7f582d63e16f3cb8bb7c6ccd58682' },
|
|
40
|
+
{ path: 'Embedding.mlmodelc/coremldata.bin', size: 704, sha256: '4a705bac27d151d9642f37609296042a15602a42253039e0921dc9e75da7e004' },
|
|
41
|
+
{ path: 'Embedding.mlmodelc/metadata.json', size: 2818, sha256: '1854371eb6b438fb8aeac96afb45c999af7902581c06afdfcd7ff3cb1ce66be5' },
|
|
42
|
+
{ path: 'Embedding.mlmodelc/model.mil', size: 78432, sha256: '22fa958aef72a561c21f874a07cbdcd30fdf40ee961c0bc2fb67c119273b46d3' },
|
|
43
|
+
{ path: 'Embedding.mlmodelc/weights/weight.bin', size: 13412288, sha256: '99356b2985b8d43880a657024d941d450b38820451ccff903f76ed4e52d1868b' },
|
|
44
|
+
{ path: 'FBank.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '0e8bd3a8b82ac123580989f490e4d9245127c535857630b543311268accc3f0a' },
|
|
45
|
+
{ path: 'FBank.mlmodelc/coremldata.bin', size: 853, sha256: '57ac436bb0671cbb5527a339134d695f752eb77f7a18966b93c6835335595759' },
|
|
46
|
+
{ path: 'FBank.mlmodelc/metadata.json', size: 3409, sha256: '2623785f5d186893b82d01e84aa33a7704ef763c3309e02055f22dc9d871ce9a' },
|
|
47
|
+
{ path: 'FBank.mlmodelc/model.mil', size: 15667, sha256: '27aaeb21569e81bdbe2eef87789f50a37cfea800039bd134448a9417de2f30ed' },
|
|
48
|
+
{ path: 'FBank.mlmodelc/weights/weight.bin', size: 1776896, sha256: '9e83fdd3ea78064b078069e4d9141603c61c47a27fd19e7e3142ff7476f8db36' },
|
|
49
|
+
{ path: 'PldaRho.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '8940ea6044dbcbefa22da8cc41e0b485e1fb5ed89aecaf37c6e0c483a97ddcd7' },
|
|
50
|
+
{ path: 'PldaRho.mlmodelc/coremldata.bin', size: 763, sha256: '4d9741477f721c79b09fcdfe455110c4b7d4272e2de3496bf1729d966d3ee418' },
|
|
51
|
+
{ path: 'PldaRho.mlmodelc/metadata.json', size: 2749, sha256: 'b314cf25a93e46b4076883a6f5a2f8848b73c3851bd9d36074d067f35a1c7945' },
|
|
52
|
+
{ path: 'PldaRho.mlmodelc/model.mil', size: 7613, sha256: '83aee2e5310d19b5f202aea97d07a0e12102556d1b32ef3ed08b36f7f9725041' },
|
|
53
|
+
{ path: 'PldaRho.mlmodelc/weights/weight.bin', size: 200192, sha256: '80f7d229202636d372428c90596f11a91545f07da77259f07153aaf225914a36' },
|
|
54
|
+
{ path: 'Segmentation.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '64265f8e7ad41a5f68d630c15288c2499cca5892ad49e20096819cdeac004cdb' },
|
|
55
|
+
{ path: 'Segmentation.mlmodelc/coremldata.bin', size: 812, sha256: 'ea51481b8bd3e496ad3cf16f066ddaa37f20e8772eaac76b3393c28de20e06bc' },
|
|
56
|
+
{ path: 'Segmentation.mlmodelc/metadata.json', size: 3410, sha256: '88dbf0b07208fe142e1729c2b4c974ad3599fcb2ae5d5f18fce782b225384124' },
|
|
57
|
+
{ path: 'Segmentation.mlmodelc/model.mil', size: 43063, sha256: 'd37e4ce30b406a6b34f765f769b9baed3178cc0c2b2e299c641daa43a052dd3f' },
|
|
58
|
+
{ path: 'Segmentation.mlmodelc/weights/weight.bin', size: 5959360, sha256: 'c3189a64946c75bc24fcb98afe89ad78c52bdbadfdf65e857fb1b81e2cc9fbb2' },
|
|
59
|
+
{ path: 'plda-parameters.json', size: 89416, sha256: '38ee28d4269c076cef254ee760bbd811f0738a92e0f01f9699ad372828c5de8f' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** The top-level names the private HOME links to. Each must hold exactly its pinned files. */
|
|
63
|
+
export const SHADOW_MODEL_ENTRIES: readonly string[] = [...new Set(SHADOW_MODEL_FILES.map(f => f.path.split('/')[0]))]
|
|
64
|
+
|
|
65
|
+
export function speakerShadowRoot(): string {
|
|
66
|
+
return dataPath('speaker-shadow')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function speakerShadowModelsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
70
|
+
const override = env.COS_SPEAKER_SHADOW_MODELS?.trim()
|
|
71
|
+
return override ? resolve(override) : join(speakerShadowRoot(), 'models', SHADOW_MODEL_FOLDER)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The CLI's private HOME (CoreML compiled cache and the linked model folder). */
|
|
75
|
+
export function speakerShadowHome(): string {
|
|
76
|
+
return join(speakerShadowRoot(), 'home')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ShadowModelCheck =
|
|
80
|
+
| { ok: true; dir: string; bytes: number }
|
|
81
|
+
| { ok: false; reason: 'models_missing' | 'models_mismatch'; detail: string }
|
|
82
|
+
|
|
83
|
+
/** Cheap pre-check for the scheduler: every pinned file exists with its size. No hashing. */
|
|
84
|
+
export function shadowModelsPresent(dir: string): boolean {
|
|
85
|
+
return SHADOW_MODEL_FILES.every(f => { try { const st = statSync(join(dir, f.path)); return st.isFile() && st.size === f.size } catch { return false } })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function sha256File(path: string): Promise<string> {
|
|
89
|
+
const hash = createHash('sha256')
|
|
90
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
91
|
+
return hash.digest('hex')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listFiles(root: string, rel = ''): string[] {
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
for (const name of readdirSync(join(root, rel))) {
|
|
97
|
+
const path = rel ? `${rel}/${name}` : name
|
|
98
|
+
const st = lstatSync(join(root, path))
|
|
99
|
+
if (st.isSymbolicLink()) throw new Error(`symlink inside the model folder: ${path}`)
|
|
100
|
+
if (st.isDirectory()) out.push(...listFiles(root, path))
|
|
101
|
+
else out.push(path)
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Full check: every pinned file hashes to its pin, and each linked entry holds exactly the
|
|
108
|
+
* pinned files (an extra file inside a `.mlmodelc` would be read by CoreML unverified).
|
|
109
|
+
* Runs in the worker, before every diarization.
|
|
110
|
+
*/
|
|
111
|
+
export async function verifyShadowModels(dir: string): Promise<ShadowModelCheck> {
|
|
112
|
+
let real: string
|
|
113
|
+
try { real = realpathSync(dir) } catch { return { ok: false, reason: 'models_missing', detail: 'model folder not found' } }
|
|
114
|
+
let bytes = 0
|
|
115
|
+
for (const f of SHADOW_MODEL_FILES) {
|
|
116
|
+
const path = join(real, f.path)
|
|
117
|
+
let size: number
|
|
118
|
+
try {
|
|
119
|
+
const st = lstatSync(path)
|
|
120
|
+
if (!st.isFile()) return { ok: false, reason: 'models_mismatch', detail: `${f.path} is not a regular file` }
|
|
121
|
+
size = st.size
|
|
122
|
+
} catch { return { ok: false, reason: 'models_missing', detail: `${f.path} is missing` } }
|
|
123
|
+
if (size !== f.size || await sha256File(path) !== f.sha256) return { ok: false, reason: 'models_mismatch', detail: `${f.path} does not match its pinned SHA-256` }
|
|
124
|
+
bytes += size
|
|
125
|
+
}
|
|
126
|
+
const pinned = new Set(SHADOW_MODEL_FILES.map(f => f.path))
|
|
127
|
+
for (const entry of SHADOW_MODEL_ENTRIES) {
|
|
128
|
+
if (!lstatSync(join(real, entry)).isDirectory()) continue
|
|
129
|
+
let files: string[]
|
|
130
|
+
try { files = listFiles(real, entry) } catch (error) { return { ok: false, reason: 'models_mismatch', detail: error instanceof Error ? error.message : String(error) } }
|
|
131
|
+
const extra = files.filter(p => !pinned.has(p))
|
|
132
|
+
if (extra.length) return { ok: false, reason: 'models_mismatch', detail: `unpinned file in ${entry}: ${extra[0]}` }
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, dir: real, bytes }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rebuild the private HOME's model folder: symlinks to the verified entries and the
|
|
139
|
+
* revision marker. The folder only ever holds links and the marker, so rebuilding it from
|
|
140
|
+
* scratch loses nothing (fs.rm unlinks a symlink, never its target).
|
|
141
|
+
*/
|
|
142
|
+
export function prepareShadowHome(home: string, verifiedDir: string): string {
|
|
143
|
+
const folder = join(home, 'Library', 'Application Support', 'FluidAudio', 'Models', SHADOW_MODEL_FOLDER)
|
|
144
|
+
mkdirSync(join(home, 'Library', 'Application Support', 'FluidAudio', 'Models'), { recursive: true, mode: 0o700 })
|
|
145
|
+
rmSync(folder, { recursive: true, force: true })
|
|
146
|
+
mkdirSync(folder, { mode: 0o700 })
|
|
147
|
+
for (const entry of SHADOW_MODEL_ENTRIES) symlinkSync(join(verifiedDir, entry), join(folder, entry))
|
|
148
|
+
writeFileSync(join(folder, SHADOW_REVISION_MARKER.path), SHADOW_REVISION_MARKER.content, { mode: 0o600 })
|
|
149
|
+
return folder
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|