Skip to content

feat(player): AHX/THX playback engine (Chunk α — engine + facade)#37

Merged
indigo423 merged 3 commits into
mainfrom
feat/add-ahx-playback
May 17, 2026
Merged

feat(player): AHX/THX playback engine (Chunk α — engine + facade)#37
indigo423 merged 3 commits into
mainfrom
feat/add-ahx-playback

Conversation

@indigo423
Copy link
Copy Markdown
Collaborator

Chunk α of the add-ahx-playback OpenSpec change. Brings the AHX/THX engine
online and wires it into the AudioPlayer facade's dispatch. Chunk β (download
filename + UI gating refactor + Library smoke) and Chunk γ (docs + release)
will follow as separate PRs.

Summary

  • New audio engine: ahx2play (Olav Sørensen, BSD-3-Clause) vendored at
    vendor/ahx2play/ @ 7620a9f. Three core C files compiled to a 36 KB
    single-file ES module (~14% the size of libtfmx).
  • Magic-byte dispatch at AudioPlayer.play(): ArrayBuffer with AHX or THX
    3-byte prefix AND version byte ∈ {0x00, 0x01} routes to the new
    ahx-processor. The version-byte gate eliminates a meaningful slice of
    false positives.
  • ~1,000 AHX modules on modarchive that previously errored with "Couldn't
    play this track" now play correctly — closes the latent papercut as a
    side-effect of dispatch.
  • The Phase 0 spike's stereo-separation finding is honoured: AHX accepts
    stereoSeparation at the same 0..100 scale as libopenmpt, and
    setStereoSeparation forwards live to the AHX worklet via
    paulaSetStereoSeparation (D9 amendment).

What's in this PR

