feat(player): AHX/THX playback engine (Chunk α — engine + facade)#37
Merged
Conversation
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
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>
652f178 to
0c6bd53
Compare
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Chunk α of the add-ahx-playback OpenSpec change. Brings the AHX/THX engine
online and wires it into the
AudioPlayerfacade's dispatch. Chunk β (downloadfilename + UI gating refactor + Library smoke) and Chunk γ (docs + release)
will follow as separate PRs.
Summary
vendor/ahx2play/@7620a9f. Three core C files compiled to a 36 KBsingle-file ES module (~14% the size of libtfmx).
AudioPlayer.play(): ArrayBuffer withAHXorTHX3-byte prefix AND version byte ∈ {0x00, 0x01} routes to the new
ahx-processor. The version-byte gate eliminates a meaningful slice offalse positives.
play this track" now play correctly — closes the latent papercut as a
side-effect of dispatch.
stereoSeparationat the same 0..100 scale as libopenmpt, andsetStereoSeparationforwards live to the AHX worklet viapaulaSetStereoSeparation(D9 amendment).What's in this PR
vendor/ahx2play/*.{c,h}+ LICENSE + README.md7620a9fscripts/build-ahx-wasm.sh+scripts/ahx-driver.{c,h}public/libahx.worklet.js(36 KB)public/ahx.worklet.jsahx-processor, mirrorstfmx-processorshapelib/audio-player.tsactiveEnginegettercomponents/sources/index.tsAHX_EXTENSIONS = [".ahx", ".thx"]eslint.config.mjs.claude/tool dirmake verifygreen (lint + typecheck + audit + build).What's NOT in this PR
These land in Chunk β (follow-up PR):
downloadTrack/downloadFavoriteModsfilename dispatch (currently stillhardcodes
.modfor modarchive — AHX downloads would mislabel).meta.typetoper-control
EngineKindmatrix. TheactiveEnginegetter is in placeso the consumer migration is the only remaining work.
m-key handler migration (mirrors SoundPane).Chunk γ (third PR): HELP.md / README updates + release tag.
Test plan
CI:
make verifypasses locallyManual smoke (pending human — needs browser):
.ahxfile on the Local tab → audio plays. Reference filescached at
/tmp/ahx_163460.bin,/tmp/ahx_164238.bin,/tmp/ahx_163890.bin(or any modarchive*.ahxrow).modules → click an AHX row → audio plays (no "Couldn't play this
track" toast).
audio mixing, no stuck silence). The stop-ack handshake is the
critical path here.
playing.
Refs
openspec/changes/add-ahx-playback/(local artifact;openspec/is gitignored by project convention)design.md"Resolved → Phase 0 memo"163460,164238,163890)