Skip to content

Sub C: DSP test harness — Tier1→3b infrastructure (closes #4) - #12

Merged
yuichkun merged 6 commits into
mainfrom
feature/sub-c
Apr 14, 2026
Merged

Sub C: DSP test harness — Tier1→3b infrastructure (closes #4)#12
yuichkun merged 6 commits into
mainfrom
feature/sub-c

Conversation

@yuichkun

Copy link
Copy Markdown
Owner

Summary

Implements Sub C of the Foundation epic — the DSP verification infrastructure that Sub D (Gain) and every future effect will slot into without inventing testing conventions. Validates end-to-end on the Sub B Passthrough stub.

Deliverables (all from issue #4):

  • scripts/gen-golden/ — Python + scipy + uv. Produces 8 canonical signals × passthrough preset → packages/test-utils/golden/passthrough/. Committed to repo (issue D6).
  • @denaudio/test-utils — subpath exports (./signals browser-safe, ./node adds node:fs-using wav reader + Tier2 runner). Multi-entry Vite lib build.
  • Tier2 runner — iterates over the EXPECTED (preset × signal) matrix; surfaces a missing golden file as missing golden: ... so issue §7.2's "delete one file → fail" check works.
  • @denaudio/examples — Vite SPA with hash router + Canvas2D waveform/spectrogram + A/B player. Pipeline probe runs on OfflineAudioContext (no user gesture) so the page loads in headless Chromium; realtime A/B player is gesture-gated behind an Enable button.
  • Tier3a Playwright — 3 OAC tests (chirp, sine_1k, impulse) at -96 dBFS. Page→test bridge via window.__denTier3a because page.evaluate cannot resolve bare-specifier imports.
  • Vercel native GH integration — vercel.ts + scripts/vercel-install.sh (rustup + binaryen-from-source) + scripts/vercel-build.sh (vp run build). No .github/workflows/preview.yml.
  • CI extended with test-tier2 and test-tier3a jobs.

Links

§0.6 Disclosure — issue body updates during implementation

Per AGENTS.md §0.1 the issue body is treated as living-doc SSoT. While implementing Sub C I found gaps and applied 5 rounds of body edits (no comment-style addenda) before writing the corresponding code:

# Discrepancy Resolution
1 Issue's vite ^5.4.0 for examples did NOT match Sub B's actual ^6.0.0 everywhere else, and the prose claim "matches packages/core" was factually wrong Aligned to vite ^6.0.0 to keep one Vite major across the monorepo; rewrote the prose.
2 __dirname used in runner.ts / Tier2 test / Playwright test, but those files live in "type": "module" packages Replaced with import.meta.dirname (Node 20.11+, non-experimental). Same pattern as Sub B's build-wasm.mjs. Also added /// <reference types="node" /> to runner/wav/test files because vp check --type-aware (oxlint + tsgolint) doesn't auto-load @types/node's ImportMeta augmentation under "moduleResolution": "Bundler".
3 test-utils/src/index.ts re-exporting wav.ts (which uses node:fs) into a single bundle that page.evaluate would import test-utils now has subpath exports ./signals and ./node. Browser-side imports /signals; Node-side imports /node. No default . entry.
4 node --test packages/test-utils/tests did not work in Node 22 < 22.18 (required --experimental-strip-types) AND failed for directory paths under strip-types Switched to node --experimental-strip-types --test 'packages/test-utils/tests/**/*.test.ts' (glob).
5 > **Parent**: #<parent-issue-number> placeholder > **Parent**: #1
6 scipy.signal.chirp returns cos(phase + phi), but the TS port used Math.sin(phase). First sample diff was 0 vs 0.7079, blowing diff RMS to -3 dBFS Rewrote chirpLog to use Math.cos. Now diff vs Python golden is < -190 dBFS for chirp.
7 Issue's Playwright snippet did await import("@denaudio/effects") inside page.evaluate — bare specifiers don't resolve in browser without a bundler Page exposes window.__denTier3a = { Passthrough, CANONICAL, workletUrl }; spec reads from window.
8 Issue's CI YAML and prose used vp run <script> --filter <pkg> — but Vite+ parses --filter as a script arg unless it comes BEFORE the script name. Hit "Task X not found" Standardized on vp run --filter <pkg> <script> (or -F) everywhere.
9 Sub B's worklet/main.js inlines processor.js content as a data:video/mp2t URL when Vite builds it (the runtime audioWorklet.addModule then chokes on raw TS source). Issue #3 §8 Fallback #2 already noted this and deferred Playwright smoke to Sub C Examples page passes workletUrl explicitly via Vite ?url import on the workspace-relative dist path. Properly fixing Sub B's URL emission is out of scope for Sub C; flagged for a follow-up.

All 9 changes are now reflected in the issue #4 body — please re-read the issue before reviewing the diff.

Maintainer setup reminder

Vercel native GH integration is one-time manual setup at https://vercel.com (Add Project → import yuichkun/den → set Install/Build commands per CONTRIBUTING.md "Maintainer setup"). The PR cannot configure this from the runner — please do this once before merge so Tier3b becomes "green = preview URL posted". Issue §8 Fallback #1 covers the not-yet-set-up case.

Definition of done

  • scripts/gen-golden/ exists with pyproject.toml, signals.py, gen.py, effects.py, README.md
  • vp run gen-golden produces 8 passthrough goldens (chirp/pink/sine_1k/5k/10k/impulse/dc_half/silence)
  • @denaudio/test-utils subpath exports ./signals + ./node, no default .
  • vp run test:tier2 green at -96 dBFS (7 signals — pink omitted from TS CANONICAL by design)
  • vp run --filter @denaudio/examples dev serves /#/passthrough
  • packages/examples/tests/passthrough.spec.ts runs 3 chromium tests, all green locally
  • CI has test-tier2 + test-tier3a jobs, no preview.yml
  • vercel.ts mirrors dashboard config; scripts/vercel-*.sh mode 100755
  • Vercel preview URL on this PR (depends on dashboard setup above)
  • Manually verified missing-golden error path: mv golden/.../default__chirp.wav /tmp && vp run test:tier2 exits with missing golden: ...
  • cargo fmt --check, clippy --workspace --exclude den-core, clippy -p den-core --target wasm32-unknown-unknown all green (Tier1 untouched)
  • vp check + vp run -r typecheck green
  • CONTRIBUTING.md "Maintainer setup" section added; README.md "Build" section lists vp run gen-golden / vp run test:tier2/3a/test
  • Section 4 (Out of Scope) preserved — no new effects, no Firefox/Safari, no preset browser

Test plan

  • vp install && vp run build && vp run gen-golden && vp run test from a fresh clone
  • Manual: vp run --filter @denaudio/examples devhttp://localhost:5173/#/passthrough — pipeline-ready probe shows pipeline ready ✓, click "Enable A/B player", select chirp, hit Play, confirm waveform/spectrogram render
  • After Vercel dashboard setup: confirm bot comment with preview URL on this PR within ~3-4 min

🤖 Generated with Claude Code

Implements Sub C of the Foundation epic (#1):

- Python scipy golden generator under `scripts/gen-golden/` (uv-pinned),
  producing 8 canonical signals × passthrough preset to
  `packages/test-utils/golden/passthrough/`.
- `@denaudio/test-utils` rebuilt with subpath exports: `./signals`
  (browser-safe — chirp/sine/impulse/dc/silence + null helpers) and
  `./node` (adds Node-only `wav.ts` RIFF reader + Tier2 `runner.ts`).
  Multi-entry Vite lib build emits each module separately so Playwright's
  in-page bundle never drags `node:fs` into Chromium.
- Tier2 runner iterates over the EXPECTED (preset × signal) matrix and
  surfaces missing golden files as `missing golden: ...`, satisfying
  issue §7.2's "delete one file → fail" check.
- `@denaudio/examples` Vite SPA scaffolding with hash-router + Canvas2D
  waveform/spectrogram + A/B player. Catalog probes the pipeline on an
  OfflineAudioContext so the page loads cleanly in headless Chromium;
  the realtime A/B player is gesture-gated.
- Tier3a Playwright runs 3 OAC tests (chirp, sine_1k, impulse) at
  -96 dBFS, bridged through `window.__denTier3a` because page.evaluate
  cannot resolve bare-specifier imports.
- Vercel native GH integration via repo-root `vercel.ts` +
  `scripts/vercel-install.sh` (rustup + binaryen-from-source) +
  `scripts/vercel-build.sh` (vp run build). No `.github/workflows/preview.yml`.
- CI extended with `test-tier2` and `test-tier3a` jobs (both
  `needs: [wasm-build]`).

Issue #4 body was updated 5 times during implementation as living-doc
SSoT (per AGENTS.md §0.1) for: Vite version (^5.4 → ^6.0 to match the
rest of the monorepo), `__dirname` → `import.meta.dirname`, subpath
exports for test-utils, `--experimental-strip-types` for `node --test`
on `.ts`, scipy chirp returns cos (not sin — caused diff RMS to blow
past -3 dBFS), exposing the page→test bridge on `window`, and the
`vp run --filter <pkg> <script>` argument ordering quirk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40aa922785

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/vercel-install.sh Outdated
Comment thread scripts/vercel-install.sh Outdated
Comment thread packages/examples/tests/passthrough.spec.ts Outdated
The first version of these scripts had three real bugs that would have
broken the very first Vercel deploy:

1. `vp` is installed to `$HOME/.vite-plus/bin/vp` (verified by
   reading the install script at https://vite.plus 2026-04-14), NOT
   `$HOME/.local/bin/vp` as the original PATH export assumed. After
   `curl ... | bash` returned, the `vp install --frozen-lockfile`
   on the next line would have failed with `command not found`.

2. `command -v vp` (and `command -v cargo`) was used as the install
   guard, but Vercel runs the script in a fresh non-interactive bash
   that doesn't source `~/.bashrc`. On warm builds where the binary
   exists but isn't yet on PATH, the guard would falsely report "not
   installed" and re-run the installer needlessly. Switched to direct
   binary file checks (`[ -x "$HOME/.cargo/bin/cargo" ]`).

3. The Vite+ installer prompts interactively about whether to enable
   its bundled Node.js manager when no $VP_NODE_MANAGER env is set,
   which would have hung Vercel's CI shell forever. Now sets
   `VP_NODE_MANAGER=yes` explicitly so vp's bundled node + npm + npx
   shims (which read `.node-version`) are installed unconditionally.

Also: `.` (source) of `~/.cargo/env` is now guarded with `[ -f ... ]`
so a system-installed cargo doesn't trip `set -e` when rustup hasn't
written that file. End-of-install summary echoes resolved binary
paths so first-run logs make the toolchain layout obvious.

vercel-build.sh's PATH now includes all three install dirs
(~/.vite-plus/bin, ~/.local/bin, ~/.cargo/bin) explicitly, mirroring
vercel-install.sh — necessary because Vercel runs install + build in
separate shell invocations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
den Ready Ready Preview, Comment Apr 14, 2026 8:57am

Request Review

Two real issues codex flagged on the original commit:

P2 (vercel-install.sh): the rustup install branch installed the
wasm32-unknown-unknown target, but the install was guarded by
`! command -v cargo`. On warm Vercel builds where ~/.cargo/bin is
cached but ~/.rustup/toolchains/.../wasm32-unknown-unknown/ is not,
or in environments with a system-preinstalled cargo (no rustup),
subsequent `cargo build --target wasm32-unknown-unknown` would fail
with a missing-target error. Now `rustup target add
wasm32-unknown-unknown` runs unconditionally outside the bootstrap
branch (idempotent — no-op when the target is already present).

P2 (passthrough.spec.ts): the Tier3a null test only returned
`getChannelData(0)` from the rendered offline buffer and compared it
to `golden.samples[0]` (left only). Tier2's runner already compares
both channels via `rmsDiffDbFs(actual, golden.samples, ...)`. The
asymmetry meant a future worklet bug that affected only the right
channel (stereo widener, ping-pong delay, etc.) would slip past CI's
stereo-verification check. Now both channels are returned and
compared.

P1 from the same review (unconditional `~/.cargo/env` source) was
already addressed in commit b129372 — codex reviewed the original
40aa922 before that fix landed.

Issue #4 §6.5 body updated to reflect the both-channel snippet.
Also gitignored `.claude/` session-state dir that Claude Code
creates locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yuichkun

Copy link
Copy Markdown
Owner Author

@codex review

Re-review request after addressing the previous round (commit 31bed52):

  • P1 (rustup env source unconditional): already addressed in commit b129372 which landed before your previous review's reviewed commit 40aa922. . "$HOME/.cargo/env" is now guarded with [ -f "$HOME/.cargo/env" ].
  • P2 (wasm32 target only inside rustup bootstrap): addressed in 31bed52. rustup target add wasm32-unknown-unknown now runs unconditionally outside the bootstrap branch (idempotent).
  • P2 (Tier3a only compares L channel): addressed in 31bed52. page.evaluate now returns both L+R, and the Node side compares both via a single rmsDiffDbFs call.

Please verify the fixes and check anything else you find.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Two visible bugs the catalog page screenshot exposed:

1. mountWaveform drew both L and R into the SAME vertical band
   ([0, h], with `(1-min)*h/2` y-coords). Whichever channel was drawn
   second (R, in red) completely overwrote the first (L, in blue).
   For Passthrough where L == R the L stroke was 100% hidden. Now L
   takes the top half and R takes the bottom (DAW-style stacked
   layout), with a faint center line per band for amplitude
   reference. Single-channel buffers fall back to the full canvas.

2. mountSpectrogram called `g.putImageData(bmp, 0, 0)` to write a
   small `frames × (SPEC_FFT/2)` bitmap to the top-left of the
   display canvas, then `g.drawImage(canvas, ...)` to scale it up —
   reading from the same canvas it was writing to. Per the Canvas2D
   spec, src/dest overlap is undefined, and in practice the result
   was just the tiny bitmap stuck in the corner. Now renders to a
   separate offscreen canvas and drawImage-scales onto the display
   canvas.

   Also fixed the "log-freq" claim: the old code used a linear bin →
   row mapping (`row = SPEC_FFT/2 - 1 - k`), so a log chirp would
   show up as a near-vertical streak on the right. New version
   precomputes a log-freq display-row → FFT-bin lookup (top =
   Nyquist, bottom = 20 Hz) so a log chirp draws as the expected
   diagonal.

   FFT magnitudes are now normalized by `SPEC_FFT/2` so a full-scale
   sinusoid lands near 0 dBFS instead of saturating at +50 dBFS,
   making the (db+96)/96 → [0,1] colormap behave intuitively.

No functional change to the WASM/worklet/test paths; widgets only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…+ clearer labels

UX feedback from the deployed catalog page:

1. Switching the Signal selector left the wave/spectrogram canvases
   stale until you hit "Re-render wave + spec". mountABPlayer now
   accepts an optional `onSignalChange` callback that the page wires
   to refreshViz, so the visualizers update automatically. If the
   realtime player is already playing, the source signal is also
   restarted so the audio matches.

2. The Wet/Dry slider and the visualizer were operating on different
   pipelines (slider = realtime mix, viz = always wet via offline
   render through the effect), but neither the labels nor the
   surrounding copy made that clear. Added a caption between the
   player and the visualizers explaining the split, and renamed the
   widget headers to "Waveform — effect output (L top / R bottom)"
   and "Spectrogram — effect output (L top / R bottom, log-freq …)".

3. mountSpectrogram only rendered channel 0. Now stacks L on the top
   half and R on the bottom (matching the waveform widget's layout)
   with a faint divider line between them. L gets the existing blue-
   tinted colormap, R gets a red-tinted one so the bands are
   distinguishable even when L == R (Passthrough's case). The log-
   freq bin lookup is computed once per band height and reused for
   both channels.

No change to the worklet/test paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Re-render wave + spec" button became dead weight after the
auto-refresh on signal change landed:

- Signal change auto-refreshes via mountABPlayer's onSignalChange.
- Wet/Dry only affects realtime playback; visualizers always show
  the wet effect output, so the slider can't change what's drawn.
- Sub D's Gain (and any future effect) will wire its param sliders
  to call refreshViz directly, so no manual button needed there
  either.
- Window resize is the one remaining case the button could help
  with, but the canvases are pinned to mount-time clientWidth × DPR
  and won't redraw correctly even with a manual click; proper
  resize support would need ResizeObserver and is out of scope here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yuichkun
yuichkun merged commit ce4de0d into main Apr 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sub C: DSP test harness — Tier1→3b infrastructure + effect catalog + Vercel preview

1 participant