Layer File Notes
Vendoring vendor/ahx2play/*.{c,h} + LICENSE + README.md Unmodified upstream; commit 7620a9f
Build scripts/build-ahx-wasm.sh + scripts/ahx-driver.{c,h} emcc one-shot; no autotools
Build artifact public/libahx.worklet.js (36 KB) Single-file ES module, WASM inlined
Worklet public/ahx.worklet.js ahx-processor, mirrors tfmx-processor shape
Facade lib/audio-player.ts 3-arm dispatch, cross-engine handshakes, activeEngine getter
Sources components/sources/index.ts AHX_EXTENSIONS = [".ahx", ".thx"]
Lint config eslint.config.mjs Pre-existing fix: ignore .claude/ tool dir

make verify green (lint + typecheck + audit + build).

What's NOT in this PR

These land in Chunk β (follow-up PR):

  • downloadTrack / downloadFavoriteMods filename dispatch (currently still
    hardcodes .mod for modarchive — AHX downloads would mislabel).
  • Sound pane gating refactor from string-equality on meta.type to
    per-control EngineKind matrix. The activeEngine getter is in place
    so the consumer migration is the only remaining work.
  • Player.tsx keyboard m-key handler migration (mirrors SoundPane).
  • Stereo-separation persistence smoke across MOD→AHX→TFMX→MOD.
  • Library + Mod Archive smoke tasks (1.8.3, 1.8.4).

Chunk γ (third PR): HELP.md / README updates + release tag.

Test plan

CI:

  • make verify passes locally
  • CI verify + e2e green on the PR

Manual smoke (pending human — needs browser):

  • Drop a .ahx file on the Local tab → audio plays. Reference files
    cached at /tmp/ahx_163460.bin, /tmp/ahx_164238.bin,
    /tmp/ahx_163890.bin (or any modarchive *.ahx row).
  • Open Mod Archive → Artist Charts → drill into an artist with AHX
    modules → click an AHX row → audio plays (no "Couldn't play this
    track" toast).
  • Switch MOD → AHX → MOD and confirm audio swaps cleanly (no double-
    audio mixing, no stuck silence). The stop-ack handshake is the
    critical path here.
  • Subsong picker appears when an AHX file with multiple subsongs is
    playing.

Refs

  • OpenSpec change: openspec/changes/add-ahx-playback/ (local artifact;
    openspec/ is gitignored by project convention)
  • Phase 0 spike memo: see design.md "Resolved → Phase 0 memo"
  • Reference corpus: 3 modarchive IDs validated (163460, 164238, 163890)

indigo423 added a commit that referenced this pull request May 17, 2026
…ng clamp, songIndex

Three fixes from the adversarial review of PR #37 (Chunk α). All
tightly scoped to public/ahx.worklet.js.

1. wasm_play(0) failure now calls wasm_free() before bailing.
   Previously, a successful wasm_load followed by a failed wasm_play
   left ahx2play's global song_t populated but this.loaded=false.
   The next _play would call _stop, _stop's guard would skip
   wasm_free (since loaded=false), and the leaked song state would
   persist for the session. Now the failure branch explicitly
   wasm_free's before posting the err event. (Blind Hunter B4.)

2. selectSubsong handler clamps the picker index against
   this.numSubsongs and rejects non-numeric values. Mirrors
   Player.tsx's `idx < count` gate on handleSubsongChange. The
   prior code relied on a coincidence between ahx2play's accepted
   index range and our picker mapping; the clamp turns the
   coincidence into a guarantee. (Acceptance Auditor A2.)

3. _meta() now includes meta.song.songIndex so the spec scenario
   "Subsong selection switches the playing subsong" has the
   verifiable anchor it asks for — a consumer can confirm the
   switch took effect by reading the field. (Acceptance Auditor
   A3.)

The remaining review findings are pre-existing TFMX-shared
behaviour (handshake gap, ackslot reuse, worklet-side Set leak,
generation-counter lockstep) and have been recorded in
_bmad-output/implementation-artifacts/deferred-work.md for joint
follow-up.

make verify green.

Assisted-by: ClaudeCode:claude-opus-4-7
indigo423 added 3 commits May 18, 2026 00:07
ESLint was scanning into a leftover Claude Code worktree at
.claude/worktrees/investigation/ and erroring on the Emscripten glue
inside it (no-assign-module-variable). The .claude/ directory holds
skills, agents, and transient worktrees — meta-tooling that should
never be linted as application code. Mirrors the pre-existing
openspec/** ignore.

Pre-existing failure on main; surfaces today because the new AHX
build path lands in the same `make verify` run.

Assisted-by: ClaudeCode:claude-opus-4-7
Signed-off-by: Ronny Trommer <ronny@no42.org>
…dispatch)

Third playback engine alongside libopenmpt and libtfmx, bringing AHX/THX
(Abyss' Highest eXperience, synth-based Amiga tracker from 1992) support
to CoolModFiles. ~1,000 AHX modules on modarchive that were previously
unplayable (errored with "Couldn't play this track") now route through
the new engine — a side-effect of the magic-byte dispatch that closes
that latent papercut for the entire catalogue.

Components:
- vendor/ahx2play/    : ahx2play @ 7620a9f (Olav Sørensen, BSD-3-Clause)
                       Three core C files (replayer.c, loader.c, paula.c)
                       + headers + unmodified LICENSE. README.md documents
                       upstream, commit, build pointer.
- scripts/ahx-driver.{c,h} : null audio driver shim + ccall entry points
                       (load/init/play/render/free/...). Force-included
                       on each ahx2play compile unit so the vendored tree
                       stays byte-identical to upstream.
- scripts/build-ahx-wasm.sh : emcc -Oz one-shot build. No autotools
                       (unlike libtfmx). Output is a 36 KB single-file
                       ES module — ~14% the size of libtfmx, because
                       ahxLoadFromRAM takes a raw buffer (no MEMFS dance).
- public/libahx.worklet.js : Emscripten-built WASM + JS glue (committed).
- public/ahx.worklet.js : AudioWorkletProcessor 'ahx-processor'. Mirrors
                       the structure of public/tfmx.worklet.js — same
                       message vocabulary, same stop-ack handshake, same
                       throttled pos. Single-buffer load (no pair to
                       manage). Subsong count exposed as wasm_subsongs()+1
                       per the spike-confirmed extras-vs-total mapping.
                       Implements setStereoSeparation natively via
                       paulaSetStereoSeparation (D9 amendment).
- lib/audio-player.ts : 3-arm play() dispatch. TfmxPair → tfmx, ArrayBuffer
                       with AHX/THX magic + valid version byte → ahx,
                       else → libopenmpt. Magic-byte sniff is 4 bytes
                       (3-letter prefix + version byte in {0x00, 0x01}).
                       Generation counters for tfmx and ahx are
                       independent. Cross-engine stop-ack handshake
                       generalises to all worklet → libopenmpt and
                       worklet → worklet transitions. New getter
                       `activeEngine: EngineKind` exposes the facade's
                       routing decision to UI gating without coupling
                       to meta.type strings. setStereoSeparation forwards
                       to AHX (live); load(url) JSDoc'd as
                       libopenmpt-only-by-design per D14.
- components/sources/index.ts : AHX_EXTENSIONS = [".ahx", ".thx"];
                       isModuleFile() union. Page-level API files import
                       isModuleFile directly from here, so this single
                       edit widens both client catalogues and the server-
                       side Library endpoints transitively.

What's NOT in this commit (Chunk β / γ scope):
- Download dispatch: downloadTrack still hardcodes .mod for modarchive.
- UI gating: Sound pane still gates per the old (string-equality) predicate;
  EngineKind getter is in place but the consumers haven't migrated.
- Smoke task 1.8.1 (drop .ahx → audio plays) requires browser; pending
  human verification before Chunk β.
- HELP.md / README updates, release tagging.

`make verify` green (lint + typecheck + audit + build).

Refs proposal: openspec/changes/add-ahx-playback/ (local artifact;
openspec/ is gitignored by project convention).

Assisted-by: ClaudeCode:claude-opus-4-7
Signed-off-by: Ronny Trommer <ronny@no42.org>
…ng clamp, songIndex

Three fixes from the adversarial review of PR #37 (Chunk α). All
tightly scoped to public/ahx.worklet.js.

1. wasm_play(0) failure now calls wasm_free() before bailing.
   Previously, a successful wasm_load followed by a failed wasm_play
   left ahx2play's global song_t populated but this.loaded=false.
   The next _play would call _stop, _stop's guard would skip
   wasm_free (since loaded=false), and the leaked song state would
   persist for the session. Now the failure branch explicitly
   wasm_free's before posting the err event. (Blind Hunter B4.)

2. selectSubsong handler clamps the picker index against
   this.numSubsongs and rejects non-numeric values. Mirrors
   Player.tsx's `idx < count` gate on handleSubsongChange. The
   prior code relied on a coincidence between ahx2play's accepted
   index range and our picker mapping; the clamp turns the
   coincidence into a guarantee. (Acceptance Auditor A2.)

3. _meta() now includes meta.song.songIndex so the spec scenario
   "Subsong selection switches the playing subsong" has the
   verifiable anchor it asks for — a consumer can confirm the
   switch took effect by reading the field. (Acceptance Auditor
   A3.)

The remaining review findings are pre-existing TFMX-shared
behaviour (handshake gap, ackslot reuse, worklet-side Set leak,
generation-counter lockstep) and have been recorded in
_bmad-output/implementation-artifacts/deferred-work.md for joint
follow-up.

make verify green.

Assisted-by: ClaudeCode:claude-opus-4-7
Signed-off-by: Ronny Trommer <ronny@no42.org>
@indigo423 indigo423 force-pushed the feat/add-ahx-playback branch from 652f178 to 0c6bd53 Compare May 17, 2026 22:07
@indigo423 indigo423 merged commit fa1b483 into main May 17, 2026
4 checks passed
@indigo423 indigo423 deleted the feat/add-ahx-playback branch May 17, 2026 22:09
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.

1 participant