diff --git a/tools/aqua-listen/README.md b/tools/aqua-listen/README.md index 741de9c..ea74125 100644 --- a/tools/aqua-listen/README.md +++ b/tools/aqua-listen/README.md @@ -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 `; 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 /clips --out /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 [--kit kit.json --renders ]` writes `.spec.png` (spectrogram, Hann 512 / hop 128, 0-8 kHz, -90..0 dB re the clip peak) and `.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 ` 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` diff --git a/tools/aqua-listen/build.mjs b/tools/aqua-listen/build.mjs index 16526ab..938f5bb 100644 --- a/tools/aqua-listen/build.mjs +++ b/tools/aqua-listen/build.mjs @@ -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 .spec.png and .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(/{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, "&").replace(/ 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}`); diff --git a/tools/aqua-listen/clip_images.py b/tools/aqua-listen/clip_images.py new file mode 100644 index 0000000..bf5c9ca --- /dev/null +++ b/tools/aqua-listen/clip_images.py @@ -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 [--kit kit.json --renders ] + +Needs numpy and matplotlib (Agg); no scipy. Writes /.spec.png and .wave.png for each *.wav in (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 +(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//script.aqua and out//.wav, outnb//.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=" and outnb/, 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/ 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() diff --git a/tools/aqua-listen/kits/tract-stops/render.sh b/tools/aqua-listen/kits/tract-stops/render.sh index 21d4436..4d0cc87 100755 --- a/tools/aqua-listen/kits/tract-stops/render.sh +++ b/tools/aqua-listen/kits/tract-stops/render.sh @@ -4,7 +4,8 @@ # render.sh [jobs] # Needs CULTLIB_ROOT at the admitted CultLib revision and AQUASYNTH_FAUST_HOME. Under load each render is minutes (a Faust compile). # /out//.wav /outnb//.wav (--no-burst) /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 /clips --out /dist +# /images, burst-notes.* clip_images.py: PNGs per clip and the burst times (needs numpy + matplotlib: PYTHON=) +# Build the page: node tools/aqua-listen/build.mjs --manifest tools/aqua-listen/kits/tract-stops/kit.json --clips /clips --out /dist [--images /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" diff --git a/tools/aqua-listen/kits/tract-weak-spots/render.sh b/tools/aqua-listen/kits/tract-weak-spots/render.sh index 3d63080..ecdd7f4 100755 --- a/tools/aqua-listen/kits/tract-weak-spots/render.sh +++ b/tools/aqua-listen/kits/tract-weak-spots/render.sh @@ -6,6 +6,7 @@ # /out//.wav 140 Hz renders; v__s is the vowel with primary stress, nibu has it on /i/ # /out148/v_ the unstressed vowel at 148.4 Hz (140 x 1.06: the f0 rise of primary stress alone) # /outnb/ the same word with --no-burst (plosive release_flow splines dropped) +# /images, burst-notes.* clip_images.py: PNGs per clip and the burst times (needs numpy + matplotlib: PYTHON=) # /clips/*.wav the files kit.json names; weak_spots.py 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" diff --git a/tools/aqua-listen/test.mjs b/tools/aqua-listen/test.mjs index 702e122..b385f7e 100644 --- a/tools/aqua-listen/test.mjs +++ b/tools/aqua-listen/test.mjs @@ -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(/" , feel: ""})]); const h = buildKit(m, dir).html; diff --git a/tools/aqua-listen/test_clip_images.py b/tools/aqua-listen/test_clip_images.py new file mode 100644 index 0000000..9d03d8d --- /dev/null +++ b/tools/aqua-listen/test_clip_images.py @@ -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(" 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() diff --git a/tools/aqua-listen/web/page.js b/tools/aqua-listen/web/page.js index e232f3a..b43f226 100644 --- a/tools/aqua-listen/web/page.js +++ b/tools/aqua-listen/web/page.js @@ -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); diff --git a/tools/aqua-listen/web/styles.css b/tools/aqua-listen/web/styles.css index 00652d8..a54887a 100644 --- a/tools/aqua-listen/web/styles.css +++ b/tools/aqua-listen/web/styles.css @@ -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; }