Skip to content

feat(tfmx): play single-file Hippel TFMX and Future Composer formats#99

Merged
indigo423 merged 3 commits into
mainfrom
feat/add-tfmx-single-file-formats
Jul 8, 2026
Merged

feat(tfmx): play single-file Hippel TFMX and Future Composer formats#99
indigo423 merged 3 commits into
mainfrom
feat/add-tfmx-single-file-formats

Conversation

@indigo423

Copy link
Copy Markdown
Collaborator

Why

The vendored libtfmx backend already decodes single-file Amiga formats — Jochen Hippel TFMX/MCMD (.hip .hipc .hip7 .mcmd) and Future Composer (.fc .fc3 .fc4 .fc13 .fc14 .smod) — but the app only ever handed it two-file Huelsbeck pairs, so those files were rejected at every layer. This addresses issue #89, where the library author (mschwendt) asked "why do you reject the other file types? Like Jochen Hippel's TFMX files? Like Future Composer? MCMD?" — and the maintainer agreed we shouldn't.

Scope note: issue #89 also reports a Huelsbeck pair failing to play on prod — that's a separate bug (needs a repro file), out of scope here.

What changes

Single-file formats now play from Library browse/search and Local drop, alongside the existing pairs:

  • Allowlist TFMX_SINGLE_EXTENSIONS + isTfmxSingleFile/tfmxSingleExt. Deliberately kept out of isModuleFile (distinct engine) and excluding the ambiguous .mdat/.tfm/.tfmx names — a lone .mdat could be an orphan Huelsbeck half or a complete Hippel file, so it stays pair-only (deferred).
  • New Source variants tfmx-single-local / tfmx-single-library (single buffer, no sample half), threaded through getBuffer, the play() dispatch, and the drawer picked-state.
  • Worklet loads a lone music-data file: skips the .sam write and names the MEMFS file with the real extension (e.g. song.fc) so libtfmx's sidecar-guessing doesn't hunt for a phantom .sam; tfx_load then content-detects the format.
  • Server: the byte-server perimeter allows single-file extensions with no partner check (orphan-half rejection preserved for pair formats); listing + search surface singles; tfmx-random left pair-only.

No vendored-library or WASM change — the engine already plays these formats.

Verification

Driven end-to-end against a running server (via scripts/run-driver.mjs):

  • /api/library/file serves .fc/.hipc without a partner → 200; orphan .tfx/.mdat still → 404 (perimeter intact); tfmx-random → 404 (singles excluded).
  • A Future Composer and a Hippel file decode and play through the tfmx-single-library engine path (screenshot: "Playing from: tfmx-single-library").
  • make verify green (lint 0 errors, typecheck, 232 unit tests incl. 16 new, build).

Notes / follow-ups

  • Single-file rows render the filename (e.g. chambers-balance.fc) rather than a synthesized (FC)/(Hippel) label — the extension carries the format identity. Easy to add a family tag if wanted.
  • Perimeter behavior verified live (curl); no automated handler integration test yet.
  • Adds scripts/run-driver.mjs, a Playwright driver for headless player smoke-tests (documented in the local run-coolmodfiles skill). Heads-up: TFMX decode fails under Playwright's bundled Chromium because its AudioWorkletGlobalScope lacks TextDecoder — an env gap, not a product bug; real browsers have it.

OpenSpec change: add-tfmx-single-file-formats (gitignored).

🤖 Generated with Claude Code

indigo423 added 2 commits July 8, 2026 18:25
The libtfmx backend already decodes single-file formats (Jochen Hippel
TFMX/MCMD: .hip/.hipc/.hip7/.mcmd; Future Composer: .fc/.fc3/.fc4/.fc13/
.fc14/.smod), but the app only ever handed it two-file Huelsbeck pairs,
so those files were rejected at every layer. Surfaces mschwendt's ask in
issue #89 ("why reject Hippel TFMX / Future Composer / MCMD?").

Single-file formats now play from Library browse/search and Local drop
alongside the existing pairs:

- New TFMX_SINGLE_EXTENSIONS allowlist + isTfmxSingleFile/tfmxSingleExt;
  deliberately kept out of isModuleFile (distinct engine) and excluding
  the ambiguous .mdat/.tfm/.tfmx names (a lone .mdat could be an orphan
  Huelsbeck half OR a complete Hippel file — deferred).
- New tfmx-single-{local,library} Source variants (single buffer, no
  sample half) wired through getBuffer / play dispatch / drawer state.
- Worklet loads a lone music-data file: skips the .sam write and names
  the MEMFS file with the real extension (e.g. song.fc) so libtfmx's
  sidecar-guessing doesn't hunt for a phantom .sam; tfx_load then
  content-detects the format.
