@gobing-ai/knowledge-kit 0.1.5 → 0.1.6
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 +46 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +24 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +19 -6
- 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
package/dist/index.js
CHANGED
|
@@ -51661,7 +51661,8 @@ async function createItemDecisionAsker(options = {}) {
|
|
|
51661
51661
|
const injected = options.hub ?? await loadInjectedHub();
|
|
51662
51662
|
const fallback = options.fallback ?? (injected === undefined ? (decisionId, input, maker) => runDecisionSubprocess(decisionId, input, maker, fs) : undefined);
|
|
51663
51663
|
const hub = injected ?? await (await import("@gobing-ai/ts-ai-decision")).createDecisionHub({
|
|
51664
|
-
catalogs: [catalogPath]
|
|
51664
|
+
catalogs: [catalogPath],
|
|
51665
|
+
builtins: { makerOptions: { fetch: bufferedDecisionFetch } }
|
|
51665
51666
|
});
|
|
51666
51667
|
const backendFailures = new Map;
|
|
51667
51668
|
return {
|
|
@@ -51696,6 +51697,24 @@ async function createItemDecisionAsker(options = {}) {
|
|
|
51696
51697
|
}
|
|
51697
51698
|
};
|
|
51698
51699
|
}
|
|
51700
|
+
function createBufferedDecisionFetch(inner = fetch) {
|
|
51701
|
+
return async (input, init) => {
|
|
51702
|
+
const response = await inner(input, init);
|
|
51703
|
+
const text = await response.text();
|
|
51704
|
+
const buffered = {
|
|
51705
|
+
ok: response.ok,
|
|
51706
|
+
status: response.status,
|
|
51707
|
+
statusText: response.statusText,
|
|
51708
|
+
headers: response.headers,
|
|
51709
|
+
body: null,
|
|
51710
|
+
text: async () => text,
|
|
51711
|
+
json: async () => JSON.parse(text),
|
|
51712
|
+
clone: () => buffered
|
|
51713
|
+
};
|
|
51714
|
+
return buffered;
|
|
51715
|
+
};
|
|
51716
|
+
}
|
|
51717
|
+
var bufferedDecisionFetch = createBufferedDecisionFetch();
|
|
51699
51718
|
function runDecisionSubprocess(decisionId, input, maker, fs = createNodeFileSystem()) {
|
|
51700
51719
|
const command = process.env.KK_SPUR_BIN ?? "spur";
|
|
51701
51720
|
const scratch = join21(tmpdir3(), `kk-decision-${randomUUID2()}`);
|
package/package.json
CHANGED
|
@@ -12399,12 +12399,14 @@ ${items}
|
|
|
12399
12399
|
`;
|
|
12400
12400
|
}
|
|
12401
12401
|
var AI_NEWS_TAG = "ai-news";
|
|
12402
|
+
var AI_DAILY_SUB_CATEGORY_TAG = "sub-category:ai-daily";
|
|
12402
12403
|
function tagsFor(docs) {
|
|
12403
12404
|
const existing = docs.flatMap((d) => {
|
|
12404
12405
|
const meta = d.metadata;
|
|
12405
12406
|
return Array.isArray(meta?.tags) ? meta.tags.filter((t) => typeof t === "string") : [];
|
|
12406
12407
|
});
|
|
12407
|
-
|
|
12408
|
+
const mandatory = [AI_NEWS_TAG, AI_DAILY_SUB_CATEGORY_TAG];
|
|
12409
|
+
return [...new Set([...mandatory, ...existing.filter((t) => !mandatory.includes(t))])];
|
|
12408
12410
|
}
|
|
12409
12411
|
var LEADING_H1_RE = /^#(?!#)\s*[^\n]+\n+/.source;
|
|
12410
12412
|
var NOTE_EXCLUDED_RE = /\u672A\u5199\u5165[\uFF1A:](.*)/g;
|
|
@@ -122,16 +122,18 @@ export function renderDailyArticle(docs: Doc[], date: string): string {
|
|
|
122
122
|
return `# ${episodeTitle(date)}\n\n${intro}\n\n${items}\n`;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/** Single-source daily
|
|
125
|
+
/** Single-source daily tags: stamped on every Content this generator emits (task 0119 + sub-category series tag). */
|
|
126
126
|
const AI_NEWS_TAG = 'ai-news';
|
|
127
|
+
const AI_DAILY_SUB_CATEGORY_TAG = 'sub-category:ai-daily';
|
|
127
128
|
|
|
128
|
-
/** Merge existing item tags (deduped) with the mandatory `ai-news`
|
|
129
|
+
/** Merge existing item tags (deduped) with the mandatory `ai-news` and `sub-category:ai-daily` tags, leading positions. */
|
|
129
130
|
function tagsFor(docs: Doc[]): string[] {
|
|
130
131
|
const existing = docs.flatMap((d) => {
|
|
131
132
|
const meta = d.metadata as Record<string, unknown> | undefined;
|
|
132
133
|
return Array.isArray(meta?.tags) ? meta.tags.filter((t): t is string => typeof t === 'string') : [];
|
|
133
134
|
});
|
|
134
|
-
|
|
135
|
+
const mandatory = [AI_NEWS_TAG, AI_DAILY_SUB_CATEGORY_TAG];
|
|
136
|
+
return [...new Set([...mandatory, ...existing.filter((t) => !mandatory.includes(t))])];
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
/** A leading H1 line — agent bodies used to carry their own title; the canonical title is computed (task 0189 R4). */
|
|
@@ -79,6 +79,24 @@ that evidence belongs to task 0258.
|
|
|
79
79
|
> Grade any render with `scripts/audit-speaker-consistency.py` (needs this plugin's venv):
|
|
80
80
|
> a per-item F0 spread over 45 Hz means the profile is not anchoring a speaker.
|
|
81
81
|
|
|
82
|
+
### Known artifact: a 40 ms echo lattice on long renders (2026-10-20)
|
|
83
|
+
|
|
84
|
+
A `reference`-anchored profile renders short text clean and long text with a 40 ms (≈25 Hz) periodic
|
|
85
|
+
reflection plus a pause floor only 16–22 dB below speech — heard as 「回声感、瓮声瓮气」. Measured on the
|
|
86
|
+
raw model output (no ffmpeg join, no mp3): `kk stage audio-quality` reports `cepstrum 40 ms @ 27–31 dB`
|
|
87
|
+
for renders >30 s, `pause floor −16…−22 dB`, while a 10 s render of the same profile/text measures
|
|
88
|
+
`22 ms @ 19 dB / −22.5 dB` and the same days' `gemini-tts` episodes measure `20–25 ms @ 19–20 dB /
|
|
89
|
+
−34…−37 dB`. `inference_timesteps`, `cfg_value` and `normalize` do not move it (measured 2026-10-20).
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# reproducible in ~2.5 min, no episode needed:
|
|
93
|
+
plugins/generations/voxcpm-tts/.venv/bin/python \
|
|
94
|
+
plugins/generations/voxcpm-tts/scripts/probe-long-render.py --out /tmp/voxcpm-probe
|
|
95
|
+
kk stage audio-quality /tmp/voxcpm-probe/long-baseline.wav
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Treat a driver/profile change as unproven until a LONG render is measured, not a single sentence.
|
|
99
|
+
|
|
82
100
|
## Control policy
|
|
83
101
|
|
|
84
102
|
`normalize` maps directly; an explicit `seed` calls `torch.manual_seed` before synthesis (the
|
|
@@ -90,3 +108,16 @@ one for every segment, derived from its `emotion`, and the anchored clip already
|
|
|
90
108
|
A per-request `engine`,
|
|
91
109
|
`personality=true` or an active `effects_chain` is rejected. Speed stays in shared postprocessing.
|
|
92
110
|
Transcription is omitted, so ASR-dependent QC is reported unavailable (not passing evidence).
|
|
111
|
+
|
|
112
|
+
#### API/library claim sources and confidence
|
|
113
|
+
|
|
114
|
+
The externally changeable claims this file makes about the installed `voxcpm` package, each with the
|
|
115
|
+
artifact it was read from. This block is enforced by `structure/api-library-claim-citation` (the file
|
|
116
|
+
is in that rule's `--require-block` registry), so a HIGH claim without a citation fails the gate.
|
|
117
|
+
|
|
118
|
+
| Claim | Source | Local verification | Confidence |
|
|
119
|
+
| --- | --- | --- | --- |
|
|
120
|
+
| `reference_wav_path` is accepted only by VoxCPM2 checkpoints; any other model id refuses it, so a `reference` profile is VoxCPM2-only while older checkpoints clone through `prompt_wav_path` + `prompt_text` | installed `voxcpm` package | `plugins/generations/voxcpm-tts/.venv/lib/python3.12/site-packages/voxcpm/core.py:239` (`ValueError("reference_wav_path is only supported with VoxCPM2 models")`); reproduced with `scripts/probe-long-render.py --model openbmb/VoxCPM1.5` | HIGH |
|
|
121
|
+
| `generate()`/`_generate()` exposes `cfg_value` (default 2.0), `inference_timesteps` (10), `normalize`, `denoise` and `max_len`, and this plugin passes none of them — the profile and text are the only inputs it sets | installed `voxcpm` package plus this plugin's worker | `…/site-packages/voxcpm/core.py:180`–`:193` (signature and defaults) and `python/worker.py:252`–`:262` (the three `generate(...)` call shapes) | HIGH |
|
|
122
|
+
| `VOXCPM_MODEL` defaults to `openbmb/VoxCPM2`, the model self-reports its output rate, and that rate is what the WAV header carries (no resampling in this plugin) | this plugin's worker | `python/worker.py:181` (`environ.get("VOXCPM_MODEL", "openbmb/VoxCPM2")`), `:207` (`int(model.tts_model.sample_rate)`) and `:264` (`write_pcm16_wav(dest, pcm, sample_rate)`) | HIGH |
|
|
123
|
+
| A `reference` profile pins one speaker (median-pitch swing 22–26 Hz, sd 9–12 Hz) while `instruct` samples a new speaker per request (141 Hz swing), so multi-segment work needs a reference or continuation profile | measured on this machine, 2026-10-15, three texts | `scripts/audit-speaker-consistency.py` grades a render; the 2026-10-15 figures are the ones its docstring records | MEDIUM |
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reproduce the long-form voxcpm "echo / hollow" artifact in ~2.5 minutes.
|
|
3
|
+
|
|
4
|
+
Why this exists: the 2026-10-20 operator report ("回声感、瓮声瓮气") is not reproducible on a short
|
|
5
|
+
sentence — a 10 s render comes out clean — but it IS reproducible on a paragraph of ~450 Chinese
|
|
6
|
+
characters, which is the length the daily pipeline actually renders per section. Measured 2026-10-20
|
|
7
|
+
with `kk stage audio-quality` (real-cepstrum echo probe + pause floor):
|
|
8
|
+
|
|
9
|
+
short render (~10 s) cepstrum 22 ms @ 19 dB pause floor -22.5 dB clean
|
|
10
|
+
long render (~72 s) cepstrum 40 ms @ 30 dB pause floor -16.1 dB reproduced
|
|
11
|
+
shipped 2026-10-20 §00 cepstrum 40 ms @ 23 dB pause floor -15.7 dB reproduced
|
|
12
|
+
|
|
13
|
+
So the artifact is duration-dependent, and a knob experiment has to use a LONG text or it measures
|
|
14
|
+
nothing. The same run measured `inference_timesteps=20` (29.6 dB) and `cfg_value=2.5` (31.5 dB) against
|
|
15
|
+
the default's 30 dB: none of the exposed sampler knobs remove the 40 ms periodicity.
|
|
16
|
+
|
|
17
|
+
Usage (needs the plugin venv):
|
|
18
|
+
|
|
19
|
+
plugins/generations/voxcpm-tts/.venv/bin/python \
|
|
20
|
+
plugins/generations/voxcpm-tts/scripts/probe-long-render.py [--out /tmp/voxcpm-probe]
|
|
21
|
+
|
|
22
|
+
kk stage audio-quality /tmp/voxcpm-probe/long-baseline.wav # then read cepstrum + pauseFloor
|
|
23
|
+
|
|
24
|
+
Options: `--text-file <path>` (default: the built-in paragraph below), `--ref <wav>` (default: the
|
|
25
|
+
operator's `robin-news` clip), `--steps N`, `--cfg F`, `--normalize`, `--name <label>`.
|
|
26
|
+
|
|
27
|
+
The verdict vocabulary lives in `plugins/kk/scripts/audio-quality.ts` (`kk stage audio-quality`);
|
|
28
|
+
this script only renders.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import contextlib
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import wave
|
|
39
|
+
|
|
40
|
+
DEFAULT_TEXT = (
|
|
41
|
+
"大家好,今天是2026年10月20日,星期二。这里是由Robin为你带来的咸话咸说之每日AI乱炖。"
|
|
42
|
+
"今天聊两件跟动手有关的事,一位自称几乎不懂游戏开发的作者,靠编程智能体把一款手机端的即时战略游戏"
|
|
43
|
+
"一路做到能离线运行;另一件是 Google 开源了让 PyTorch 跑上 TPU 的项目。两条线索都指向同一件事:"
|
|
44
|
+
"工具链的边界在移动,真正决定成败的不是模型榜单,而是谁能把验收口径写清楚。"
|
|
45
|
+
"先说游戏那条,作者的说法很朴素,他不写引擎,只把需求拆成可以验证的小步,让智能体一步一步改,"
|
|
46
|
+
"每一步都跑一次构建,坏了自己回滚。这种做法听起来笨,但它把不确定性压到了单步之内,"
|
|
47
|
+
"出问题的时候能立刻定位到底层接口还是生成逻辑。我更在意的是这套流程对工程团队的启示:"
|
|
48
|
+
"当生成成本降到几乎为零,稀缺的就不是代码,而是判断哪一步值得交给机器、哪一步必须留给人验。"
|
|
49
|
+
)
|
|
50
|
+
DEFAULT_REF = os.path.expanduser("~/.config/kk/voices/robin-news.wav")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
parser = argparse.ArgumentParser(description="Render one long-form voxcpm sample for echo triage.")
|
|
55
|
+
parser.add_argument("--text-file", default=None, help="UTF-8 text to render (default: built-in paragraph)")
|
|
56
|
+
parser.add_argument("--ref", default=DEFAULT_REF, help="reference clip (default: %(default)s)")
|
|
57
|
+
parser.add_argument("--out", default="/tmp/voxcpm-probe", help="output directory (default: %(default)s)")
|
|
58
|
+
parser.add_argument("--name", default="long-baseline", help="output file stem (default: %(default)s)")
|
|
59
|
+
parser.add_argument("--steps", type=int, default=10, help="inference_timesteps (default 10, the plugin's)")
|
|
60
|
+
parser.add_argument("--cfg", type=float, default=2.0, help="cfg_value (default 2.0, the plugin's)")
|
|
61
|
+
parser.add_argument("--normalize", action="store_true", help="pass normalize=True")
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--mode",
|
|
64
|
+
choices=["reference", "continuation", "instruct"],
|
|
65
|
+
default="reference",
|
|
66
|
+
help="reference cloning (VoxCPM2 only), prompt continuation, or instruct-only (default reference)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--prompt-text", default=None, help="transcript of --ref, required by --mode continuation")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--denoise",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="load the ZipEnhancer denoiser and pass denoise=True (the plugin ships load_denoiser=False)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("--model", default="openbmb/VoxCPM2", help="hub id or local snapshot")
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
text = open(args.text_file, encoding="utf-8").read().strip() if args.text_file else DEFAULT_TEXT
|
|
78
|
+
if not text:
|
|
79
|
+
print("probe: empty text", file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
if not os.path.isfile(args.ref):
|
|
82
|
+
print(f"probe: reference clip not found: {args.ref}", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
os.makedirs(args.out, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# The model prints its own progress on stdout; keep this script's stdout for the summary.
|
|
87
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
88
|
+
import numpy as np
|
|
89
|
+
import voxcpm
|
|
90
|
+
|
|
91
|
+
model = voxcpm.VoxCPM.from_pretrained(
|
|
92
|
+
args.model, load_denoiser=args.denoise, optimize=False, device=None
|
|
93
|
+
)
|
|
94
|
+
sample_rate = int(model.tts_model.sample_rate)
|
|
95
|
+
|
|
96
|
+
if args.mode == "continuation" and not args.prompt_text:
|
|
97
|
+
print("probe: --mode continuation needs --prompt-text (the transcript of --ref)", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
# Only VoxCPM2 accepts `reference_wav_path`; every other checkpoint clones through a prompt pair.
|
|
100
|
+
anchoring = {
|
|
101
|
+
"reference": {"reference_wav_path": args.ref},
|
|
102
|
+
"continuation": {"prompt_wav_path": args.ref, "prompt_text": args.prompt_text or ""},
|
|
103
|
+
"instruct": {},
|
|
104
|
+
}[args.mode]
|
|
105
|
+
|
|
106
|
+
started = time.time()
|
|
107
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
108
|
+
waveform = model.generate(
|
|
109
|
+
text=text,
|
|
110
|
+
cfg_value=args.cfg,
|
|
111
|
+
inference_timesteps=args.steps,
|
|
112
|
+
normalize=args.normalize,
|
|
113
|
+
denoise=args.denoise,
|
|
114
|
+
**anchoring,
|
|
115
|
+
)
|
|
116
|
+
samples = np.clip(np.asarray(waveform).squeeze(), -1.0, 1.0)
|
|
117
|
+
path = os.path.join(args.out, f"{args.name}.wav")
|
|
118
|
+
with wave.open(path, "wb") as handle:
|
|
119
|
+
handle.setnchannels(1)
|
|
120
|
+
handle.setsampwidth(2)
|
|
121
|
+
handle.setframerate(sample_rate)
|
|
122
|
+
handle.writeframes((samples * 32767).astype(np.int16).tobytes())
|
|
123
|
+
|
|
124
|
+
seconds = len(samples) / sample_rate
|
|
125
|
+
print(
|
|
126
|
+
f"{path} {seconds:.1f}s sr={sample_rate} chars={len(text)} "
|
|
127
|
+
f"mode={args.mode} steps={args.steps} cfg={args.cfg} normalize={args.normalize} denoise={args.denoise} render={time.time() - started:.1f}s"
|
|
128
|
+
)
|
|
129
|
+
print("measure it with: kk stage audio-quality " + path, file=sys.stderr)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
package/plugins/kk/plugin.json
CHANGED
|
@@ -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));
|
|
@@ -1981,6 +1981,52 @@ export function dailySafetyGate(args: string[]): void {
|
|
|
1981
1981
|
});
|
|
1982
1982
|
}
|
|
1983
1983
|
|
|
1984
|
+
/**
|
|
1985
|
+
* `daily-preflight <workflow.yaml>…` — assert the stage runner that `kk stage` just loaded
|
|
1986
|
+
* advertises every `kk stage <name>` these workflow definitions invoke.
|
|
1987
|
+
*
|
|
1988
|
+
* WHY (2026-10-20): the scheduler runs the CHECKOUT's workflow YAML while `kk stage` resolves the
|
|
1989
|
+
* runner from the INSTALLED package (`apps/cli/src/stage.ts`). A commit that adds a stage therefore
|
|
1990
|
+
* outruns the global install silently, and the mismatch only surfaces where the new stage is first
|
|
1991
|
+
* reached. That day's 04:30 run died 29 minutes in at `safety` with `unknown stage:
|
|
1992
|
+
* daily-safety-verdict` (present in the checkout, absent from the installed 0.1.4) and published
|
|
1993
|
+
* nothing. This runs as the first command of the run's `prepare` node, so the same skew costs one
|
|
1994
|
+
* cheap step and names both the missing stages and the refresh command.
|
|
1995
|
+
*
|
|
1996
|
+
* The known-name set is the dispatch switch of the sibling `kk-workflow-stages.ts` — the file beside
|
|
1997
|
+
* this one, i.e. the copy actually in force, never a checkout-vs-install guess.
|
|
1998
|
+
*/
|
|
1999
|
+
export function dailyPreflight(args: string[]): void {
|
|
2000
|
+
const files = args.filter((arg) => arg !== '');
|
|
2001
|
+
timed('', 'preflight', () => {
|
|
2002
|
+
if (files.length === 0) failWith(2, 'daily-preflight: pass at least one workflow YAML path');
|
|
2003
|
+
const runner = join(import.meta.dir, 'kk-workflow-stages.ts');
|
|
2004
|
+
if (!isFile(runner)) failWith(1, `daily-preflight.runner-missing: ${runner} is not beside this stage body`);
|
|
2005
|
+
const known = new Set(
|
|
2006
|
+
[...readFileSync(runner, 'utf-8').matchAll(/^\s*case '([a-z][a-z0-9-]*)':/gm)].map((match) =>
|
|
2007
|
+
String(match[1]),
|
|
2008
|
+
),
|
|
2009
|
+
);
|
|
2010
|
+
const missing: string[] = [];
|
|
2011
|
+
for (const file of files) {
|
|
2012
|
+
if (!isFile(file)) failWith(1, `daily-preflight.workflow-missing: ${file}`);
|
|
2013
|
+
for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage ([a-z][a-z0-9-]*)/g)) {
|
|
2014
|
+
const name = String(match[1]);
|
|
2015
|
+
if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
if (missing.length > 0) {
|
|
2019
|
+
failWith(
|
|
2020
|
+
1,
|
|
2021
|
+
`daily-preflight.runner-stale: the installed stage runner ${runner} does not know ${missing.join(', ')} — refresh it (install the released kk package, or rsync plugins/kk/scripts/ per AGENTS.md) before the scheduled run`,
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
console.log(
|
|
2025
|
+
`preflight: stage runner advertises every stage ${files.map((file) => basename(file)).join(' + ')} invokes`,
|
|
2026
|
+
);
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1984
2030
|
/**
|
|
1985
2031
|
* Task 0317 R6 — fold the gate's own classification, its stamp and the `kk-safety-verdict` catalog
|
|
1986
2032
|
* decision into ONE accept/refuse token the `judge -> clean` guard reads as a single predicate
|
|
@@ -159,6 +159,20 @@ async function loadDailyStages(): Promise<typeof import('./daily-stages.ts')> {
|
|
|
159
159
|
);
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Load the audio-quality diagnostic lazily: it shells out to ffprobe/ffmpeg and is only needed when an
|
|
164
|
+
* operator asks for it, so the runner must not pay for it on every stage (and an install without
|
|
165
|
+
* ffmpeg on PATH still runs every other stage).
|
|
166
|
+
*/
|
|
167
|
+
async function loadAudioQuality(): Promise<typeof import('./audio-quality.ts')> {
|
|
168
|
+
try {
|
|
169
|
+
return await import('./audio-quality.ts');
|
|
170
|
+
} catch {
|
|
171
|
+
return fail(
|
|
172
|
+
'audio-quality.ts not found next to kk-workflow-stages.ts — install or refresh the kk package (bun link in a checkout)',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
162
176
|
export const ERRNO_REASONS: Record<string, string> = {
|
|
163
177
|
EACCES: 'Permission denied',
|
|
164
178
|
EROFS: 'Read-only file system',
|
|
@@ -1638,6 +1652,16 @@ if (import.meta.main)
|
|
|
1638
1652
|
dailyScript(rest);
|
|
1639
1653
|
break;
|
|
1640
1654
|
}
|
|
1655
|
+
case 'audio-quality': {
|
|
1656
|
+
const { runAudioQuality } = await loadAudioQuality();
|
|
1657
|
+
await runAudioQuality(rest);
|
|
1658
|
+
break;
|
|
1659
|
+
}
|
|
1660
|
+
case 'daily-preflight': {
|
|
1661
|
+
const { dailyPreflight } = await loadDailyStages();
|
|
1662
|
+
dailyPreflight(rest);
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1641
1665
|
case 'daily-safety-prep': {
|
|
1642
1666
|
const { dailySafetyPrep } = await loadDailyStages();
|
|
1643
1667
|
dailySafetyPrep(rest);
|
|
@@ -25,9 +25,15 @@ vars:
|
|
|
25
25
|
# Voice driver switch — voxcpm (voxcpm-tts) | gemini (gemini-tts) | voicebox (voice-gen)
|
|
26
26
|
# | ominivoice (omni-voice-gen). The generate tier below mirrors the fallback order, and the
|
|
27
27
|
# fit/duration gates take their spoken-char rate from this var — switching drivers here is enough.
|
|
28
|
-
# 2026-10-
|
|
29
|
-
#
|
|
30
|
-
#
|
|
28
|
+
# 2026-10-20 (operator decision): voxcpm STAYS the primary. It is audibly echoey - a 40 ms periodic
|
|
29
|
+
# reflection on renders longer than ~30 s plus a pause floor only 16-22 dB below speech, measured by
|
|
30
|
+
# `kk stage audio-quality` on every episode's raw model output since 10-15 (cepstrum 40 ms @ 26-31 dB
|
|
31
|
+
# / pauseFloor -16..-22 dB, versus gemini's 20-25 ms @ 19-20 dB / -34..-37 dB) - and no exposed knob
|
|
32
|
+
# (cfg_value, inference_timesteps, normalize, denoise) or usable checkpoint fixes it (VoxCPM1.5:
|
|
33
|
+
# 80 ms @ 31 dB; VoxCPM-0.5B loses the lattice but measures -15.5 dB at 16 kHz). The operator keeps
|
|
34
|
+
# the local driver for its speed and cost and will monitor it episode by episode until a proper fix
|
|
35
|
+
# exists; flipping this one var to "gemini" is the escape hatch (measured dry: 20-25 ms / -34..-37 dB)
|
|
36
|
+
# at the price of a 1h47m-2h21m remote render.
|
|
31
37
|
vdriver: "voxcpm"
|
|
32
38
|
intro_music: ".works/kk-daily-ai-voice/AI每日资讯_8秒播客片头.mp3"
|
|
33
39
|
surfdash_root: "/Users/robin/xprojects/surfdash"
|
|
@@ -113,11 +119,18 @@ nodes:
|
|
|
113
119
|
the artifact directories and the safety revise budget. `kk ledger sync` records runs that
|
|
114
120
|
already finished without a ledger row (failed ones included) — a fail-fast DAG never reaches
|
|
115
121
|
its own `ledger` node, so the next run's prepare is the backfill (design §7).
|
|
122
|
+
The `daily-preflight` stage runs FIRST: the scheduler executes this checkout's YAML while
|
|
123
|
+
`kk stage` resolves its stage runner from the installed package, so a run whose YAML names a
|
|
124
|
+
stage the installed runner lacks must fail here, naming the stage, instead of 29 minutes later
|
|
125
|
+
inside the nested safety machine (2026-10-20: `unknown stage: daily-safety-verdict`, nothing
|
|
126
|
+
published).
|
|
116
127
|
action:
|
|
117
128
|
kind: shell
|
|
118
129
|
options:
|
|
119
130
|
command: >-
|
|
120
|
-
kk daily
|
|
131
|
+
kk stage daily-preflight plugins/kk/workflows/kk-daily-ai-voice2.yaml
|
|
132
|
+
plugins/kk/workflows/kk-daily-safety.yaml
|
|
133
|
+
&& kk daily prepare --workflow kk-daily-ai-voice2 --run-date "${vars.run_date}" --ledger-sync
|
|
121
134
|
--run "${vars.__runId}" --definition-digest "${vars.__definitionDigest}"
|
|
122
135
|
--setting window="${vars.window}" --setting language="${vars.language}" --setting vdriver="${vars.vdriver}"
|
|
123
136
|
--setting skip_review="${vars.skip_review}" --setting transcode_mp3="${vars.transcode_mp3}"
|
|
@@ -548,7 +561,7 @@ nodes:
|
|
|
548
561
|
kk stage daily-wrap-docs "${vars.work_dir}" "${vars.run_date}" "${vars.voice_profile}"
|
|
549
562
|
|
|
550
563
|
- id: generate
|
|
551
|
-
description: "Voice tier, primary driver (vars.vdriver — voxcpm
|
|
564
|
+
description: "Voice tier, primary driver (vars.vdriver — voxcpm; its 40 ms lattice is known and monitored, see the vdriver comment). onError: continue — the fallback siblings below are the loud chain (same pattern as 0238 R2)."
|
|
552
565
|
# wrap-docs is a data dependency, not a sibling: daily-generate reads its _10_wrap-docs_docs.json
|
|
553
566
|
# as the TTS input. Without this edge the two nodes race in the same wave (0212 dogfood finding).
|
|
554
567
|
dependsOn: [script, safety, review, wrap-docs]
|
|
@@ -570,7 +583,7 @@ nodes:
|
|
|
570
583
|
# its plugin id and the cache ignores foreign-driver sections, so a failed attempt can neither be
|
|
571
584
|
# relabeled nor mix voices). Non-final siblings continue on error; the last one is loud.
|
|
572
585
|
- id: generate-fallback
|
|
573
|
-
description: "Voice tier fallback 1: gemini (gemini-tts) when the primary driver left no valid Content"
|
|
586
|
+
description: "Voice tier fallback 1: gemini (gemini-tts) when the primary driver left no valid Content — the measured-dry escape hatch from voxcpm's lattice"
|
|
574
587
|
dependsOn: [generate]
|
|
575
588
|
condition:
|
|
576
589
|
kind: shell
|
|
@@ -12423,6 +12423,10 @@ ${crossLinkFor(locale, request.dateIso, request.showNotesUrl)}
|
|
|
12423
12423
|
if (md !== before) {
|
|
12424
12424
|
await fs.writeFile(indexMd, md);
|
|
12425
12425
|
written.push(indexMd);
|
|
12426
|
+
const assetsDir = join(dir, "assets");
|
|
12427
|
+
if (await fs.exists(assetsDir) && !written.includes(join(assetsDir, "cover.png"))) {
|
|
12428
|
+
written.push(assetsDir);
|
|
12429
|
+
}
|
|
12426
12430
|
changed += 1;
|
|
12427
12431
|
}
|
|
12428
12432
|
}
|
|
@@ -12437,6 +12441,14 @@ var COVER_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
|
|
|
12437
12441
|
function slugify2(str) {
|
|
12438
12442
|
return str.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
|
|
12439
12443
|
}
|
|
12444
|
+
function normalizeTag(raw) {
|
|
12445
|
+
const match = raw.match(/^\s*sub-category\s*:(.*)$/i);
|
|
12446
|
+
if (match) {
|
|
12447
|
+
const subSlug = slugify2(match[1] ?? "");
|
|
12448
|
+
return subSlug ? `sub-category:${subSlug}` : "";
|
|
12449
|
+
}
|
|
12450
|
+
return slugify2(raw);
|
|
12451
|
+
}
|
|
12440
12452
|
function mapContentToSurfingMarkdown(content, options = {}) {
|
|
12441
12453
|
let title = content.title;
|
|
12442
12454
|
if (!title) {
|
|
@@ -12452,9 +12464,13 @@ function mapContentToSurfingMarkdown(content, options = {}) {
|
|
|
12452
12464
|
description = paragraphs[0];
|
|
12453
12465
|
}
|
|
12454
12466
|
}
|
|
12455
|
-
const rawTags = Array.isArray(content.metadata?.tags) ? content.metadata.tags : Array.isArray(content.metadata?.keywords) ? content.metadata.keywords : [];
|
|
12467
|
+
const rawTags = Array.isArray(content.metadata?.tags) ? [...content.metadata.tags] : Array.isArray(content.metadata?.keywords) ? [...content.metadata.keywords] : [];
|
|
12468
|
+
if (typeof content.metadata?.subCategory === "string" && content.metadata.subCategory.trim().length > 0) {
|
|
12469
|
+
const subRaw = content.metadata.subCategory.trim();
|
|
12470
|
+
rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
|
|
12471
|
+
}
|
|
12456
12472
|
const tags = [
|
|
12457
|
-
...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) =>
|
|
12473
|
+
...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => normalizeTag(t)))
|
|
12458
12474
|
].filter((tag) => tag.length > 0);
|
|
12459
12475
|
const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
|
|
12460
12476
|
const firstRefUrl = content.references?.[0]?.url;
|
|
@@ -12743,7 +12759,17 @@ async function patchPost(options) {
|
|
|
12743
12759
|
if (outcome.changed === 0)
|
|
12744
12760
|
echoError(`patch: no post bytes changed for ${outcome.slug}`);
|
|
12745
12761
|
const synced = await syncPost({ postPath: postPath.postPath });
|
|
12746
|
-
const
|
|
12762
|
+
const commitPaths = new Set(outcome.written.map((path) => relative(surfdashRoot, path)));
|
|
12763
|
+
for (const locale of PATCH_LOCALES) {
|
|
12764
|
+
const indexMd = localeIndexPath(postPath.postPath, locale);
|
|
12765
|
+
if (!indexMd)
|
|
12766
|
+
continue;
|
|
12767
|
+
const assetsDir = join2(dirname3(indexMd), "assets");
|
|
12768
|
+
if (existsSync2(assetsDir)) {
|
|
12769
|
+
commitPaths.add(relative(surfdashRoot, assetsDir));
|
|
12770
|
+
}
|
|
12771
|
+
}
|
|
12772
|
+
const commit = await commitPostPaths(surfdashRoot, [...commitPaths], `feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`, process.env.SURFDASH_AUTO_PUSH !== "0");
|
|
12747
12773
|
if (!commit.ok)
|
|
12748
12774
|
return fail(`patch: ${commit.error}`);
|
|
12749
12775
|
const metadata = {
|
|
@@ -12922,6 +12948,7 @@ export {
|
|
|
12922
12948
|
main2 as main,
|
|
12923
12949
|
mapContentToSurfingMarkdown,
|
|
12924
12950
|
normalizeLocaleFrontmatter,
|
|
12951
|
+
normalizeTag,
|
|
12925
12952
|
processPublishIO,
|
|
12926
12953
|
pushPostGit,
|
|
12927
12954
|
slugify2 as slugify,
|
|
@@ -6,7 +6,7 @@ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-
|
|
|
6
6
|
import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
|
|
7
7
|
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
8
8
|
import { echoError } from '@gobing-ai/ts-utils';
|
|
9
|
-
import { type PatchPostOutcome, patchPostFiles } from './patch';
|
|
9
|
+
import { localeIndexPath, PATCH_LOCALES, type PatchPostOutcome, patchPostFiles } from './patch';
|
|
10
10
|
|
|
11
11
|
/** Result target name for this plugin. */
|
|
12
12
|
const TARGET = 'surfdash';
|
|
@@ -62,6 +62,19 @@ export function slugify(str: string): string {
|
|
|
62
62
|
.replace(/[\s_]+/g, '-');
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Normalizes a raw tag string for frontmatter: preserves `sub-category:<slug>` prefixes
|
|
67
|
+
* while slugifying the value portion (or ordinary tags).
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeTag(raw: string): string {
|
|
70
|
+
const match = raw.match(/^\s*sub-category\s*:(.*)$/i);
|
|
71
|
+
if (match) {
|
|
72
|
+
const subSlug = slugify(match[1] ?? '');
|
|
73
|
+
return subSlug ? `sub-category:${subSlug}` : '';
|
|
74
|
+
}
|
|
75
|
+
return slugify(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
/**
|
|
66
79
|
* Maps Content object into Surfing frontmatter and body per the publish spec.
|
|
67
80
|
*/
|
|
@@ -86,18 +99,23 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
|
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
const rawTags: unknown[] = Array.isArray(content.metadata?.tags)
|
|
89
|
-
? content.metadata.tags
|
|
102
|
+
? [...content.metadata.tags]
|
|
90
103
|
: Array.isArray(content.metadata?.keywords)
|
|
91
|
-
? content.metadata.keywords
|
|
104
|
+
? [...content.metadata.keywords]
|
|
92
105
|
: [];
|
|
106
|
+
if (typeof content.metadata?.subCategory === 'string' && content.metadata.subCategory.trim().length > 0) {
|
|
107
|
+
const subRaw = content.metadata.subCategory.trim();
|
|
108
|
+
rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
|
|
109
|
+
}
|
|
93
110
|
// `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
|
|
94
111
|
// YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
|
|
95
|
-
// also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes.
|
|
112
|
+
// also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
|
|
113
|
+
// `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
|
|
96
114
|
const tags = [
|
|
97
115
|
...new Set(
|
|
98
116
|
rawTags
|
|
99
117
|
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
|
|
100
|
-
.map((t: string) =>
|
|
118
|
+
.map((t: string) => normalizeTag(t)),
|
|
101
119
|
),
|
|
102
120
|
].filter((tag) => tag.length > 0);
|
|
103
121
|
|
|
@@ -519,9 +537,18 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
|
|
|
519
537
|
}
|
|
520
538
|
if (outcome.changed === 0) echoError(`patch: no post bytes changed for ${outcome.slug}`);
|
|
521
539
|
const synced = await syncPost({ postPath: postPath.postPath });
|
|
540
|
+
const commitPaths = new Set(outcome.written.map((path) => relative(surfdashRoot, path)));
|
|
541
|
+
for (const locale of PATCH_LOCALES) {
|
|
542
|
+
const indexMd = localeIndexPath(postPath.postPath, locale);
|
|
543
|
+
if (!indexMd) continue;
|
|
544
|
+
const assetsDir = join(dirname(indexMd), 'assets');
|
|
545
|
+
if (existsSync(assetsDir)) {
|
|
546
|
+
commitPaths.add(relative(surfdashRoot, assetsDir));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
522
549
|
const commit = await commitPostPaths(
|
|
523
550
|
surfdashRoot,
|
|
524
|
-
|
|
551
|
+
[...commitPaths],
|
|
525
552
|
`feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`,
|
|
526
553
|
process.env.SURFDASH_AUTO_PUSH !== '0',
|
|
527
554
|
);
|
|
@@ -137,6 +137,10 @@ export async function patchPostFiles(request: PatchPostRequest): Promise<PatchPo
|
|
|
137
137
|
if (md !== before) {
|
|
138
138
|
await fs.writeFile(indexMd, md);
|
|
139
139
|
written.push(indexMd);
|
|
140
|
+
const assetsDir = join(dir, 'assets');
|
|
141
|
+
if ((await fs.exists(assetsDir)) && !written.includes(join(assetsDir, 'cover.png'))) {
|
|
142
|
+
written.push(assetsDir);
|
|
143
|
+
}
|
|
140
144
|
changed += 1;
|
|
141
145
|
}
|
|
142
146
|
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# Feature-scoped verification pass (ADR-119, feature D62, task 0872; D63 task
|
|
2
|
-
# 0915 remediation folds the evidence contract into a workflow-owned script).
|
|
3
|
-
#
|
|
4
|
-
# Owns a lifecycle boundary no per-task graph owns: the repo-wide checks whose
|
|
5
|
-
# invariant spans the repository rather than a single task's diff. They run
|
|
6
|
-
# once per feature, deliberately the slow pass; `feature-lifecycle`'s verifying
|
|
7
|
-
# entry invokes this workflow (task 0880 caller wiring), and its verifying→done
|
|
8
|
-
# guard refuses to complete the feature until this pass records PASS (ADR-119
|
|
9
|
-
# consequence: a feature is not done until it passes).
|
|
10
|
-
#
|
|
11
|
-
# Run: spur workflow run feature-verification.yaml --vars '{"featureId":"D63"}'
|
|
12
|
-
#
|
|
13
|
-
# No project-side override is required (task 1185, A33). Since the pass moved into the
|
|
14
|
-
# application (1176 / efd78f058) the bundled definition owns it end to end, so a
|
|
15
|
-
# project-layer copy of this file (plus a rooted port under `.spur/scripts/`) is a
|
|
16
|
-
# leftover — the port was written against an entry export contract that has since
|
|
17
|
-
# changed. Remove the override so this definition applies, or migrate the port to the
|
|
18
|
-
# current app-owned operation `runFeatureVerification` (the generated inline bundle's
|
|
19
|
-
# export). If a stale consumer is still reached, feature-lifecycle's `verifying` entry
|
|
20
|
-
# names it: the override path, the consumer script, the export it requires that the
|
|
21
|
-
# shipped entry no longer provides, and the entry path. Reinstalling or upgrading the
|
|
22
|
-
# plugin cannot supply a retired export — only the two remedies above can.
|
|
23
|
-
#
|
|
24
|
-
# Shape: verify -> done | failed. The onEnter step runs `spur feature verify`
|
|
25
|
-
# (D63 task 0915; script-absorption PR-5 retired the feature-verification-steps
|
|
26
|
-
# plugin script): it
|
|
27
|
-
# resolves this very definition for the verifier identity (name, source path,
|
|
28
|
-
# layer, definition digest), records the RUNNING receipt (run-scoped +
|
|
29
|
-
# feature-latest copies), registers the run-scoped receipt as a run artifact,
|
|
30
|
-
# runs the configured verification command via the safe launch splitter, and
|
|
31
|
-
# completes the receipt PASS/FAIL with the after-pass proof-input digest. A
|
|
32
|
-
# changed tree during the pass records FAIL.
|
|
33
|
-
#
|
|
34
|
-
# Reliability (aligned with task-pipeline / ADR-043): no agent.run nodes; the
|
|
35
|
-
# verification command comes from `vars.verificationCmd` below and is never
|
|
36
|
-
# interpolated through a shell. The always-exit-0 onEnter means missing,
|
|
37
|
-
# corrupt or FAIL evidence routes to `failed` via the guard (fail-closed),
|
|
38
|
-
# never a silent PASS; infrastructure errors exit nonzero and surface.
|
|
39
|
-
"$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
|
|
40
|
-
kind: state-machine
|
|
41
|
-
name: feature-verification
|
|
42
|
-
version: "1"
|
|
43
|
-
description: "Feature-scoped verification pass (ADR-119): runs the repo-wide check set once per feature and records a bound PASS/FAIL evidence receipt. Invoked by feature-lifecycle's verifying entry."
|
|
44
|
-
iterationBound: 2
|
|
45
|
-
initialState: verify
|
|
46
|
-
terminalStates:
|
|
47
|
-
- done
|
|
48
|
-
- failed
|
|
49
|
-
failureStates:
|
|
50
|
-
- failed
|
|
51
|
-
vars:
|
|
52
|
-
featureId: "X"
|
|
53
|
-
spurBin: "spur"
|
|
54
|
-
verificationCmd: "bun run spur-check"
|
|
55
|
-
__runId: ""
|
|
56
|
-
|
|
57
|
-
states:
|
|
58
|
-
- id: verify
|
|
59
|
-
description: >
|
|
60
|
-
Run the repo-wide check set via `spur feature verify`, which
|
|
61
|
-
records the bound evidence receipt (verifier identity, checked-input
|
|
62
|
-
digest, run identity) and the coarse status file, always exit 0 so the
|
|
63
|
-
transition guard routes missing/corrupt/FAIL to `failed`.
|
|
64
|
-
onEnter:
|
|
65
|
-
- kind: shell
|
|
66
|
-
options:
|
|
67
|
-
command: >-
|
|
68
|
-
mkdir -p .spur/run; $spurBin feature verify "$featureId" --run-id "$__runId" --no-logo || printf 'FAIL feature-verification failed closed — spur feature verify exited nonzero\n' > ".spur/run/$__runId-feature-verification.status"; exit 0
|
|
69
|
-
|
|
70
|
-
- id: done
|
|
71
|
-
description: Terminal — repo-wide checks passed and the receipt is bound.
|
|
72
|
-
|
|
73
|
-
- id: failed
|
|
74
|
-
description: Terminal — repo-wide checks failed or the evidence is missing/corrupt.
|
|
75
|
-
|
|
76
|
-
transitions:
|
|
77
|
-
- from: verify
|
|
78
|
-
to: done
|
|
79
|
-
description: Repo-wide checks passed — `spur feature verify` completed the receipt with PASS.
|
|
80
|
-
guard:
|
|
81
|
-
kind: shell
|
|
82
|
-
options:
|
|
83
|
-
command: 'test "$(cat .spur/run/$__runId-feature-verification.status 2>/dev/null)" = PASS'
|
|
84
|
-
- from: verify
|
|
85
|
-
to: failed
|
|
86
|
-
terminalReason: failed-check
|
|
87
|
-
description: Repo-wide checks failed (or the evidence is missing/corrupt) — stop at failed.
|
|
88
|
-
guard:
|
|
89
|
-
kind: always
|