Skip to content

feat: play .deck in the browser, and publish the docs - #14

Merged
spacedevin merged 3 commits into
mainfrom
feat/web-audio-player-and-docs-site
Aug 7, 2026
Merged

feat: play .deck in the browser, and publish the docs#14
spacedevin merged 3 commits into
mainfrom
feat/web-audio-player-and-docs-site

Conversation

@spacedevin

Copy link
Copy Markdown
Owner

Two additions, both host-side, so the language package stays what it says it is.

@spacedevin/deck-player

Web Audio playback, as a second package in this repo. The parser is deliberately parse-only — absent optionals stay null and nothing is clamped, because that is host policy. This package is that host: defaults, clamps, synthesis, transport, plus a <deck-player> element.

The synths are a source-level port of Deckard's, which are already Tish and already pure functions, cross-checked against tish-gba's build-time bake. That means real hardware behaviour rather than an impression of it — the four DMG duty tables, a genuine 15/7-bit LFSR, 4-bit wave RAM, the 64/32 Hz frequency floors, the GBA's 256-step DAC staircase and ~16 kHz mixing roll-off.

All 62 songs in the tish-gba corpus parse with zero errors and zero substituted generators.

Three fidelity bugs found on the way

  • An unrecognised duty produced a constant -1.0 buffer — a silent channel — where the bake falls back to 50%.
  • Notes past * N are legal and extend the pattern (the bake computes the span as max(note ends, bars*4)). Rejecting them silenced 10 of those 62 songs.
  • The GBA host extensions wave and layer were unregistered, so a sixth of the corpus reported unexpected top-level: wave and played a generic saw instead of its own wave table.

Deliberate deviations from Deckard

  • Voices return their cleanup instead of arming a per-note setTimeout — wall-clock timers are wrong for an OfflineAudioContext render and untestable in Node.
  • The reverb impulse is seeded, so two renders of one song match.
  • Players are exclusive by default (starting one stops the others) and share a single page-level AudioContext.

Unported generators (matrixFm, patch, nes2a03, …) fall back to a plain oscillator and say so in song.substitutions. ttsVocal/meSpeakVocal are out of scope — they need the Web Speech API.

Docs site

site/ renders the markdown already in this repo to https://spacedevin.github.io/deck/. Adding a page means adding a .md file — no route, no nav entry. Sources are read in place, because docs/*.md are package exports and a content/ copy would fork the canonical text.

  • .deck highlighting comes from this package's own isKeyword / isStepToken / classifyLine rather than a second keyword list, so it can't drift from the language.
  • A block gets a play button when it actually parses into something audible — so note <midi> <startBeat> notation never offers one it can't honour.
  • llms.txt and llms-full.txt are generated from the same pages, so they can't fall behind.

docs/EXAMPLES.md is new: eight complete runnable songs, one per idea, all clean and all playable on the site.

Tests

44 player tests (Song IR over the conformance corpus, pure timing math, and a recording fake AudioContext asserting the scheduled graph). The language package is untouched and still passes at 100% line/function/statement coverage. No conformance cases were added — that corpus is the cross-implementation contract and a new case would force every profile to declare its position.

Before merge

Both packages release in lockstep from one tag. @spacedevin/deck-player needs its own npm trusted-publisher entry before the first publish, since trusted publishers are per package.

Pages is already set to build_type: workflow, so merging this deploys the site.

Two additions, both host-side, so the language package stays what it says it is.

@spacedevin/deck-player (packages/player) — Web Audio playback. The parser is
deliberately parse-only: absent optionals stay null and nothing is clamped,
because that is host policy. This package is that host — defaults, clamps,
synthesis, transport, plus a <deck-player> element.

The synths are a source-level port of Deckard's, which are already Tish and
already pure, and are cross-checked against tish-gba's build-time bake. That
means real hardware behaviour rather than an impression of it: the four DMG duty
tables, a genuine 15/7-bit LFSR, 4-bit wave RAM, the 64/32 Hz floors, the GBA's
256-step DAC staircase and ~16 kHz mixing roll-off. All 62 songs in the tish-gba
corpus parse with zero errors and zero substituted generators.