- Server: byte-server perimeter allows single-file extensions with no
  partner check (orphan-half rejection preserved for pair formats);
  listing + search surface singles; tfmx-random left pair-only.

Verified end to end: Library serves .fc/.hipc without a partner (200),
orphan .tfx/.mdat still 404, and both a Future Composer and a Hippel
file decode and play through the tfmx-single-library engine path.

Adds scripts/run-driver.mjs (Playwright driver behind the local
run-coolmodfiles skill) for driving the player headless.

OpenSpec change: add-tfmx-single-file-formats.

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
…; pair-half precedence

Code-review follow-ups on the single-file TFMX feature (PR #99).

A) Auto-advance / error recovery for single-file sources. pickRandomNext
returned null for both tfmx-single-* arms, so on a getBuffer fetch failure
(deleted/renamed library file, network blip) the retry path treated the
source family as empty and teleported the user onto a random Mod Archive
track — exactly what the surrounding code comments warn against for
user-picked sources, and inconsistent with library/tfmx-library which
retry in-family. Now:
- tfmx-single-local mirrors tfmx-local (random-pick from the dropped set),
  so local singles auto-advance and retry in-family;
- tfmx-single-library does a sequential folder walk (pickNextInFolder now
  understands the listing's singles[]) and, since single formats have no
  tfmx-random endpoint, falls back to /api/library/random — staying in the
  library instead of jumping to Mod Archive.

B) Single-vs-pair-half precedence. Single-file detection classified files
by extension alone, so a prefix-Amiga pair whose base ends in a single-file
token (mdat.fc + smpl.fc, base "fc") was mis-split into bogus singles on
local drop and double-listed (pair + phantom singles) by /api/library —
while the search endpoint already got it right. detectSinglesInDir, the
local detectTfmxPairs, and the search single-arm now all require
parseHalfName()===null: a file that is a pair half is never a single. Pair
detection takes precedence uniformly; orphan halves remain 404'd.

Verified live: mdat.fc+smpl.fc lists as one pair (not singles); genuine
.fc/.hipc still list and serve; orphan mdat.fc → 404. +2 regression tests
(234 passing); typecheck + lint clean.

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
@indigo423

Copy link
Copy Markdown
Collaborator Author

Applied max-effort code-review follow-ups in abb0430:

A — single-file auto-advance / error recovery (the reachable one): pickRandomNext returned null for both tfmx-single-* arms, so a getBuffer fetch failure on a user-picked single teleported to a random Mod Archive track — contradicting the anti-teleport comment right above the fallback, and inconsistent with library/tfmx-library (which retry in-family). Now tfmx-single-local mirrors tfmx-local (random-pick from the dropped set) and tfmx-single-library does a folder walk (pickNextInFolder now understands singles[]) with an /api/library/random fallback — staying in the library.

B — single-vs-pair-half precedence: detection classified by extension alone, so a prefix-Amiga pair whose base ends in a single-file token (mdat.fc + smpl.fc) was mis-split into bogus singles on local drop and double-listed by /api/library. detectSinglesInDir, the local detectTfmxPairs, and the search single-arm now all require parseHalfName()===null — pair detection takes precedence uniformly (matching what search.ts already did).

Verified live: mdat.fc+smpl.fc lists as one pair (not singles); genuine .fc/.hipc still list + serve; orphan mdat.fc → 404. +2 regression tests (234 passing); typecheck + lint clean.

Not changed: the latent worklet musicExt .tfx fallback (finding #5) — kept as-is since it's unreachable today; can harden separately if desired.

…ternary

Code-review follow-ups on PR #99:

- lib/tfmx/pairs.ts: new parseSingleName(name) — the single definition
  of the "single-file with pair-half precedence" invariant (a name that
  is also a pair half, e.g. mdat.fc, is never a single). Previously the
  rule was repeated verbatim at three sites (detectSinglesInDir, the
  Local-drop detector, the search endpoint); an edit to one could
  silently desynchronize listing, search, and Local drop. All three now
  call the shared parser.
- components/Player.tsx: collapse pickNextInFolder's currentPath ternary
  — the library and tfmx-single-library arms were identical.
- components/sources/index.ts: add the missing SPDX header (identical to
  the one added on fix/mod-misrouted-as-recording, so the branches still
  merge cleanly).

No behavior change; the mdat.fc-precedence regression tests now pin the
shared parser (234 passing).

Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Ronny Trommer <ronny@no42.org>
@indigo423 indigo423 merged commit 207b1e5 into main Jul 8, 2026
3 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.

1 participant