Skip to content

Commit 49dfda8

Browse files
committed
Extract SDK-free audiobook verification primitives
1 parent 86c9ce9 commit 49dfda8

10 files changed

Lines changed: 152 additions & 63 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ jobs:
5454
- name: Audit context regions
5555
run: npm run audit:context-regions
5656

57+
# Website tests run first without the provider SDK; the CLI's static imports
58+
# require scripts/ dependencies even for its credential-free lint command.
59+
- name: Install audiobook CLI dependencies
60+
run: npm ci --prefix ../scripts
61+
5762
- name: Lint audiobook dialogue
5863
run: npm run audio:dialogue:lint
5964

‎.github/workflows/pr.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ jobs:
4747
- name: Audit context regions
4848
run: npm run audit:context-regions
4949

50+
# Website tests run first without the provider SDK; the CLI's static imports
51+
# require scripts/ dependencies even for its credential-free lint command.
52+
- name: Install audiobook CLI dependencies
53+
run: npm ci --prefix ../scripts
54+
5055
- name: Lint audiobook dialogue
5156
run: npm run audio:dialogue:lint
5257

‎scripts/audiobook/cli.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import { criticalTerms } from '../../website/src/audiobook/wer.ts';
3232
import { dialogueStream, chunkState, discardChunk, printDialoguePlan } from './dialogue.mjs';
3333
import { encodeChapter } from './encode.mjs';
3434
import { masterStream } from './master.mjs';
35-
import { CACHE_DIR, chapterMp3, exists, log, MANIFEST_FILE, readJsonIfExists, requireApiKey, seconds, table, warn } from './report.mjs';
35+
import { CACHE_DIR, chapterMp3, exists, MANIFEST_FILE, readJsonIfExists, requireApiKey, seconds, table } from './report.mjs';
36+
import { log, warn } from './primitives.mjs';
3637
import { printVerification, verifyChapter, verifyProblems } from './verify.mjs';
3738

3839
const COMMANDS = { '--build': 'build', '--verify': 'verify', '--list': 'list', '--lint': 'lint', '--source': 'source' };

‎scripts/audiobook/dialogue.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { AUDIO_CONFIG } from '../../website/src/audiobook/config.ts';
1313
import { chunkTurns, turnSliceBounds, turnStartMs } from '../../website/src/audiobook/dialogueScript.ts';
1414
import { dialogueChunkCacheKey } from '../../website/src/audiobook/hash.ts';
1515
import { renderDialogueChunk } from './render.mjs';
16-
import { cacheFile, exists, log, pcmBytesForMs, pcmDurationMs, readBytes, readJsonIfExists, table, transcribe, warn, writeFile, writeJson } from './report.mjs';
16+
import { cacheFile, exists, pcmBytesForMs, pcmDurationMs, readBytes, readJsonIfExists, table, transcribe, writeFile, writeJson } from './report.mjs';
17+
import { log, warn } from './primitives.mjs';
1718

1819
export function chunkState(turns, force = false) {
1920
const cacheKey = dialogueChunkCacheKey(turns);

‎scripts/audiobook/master.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
*/
1717
import { linearityProblems } from '../../website/src/audiobook/artifacts.ts';
1818
import { AUDIO_CONFIG } from '../../website/src/audiobook/config.ts';
19-
import { asInt16, FFMPEG, log, measureChapter, PCM_HEAD, PCM_INPUT, pcmBytesForMs, runTool } from './report.mjs';
19+
import { FFMPEG, measureChapter, PCM_HEAD, PCM_INPUT, pcmBytesForMs, runTool } from './report.mjs';
20+
import { asInt16, log } from './primitives.mjs';
2021

2122
const PCM_OUTPUT = [...PCM_HEAD, '-'];
2223

‎scripts/audiobook/primitives.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Zero-provider helpers shared by every audio stage and the website unit tests.
3+
*
4+
* Kept apart from report.mjs on purpose: importing them must never drag a
5+
* provider SDK (`@google/genai`) into a pure-function test's module graph. CI
6+
* installs only `website/` deps, so a static SDK import reached from a website
7+
* test fails the whole test file (see verifyUnits.mjs).
8+
*/
9+
10+
/**
11+
* Copy of s16le PCM as int16 samples: sample math without a per-sample Buffer read. The copy keeps
12+
* this correct for any byteOffset alignment, which a zero-copy view would not be.
13+
*/
14+
export const asInt16 = (pcm) =>
15+
new Int16Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.byteLength >> 1 << 1)));
16+
17+
export const log = (stage, message) => console.log(`[${stage}] ${message}`);
18+
export const warn = (stage, message) => console.warn(`[${stage}] ${message}`);

