@gobing-ai/knowledge-kit 0.1.5 → 0.1.7
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/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +20 -1
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +3 -1
- package/plugins/generations/daily-article-gen/src/index.ts +5 -3
- package/plugins/generations/voxcpm-tts/README.md +31 -0
- package/plugins/generations/voxcpm-tts/scripts/probe-long-render.py +134 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/audio-quality.ts +635 -0
- package/plugins/kk/scripts/daily-stages.ts +51 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +25 -1
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +3 -1
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +20 -7
- package/plugins/publishings/surfdash-pub/dist/index.js +30 -3
- package/plugins/publishings/surfdash-pub/src/index.ts +33 -6
- package/plugins/publishings/surfdash-pub/src/patch.ts +4 -0
- package/plugins/kk/workflows/feature-verification.yaml +0 -89
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Audio quality triage for narrated episodes — MEASURE ONLY, never wired into the daily pipeline.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (2026-10-20): the operator reported that episodes rendered through `voxcpm` (from
|
|
6
|
+
* 2026-10-15 on) sound hollow and echoey ("瓮声瓮气,有明显回声感") while the earlier `gemini-tts`
|
|
7
|
+
* episodes do not. The pipeline's `daily-voice-audit` checks speaker consistency and the duration gate
|
|
8
|
+
* checks length; nothing measured the ACOUSTIC character, so a regression in the voice reference or in
|
|
9
|
+
* the driver could ship for days unnoticed. This tool makes that character measurable, so a
|
|
10
|
+
* driver/profile change is judged on numbers rather than memory.
|
|
11
|
+
*
|
|
12
|
+
* It is a `kk stage` body and NOT a pipeline node: `kk-daily-ai-voice2` never calls it, so the daily
|
|
13
|
+
* generate → publish path keeps its own gates (duration, speaker audit) and this stays the tool an
|
|
14
|
+
* operator reaches for when an episode sounds wrong. Living in `plugins/kk/scripts/` rather than the
|
|
15
|
+
* repo's root `scripts/` is deliberate — root `scripts/` is build/release tooling run from a checkout,
|
|
16
|
+
* while this measures what the installed product produces, so it must ship with the plugin and resolve
|
|
17
|
+
* from an installed package (`kk stage audio-quality …`), exactly like every other stage body here.
|
|
18
|
+
* It is not driver-specific either, so `plugins/generations/voxcpm-tts/scripts/` (where the
|
|
19
|
+
* VoxCPM-only `audit-speaker-consistency.py` lives) would have been the wrong home.
|
|
20
|
+
*
|
|
21
|
+
* kk stage audio-quality <file…> # human report per file
|
|
22
|
+
* kk stage audio-quality --json <file…> # machine report
|
|
23
|
+
* kk stage audio-quality --baseline <good> <file…> # per-band delta against a known-good file
|
|
24
|
+
* kk stage audio-quality --treat <ref-in> <ref-out> # de-box a REFERENCE clip (not an episode)
|
|
25
|
+
* bun run audio:quality <file…> # the same body from a checkout
|
|
26
|
+
*
|
|
27
|
+
* WHAT IS MEASURED, mapped to the two complaints:
|
|
28
|
+
*
|
|
29
|
+
* "回声感" (echo / ringing)
|
|
30
|
+
* - `decay` — the energy-decay curve 50/100/200/400 ms after each speech offset whose next 500 ms
|
|
31
|
+
* stays quiet (so the following syllable cannot be mistaken for a tail). A dry studio read is
|
|
32
|
+
* -20 dB or lower at 100 ms and keeps falling; a room/echo tail plateaus around -10…-15 dB. This
|
|
33
|
+
* is the primary reverb instrument.
|
|
34
|
+
* - `cepstrum` — real-cepstrum peak in the 20–120 ms quefrency band. A single reflection puts a
|
|
35
|
+
* periodic ripple in the log spectrum, so its cepstrum peaks at the delay. Starting at 20 ms keeps
|
|
36
|
+
* the pitch period (5–12 ms for a human voice, and its multiples) out of the way; a residual peak
|
|
37
|
+
* near 2× the pitch period is why this is reported as evidence, not as the verdict.
|
|
38
|
+
* - `echo` — envelope-autocorrelation repetition (first-differenced so slow syllable modulation
|
|
39
|
+
* cannot fake a lag). Corroborates a doubled render / comb filter at a fixed delay.
|
|
40
|
+
*
|
|
41
|
+
* "瓮声瓮气" (boxy / hollow)
|
|
42
|
+
* - `bands` — average spectrum in octave-ish bands, as dB relative to full band; "boxy" is a
|
|
43
|
+
* 250–500 Hz plateau with a 2–4 kHz deficit, reported as `boxy` dB (positive = boxy).
|
|
44
|
+
* - `centroidHz` / `rolloff85Hz` — one number each for "dark vs bright"; speech naturally sits low,
|
|
45
|
+
* so these are compared against a baseline rather than thresholded.
|
|
46
|
+
* - `pauseFloorDb` (+ `quantiles`) — how far the quietest 20 % of frames fall below median speech.
|
|
47
|
+
* A reverb bed or a wash keeps this high; measured on this repo's corpus: gemini episodes −37 dB,
|
|
48
|
+
* voxcpm episodes −19.6 dB, the shared `robin-news.wav` reference −27.7 dB.
|
|
49
|
+
*
|
|
50
|
+
* Thresholds are advisory (calibrated 2026-10-20 against 10-12…10-14 gemini episodes, 10-15…10-20
|
|
51
|
+
* voxcpm episodes and the reference clip); every metric is printed, so a threshold miss never hides a
|
|
52
|
+
* number. `--treat` exists because the actionable repair for a cloned voice is the REFERENCE, not the
|
|
53
|
+
* finished episode: the model reproduces its prompt's room, so the prompt is what gets corrected.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { spawnSync } from 'node:child_process';
|
|
57
|
+
|
|
58
|
+
/** Analysis rate: cheap, and Nyquist 12 kHz still covers everything that matters for speech. */
|
|
59
|
+
const ANALYSIS_RATE = 24_000;
|
|
60
|
+
/** Short-term envelope frame: 20 ms → 50 points per second. */
|
|
61
|
+
const FRAME_MS = 20;
|
|
62
|
+
|
|
63
|
+
export interface BandPowers {
|
|
64
|
+
/** Band label → level in dB relative to the full-band power. */
|
|
65
|
+
readonly bands: Record<string, number>;
|
|
66
|
+
readonly centroidHz: number;
|
|
67
|
+
readonly rolloff85Hz: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CepstrumEcho {
|
|
71
|
+
/** Quefrency of the cepstral peak: the delay of the repeated material, in milliseconds. */
|
|
72
|
+
readonly delayMs: number;
|
|
73
|
+
/** Peak height above the median cepstrum in the search range, in dB. */
|
|
74
|
+
readonly prominenceDb: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EchoMeasure {
|
|
78
|
+
readonly lagMs: number;
|
|
79
|
+
readonly strength: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DecayCurve {
|
|
83
|
+
readonly at50: number;
|
|
84
|
+
readonly at100: number;
|
|
85
|
+
readonly at200: number;
|
|
86
|
+
/** First 20 ms step at or below −30 dB, in ms (500 = never), 0 when no offset qualifies. */
|
|
87
|
+
readonly tailMs: number;
|
|
88
|
+
/** Qualifying offsets the medians came from (0 = the audio never pauses). */
|
|
89
|
+
readonly offsets: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface EnvelopeQuantiles {
|
|
93
|
+
readonly p05: number;
|
|
94
|
+
readonly p20: number;
|
|
95
|
+
readonly p50: number;
|
|
96
|
+
readonly p70: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AudioReport {
|
|
100
|
+
readonly file: string;
|
|
101
|
+
readonly sampleRate: number;
|
|
102
|
+
readonly seconds: number;
|
|
103
|
+
readonly peakDbfs: number;
|
|
104
|
+
readonly rmsDbfs: number;
|
|
105
|
+
readonly crestDb: number;
|
|
106
|
+
readonly dcOffset: number;
|
|
107
|
+
readonly clippedSamples: number;
|
|
108
|
+
readonly quantiles: EnvelopeQuantiles;
|
|
109
|
+
readonly pauseFloorDb: number;
|
|
110
|
+
readonly decay: DecayCurve;
|
|
111
|
+
readonly cepstrum: CepstrumEcho | null;
|
|
112
|
+
readonly echo: EchoMeasure | null;
|
|
113
|
+
readonly spectrum: BandPowers;
|
|
114
|
+
/** Advisory flags; empty means "nothing in this tool's vocabulary fired". */
|
|
115
|
+
readonly flags: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Advisory thresholds (see the header for the calibration corpus). */
|
|
119
|
+
export const THRESHOLDS = {
|
|
120
|
+
/** 20th-percentile frame level vs median speech; above this the read has a wash. */
|
|
121
|
+
pauseFloorDb: -30,
|
|
122
|
+
/** Level 200 ms after an offset, in dB: a dry read is below ~−32. */
|
|
123
|
+
decay200Db: -32,
|
|
124
|
+
echoStrength: 0.3,
|
|
125
|
+
cepstrumDb: 25,
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
const BAND_EDGES: readonly [string, number, number][] = [
|
|
129
|
+
['60-125', 60, 125],
|
|
130
|
+
['125-250', 125, 250],
|
|
131
|
+
['250-500', 250, 500],
|
|
132
|
+
['500-1k', 500, 1000],
|
|
133
|
+
['1k-2k', 1000, 2000],
|
|
134
|
+
['2k-4k', 2000, 4000],
|
|
135
|
+
['4k-8k', 4000, 8000],
|
|
136
|
+
['8k-12k', 8000, 12_000],
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
function percentile(sorted: readonly number[], p: number): number {
|
|
140
|
+
if (sorted.length === 0) return 0;
|
|
141
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.round((p / 100) * (sorted.length - 1))));
|
|
142
|
+
return sorted[index] as number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function median(values: readonly number[]): number {
|
|
146
|
+
if (values.length === 0) return 0;
|
|
147
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
148
|
+
return percentile(sorted, 50);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** ffprobe the first audio stream. */
|
|
152
|
+
export function probe(file: string): { sampleRate: number; channels: number; seconds: number } {
|
|
153
|
+
const run = spawnSync(
|
|
154
|
+
'ffprobe',
|
|
155
|
+
[
|
|
156
|
+
'-v',
|
|
157
|
+
'error',
|
|
158
|
+
'-select_streams',
|
|
159
|
+
'a:0',
|
|
160
|
+
'-show_entries',
|
|
161
|
+
'stream=sample_rate,channels',
|
|
162
|
+
'-show_entries',
|
|
163
|
+
'format=duration',
|
|
164
|
+
'-of',
|
|
165
|
+
'default=noprint_wrappers=1',
|
|
166
|
+
file,
|
|
167
|
+
],
|
|
168
|
+
{ encoding: 'buffer' },
|
|
169
|
+
);
|
|
170
|
+
if (run.status !== 0) throw new Error(`ffprobe failed for ${file}: ${run.stderr?.toString().trim()}`);
|
|
171
|
+
const text = run.stdout.toString();
|
|
172
|
+
const value = (key: string): number => Number(new RegExp(`^${key}=(.+)$`, 'm').exec(text)?.[1] ?? 0);
|
|
173
|
+
return { sampleRate: value('sample_rate'), channels: value('channels'), seconds: value('duration') };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Decode to mono s16 PCM at {@link ANALYSIS_RATE} (ffmpeg resamples). */
|
|
177
|
+
export function decodeMono(file: string, rate = ANALYSIS_RATE): Int16Array {
|
|
178
|
+
const run = spawnSync(
|
|
179
|
+
'ffmpeg',
|
|
180
|
+
['-v', 'error', '-i', file, '-ac', '1', '-ar', String(rate), '-f', 's16le', '-acodec', 'pcm_s16le', '-'],
|
|
181
|
+
{ maxBuffer: 1024 * 1024 * 1024 },
|
|
182
|
+
);
|
|
183
|
+
if (run.status !== 0) throw new Error(`ffmpeg decode failed for ${file}: ${run.stderr?.toString().trim()}`);
|
|
184
|
+
const buf = run.stdout as Buffer;
|
|
185
|
+
return new Int16Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Short-term RMS envelope, one point per {@link FRAME_MS}. */
|
|
189
|
+
export function envelope(pcm: Int16Array, rate = ANALYSIS_RATE): Float64Array {
|
|
190
|
+
const frame = Math.max(1, Math.round((rate * FRAME_MS) / 1000));
|
|
191
|
+
const frames = Math.floor(pcm.length / frame);
|
|
192
|
+
const out = new Float64Array(frames);
|
|
193
|
+
for (let i = 0; i < frames; i += 1) {
|
|
194
|
+
let sum = 0;
|
|
195
|
+
const base = i * frame;
|
|
196
|
+
for (let j = 0; j < frame; j += 1) {
|
|
197
|
+
const s = (pcm[base + j] as number) / 32768;
|
|
198
|
+
sum += s * s;
|
|
199
|
+
}
|
|
200
|
+
out[i] = Math.sqrt(sum / frame);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Envelope quantiles in dB relative to median speech (p70 = 0 by construction). */
|
|
206
|
+
export function envelopeQuantiles(env: Float64Array): EnvelopeQuantiles {
|
|
207
|
+
const db = Array.from(env, (v) => 20 * Math.log10(Math.max(v, 1e-7))).sort((a, b) => a - b);
|
|
208
|
+
const p70 = percentile(db, 70);
|
|
209
|
+
return {
|
|
210
|
+
p05: Number((percentile(db, 5) - p70).toFixed(1)),
|
|
211
|
+
p20: Number((percentile(db, 20) - p70).toFixed(1)),
|
|
212
|
+
p50: Number((percentile(db, 50) - p70).toFixed(1)),
|
|
213
|
+
p70: 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Pause floor: the 20th-percentile frame level minus median speech, in dB. */
|
|
218
|
+
export function pauseFloorDb(env: Float64Array): number {
|
|
219
|
+
return envelopeQuantiles(env).p20;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Energy-decay curve across PHRASE ENDS — the reverb instrument.
|
|
224
|
+
*
|
|
225
|
+
* Offsets are found as runs of frames at least 20 dB below the phrase level that last ≥80 ms; for each
|
|
226
|
+
* run the peak of the preceding 100 ms is the reference, and the level 50/100/200/400 ms after that
|
|
227
|
+
* peak is recorded. A dry read is already 20 dB down by 100 ms and keeps falling; a room tail plateaus
|
|
228
|
+
* around −10…−15 dB. (An earlier version keyed on local maxima, which found nothing: in a continuous
|
|
229
|
+
* narration read every local maximum sits mid-phrase, so "200 ms of quiet after the peak" never holds.)
|
|
230
|
+
*/
|
|
231
|
+
export function decayCurve(env: Float64Array): DecayCurve {
|
|
232
|
+
const db = Array.from(env, (v) => 20 * Math.log10(Math.max(v, 1e-7)));
|
|
233
|
+
const speech = percentile(
|
|
234
|
+
[...db].sort((a, b) => a - b),
|
|
235
|
+
70,
|
|
236
|
+
);
|
|
237
|
+
const quietBelow = speech - 20;
|
|
238
|
+
const buckets = {
|
|
239
|
+
at50: [] as number[],
|
|
240
|
+
at100: [] as number[],
|
|
241
|
+
at200: [] as number[],
|
|
242
|
+
tail: [] as number[],
|
|
243
|
+
};
|
|
244
|
+
const RAMP = Math.round(80 / FRAME_MS);
|
|
245
|
+
const LOOKBACK = Math.round(100 / FRAME_MS);
|
|
246
|
+
for (let s = LOOKBACK; s < db.length; s += 1) {
|
|
247
|
+
if ((db[s] as number) >= quietBelow) continue;
|
|
248
|
+
let runEnd = s;
|
|
249
|
+
while (runEnd < db.length && (db[runEnd] as number) < quietBelow) runEnd += 1;
|
|
250
|
+
const runLength = runEnd - s;
|
|
251
|
+
s = runEnd;
|
|
252
|
+
if (runLength < RAMP) continue;
|
|
253
|
+
let peakIndex = -1;
|
|
254
|
+
let peak = -Infinity;
|
|
255
|
+
for (let j = Math.max(0, s - runLength - LOOKBACK); j < s; j += 1) {
|
|
256
|
+
if ((db[j] as number) > peak) {
|
|
257
|
+
peak = db[j] as number;
|
|
258
|
+
peakIndex = j;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (peakIndex < 0 || peak < speech) continue;
|
|
262
|
+
const rel = (ms: number): number | null => {
|
|
263
|
+
const j = peakIndex + Math.round(ms / FRAME_MS);
|
|
264
|
+
return j < db.length ? Number(((db[j] as number) - peak).toFixed(1)) : null;
|
|
265
|
+
};
|
|
266
|
+
const v50 = rel(50);
|
|
267
|
+
const v100 = rel(100);
|
|
268
|
+
const v200 = rel(200);
|
|
269
|
+
if (v50 === null || v100 === null || v200 === null) continue;
|
|
270
|
+
buckets.at50.push(v50);
|
|
271
|
+
buckets.at100.push(v100);
|
|
272
|
+
buckets.at200.push(v200);
|
|
273
|
+
let tail = 500;
|
|
274
|
+
for (let ms = FRAME_MS; ms <= 500; ms += FRAME_MS) {
|
|
275
|
+
const value = rel(ms);
|
|
276
|
+
if (value !== null && value <= -30) {
|
|
277
|
+
tail = ms;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
buckets.tail.push(tail);
|
|
282
|
+
}
|
|
283
|
+
const round1 = (value: number): number => Number(value.toFixed(1));
|
|
284
|
+
return {
|
|
285
|
+
at50: round1(median(buckets.at50)),
|
|
286
|
+
at100: round1(median(buckets.at100)),
|
|
287
|
+
at200: round1(median(buckets.at200)),
|
|
288
|
+
tailMs: buckets.tail.length === 0 ? 0 : median(buckets.tail),
|
|
289
|
+
offsets: buckets.tail.length,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Envelope-autocorrelation repetition in the 40–160 ms lag range, on the FIRST-DIFFERENCED envelope so
|
|
295
|
+
* the slow syllable/modulation trend cannot masquerade as a repetition (measured 2026-10-20: the raw
|
|
296
|
+
* envelope reported 0.83 at the minimum lag on every file, a dry gemini read included).
|
|
297
|
+
*/
|
|
298
|
+
export function echoMeasure(env: Float64Array): EchoMeasure | null {
|
|
299
|
+
if (env.length < 40) return null;
|
|
300
|
+
const n = env.length - 1;
|
|
301
|
+
const centred = new Float64Array(n);
|
|
302
|
+
let mean = 0;
|
|
303
|
+
for (let i = 0; i < n; i += 1) {
|
|
304
|
+
centred[i] = (env[i + 1] as number) - (env[i] as number);
|
|
305
|
+
mean += centred[i] as number;
|
|
306
|
+
}
|
|
307
|
+
mean /= n;
|
|
308
|
+
let energy = 0;
|
|
309
|
+
for (let i = 0; i < n; i += 1) {
|
|
310
|
+
centred[i] = (centred[i] as number) - mean;
|
|
311
|
+
energy += (centred[i] as number) ** 2;
|
|
312
|
+
}
|
|
313
|
+
if (energy <= 0) return null;
|
|
314
|
+
const minLag = Math.max(2, Math.round(40 / FRAME_MS));
|
|
315
|
+
const maxLag = Math.min(n - 2, Math.round(160 / FRAME_MS));
|
|
316
|
+
const scores = new Float64Array(maxLag + 2);
|
|
317
|
+
let best = { lag: minLag, strength: 0 };
|
|
318
|
+
for (let lag = minLag; lag <= maxLag; lag += 1) {
|
|
319
|
+
let sum = 0;
|
|
320
|
+
for (let i = 0; i + lag < n; i += 1) sum += (centred[i] as number) * (centred[i + lag] as number);
|
|
321
|
+
const value = sum / energy;
|
|
322
|
+
scores[lag] = value;
|
|
323
|
+
if (value > best.strength) best = { lag, strength: value };
|
|
324
|
+
}
|
|
325
|
+
if (best.strength <= 0) return null;
|
|
326
|
+
const neighbourhood = [scores[best.lag - 1] ?? 0, scores[best.lag + 1] ?? 0];
|
|
327
|
+
if (neighbourhood.some((value) => value >= best.strength)) return null;
|
|
328
|
+
return { lagMs: best.lag * FRAME_MS, strength: Number(best.strength.toFixed(3)) };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** In-place iterative radix-2 FFT; `re`/`im` must be power-of-two length. */
|
|
332
|
+
export function fft(re: Float64Array, im: Float64Array): void {
|
|
333
|
+
const n = re.length;
|
|
334
|
+
for (let i = 1, j = 0; i < n; i += 1) {
|
|
335
|
+
let bit = n >> 1;
|
|
336
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
337
|
+
j ^= bit;
|
|
338
|
+
if (i < j) {
|
|
339
|
+
[re[i], re[j]] = [re[j] as number, re[i] as number];
|
|
340
|
+
[im[i], im[j]] = [im[j] as number, im[i] as number];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
344
|
+
const angle = (-2 * Math.PI) / len;
|
|
345
|
+
const wRe = Math.cos(angle);
|
|
346
|
+
const wIm = Math.sin(angle);
|
|
347
|
+
for (let i = 0; i < n; i += len) {
|
|
348
|
+
let curRe = 1;
|
|
349
|
+
let curIm = 0;
|
|
350
|
+
for (let k = 0; k < len / 2; k += 1) {
|
|
351
|
+
const evenRe = re[i + k] as number;
|
|
352
|
+
const evenIm = im[i + k] as number;
|
|
353
|
+
const oddRe = re[i + k + len / 2] as number;
|
|
354
|
+
const oddIm = im[i + k + len / 2] as number;
|
|
355
|
+
const tRe = oddRe * curRe - oddIm * curIm;
|
|
356
|
+
const tIm = oddRe * curIm + oddIm * curRe;
|
|
357
|
+
re[i + k] = evenRe + tRe;
|
|
358
|
+
im[i + k] = evenIm + tIm;
|
|
359
|
+
re[i + k + len / 2] = evenRe - tRe;
|
|
360
|
+
im[i + k + len / 2] = evenIm - tIm;
|
|
361
|
+
const nextRe = curRe * wRe - curIm * wIm;
|
|
362
|
+
curIm = curRe * wIm + curIm * wRe;
|
|
363
|
+
curRe = nextRe;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Real-cepstrum echo probe. A reflection adds a periodic ripple to the log-magnitude spectrum whose
|
|
371
|
+
* period is 1/delay, so the cepstrum (inverse transform of that log spectrum) peaks at the delay.
|
|
372
|
+
* The search starts at 20 ms: a 5–12 ms pitch period and its multiples would otherwise dominate.
|
|
373
|
+
* Frames are capped so a 20-minute episode still costs about a second.
|
|
374
|
+
*/
|
|
375
|
+
export function cepstrumEcho(pcm: Int16Array, rate = ANALYSIS_RATE): CepstrumEcho | null {
|
|
376
|
+
const size = 8192;
|
|
377
|
+
const frames = Math.floor(pcm.length / size);
|
|
378
|
+
if (frames < 2) return null;
|
|
379
|
+
const step = Math.max(1, Math.floor(frames / 400));
|
|
380
|
+
const logPower = new Float64Array(size);
|
|
381
|
+
const window = Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
|
|
382
|
+
let used = 0;
|
|
383
|
+
for (let f = 0; f < frames; f += step) {
|
|
384
|
+
const re = new Float64Array(size);
|
|
385
|
+
const im = new Float64Array(size);
|
|
386
|
+
for (let i = 0; i < size; i += 1) re[i] = ((pcm[f * size + i] as number) / 32768) * (window[i] as number);
|
|
387
|
+
fft(re, im);
|
|
388
|
+
for (let k = 0; k < size; k += 1) {
|
|
389
|
+
logPower[k] =
|
|
390
|
+
(logPower[k] as number) + Math.log(Math.max((re[k] as number) ** 2 + (im[k] as number) ** 2, 1e-20));
|
|
391
|
+
}
|
|
392
|
+
used += 1;
|
|
393
|
+
}
|
|
394
|
+
if (used === 0) return null;
|
|
395
|
+
for (let k = 0; k < size; k += 1) logPower[k] = (logPower[k] as number) / used;
|
|
396
|
+
// The real cepstrum is the inverse transform of the (real, even) log spectrum; fft/N is that inverse.
|
|
397
|
+
const re = Float64Array.from(logPower);
|
|
398
|
+
const im = new Float64Array(size);
|
|
399
|
+
fft(re, im);
|
|
400
|
+
const cepstrum = Float64Array.from(re, (value) => Math.abs(value) / size);
|
|
401
|
+
const perMs = rate / 1000;
|
|
402
|
+
const lo = Math.max(2, Math.round(20 * perMs));
|
|
403
|
+
const hi = Math.min(Math.floor(size / 2) - 1, Math.round(120 * perMs));
|
|
404
|
+
const slice: number[] = [];
|
|
405
|
+
for (let q = lo; q <= hi; q += 1) slice.push(cepstrum[q] as number);
|
|
406
|
+
slice.sort((a, b) => a - b);
|
|
407
|
+
const floor = percentile(slice, 50);
|
|
408
|
+
let best = { q: lo, value: 0 };
|
|
409
|
+
for (let q = lo; q <= hi; q += 1) {
|
|
410
|
+
const value = cepstrum[q] as number;
|
|
411
|
+
if (value > best.value) best = { q, value };
|
|
412
|
+
}
|
|
413
|
+
const prominenceDb = 20 * Math.log10(Math.max(best.value, 1e-12) / Math.max(floor, 1e-12));
|
|
414
|
+
return { delayMs: Math.round((best.q / perMs) * 10) / 10, prominenceDb: Number(prominenceDb.toFixed(1)) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Average power spectrum over Hann-windowed 2048-sample frames, plus bands/centroid/rolloff. */
|
|
418
|
+
export function spectrum(pcm: Int16Array, rate = ANALYSIS_RATE): BandPowers {
|
|
419
|
+
const size = 2048;
|
|
420
|
+
const frames = Math.floor(pcm.length / size);
|
|
421
|
+
const power = new Float64Array(size / 2);
|
|
422
|
+
const window = Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
|
|
423
|
+
for (let f = 0; f < frames; f += 1) {
|
|
424
|
+
const re = new Float64Array(size);
|
|
425
|
+
const im = new Float64Array(size);
|
|
426
|
+
for (let i = 0; i < size; i += 1) re[i] = ((pcm[f * size + i] as number) / 32768) * (window[i] as number);
|
|
427
|
+
fft(re, im);
|
|
428
|
+
for (let k = 0; k < size / 2; k += 1) {
|
|
429
|
+
power[k] = (power[k] as number) + (re[k] as number) ** 2 + (im[k] as number) ** 2;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (frames > 0) for (let k = 0; k < power.length; k += 1) power[k] = (power[k] as number) / frames;
|
|
433
|
+
const binHz = rate / size;
|
|
434
|
+
const total = power.reduce((a, b) => a + b, 0) || 1;
|
|
435
|
+
const bands: Record<string, number> = {};
|
|
436
|
+
for (const [label, low, high] of BAND_EDGES) {
|
|
437
|
+
const lo = Math.max(1, Math.floor(low / binHz));
|
|
438
|
+
const hi = Math.min(power.length - 1, Math.ceil(high / binHz));
|
|
439
|
+
let sum = 0;
|
|
440
|
+
for (let k = lo; k <= hi; k += 1) sum += power[k] as number;
|
|
441
|
+
bands[label] = Number((10 * Math.log10(Math.max(sum / total, 1e-20))).toFixed(1));
|
|
442
|
+
}
|
|
443
|
+
let weighted = 0;
|
|
444
|
+
let cumulative = 0;
|
|
445
|
+
let rolloff = 0;
|
|
446
|
+
for (let k = 1; k < power.length; k += 1) {
|
|
447
|
+
const p = power[k] as number;
|
|
448
|
+
weighted += p * k * binHz;
|
|
449
|
+
cumulative += p;
|
|
450
|
+
if (rolloff === 0 && cumulative >= total * 0.85) rolloff = k * binHz;
|
|
451
|
+
}
|
|
452
|
+
return { bands, centroidHz: Math.round(weighted / total), rolloff85Hz: Math.round(rolloff) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Boxy = the 250–500 Hz band sits above the 2–4 kHz band (a hollow, cupped read). */
|
|
456
|
+
export function boxinessDb(bands: BandPowers): number {
|
|
457
|
+
return Number((((bands.bands['250-500'] as number) - (bands.bands['2k-4k'] as number)) as number).toFixed(1));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function analyse(file: string): AudioReport {
|
|
461
|
+
const meta = probe(file);
|
|
462
|
+
const pcm = decodeMono(file);
|
|
463
|
+
if (pcm.length === 0) throw new Error(`${file}: decoded no audio samples`);
|
|
464
|
+
const env = envelope(pcm);
|
|
465
|
+
let peak = 0;
|
|
466
|
+
let sum = 0;
|
|
467
|
+
let dc = 0;
|
|
468
|
+
let clipped = 0;
|
|
469
|
+
for (let i = 0; i < pcm.length; i += 1) {
|
|
470
|
+
const s = (pcm[i] as number) / 32768;
|
|
471
|
+
const abs = Math.abs(s);
|
|
472
|
+
if (abs > peak) peak = abs;
|
|
473
|
+
if (abs >= 0.999) clipped += 1;
|
|
474
|
+
sum += s * s;
|
|
475
|
+
dc += s;
|
|
476
|
+
}
|
|
477
|
+
const rms = Math.sqrt(sum / pcm.length);
|
|
478
|
+
const quantiles = envelopeQuantiles(env);
|
|
479
|
+
const decay = decayCurve(env);
|
|
480
|
+
const cepstrum = cepstrumEcho(pcm);
|
|
481
|
+
const echo = echoMeasure(env);
|
|
482
|
+
const spec = spectrum(pcm);
|
|
483
|
+
|
|
484
|
+
const flags: string[] = [];
|
|
485
|
+
if (quantiles.p20 > THRESHOLDS.pauseFloorDb) flags.push(`washy-pauses:${quantiles.p20}dB`);
|
|
486
|
+
if (decay.offsets > 0 && decay.at200 > THRESHOLDS.decay200Db) flags.push(`reverb-tail:${decay.at200}dB@200ms`);
|
|
487
|
+
// A strong tail never lets the level fall 20 dB below the phrase level, so no offset qualifies:
|
|
488
|
+
// absence of quiet runs, together with a high pause floor, IS the reverb finding.
|
|
489
|
+
if (decay.offsets === 0 && quantiles.p20 > THRESHOLDS.pauseFloorDb) flags.push('no-quiet-runs');
|
|
490
|
+
if (echo !== null && echo.strength >= THRESHOLDS.echoStrength) flags.push(`echo:${echo.lagMs}ms@${echo.strength}`);
|
|
491
|
+
if (cepstrum !== null && cepstrum.prominenceDb >= THRESHOLDS.cepstrumDb) {
|
|
492
|
+
flags.push(`cepstral-echo:${cepstrum.delayMs}ms@${cepstrum.prominenceDb}dB`);
|
|
493
|
+
}
|
|
494
|
+
if (clipped > 0) flags.push(`clipped:${clipped}`);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
file,
|
|
498
|
+
sampleRate: meta.sampleRate,
|
|
499
|
+
seconds: Math.round((pcm.length / ANALYSIS_RATE) * 10) / 10,
|
|
500
|
+
peakDbfs: Number((20 * Math.log10(Math.max(peak, 1e-7))).toFixed(1)),
|
|
501
|
+
rmsDbfs: Number((20 * Math.log10(Math.max(rms, 1e-7))).toFixed(1)),
|
|
502
|
+
crestDb: Number((20 * Math.log10(Math.max(peak, 1e-7) / Math.max(rms, 1e-7))).toFixed(1)),
|
|
503
|
+
dcOffset: Number((dc / pcm.length).toFixed(5)),
|
|
504
|
+
clippedSamples: clipped,
|
|
505
|
+
quantiles,
|
|
506
|
+
pauseFloorDb: quantiles.p20,
|
|
507
|
+
decay,
|
|
508
|
+
cepstrum,
|
|
509
|
+
echo,
|
|
510
|
+
spectrum: spec,
|
|
511
|
+
flags,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Corrective chain for a REFERENCE clip: not a de-reverb, but it removes the boxy room tone. */
|
|
516
|
+
export const TREAT_FILTER =
|
|
517
|
+
'highpass=f=80,equalizer=f=320:t=q:w=1.2:g=-3.5,equalizer=f=3000:t=q:w=1.0:g=2,acompressor=threshold=-18dB:ratio=2.5:attack=8:release=180,alimiter=limit=0.95';
|
|
518
|
+
|
|
519
|
+
export function treat(input: string, output: string): void {
|
|
520
|
+
const { sampleRate } = probe(input);
|
|
521
|
+
const run = spawnSync(
|
|
522
|
+
'ffmpeg',
|
|
523
|
+
[
|
|
524
|
+
'-y',
|
|
525
|
+
'-hide_banner',
|
|
526
|
+
'-loglevel',
|
|
527
|
+
'error',
|
|
528
|
+
'-i',
|
|
529
|
+
input,
|
|
530
|
+
'-af',
|
|
531
|
+
TREAT_FILTER,
|
|
532
|
+
'-ar',
|
|
533
|
+
String(sampleRate),
|
|
534
|
+
'-ac',
|
|
535
|
+
'1',
|
|
536
|
+
'-c:a',
|
|
537
|
+
'pcm_s16le',
|
|
538
|
+
output,
|
|
539
|
+
],
|
|
540
|
+
{ encoding: 'buffer' },
|
|
541
|
+
);
|
|
542
|
+
if (run.status !== 0) throw new Error(`ffmpeg treat failed: ${run.stderr?.toString().trim()}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function printReport(report: AudioReport, baseline?: AudioReport): void {
|
|
546
|
+
const spec = report.spectrum;
|
|
547
|
+
console.log(`\n${report.file}`);
|
|
548
|
+
console.log(
|
|
549
|
+
` ${report.seconds}s @ ${report.sampleRate} Hz · peak ${report.peakDbfs} dBFS · rms ${report.rmsDbfs} dBFS · crest ${report.crestDb} dB · dc ${report.dcOffset}`,
|
|
550
|
+
);
|
|
551
|
+
console.log(
|
|
552
|
+
` decay 50/100/200 ms: ${report.decay.at50}/${report.decay.at100}/${report.decay.at200} dB · tail ${report.decay.tailMs} ms over ${report.decay.offsets} offsets`,
|
|
553
|
+
);
|
|
554
|
+
console.log(
|
|
555
|
+
` pauseFloor ${report.pauseFloorDb} dB (p05/p20/p50 ${report.quantiles.p05}/${report.quantiles.p20}/${report.quantiles.p50}) · cepstrum ${
|
|
556
|
+
report.cepstrum === null ? 'n/a' : `${report.cepstrum.delayMs} ms @ ${report.cepstrum.prominenceDb} dB`
|
|
557
|
+
} · echo ${report.echo === null ? 'none' : `${report.echo.lagMs} ms @ ${report.echo.strength}`} · boxy ${boxinessDb(spec)} dB`,
|
|
558
|
+
);
|
|
559
|
+
console.log(` centroid ${spec.centroidHz} Hz · rolloff85 ${spec.rolloff85Hz} Hz`);
|
|
560
|
+
console.log(
|
|
561
|
+
` bands ${Object.entries(spec.bands)
|
|
562
|
+
.map(([label, db]) => `${label}:${db}`)
|
|
563
|
+
.join(' ')}`,
|
|
564
|
+
);
|
|
565
|
+
if (baseline !== undefined) {
|
|
566
|
+
const delta = Object.entries(spec.bands)
|
|
567
|
+
.map(
|
|
568
|
+
([label, db]) => `${label}:${((db as number) - (baseline.spectrum.bands[label] as number)).toFixed(1)}`,
|
|
569
|
+
)
|
|
570
|
+
.join(' ');
|
|
571
|
+
console.log(` Δvs baseline ${delta}`);
|
|
572
|
+
}
|
|
573
|
+
console.log(` verdict ${report.flags.length === 0 ? 'no flag' : report.flags.join(', ')}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function main(argv: string[]): Promise<void> {
|
|
577
|
+
const files: string[] = [];
|
|
578
|
+
let baseline: string | undefined;
|
|
579
|
+
let json = false;
|
|
580
|
+
let treatMode: [string, string] | undefined;
|
|
581
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
582
|
+
const arg = argv[i] as string;
|
|
583
|
+
if (arg === '--json') {
|
|
584
|
+
json = true;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (arg === '--baseline') {
|
|
588
|
+
i += 1;
|
|
589
|
+
baseline = argv[i] as string;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (arg === '--treat') {
|
|
593
|
+
treatMode = [argv[i + 1] as string, argv[i + 2] as string];
|
|
594
|
+
i += 2;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (arg === '--help' || arg === '-h') {
|
|
598
|
+
console.log(
|
|
599
|
+
'usage: kk stage audio-quality [--json] [--baseline <file>] <file…>\n kk stage audio-quality --treat <reference-in> <reference-out>',
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
files.push(arg);
|
|
604
|
+
}
|
|
605
|
+
if (treatMode !== undefined) {
|
|
606
|
+
treat(treatMode[0], treatMode[1]);
|
|
607
|
+
console.log(`treated ${treatMode[0]} -> ${treatMode[1]}\n chain ${TREAT_FILTER}`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (files.length === 0) {
|
|
611
|
+
console.error('usage: kk stage audio-quality [--json] [--baseline <file>] <file…>');
|
|
612
|
+
process.exit(2);
|
|
613
|
+
}
|
|
614
|
+
const baselineReport = baseline === undefined ? undefined : analyse(baseline);
|
|
615
|
+
const reports = files.map((file) => analyse(file));
|
|
616
|
+
if (json) {
|
|
617
|
+
console.log(
|
|
618
|
+
JSON.stringify(baselineReport === undefined ? reports : { baseline: baselineReport, reports }, null, 2),
|
|
619
|
+
);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
for (const report of reports) printReport(report, baselineReport);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Stage entry point: `kk stage audio-quality [--json] [--baseline <file>] <file…>`.
|
|
627
|
+
*
|
|
628
|
+
* The stage runner passes the args after the stage name, so this is the same parser the direct
|
|
629
|
+
* `bun run audio:quality` entry uses — one body, two spellings.
|
|
630
|
+
*/
|
|
631
|
+
export async function runAudioQuality(args: string[]): Promise<void> {
|
|
632
|
+
await main(args);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (import.meta.main) await runAudioQuality(process.argv.slice(2));
|