Three fidelity bugs found on the way. An unrecognised duty produced a constant
-1.0 buffer — a silent channel — where the bake falls back to 50%. Notes past
`* N` are legal and extend the pattern (the bake computes the span as
max(note ends, bars*4)); rejecting them silenced 10 of those 62 songs. And the
GBA host extensions `wave` and `layer` were unregistered, so a sixth of the
corpus reported "unexpected top-level: wave" and played a generic saw instead of
its own wave table.

Deviations from Deckard, both deliberate: voices return their cleanup instead of
arming a per-note setTimeout, since wall-clock timers are wrong for an offline
render; and the reverb impulse is seeded, so two renders of one song match.
Players are exclusive by default — starting one stops the others — and share a
single page-level AudioContext.

Docs site (site/, .github/workflows/pages.yml) — renders the markdown already in
this repo to spacedevin.github.io/deck. Adding a page means adding a .md file;
sources are read in place, because docs/*.md are package exports and a content/
copy would fork the canonical text. `.deck` highlighting comes from this
package's own isKeyword/isStepToken/classifyLine rather than a second keyword
list, and a block gets a play button when it actually parses into something
audible. llms.txt and llms-full.txt are generated from the same pages.

docs/EXAMPLES.md is new: eight complete runnable songs, one per idea, all clean
and all playable on the site.

Both packages release in lockstep from one tag; the player needs its own npm
trusted-publisher entry before the first publish.
A dev landing on the readme should hear the language before reading about it,
so a complete three-track song is now the first thing after the tagline —
melody as notes on a beat grid, drums as a step pattern, both chip synths. On
the docs site it renders with a play button, since a block earns one by parsing
into something audible.

Also fixes the release job to pin the player's dependency with a caret rather
than an exact version. `tish build` inlines the parser into dist/deck-player.js,
so the dependency only matters to a consumer compiling from src/index.tish via
the `tish` export condition; an exact pin would have forced npm to install a
second copy of @spacedevin/deck alongside a consumer's own.
HOST.md carried an "AST shape (summary)" table that covered the top-level
parseProgram fields and stopped there — it never documented the typed body rows
from parseTrackBody, which is the shape a host actually consumes. Until now the
only complete statement of it was rust/facade.rs, in Rust.

docs/AST.md documents both halves: every top-level field, every body-row kind
with its fields, bar selectors, gen blocks, and the two rules that shape all of
it — the parser never throws, and null means "the source didn't say" rather than
"the default", because applying defaults is host policy.

Shapes were derived by running the parser over the conformance corpus rather
than written from memory, so the samples are real output.

HOST.md now links here instead of keeping a second copy that could drift. Also
exported as `@spacedevin/deck/ast`, alongside a new `./examples`.
@spacedevin
spacedevin merged commit 7b4d5d9 into main Aug 7, 2026
7 of 9 checks passed
spacedevin added a commit that referenced this pull request Aug 27, 2026
…#17)

The Rust crate job has been posting four `##[error]` annotations on green runs:

  ENOENT: opendir '…/crate/target/package/deckfile-0.1.0/tests/trybuild'
  ENOENT: opendir '…/crate/target/package/deckfile-0.1.0/tests/target'

They come from `Post Cache cargo`, not from any build step. `cargo publish
--dry-run` leaves an extracted copy of the crate under `target/package/`, and
that copy has a `tests/` directory (holding `conformance.rs`). rust-cache prunes
`target/` before saving it, sees `tests/`, and goes looking for the trybuild
layout — `tests/target` and `tests/trybuild` — which this crate has never had.

It only surfaces on a cache MISS, because rust-cache skips the prune entirely on
a hit. That is why it looks sporadic: it appeared on #14, not on #15, and again
on #16, each time a change to the emitted crate moved the cache key.

Nothing should be caching the output of a dry-run publish, so remove it once the
check has served its purpose. The check itself is unchanged.

Worth fixing rather than muting: a green run that reports four errors teaches
everyone to skim past annotations, which is when a real one gets missed.
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