‎scripts/audiobook/report.mjs‎

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
/**
2-
* Shared primitives for the local-only audio stages (render, verify, stitch,
3-
* encode).
2+
* Shared report/pipeline helpers for the local-only audio stages (render,
3+
* verify, stitch, encode).
44
*
5-
* Every stage needs the artifact layout, the cache reader and the same stdout
6-
* shape, so all three live here; duplicating any of them would let the stages
7-
* drift. The Gemini provider seam (client, retry, WAV wrap, ASR) lives here too
8-
* because render/dialogue TTS and verify/dialogue ASR share it; the ffmpeg
9-
* encoder stays in encode.mjs.
5+
* Every stage needs the artifact layout and the cache reader, so both live
6+
* here; duplicating either would let the stages drift. The Gemini provider seam
7+
* (client, retry, WAV wrap, ASR) lives here too because render/dialogue TTS and
8+
* verify/dialogue ASR share it; the ffmpeg encoder stays in encode.mjs.
109
*
11-
* All of it is local-only tooling: nothing here is imported by the website build.
10+
* Provider-free helpers (PCM view, stdout shape) live in primitives.mjs so the
11+
* website unit tests can import them without this module's `@google/genai`.
12+
* Nothing here is imported by the website build.
1213
*/
1314
import { GoogleGenAI } from '@google/genai';
1415
import { spawnSync } from 'node:child_process';
@@ -44,13 +45,6 @@ export const exists = (file) => existsSync(file);
4445
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
4546
export const readJsonIfExists = (file) => (existsSync(file) ? readJson(file) : undefined);
4647
export const readBytes = (file) => readFileSync(file);
47-
/**
48-
* Copy of s16le PCM as int16 samples: sample math without a per-sample Buffer read. The copy keeps
49-
* this correct for any byteOffset alignment, which a zero-copy view would not be.
50-
*/
51-
export const asInt16 = (pcm) =>
52-
new Int16Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.byteLength >> 1 << 1)));
53-
5448
/** Every artifact is written through here so directories can never be forgotten. */
5549
export function writeFile(file, data) {
5650
mkdirSync(dirname(file.pathname), { recursive: true });
@@ -173,9 +167,6 @@ export function runTool(tool, args, input) {
173167
return { stdout: result.stdout, stderr: String(result.stderr) };
174168
}
175169

176-
export const log = (stage, message) => console.log(`[${stage}] ${message}`);
177-
export const warn = (stage, message) => console.warn(`[${stage}] ${message}`);
178-
179170
/**
180171
* The one PCM stream shape the whole pipeline speaks (24 kHz 16-bit mono), shared
181172
* by every ffmpeg invocation that reads it — a stage that disagrees with the rest

‎scripts/audiobook/verify.mjs‎

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
*/
1212
import { spliceProblems } from '../../website/src/audiobook/artifacts.ts';
1313
import { AUDIO_CONFIG } from '../../website/src/audiobook/config.ts';
14-
import { missingCriticalTerms, normalizeForWer, wordErrorRate } from '../../website/src/audiobook/wer.ts';
15-
import { medianPitchHz, voiceProblems } from '../../website/src/audiobook/voice.ts';
16-
import { asInt16, log, measureChapter, measureLevels, pcmBytesForMs, reportFile, statusLine, table, transcribe, warn, writeJson } from './report.mjs';
14+
import { missingCriticalTerms, wordErrorRate } from '../../website/src/audiobook/wer.ts';
15+
import { measureChapter, measureLevels, pcmBytesForMs, reportFile, statusLine, table, transcribe, writeJson } from './report.mjs';
16+
import { asInt16, log } from './primitives.mjs';
17+
import { attachVoiceProblems, turnPitches, unitRequiredTerms } from './verifyUnits.mjs';
1718

1819
/**
1920
* Integrated loudness is a chapter-file contract, not a per-segment one: natural
@@ -40,12 +41,6 @@ async function verifySegment(segment, pcm, requiredTerms = []) {
4041
return { segmentId: segment.id, kind: segment.kind, wer, missing, ...levels, transcript, problems: segmentProblems(wer, missing, levels) };
4142
}
4243

43-
/** Declared terms this unit owns: those that actually occur in its text (they live in one chunk). */
44-
export const unitRequiredTerms = (text, requiredTerms) => {
45-
const haystack = ` ${normalizeForWer(text)} `;
46-
return requiredTerms.filter((term) => haystack.includes(` ${normalizeForWer(term)} `));
47-
};
48-
4944
/**
5045
* Loudness is a WINDOW, not a target: the master picks the loudest gain the true peak
5146
* allows, so a chapter may come out quieter than the book, never louder. The floor
@@ -105,36 +100,6 @@ function chapterReport(script, stream, segments) {
105100
};
106101
}
107102

108-
/**
109-
* Per-turn pitch, tagged with the render unit that owns the turn.
110-
* `unitId` is what the build's re-roll loop discards, so a collapsed take is retried.
111-
*/
112-
export function turnPitches(stream) {
113-
const unitOfPart = new Map();
114-
stream.units.forEach((unit) => unit.partIndexes.forEach((index) => unitOfPart.set(index, unit.id)));
115-
return stream.parts.map((part, index) => {
116-
const unitId = unitOfPart.get(index);
117-
// A part with no owning unit would verify under `undefined`: warn loudly instead of passing silently.
118-
if (unitId === undefined) warn('verify', `part ${index} (${part.segment.id}) has no owning unit`);
119-
return {
120-
unitId,
121-
segmentId: part.segment.id,
122-
speaker: part.segment.speaker,
123-
pitchHz: medianPitchHz(asInt16(part.pcm), AUDIO_CONFIG.sourceSampleRate, AUDIO_CONFIG.voice),
124-
};
125-
});
126-
}
127-
128-
/** Appends each voice-identity failure to the render unit it belongs to. */
129-
export function attachVoiceProblems(segments, turns) {
130-
voiceProblems(turns, AUDIO_CONFIG.voice).forEach(({ unitId, problem }) => {
131-
const segment = segments.find((segment) => segment.segmentId === unitId);
132-
// A problem with no owning segment would vanish: warn loudly instead of dropping it.
133-
if (!segment) warn('verify', `voice problem for unit ${unitId} has no matching segment: ${problem}`);
134-
else segment.problems.push(problem);
135-
});
136-
}
137-
138103
export async function verifyChapter(script, stream) {
139104
if (!stream.units) throw new Error(`${script.chapterId}: stream carries no verify units`);
140105
const segments = await verifyUnits(stream, script.requiredTerms ?? []);

‎scripts/audiobook/verifyUnits.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* The render-unit model: the grain the build's re-roll loop discards and the
3+
* website unit test gates (`website/src/audiobook/verifyUnits.test.ts`).
4+
*
5+
* Imported by verify.mjs and the test, but never by the provider path: the test
6+
* runs under the website suite, which installs only `website/` deps, so pulling
7+
* report.mjs (and its `@google/genai`) in here would fail CI. Keep this module's
8+
* graph to pure website logic plus primitives.mjs.
9+
*/
10+
import { AUDIO_CONFIG } from '../../website/src/audiobook/config.ts';
11+
import { normalizeForWer } from '../../website/src/audiobook/wer.ts';
12+
import { medianPitchHz, voiceProblems } from '../../website/src/audiobook/voice.ts';
13+
import { asInt16, warn } from './primitives.mjs';
14+
15+
/** Declared terms this unit owns: those that actually occur in its text (they live in one chunk). */
16+
export const unitRequiredTerms = (text, requiredTerms) => {
17+
const haystack = ` ${normalizeForWer(text)} `;
18+
return requiredTerms.filter((term) => haystack.includes(` ${normalizeForWer(term)} `));
19+
};
20+
21+
/**
22+
* Per-turn pitch, tagged with the render unit that owns the turn.
23+
* `unitId` is what the build's re-roll loop discards, so a collapsed take is retried.
24+
*/
25+
export function turnPitches(stream) {
26+
const unitOfPart = new Map();
27+
stream.units.forEach((unit) => unit.partIndexes.forEach((index) => unitOfPart.set(index, unit.id)));
28+
return stream.parts.map((part, index) => {
29+
const unitId = unitOfPart.get(index);
30+
// A part with no owning unit would verify under `undefined`: warn loudly instead of passing silently.
31+
if (unitId === undefined) warn('verify', `part ${index} (${part.segment.id}) has no owning unit`);
32+
return {
33+
unitId,
34+
segmentId: part.segment.id,
35+
speaker: part.segment.speaker,
36+
pitchHz: medianPitchHz(asInt16(part.pcm), AUDIO_CONFIG.sourceSampleRate, AUDIO_CONFIG.voice),
37+
};
38+
});
39+
}
40+
41+
/** Appends each voice-identity failure to the render unit it belongs to. */
42+
export function attachVoiceProblems(segments, turns) {
43+
voiceProblems(turns, AUDIO_CONFIG.voice).forEach(({ unitId, problem }) => {
44+
const segment = segments.find((segment) => segment.segmentId === unitId);
45+
// A problem with no owning segment would vanish: warn loudly instead of dropping it.
46+
if (!segment) warn('verify', `voice problem for unit ${unitId} has no matching segment: ${problem}`);
47+
else segment.problems.push(problem);
48+
});
49+
}

‎website/src/audiobook/verifyUnits.test.ts‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,61 @@
11
import assert from 'node:assert/strict';
2+
import { readFileSync } from 'node:fs';
23
import test from 'node:test';
34
import { AUDIO_CONFIG } from './config.ts';
45
import type { TurnPitch } from './voice.ts';
56
import {
67
attachVoiceProblems,
78
turnPitches,
89
unitRequiredTerms,
9-
} from '../../../scripts/audiobook/verify.mjs';
10+
} from '../../../scripts/audiobook/verifyUnits.mjs';
11+
12+
// The verifyUnits module graph must never reach the provider SDK: the website
13+
// suite runs before scripts/ deps are installed in CI, so a static SDK import
14+
// anywhere in this graph fails the test file. Walk the graph instead of relying
15+
// on that install order alone; literal dynamic imports must stay outside it too.
16+
const importSpecifiers = (source: string): string[] =>
17+
[
18+
...source.matchAll(
19+
/(?:\bfrom\s+|\bimport\s*(?:\(\s*|\s+))['"]([^'"]+)['"]/g
20+
),
21+
].map(([, specifier]) => specifier);
22+
const importsProvider = (specifier: string): boolean =>
23+
specifier === '@google/genai' || specifier.startsWith('@google/genai/');
24+
25+
const importGraph = (entry: URL): string[] => {
26+
const visited = new Set<string>();
27+
const walk = (file: URL): void => {
28+
if (visited.has(file.pathname)) return;
29+
visited.add(file.pathname);
30+
for (const specifier of importSpecifiers(readFileSync(file, 'utf8')))
31+
if (specifier.startsWith('.')) walk(new URL(specifier, file));
32+
else visited.add(specifier);
33+
};
34+
walk(entry);
35+
return [...visited];
36+
};
37+
38+
test('the verifyUnits module graph never reaches the provider SDK', () => {
39+
const audiobook = new URL('../../../scripts/audiobook/', import.meta.url);
40+
const graph = [
41+
...importGraph(new URL('verifyUnits.mjs', audiobook)),
42+
...importGraph(new URL('primitives.mjs', audiobook)),
43+
];
44+
assert.ok(
45+
!graph.some(importsProvider),
46+
'graph must not import @google/genai'
47+
);
48+
assert.ok(
49+
!graph.some((file) => file.endsWith('report.mjs')),
50+
'graph must not import report.mjs'
51+
);
52+
});
53+
54+
test('the graph guard recognizes literal dynamic SDK subpath imports', () => {
55+
assert.ok(
56+
importSpecifiers("await import('@google/genai/web')").some(importsProvider)
57+
);
58+
});
1059

1160
// The verify unit is the render unit: each chunk is gated only on the terms it
1261
// actually speaks, so a chapter-level required term must not fail a chunk that
@@ -38,8 +87,7 @@ test('unitRequiredTerms matches case- and punctuation-insensitively', () => {
3887
// turnPitches tags every part with its owning render unit: unitId is what the
3988
// build's re-roll loop discards, so a misattributed turn would retry the wrong take.
4089
// Silence keeps the test on the real DSP path with a deterministic null pitch.
41-
const silence = (): Buffer =>
42-
Buffer.alloc(AUDIO_CONFIG.sourceSampleRate * 2);
90+
const silence = (): Buffer => Buffer.alloc(AUDIO_CONFIG.sourceSampleRate * 2);
4391

4492
test('turnPitches attributes every part to its owning render unit', () => {
4593
const stream = {
@@ -62,6 +110,11 @@ test('turnPitches attributes every part to its owning render unit', () => {
62110
['c2', 'c2-alex', 'alex'],
63111
]
64112
);
113+
// The silence contract the comment above relies on: no pitch detected.
114+
assert.deepEqual(
115+
turns.map((turn: TurnPitch) => turn.pitchHz),
116+
[null, null, null]
117+
);
65118
});
66119

67120
const turn = (

0 commit comments

Comments
 (0)