Spectrogram and waveform PNG beside each clip, burst times in a separate note

clip_images.py (numpy + matplotlib Agg, STFT by numpy.fft) writes a deterministic spec and wave PNG per clip and, from the with/without --no-burst renders, burst-notes.md/json. Both kits' render.sh end with it; build.mjs --images embeds the pictures in a collapsed block and widens the page policy to data: images only then. The notes never enter listen.html or kit.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-13 03:10:15 +00:00
commit 813ae0b432
9 changed files with 272 additions and 13 deletions

View file

@ -24,7 +24,7 @@ Privacy: the page has no network access at all (the browser is told to refuse it
```
node build.mjs --manifest kit.json --clips ./clips --out ./dist # writes dist/listen.html
node test.mjs # 16 checks, no dependencies, no browser
node test.mjs # 17 checks, no dependencies, no browser
node smoke.mjs dist/listen.html # optional browser smoke, see below
```
@ -59,6 +59,12 @@ The blank verdict files this was meant to match were not found under `/opt/commo
`kits/tract-weak-spots` and `kits/tract-stops` hold a `kit.json`, the `phones/*.json` that WordRender reads, and a `render.sh` that renders the clips (`render.sh <repo-root> <out-root>`; needs CULTLIB_ROOT and AQUASYNTH_FAUST_HOME, see the script header). Build a page from the rendered clips:
`node tools/aqua-listen/build.mjs --manifest tools/aqua-listen/kits/tract-stops/kit.json --clips <out-root>/clips --out <out-root>/dist`. The titles in those kit.json files carry measured figures, a prediction and a falsifier; the page hides them from the listener.
### Pictures and burst notes (for a reader who cannot listen)
`clip_images.py <clips> <images> [--kit kit.json --renders <out-root>]` writes `<stem>.spec.png` (spectrogram, Hann 512 / hop 128, 0-8 kHz, -90..0 dB re the clip peak) and `<stem>.wave.png` (fixed +-0.5) for every wav, byte-identical between runs, and with `--kit --renders` also `burst-notes.md` and `burst-notes.json` next to the images: per plosive closure start, release (start of the with-minus-without `--no-burst` difference above 1/20 of its peak), burst end and the window to look in. Both kits' `render.sh` end with it. Requirement: `python3` with numpy and matplotlib (Agg), set `PYTHON=` to one that has them; no scipy, nothing for the dotnet build. Test: `python3 -I test_clip_images.py`.
`build.mjs --images <images>` embeds a clip's two pictures in a collapsed "Pictures of this sound" block (matched by the wav's stem) and lets the page's policy admit `data:` images, only then. The burst notes are never read by the build and never enter `listen.html` or `kit.json`: give the page to the listener and the notes to whoever reads the pictures.
## Files
`logic.cjs` validation, ordering, export shape (shared by build, page, tests) · `build.mjs` assembles the page · `web/` page template, glue and styles · `test.mjs` · `smoke.mjs`
`clip_images.py` + `test_clip_images.py` pictures and burst notes · `kits/` · `logic.cjs` validation, ordering, export shape (shared by build, page, tests) · `build.mjs` assembles the page · `web/` page template, glue and styles · `test.mjs` · `smoke.mjs`

View file

@ -1,10 +1,11 @@
#!/usr/bin/env node
// Builds one self-contained listening page. Usage:
// node build.mjs --manifest kit.json --clips DIR --out DIR (writes DIR/listen.html; --clips defaults to the manifest's folder)
// node build.mjs --manifest kit.json --clips DIR --out DIR [--images DIR] (writes DIR/listen.html; --clips defaults to the manifest's folder)
// --images: clip_images.py's output; a clip whose wav stem has <stem>.spec.png and <stem>.wave.png there gets both in a collapsed block on its card.
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { dirname, extname, join, resolve } from "node:path";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
@ -12,7 +13,7 @@ const L = createRequire(import.meta.url)("./logic.cjs");
const MIME = { ".wav": "audio/wav", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".flac": "audio/flac" };
// Throws one Error listing every manifest problem and every missing file. Returns {html, hash, bytes}.
export function buildKit(manifest, clipsDir) {
export function buildKit(manifest, clipsDir, imagesDir) {
const errs = L.validateManifest(manifest);
if (errs.length === 0) for (const c of manifest.clips) {
const f = resolve(clipsDir, c.file);
@ -27,13 +28,20 @@ export function buildKit(manifest, clipsDir) {
h.update(buf);
audio[c.id] = { mime: MIME[extname(c.file).toLowerCase()], b64: buf.toString("base64") };
}
const images = {};
if (imagesDir) for (const c of manifest.clips) {
const stem = basename(c.file, extname(c.file)), pics = [["spectrogram", "spec"], ["waveform", "wave"]].map(([label, k]) => [label, resolve(imagesDir, `${stem}.${k}.png`)]);
if (!pics.every(([, f]) => existsSync(f))) continue;
images[c.id] = pics.map(([label, f]) => { const buf = readFileSync(f); h.update(buf); return { label, b64: buf.toString("base64") }; });
}
const hash = h.digest("hex").slice(0, 8);
const data = JSON.stringify({ manifest, hash, audio }).replace(/</g, "\\u003c");
const data = JSON.stringify({ manifest, hash, audio, images }).replace(/</g, "\\u003c");
const logic = `globalThis.AquaListen=(()=>{const module={exports:{}};${readFileSync(join(here, "logic.cjs"), "utf8")}\nreturn module.exports})();`;
const part = (n) => readFileSync(join(here, "web", n), "utf8");
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
// data: images only when the page carries pictures; the page without them keeps the tighter policy
// function replacers: no `$` patterns in the substituted text are interpreted
const html = part("index.html")
const html = part("index.html").replace("media-src blob:", () => Object.keys(images).length ? "media-src blob:; img-src data:" : "media-src blob:")
.replace("/*TITLE*/", () => esc(manifest.kit.title)).replace("/*STYLES*/", () => part("styles.css"))
.replace("/*DATA*/", () => data).replace("/*LOGIC*/", () => logic.replace(/<\/script/gi, "<\\/script")).replace("/*PAGE*/", () => part("page.js").replace(/<\/script/gi, "<\\/script"));
return { html, hash, bytes: Buffer.byteLength(html) };
@ -42,10 +50,10 @@ export function buildKit(manifest, clipsDir) {
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const a = process.argv.slice(2), opt = (n) => { const i = a.indexOf(n); return i < 0 ? undefined : a[i + 1]; };
const mf = opt("--manifest"), out = opt("--out");
if (!mf || !out) { console.error("usage: node build.mjs --manifest kit.json --clips DIR --out DIR"); process.exit(2); }
if (!mf || !out) { console.error("usage: node build.mjs --manifest kit.json --clips DIR --out DIR [--images DIR]"); process.exit(2); }
try {
const manifest = JSON.parse(readFileSync(mf, "utf8"));
const r = buildKit(manifest, resolve(opt("--clips") ?? dirname(resolve(mf))));
const r = buildKit(manifest, resolve(opt("--clips") ?? dirname(resolve(mf))), opt("--images") && resolve(opt("--images")));
mkdirSync(out, { recursive: true });
writeFileSync(join(out, "listen.html"), r.html);
console.log(`built ${join(out, "listen.html")}\nclips ${manifest.clips.length} bytes ${r.bytes} hash ${r.hash}`);

View file

@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""A spectrogram and a waveform PNG for every clip of a kit, and (with --kit and --renders) the burst notes.
python3 -I clip_images.py <clips-dir> <images-dir> [--kit kit.json --renders <out-root>]
Needs numpy and matplotlib (Agg); no scipy. Writes <images-dir>/<wav stem>.spec.png and .wave.png for each *.wav in <clips-dir> (sorted).
The images are deterministic: fixed figure size, dpi and STFT (Hann 512, hop 128, 0-8 kHz, -90..0 dB re the clip's loudest bin), no PNG
metadata, so two runs give identical bytes. Nothing in the pictures marks a time; the notes do, and they are written next to <images-dir>
(burst-notes.md and burst-notes.json) and are not read by build.mjs.
--renders is the out-root of a kit's render.sh (out/<w>/script.aqua and out/<w>/<w>.wav, outnb/<w>/<w>.wav). For each clip of --kit the
note gives, for each plosive gesture of the word's script: closure start (the gesture's start), release = start of the with-minus-without
(--no-burst) waveform difference above 1/20 of its peak in the gesture's window (the definition PlosiveBurstExistsOnlyAroundItsOwnRelease
uses), burst end = last such sample of the same burst, and the window to look at. A --no-burst clip says 'no burst expected'.
"""
import argparse, json, re, sys, wave
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
WIN, HOP, FMAX = 512, 128, 8000.0
DB_LO = -90.0
def read_wav(path):
with wave.open(str(path), "rb") as w:
assert w.getsampwidth() == 2 and w.getnchannels() == 1, f"{path}: expected 16-bit mono"
return np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64) / 32768.0, w.getframerate()
def stft_db(x, sr):
"""dB re the loudest bin of the clip; rows are frequency 0..FMAX, columns are frames (Hann window, numpy.fft)."""
x = np.concatenate([x, np.zeros(max(0, WIN - len(x)))])
n = 1 + (len(x) - WIN) // HOP
idx = np.arange(WIN)[None, :] + HOP * np.arange(n)[:, None]
mag = np.abs(np.fft.rfft(x[idx] * np.hanning(WIN)[None, :], axis=1)).T
f = np.fft.rfftfreq(WIN, 1.0 / sr)
mag = mag[f <= FMAX]
return 20 * np.log10(np.maximum(mag, 1e-12) / max(mag.max(), 1e-12)), f[f <= FMAX], (np.arange(n) * HOP + WIN / 2) / sr
def save(fig, path):
fig.savefig(path, dpi=100, metadata={"Software": None})
plt.close(fig)
def images(wav, out):
x, sr = read_wav(wav)
db, f, t = stft_db(x, sr)
fig, ax = plt.subplots(figsize=(8, 3.2))
im = ax.imshow(np.clip(db, DB_LO, 0), origin="lower", aspect="auto", cmap="magma", vmin=DB_LO, vmax=0,
extent=[t[0] - HOP / 2 / sr, t[-1] + HOP / 2 / sr, f[0], f[-1]])
ax.set_xlabel("time (s)"); ax.set_ylabel("Hz"); ax.set_title(f"spectrogram, Hann {WIN} hop {HOP}, 0-8 kHz", fontsize=9)
fig.colorbar(im, ax=ax, label="dB re clip peak")
fig.tight_layout(); save(fig, out / f"{wav.stem}.spec.png")
fig, ax = plt.subplots(figsize=(8, 2.4))
ax.plot(np.arange(len(x)) / sr, x, lw=0.5, color="black")
ax.set_ylim(-0.5, 0.5); ax.set_xlim(0, len(x) / sr); ax.set_xlabel("time (s)"); ax.set_ylabel("amplitude (full scale 1.0)")
ax.set_title("waveform, fixed range +-0.5", fontsize=9); ax.grid(alpha=0.3)
fig.tight_layout(); save(fig, out / f"{wav.stem}.wave.png")
PLOSIVE_IPA = set("pbtdkgɡcɟqɢʔ")
def plosives(script):
"""(start, dur) of every plosive phoneme_gesture in a render's script.aqua (WordRender writes ipa=, no descriptor)."""
out = []
for line in script.read_text(encoding="utf-8").splitlines():
if line.startswith("phoneme_gesture"):
kv = dict(p.split("=", 1) for p in line.split()[1:] if "=" in p)
if kv.get("ipa") in PLOSIVE_IPA: out.append((float(kv["start"]), float(kv["dur"])))
return out
def burst_note(root, stem):
"""Notes for one render stem: the with-minus-without difference of out/<stem> and outnb/<stem>, per plosive."""
script = root / "out" / stem / "script.aqua"
if not script.exists():
return {"burst": "not measured: no render of this word under out/"}
ps = plosives(script)
if not ps:
return {"burst": "none: the word has no plosive"}
twin = root / "outnb" / stem / f"{stem}.wav"
if not twin.exists():
return {"burst": "not measured: no --no-burst twin render of this word"}
a, sr = read_wav(root / "out" / stem / f"{stem}.wav")
b, _ = read_wav(twin)
n = min(len(a), len(b)); d = np.abs(a[:n] - b[:n])
rows = []
for start, dur in ps:
lo, hi = int(start * sr), min(n, int((start + dur + 0.12) * sr))
peak = d[lo:hi].max() if hi > lo else 0.0
hot = np.flatnonzero(d[lo:hi] > peak / 20) + lo if peak > 0 else np.array([], dtype=int)
if not len(hot):
rows.append({"closure_start_s": start, "release_s": None, "burst_end_s": None, "look_s": None, "note": "no difference between the renders"})
continue
end = hot[0]
for i in hot[1:]: # the first burst: stop at a gap over 5 ms
if i - end > 0.005 * sr: break
end = i
rel, fin = hot[0] / sr, end / sr
rows.append({"closure_start_s": round(start, 4), "release_s": round(rel, 4), "burst_end_s": round(fin, 4),
"difference_peak": round(float(peak), 4), "look_s": [round(max(0.0, rel - 0.03), 4), round(fin + 0.05, 4)]})
return {"burst": rows}
def render_of(root, clip):
"""The out/<w> render a clip file was copied from (byte-identical wav), or None: two clip ids can be one render."""
data = clip.read_bytes() if clip.exists() else None
for d in sorted((root / "out").glob("*/")):
w = d / f"{d.name}.wav"
if data is not None and w.exists() and w.read_bytes() == data: return d.name
return None
def notes(kit, root, clips):
out = []
for c in kit["clips"]:
stem = Path(c["file"]).stem
if stem.endswith("_noburst"):
out.append({"id": c["id"], "file": c["file"], "word": stem[:-8], "burst": "no burst expected"}); continue
word = render_of(root, clips / c["file"]) or stem
out.append({"id": c["id"], "file": c["file"], "word": word, **burst_note(root, word)})
return out
def notes_md(rows):
lines = ["# Burst notes", "", "Times in seconds from the clip start. Release = start of the with-minus-without difference above 1/20 of its peak. Look in the spectrogram at 2-8 kHz inside the window.", ""]
lines += ["| clip id | file | closure start | release | burst end | look in |", "|---|---|---|---|---|---|"]
for r in rows:
if isinstance(r["burst"], str):
lines.append(f"| {r['id']} | {r['file']} | {r['burst']} | | | |"); continue
for p in r["burst"]:
look = f"{p['look_s'][0]} to {p['look_s'][1]}" if p.get("look_s") else p.get("note", "")
lines.append(f"| {r['id']} | {r['file']} | {p['closure_start_s']} | {p['release_s']} | {p['burst_end_s']} | {look} |")
return "\n".join(lines) + "\n"
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("clips"); ap.add_argument("images"); ap.add_argument("--kit"); ap.add_argument("--renders")
a = ap.parse_args()
clips, out = Path(a.clips), Path(a.images)
if bool(a.kit) != bool(a.renders): ap.error("--kit and --renders go together")
wavs = sorted(clips.glob("*.wav"))
if not wavs: sys.exit(f"no .wav files in {clips}")
out.mkdir(parents=True, exist_ok=True)
for w in wavs: images(w, out)
print(f"{len(wavs)} clips, {2 * len(wavs)} images in {out}")
if a.kit:
rows = notes(json.loads(Path(a.kit).read_text(encoding="utf-8")), Path(a.renders), clips)
(out.parent / "burst-notes.json").write_text(json.dumps(rows, indent=1) + "\n", encoding="utf-8")
(out.parent / "burst-notes.md").write_text(notes_md(rows), encoding="utf-8")
print(f"burst notes for {len(rows)} clips in {out.parent}")
if __name__ == "__main__":
main()

View file

@ -4,7 +4,8 @@
# render.sh <repo-root> <out-root> [jobs]
# Needs CULTLIB_ROOT at the admitted CultLib revision and AQUASYNTH_FAUST_HOME. Under load each render is minutes (a Faust compile).
# <out-root>/out/<w>/<w>.wav <out-root>/outnb/<w>/<w>.wav (--no-burst) <out-root>/clips/*.wav the files kit.json names
# Build the page: node tools/aqua-listen/build.mjs --manifest tools/aqua-listen/kits/tract-stops/kit.json --clips <out-root>/clips --out <out-root>/dist
# <out-root>/images, burst-notes.* clip_images.py: PNGs per clip and the burst times (needs numpy + matplotlib: PYTHON=<python with both>)
# Build the page: node tools/aqua-listen/build.mjs --manifest tools/aqua-listen/kits/tract-stops/kit.json --clips <out-root>/clips --out <out-root>/dist [--images <out-root>/images]
set -eu
REPO=$(cd "$1" && pwd); OUT=$2; JOBS=${3:-4}; HERE=$REPO/tools/aqua-listen/kits/tract-stops
dotnet build "$REPO/tools/WordRender" -c Release -v q -o "$OUT/wr" | grep -E "rror\(s\)"
@ -13,8 +14,9 @@ job() { n=$1; o=$2; f=$3; shift 3; dotnet "$OUT/wr/WordRender.dll" "$OUT/$o/$n"
export -f job; export OUT HERE
{
for n in ata ada aka ala apa aba; do echo "$n out 140"; done
for n in ata ada aka; do echo "$n outnb 140 --no-burst"; done
for n in ata ada aka apa aba; do echo "$n outnb 140 --no-burst"; done # apa aba twins feed the burst notes only, no clip
} | xargs -P "$JOBS" -L1 bash -c 'job "$@"' _
C=$OUT/clips
for n in ata ada aka ala apa aba; do cp "$OUT/out/$n/$n.wav" "$C/$n.wav"; done
for n in ata ada aka; do cp "$OUT/outnb/$n/$n.wav" "$C/${n}_noburst.wav"; done
${PYTHON:-python3} -I "$REPO/tools/aqua-listen/clip_images.py" "$C" "$OUT/images" --kit "$HERE/kit.json" --renders "$OUT"

View file

@ -6,6 +6,7 @@
# <out-root>/out/<w>/<w>.wav 140 Hz renders; v_<vowel>_s is the vowel with primary stress, nibu has it on /i/
# <out-root>/out148/v_<vowel> the unstressed vowel at 148.4 Hz (140 x 1.06: the f0 rise of primary stress alone)
# <out-root>/outnb/<w> the same word with --no-burst (plosive release_flow splines dropped)
# <out-root>/images, burst-notes.* clip_images.py: PNGs per clip and the burst times (needs numpy + matplotlib: PYTHON=<python with both>)
# <out-root>/clips/*.wav the files kit.json names; weak_spots.py <out-root> measures the rest
set -eu
REPO=$(cd "$1" && pwd); OUT=$2; PRE=${3:-}; JOBS=${4:-4}; HERE=$REPO/tools/aqua-listen/kits/tract-weak-spots
@ -15,7 +16,7 @@ job() { n=$1; o=$2; f=$3; shift 3; dotnet "$OUT/wr/WordRender.dll" "$OUT/$o/$n"
export -f job; export OUT HERE
{
for n in nibu nibu_flat v_i v_i_s bin bun ba ama ana v_ih v_oo_short v_ah v_ah_s v_e v_e_s v_o v_o_s v_u v_u_s; do echo "$n out 140"; done
for n in bin ba bun; do echo "$n outnb 140 --no-burst"; done
for n in bin ba bun nibu nibu_flat; do echo "$n outnb 140 --no-burst"; done
for v in i e ah o u ih oo_short; do echo "v_$v out148 148.4"; done
} | xargs -P "$JOBS" -L1 bash -c 'job "$@"' _
if [ -n "$PRE" ]; then
@ -27,3 +28,4 @@ C=$OUT/clips
cp "$OUT/out/nibu_flat/nibu_flat.wav" "$C/nibu_flat.wav"; cp "$OUT/out/nibu_flat/nibu_flat.wav" "$C/nibu_flat_after.wav" # one render, two ids: the before/after pair is one-to-one
for n in nibu v_i v_i_s v_ih v_oo_short ama ana ba bun; do cp "$OUT/out/$n/$n.wav" "$C/$n.wav"; done
for n in ba bun; do cp "$OUT/outnb/$n/$n.wav" "$C/${n}_noburst.wav"; done
${PYTHON:-python3} -I "$REPO/tools/aqua-listen/clip_images.py" "$C" "$OUT/images" --kit "$HERE/kit.json" --renders "$OUT"

View file

@ -101,6 +101,17 @@ test("build refuses a missing file and a manifest escape, naming them", () => {
assert.throws(() => buildKit(mf([clip("a", { file: "../x.wav" })]), dir), /relative path/);
assert.throws(() => buildKit(mf([clip("a", { title: "" })]), dir), /title is required/);
});
test("pictures ride in a collapsed block only for clips that have both, and only then does the policy admit data: images", () => {
const pics = mkdtempSync(join(tmpdir(), "aqua-listen-img-")), png = Buffer.from("89504e470d0a1a0a", "hex");
writeFileSync(join(pics, "a.spec.png"), png); writeFileSync(join(pics, "a.wave.png"), png); writeFileSync(join(pics, "b.spec.png"), png);
const withPics = buildKit(manifest, dir, pics), data = JSON.parse(/<script id="kit-data" type="application\/json">(.*?)<\/script>/s.exec(withPics.html)[1]);
assert.deepEqual(Object.keys(data.images), ["a"]);
assert.deepEqual(data.images.a.map((p) => p.label), ["spectrogram", "waveform"]);
assert.match(withPics.html, /media-src blob:; img-src data:/);
const bare = buildKit(manifest, dir);
assert.doesNotMatch(bare.html, /img-src/); assert.deepEqual(JSON.parse(/<script id="kit-data" type="application\/json">(.*?)<\/script>/s.exec(bare.html)[1]).images, {});
assert.notEqual(withPics.hash, bare.hash);
});
test("markup in a title cannot break the page", () => {
const m = mf([clip("a", { title: "</script><b>" , feel: "</script>"})]);
const h = buildKit(m, dir).html;

View file

@ -0,0 +1,67 @@
"""python3 -I test_clip_images.py (numpy + matplotlib; synthetic clips, no render needed)"""
import hashlib, json, sys, tempfile, unittest, wave
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
import clip_images as ci
SR, BURST_AT = 48000, 0.30
def write(path, x):
with wave.open(str(path), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes((np.clip(x, -1, 1) * 32767).astype("<i2").tobytes())
def word(with_burst):
t = np.arange(int(0.6 * SR)) / SR
x = 0.2 * np.sin(2 * np.pi * 300 * t) * (np.abs(t - 0.3) > 0.05) # a vowel with a gap round the release
if with_burst:
i = int(BURST_AT * SR); n = int(0.012 * SR)
x[i:i + n] += np.random.default_rng(1).normal(0, 0.1, n) * np.hanning(n)
return x
class Fixture(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp()); self.clips = self.tmp / "clips"; self.clips.mkdir()
write(self.clips / "ba.wav", word(True)); write(self.clips / "ba_noburst.wav", word(False))
self.root = self.tmp / "r"
for sub, wb in (("out", True), ("outnb", False)):
(self.root / sub / "ba").mkdir(parents=True); write(self.root / sub / "ba" / "ba.wav", word(wb))
(self.root / "out" / "ba" / "script.aqua").write_text("phoneme_gesture name=ba_0_ɑ ipa=ɑ start=0 dur=0.2 intensity=1\nphoneme_gesture name=ba_1_b ipa=b start=0.2 dur=0.095 intensity=1\n")
self.kit = self.tmp / "kit.json"
self.kit.write_text(json.dumps({"clips": [{"id": "x-ba", "file": "ba.wav"}, {"id": "x-ba-nb", "file": "ba_noburst.wav"}]}))
def run_tool(self, out, *extra):
sys.argv = ["clip_images", str(self.clips), str(out), *extra]; ci.main()
def test_images_are_deterministic_and_complete(self):
a, b = self.tmp / "a" / "images", self.tmp / "b" / "images"
self.run_tool(a); self.run_tool(b)
names = sorted(p.name for p in a.iterdir())
self.assertEqual(names, ["ba.spec.png", "ba.wave.png", "ba_noburst.spec.png", "ba_noburst.wave.png"])
for n in names:
self.assertEqual(hashlib.sha256((a / n).read_bytes()).hexdigest(), hashlib.sha256((b / n).read_bytes()).hexdigest(), n)
def test_notes_find_the_release_and_the_twin_says_none(self):
self.run_tool(self.tmp / "o" / "images", "--kit", str(self.kit), "--renders", str(self.root))
rows = json.loads((self.tmp / "o" / "burst-notes.json").read_text())
p = rows[0]["burst"][0]
self.assertAlmostEqual(p["release_s"], BURST_AT, delta=0.003)
self.assertGreater(p["burst_end_s"], BURST_AT + 0.004); self.assertLess(p["burst_end_s"], BURST_AT + 0.02)
self.assertEqual(rows[1]["burst"], "no burst expected")
self.assertIn("no burst expected", (self.tmp / "o" / "burst-notes.md").read_text())
def test_burst_shows_in_the_spectrogram_at_the_noted_time_only(self):
def hi_db(x, at):
db, f, t = ci.stft_db(x, SR); col = int(np.argmin(np.abs(t - at)))
lin = 10 ** (db[f > 2000, col] / 20); return 20 * np.log10(np.sqrt((lin ** 2).sum()) + 1e-12)
w, nb = word(True), word(False)
self.assertGreaterEqual(hi_db(w, BURST_AT + 0.006) - hi_db(nb, BURST_AT + 0.006), 10)
self.assertLess(hi_db(w, BURST_AT + 0.106) - hi_db(nb, BURST_AT + 0.106), 10) # a note 100 ms late would find nothing
if __name__ == "__main__":
unittest.main()

View file

@ -1,4 +1,4 @@
// Page glue. The kit (manifest, content hash, base64 audio) is in #kit-data; logic is AquaListen (logic.cjs, inlined).
// Page glue. The kit (manifest, content hash, base64 audio, optional base64 PNGs per clip id) is in #kit-data; logic is AquaListen (logic.cjs, inlined).
(() => {
const L = globalThis.AquaListen, kit = JSON.parse(document.getElementById("kit-data").textContent);
const app = document.getElementById("app"), KEY = `aqua-listen:${kit.manifest.kit.id}:${kit.hash}`;
@ -62,6 +62,7 @@
h("h2", {}, o.slot ? `Sound ${o.slot}` : `Sound ${n}`),
h("p", { class: "feel" }, `It should feel: ${c.feel}`),
audio,
(kit.images[id] || []).length ? h("details", {}, h("summary", {}, "Pictures of this sound (optional)"), kit.images[id].map((p) => h("img", { src: "data:image/png;base64," + p.b64, alt: p.label, class: "pic" }))) : [],
h("div", { class: "row" }, h("span", {}, "Keep, fix or drop?"), radios("v-" + id, L.VERDICTS, a.verdict, (v) => touch(id, { verdict: v }))),
h("div", { class: "row" }, h("span", {}, "Did it land? 1 = not at all, 5 = completely"), radios("l-" + id, [1, 2, 3, 4, 5], a.landed, (v) => touch(id, { landed: Number(v) }))),
reason);

View file

@ -16,3 +16,4 @@ button:disabled { opacity: 0.4; cursor: default; }
.bar { position: sticky; top: 0; background: var(--bg); padding: 0.5rem 0; border-bottom: 1px solid var(--line); z-index: 1; }
.pair { border: 2px dashed var(--line); border-radius: 10px; padding: 0 0.75rem; margin: 1rem 0; }
small, .hint { opacity: 0.75; }
img.pic { max-width: 100%; height: auto; display: block; margin: 0.5rem 0; }