From 8095b5dd68676e94e6e80d69757ee37805fedd73 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 6 Aug 2026 10:28:40 -0700 Subject: [PATCH 1/3] feat: play .deck in the browser, and publish the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .github/workflows/ci.yml | 34 + .github/workflows/npm-release.yml | 35 +- .github/workflows/pages.yml | 75 ++ .gitignore | 1 + AGENTS.md | 49 +- README.md | 22 + docs/EXAMPLES.md | 203 +++++ package-lock.json | 54 +- package.json | 11 +- packages/player/AGENTS.md | 66 ++ packages/player/README.md | 143 ++++ .../player/element/deck-player-element.js | 194 +++++ packages/player/package.json | 74 ++ packages/player/src/audio/Engine.tish | 291 ++++++++ packages/player/src/audio/NoteExpand.tish | 183 +++++ packages/player/src/audio/Playback.tish | 171 +++++ packages/player/src/audio/Transport.tish | 199 +++++ .../src/generators/AdsrAmpSchedule.tish | 19 + packages/player/src/generators/BasicOsc.tish | 83 +++ packages/player/src/generators/Dispatch.tish | 32 + packages/player/src/generators/Duty.tish | 31 + .../player/src/generators/GameBoyDmg.tish | 321 ++++++++ .../player/src/generators/GbaDirectSound.tish | 215 ++++++ packages/player/src/generators/Registry.tish | 49 ++ packages/player/src/index.tish | 446 +++++++++++ packages/player/src/schedule/Engine.tish | 34 + packages/player/src/schedule/Scheduler.tish | 92 +++ packages/player/src/song/Apply.tish | 701 ++++++++++++++++++ packages/player/src/song/DeckIds.tish | 129 ++++ packages/player/src/song/Scale.tish | 70 ++ packages/player/test/fake-audio.mjs | 151 ++++ packages/player/test/schedule.mjs | 299 ++++++++ packages/player/test/song.mjs | 260 +++++++ packages/player/test/voices.mjs | 256 +++++++ packages/player/types/element.d.ts | 19 + packages/player/types/index.d.ts | 278 +++++++ site/build.mjs | 475 ++++++++++++ site/highlight.mjs | 172 +++++ site/style.css | 297 ++++++++ 39 files changed, 6224 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/EXAMPLES.md create mode 100644 packages/player/AGENTS.md create mode 100644 packages/player/README.md create mode 100644 packages/player/element/deck-player-element.js create mode 100644 packages/player/package.json create mode 100644 packages/player/src/audio/Engine.tish create mode 100644 packages/player/src/audio/NoteExpand.tish create mode 100644 packages/player/src/audio/Playback.tish create mode 100644 packages/player/src/audio/Transport.tish create mode 100644 packages/player/src/generators/AdsrAmpSchedule.tish create mode 100644 packages/player/src/generators/BasicOsc.tish create mode 100644 packages/player/src/generators/Dispatch.tish create mode 100644 packages/player/src/generators/Duty.tish create mode 100644 packages/player/src/generators/GameBoyDmg.tish create mode 100644 packages/player/src/generators/GbaDirectSound.tish create mode 100644 packages/player/src/generators/Registry.tish create mode 100644 packages/player/src/index.tish create mode 100644 packages/player/src/schedule/Engine.tish create mode 100644 packages/player/src/schedule/Scheduler.tish create mode 100644 packages/player/src/song/Apply.tish create mode 100644 packages/player/src/song/DeckIds.tish create mode 100644 packages/player/src/song/Scale.tish create mode 100644 packages/player/test/fake-audio.mjs create mode 100644 packages/player/test/schedule.mjs create mode 100644 packages/player/test/song.mjs create mode 100644 packages/player/test/voices.mjs create mode 100644 packages/player/types/element.d.ts create mode 100644 packages/player/types/index.d.ts create mode 100644 site/build.mjs create mode 100644 site/highlight.mjs create mode 100644 site/style.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7383661..1419e14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,12 @@ jobs: - name: Build run: npm run build + # The player is the host half: Web Audio, transport, host defaults/clamps. It builds against + # the language package IN THIS REPO (a file: link), so a grammar change that breaks playback + # fails here rather than after publish. + - name: Player (@spacedevin/deck-player) + run: npm test -w @spacedevin/deck-player + # The Rust crate is emitted from the SAME src/index.tish as dist/deck.js, which is what stops the # JS host and tish-gba's build-time bake from drifting. Nothing verified that: a change that broke # the rust-lib emit, or that made the two targets parse differently, would have gone unnoticed @@ -187,6 +193,27 @@ jobs: npm pack mv spacedevin-deck-*.tgz spacedevin-deck-npm-package.tgz + # The player releases in lockstep: same tag, same version. Its dependency on the language + # package is `file:../..` so the workspace links locally and CI tests the source in this repo — + # that has to become the real published version before packing, or the tarball is uninstallable. + - name: Set player version and pin its dependency + run: | + node -e " + const fs = require('fs'); + const p = './packages/player/package.json'; + const j = JSON.parse(fs.readFileSync(p)); + j.version = process.env.VERSION; + j.dependencies['@spacedevin/deck'] = process.env.VERSION; + fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); + " + env: + VERSION: ${{ steps.next_version.outputs.version }} + + - name: Create npm package tarball (@spacedevin/deck-player) + run: | + npm pack -w @spacedevin/deck-player + mv spacedevin-deck-player-*.tgz spacedevin-deck-player-npm-package.tgz + - name: Create or update release branch and push env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -270,3 +297,10 @@ jobs: -H "Content-Type: application/octet-stream" \ --data-binary @spacedevin-deck-npm-package.tgz \ "${UPLOAD_URL}?name=spacedevin-deck-npm-package.tgz&label=npm%20package%20(@spacedevin/deck)" + curl -s -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @spacedevin-deck-player-npm-package.tgz \ + "${UPLOAD_URL}?name=spacedevin-deck-player-npm-package.tgz&label=npm%20package%20(@spacedevin/deck-player)" diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 451120b..850051d 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -2,12 +2,16 @@ # Downloads the npm package tarball from the release (no rebuild). # # AUTH: npm OIDC trusted publishing (no long-lived NPM_TOKEN). One-time setup on -# npmjs.com for `@spacedevin/deck`: Settings > Trusted Publisher > GitHub Actions: +# npmjs.com — needed SEPARATELY for `@spacedevin/deck` AND `@spacedevin/deck-player`, since trusted +# publishers are per package. Settings > Trusted Publisher > GitHub Actions: # Organization or user: spacedevin # Repository: deck # Workflow filename: npm-release.yml # Environment: (leave blank) # Requires npm >= 11.5.1 (upgraded below) and the id-token: write permission. +# +# Both packages ship from one release at one version. The player is published second because its +# dependency is pinned to that exact version. name: NPM release @@ -83,6 +87,24 @@ jobs: fi curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$DECK_URL" -o spacedevin-deck-npm-package.tgz + # The player ships in lockstep from the same release. Same retry loop, same reason. A + # release predating the player has no such asset, so its absence is not fatal — it just + # means there is nothing to publish. + for attempt in 1 2 3 4 5; do + PLAYER_URL=$(curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.rel.outputs.id }}/assets?per_page=100" \ + | jq -r '.[] | select(.name == "spacedevin-deck-player-npm-package.tgz" and .state == "uploaded") | .url') + [ -n "$PLAYER_URL" ] && [ "$PLAYER_URL" != "null" ] && break + echo "player tarball not uploaded yet (attempt $attempt/5) — waiting 30s" + sleep 30 + done + if [ -n "$PLAYER_URL" ] && [ "$PLAYER_URL" != "null" ]; then + curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$PLAYER_URL" -o spacedevin-deck-player-npm-package.tgz + else + echo "No spacedevin-deck-player-npm-package.tgz on this release — skipping the player publish." + fi + - name: Setup Node uses: actions/setup-node@v4 with: @@ -98,6 +120,17 @@ jobs: - name: Publish @spacedevin/deck to npm run: npm publish spacedevin-deck-npm-package.tgz --access public + # Second, and only after the language package is live: the player's dependency was pinned to + # this exact version at pack time, so publishing it first would put an uninstallable package on + # the registry for as long as the other step takes. + - name: Publish @spacedevin/deck-player to npm + run: | + if [ -f spacedevin-deck-player-npm-package.tgz ]; then + npm publish spacedevin-deck-player-npm-package.tgz --access public + else + echo "No player tarball — nothing to publish." + fi + - name: Update release description with npm URL env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..00c662a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,75 @@ +# Build the docs site and deploy it to GitHub Pages. +# +# The site is generated from the markdown ALREADY IN THIS REPO (README, docs/*.md, the player's docs) +# — see site/build.mjs. Adding a page means adding a `.md` file; there is nothing to register here. +# +# One-time setup: repo Settings > Pages > Build and deployment > Source = "GitHub Actions". +# The site lands at https://spacedevin.github.io/deck/, which is why SITE_BASE defaults to /deck/. + +name: Pages + +on: + push: + branches: [main] + # Only rebuild when something the site is built FROM changes. + # Globs, not filenames: the generator discovers markdown, so a NEW .md in one of these trees has + # to trigger a rebuild too. Listing specific files would silently skip it. + paths: + - "README.md" + - "docs/**/*.md" + - "packages/player/**/*.md" + - "site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Never let two deploys race; queue instead, and don't cancel a run that is mid-deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + # `npm run site` builds both bundles first: the language build supplies `.deck` syntax + # highlighting from its own keyword tables, and the player build supplies the play buttons. + # Without either the site still builds — those blocks are just plain (the log says so). + - name: Build site + run: npm run site + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site/out + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index f78a638..20b780b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.log .DS_Store crate/ +site/out/ diff --git a/AGENTS.md b/AGENTS.md index e917988..de87d30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,19 @@ Language-only package for the **`.deck`** patch language. **Entry:** `src/index.tish` +## Repo layout + +This repo publishes **two** packages. The rules below are this one's. + +| Path | Package | What | +|------|---------|------| +| `/` (this file) | `@spacedevin/deck` | the language: tokenize, parse, registries, highlight | +| `packages/player/` | `@spacedevin/deck-player` | the host: Web Audio playback, transport, `` | + +The "out of scope" list below — **including audio** — is about `@spacedevin/deck`. `src/` stays +audio-free; everything that list excludes lives in `packages/player/`, which has its own +[AGENTS.md](packages/player/AGENTS.md). The player depends on this package and never the reverse. + ## In scope - Tokenize / `parseProgram` → AST @@ -28,11 +41,45 @@ Language-only package for the **`.deck`** patch language. - Project IR / JSON schemas - Apply / emit to a host project model - Session, co-DJ, ownership, skills -- Audio / Web Audio engines +- Audio / Web Audio engines — see `packages/player/` - Instrument catalogs or builtin macro *contents* (hosts `registerBuiltinMacros`) - HTML / CSS highlight styling - Graph editor mutators +## Docs site + +`site/build.mjs` renders the markdown **already in this repo** to +[spacedevin.github.io/deck](https://spacedevin.github.io/deck/) on every push to `main`. + +Adding a page means **adding a `.md` file** under `docs/` or `packages/player/` — there is no route, +nav entry, or registration to update. The title comes from `title:` frontmatter, else a per-section +override in `site/build.mjs`, else the first `#` heading, else the filename; `description:` becomes +the lede. + +Don't put frontmatter in a file listed in a package's `files` — npm renders a README verbatim, and a +`---` fence after a text line is a setext H2, so it shows up as a stray rule and a giant +`description:` heading on npmjs.com. Use the section override for those. + +**Fence tags.** `tish`, `bash`, `js`, `rust`, `html`, `json`, `yaml` and `deck` all highlight +(`site/highlight.mjs`). In `docs/` an untagged fence defaults to `deck`, since every one of them is. + +`.deck` highlighting is driven by **this package's own** `isKeyword` / `isInlineKeyword` / +`isStepToken` / `classifyLine`, not a second keyword list — add a keyword to +`src/deckfile/Highlight.tish` and the site picks it up. The player's `bootDeckRegistries()` runs at +build time too, so host vocabulary (`wave`, `layer`, …) colours as well. + +**Play buttons** are decided by *parsing*, not by the fence tag: a `deck` block gets one when +`parseSong` reports no errors and yields at least one channel that actually sounds. So a complete +song is playable wherever it appears, and the grammar's `` notation never offers a +button it can't honour. Runnable songs belong in [docs/EXAMPLES.md](docs/EXAMPLES.md); keep the +grammar reference as reference. + +**`llms.txt`** and `llms-full.txt` are generated from the same pages as the HTML, so they can't fall +behind — the usual fate of a hand-written one. A new `.md` appears in both automatically. + +Sources are read **in place**. `docs/*.md` are package exports and ship in the tarball, so a +`content/` copy would fork the canonical text. `npm run site:serve` previews locally. + ## Docs ownership | Doc | Audience | diff --git a/README.md b/README.md index d9a989b..0c893b4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,25 @@ npm run examples Apply/emit to project IR · session/co-DJ · audio engines · instrument catalogs · builtin macro catalogs · HTML highlight CSS · graph editor mutators. +## Playback + +Hearing a `.deck` file is a host job, so it is a second package in this repo: +**[`@spacedevin/deck-player`](packages/player/)** — Web Audio chip synths, a lookahead transport, and +a `` element. + +```bash +npm install @spacedevin/deck-player +``` + +```js +import { createDeckPlayer } from '@spacedevin/deck-player' +let player = createDeckPlayer() +player.load(source) +player.play() +``` + +It depends on this package and never the reverse — the language stays audio-free. + ## Rust The same `src/index.tish` also emits a Rust library crate, so a Rust consumer (tish-gba's build-time @@ -63,7 +82,10 @@ One source, three targets — Tish, JS, Rust — checked against one corpus. ## Docs +**[spacedevin.github.io/deck](https://spacedevin.github.io/deck/)** — the same markdown, as a site. + - **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface +- **[Examples](docs/EXAMPLES.md)** — complete runnable songs (playable on the site) - **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm` - **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement - **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..56b4556 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,203 @@ +# Examples + +Complete, runnable `.deck` songs — one per idea. On the docs site every block here has a **Play** +button; in a checkout, drop any of them in a `.deck` file. + +The grammar reference shows *syntax* (`note …`); this page shows *songs*. Each one +uses only `gameBoyDmg` and `gbaDirectSound`, so it sounds the same in a browser as it does on a GBA. + +## Steps + +The step grid is one bar of sixteenths. `x` is a hit, `.` is a rest, and `step_pitch` sets what a hit +plays when the channel has no `note` lines. + +```deck +deck 1 +bpm 120 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -14 + adsr a 0 d 0.08 s 0 r 0 + step_pitch 36 + steps x . . . x . . . x . . . x . . . + +track Hat id hat gen gameBoyDmg + gen type noise vol 6 + step_pitch 72 + steps . . x . . . x . . . x . . . x . +``` + +## Step locks + +A bare `steps` line resets the locks; the lanes after it restore only the steps that differ. +`step_vel` is 1–127, `step_prob` is a 0–1 chance, `step_ratchet` fills a step with sub-hits. + +```deck +deck 1 +bpm 128 + +track Snare id snare gen gbaDirectSound + gen waveform square + adsr a 0 d 0.12 s 0 r 0 + step_pitch 40 + steps . . . . x . . . . . . . x . . x + step_vel . . . . 127 . . . . . . . 100 . . 70 + step_ratchet . . . . 1 . . . . . . . 1 . . 3 + step_prob . . . . 1 . . . . . . . 1 . . 0.6 +``` + +## Notes + +Notes are placed by beat, not by step, so they can sit anywhere including off the grid. Beats are +quarter notes: one bar is 4 beats. + +```deck +deck 1 +bpm 116 + +track Lead id lead gen gameBoyDmg + gen type pulse duty 25 vol 12 + note 72 0 0.5 v 110 + note 74 0.5 0.25 v 90 + note 76 0.75 0.75 v 105 + note 79 1.5 0.5 v 100 + note 76 2 1 v 95 + note 72 3 1 v 110 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape saw vol 15 + note 36 0 2 v 120 + note 43 2 2 v 110 +``` + +## Bar selectors + +`* N` declares an N-bar pattern. A note with `bar ` starts inside its own bar and repeats +on every bar the selector matches — `even`, `1`, `0,2`, `-n+2`. + +```deck +deck 1 +bpm 124 + +track Bass id bass gen gameBoyDmg * 4 + gen type wave wave_shape saw vol 14 + note 36 0 1 v 120 bar even + note 41 0 1 v 115 bar 1 + note 43 0 1 v 115 bar 3 + note 48 2 0.5 v 90 + +track Kick id kick gen gbaDirectSound * 4 + gen waveform triangle pitch_drop -12 + adsr a 0 d 0.09 s 0 r 0 + step_pitch 36 + steps x . . . x . . . x . . . x . . . +``` + +## Euclidean fills + +`steps euclid ` spreads N hits as evenly as possible over the pattern — the Bjorklund +fill. It expands to an ordinary step grid, so the lock lanes still apply. + +```deck +deck 1 +bpm 132 + +track Perc id perc gen gameBoyDmg + gen type noise noise_mode short vol 7 + step_pitch 65 + steps euclid 7 16 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -14 + adsr a 0 d 0.08 s 0 r 0 + step_pitch 36 + steps euclid 4 16 +``` + +## Voice: chords and arpeggios + +`voice` transforms every trigger on the channel — one note becomes a stack, and `arp` ripples that +stack across the note instead of playing it together. + +```deck +deck 1 +bpm 108 + +track Chords id chords gen gameBoyDmg + gen type pulse duty 50 vol 9 + voice chord minor arp up arprate 1/16 + note 60 0 2 v 100 + note 65 2 2 v 100 + +track Sub id sub gen gameBoyDmg + gen type wave wave_shape triangle vol 15 + note 36 0 2 v 110 + note 41 2 2 v 110 +``` + +## Swing and scale + +`swing` delays every off-beat sixteenth; `scale` snaps every pitch onto a key, so a wrong note +becomes the nearest right one. + +```deck +deck 1 +bpm 96 +swing 0.4 +scale C minor + +track Keys id keys gen gameBoyDmg + gen type pulse duty 12.5 vol 10 + note 60 0 0.25 v 100 + note 61 0.25 0.25 v 85 + note 63 0.5 0.25 v 95 + note 66 0.75 0.25 v 90 + note 67 1 0.5 v 105 + note 63 1.5 0.5 v 90 + note 60 2 2 v 110 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -12 + adsr a 0 d 0.1 s 0 r 0 + step_pitch 36 + steps x . . . . . . . x . . . . . . . +``` + +## A whole song + +Three voices, four bars, with the DMG's two pulse channels carrying the melody and harmony over the +wave-channel bass. + +```deck +deck 1 +bpm 140 + +track Lead id lead gen gameBoyDmg * 4 + gen type pulse duty 25 vol 11 + note 76 0 0.5 v 112 + note 79 0.5 0.5 v 100 + note 83 1 1 v 118 + note 79 2 0.5 v 95 + note 76 2.5 0.5 v 100 + note 74 3 1 v 105 + +track Harm id harm gen gameBoyDmg * 4 + gen type pulse duty 50 vol 7 + note 67 0 1 v 80 bar even + note 71 1 1 v 78 bar even + note 69 0 1 v 80 bar 1 + note 72 1 1 v 78 bar 1 + +track Bass id bass gen gameBoyDmg * 4 + gen type wave wave_shape saw vol 15 + note 40 0 1 v 120 + note 40 1 1 v 100 + note 47 2 1 v 115 + note 45 3 1 v 105 + +track Kick id kick gen gbaDirectSound * 4 + gen waveform triangle pitch_drop -14 + adsr a 0 d 0.07 s 0 r 0 + step_pitch 36 + steps x . . . x . . x x . . . x . x . +``` diff --git a/package-lock.json b/package-lock.json index 81a7920..02cdcbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,10 @@ "": { "name": "@spacedevin/deck", "version": "0.1.0", - "license": "MIT", + "license": "PIF", + "workspaces": [ + "packages/*" + ], "devDependencies": { "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/github": "12.0.6", @@ -15,6 +18,8 @@ "@semantic-release/release-notes-generator": "14.1.0", "@tishlang/tish": "^3.2.2", "c8": "^10.1.3", + "highlight.js": "^11.11.1", + "marked": "^15.0.12", "semantic-release": "25.0.3" }, "engines": { @@ -630,11 +635,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@spacedevin/deck-player": { + "resolved": "packages/player", + "link": true + }, "node_modules/@tishlang/tish": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@tishlang/tish/-/tish-3.2.2.tgz", "integrity": "sha512-5NLkKY6c7J+4xdPhReB9TKW0a5Ra3qglvkooKgzuPnBK3H9QW3KCTBAdU3mjOtnaRGxr9COoZBEJ1w7nXNRrcQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "PIF", "bin": { @@ -1108,6 +1117,16 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cli-highlight/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2035,13 +2054,13 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/hook-std": { @@ -6408,6 +6427,29 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/player": { + "name": "@spacedevin/deck-player", + "version": "0.1.0", + "license": "PIF", + "dependencies": { + "@spacedevin/deck": "file:../.." + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@tishlang/tish": ">=3.2.2" + }, + "peerDependenciesMeta": { + "@tishlang/tish": { + "optional": true + } + } + }, + "packages/player/node_modules/@spacedevin/deck": { + "resolved": "", + "link": true } } } diff --git a/package.json b/package.json index db41afa..adf21b1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@spacedevin/deck", "version": "0.1.0", "type": "module", - "description": ".deck language only \u2014 tokenize, parse, format, registries, highlight classify", + "description": ".deck language only — tokenize, parse, format, registries, highlight classify", "license": "PIF", "author": "spacedevin", "repository": { @@ -17,6 +17,9 @@ }, "main": "./dist/deck.js", "module": "./dist/deck.js", + "workspaces": [ + "packages/*" + ], "exports": { ".": { "tish": "./src/index.tish", @@ -52,7 +55,9 @@ "prepublishOnly": "npm run build", "build:rust": "node scripts/build-rust.mjs", "test:rust": "npm run build:rust && cd crate && cargo test", - "test:conformance:tish": "tish run test/conformance.tish" + "test:conformance:tish": "tish run test/conformance.tish", + "site": "npm run build && npm run build -w @spacedevin/deck-player && node site/build.mjs", + "site:serve": "npm run site --silent && SITE_BASE=/ node site/build.mjs && npx --yes serve site/out" }, "c8": { "reporter": [ @@ -83,6 +88,8 @@ "@semantic-release/release-notes-generator": "14.1.0", "@tishlang/tish": "^3.2.2", "c8": "^10.1.3", + "highlight.js": "^11.11.1", + "marked": "^15.0.12", "semantic-release": "25.0.3" } } diff --git a/packages/player/AGENTS.md b/packages/player/AGENTS.md new file mode 100644 index 0000000..7ebb6d5 --- /dev/null +++ b/packages/player/AGENTS.md @@ -0,0 +1,66 @@ +# @spacedevin/deck-player + +The **host** side of `.deck` — Web Audio playback for programs parsed by `@spacedevin/deck`. + +**Entry:** `src/index.tish` + +This package exists so that playback never enters the language package. The root +[AGENTS.md](../../AGENTS.md) lists "Audio / Web Audio engines" as out of scope for `@spacedevin/deck`, +and that stands: `../../src/` stays audio-free. Everything that rule excludes lives here. + +## In scope + +- AST → Song IR: **defaults, clamps, range checks**. The parser deliberately does none of this + (`docs/DECK_GRAMMAR.md`: absent optional = `null`, "host policy, and hosts genuinely differ"), so + this package is where `step_vel` becomes 100 and an out-of-range lock gets decided. +- Registry boot (`registerGeneratorIdAliases`, dialects, highlight keywords) per `docs/HOST.md` +- Web Audio: channel bus, master chain, generators/voices +- Transport: lookahead scheduler, play / pause / stop / seek, loop caps +- Offline render (`OfflineAudioContext`) +- The `` custom element +- Tests: Song IR snapshots, pure timing math, a recording fake `AudioContext` for voice schedules + +## Out of scope — do not add here + +- **Grammar changes.** A new body head, top-level statement, or token shape belongs in `../../src/` + and its conformance corpus. If you need something the parser doesn't expose, fix it upstream — + never re-tokenize `.deck` text here. +- **Conformance cases.** `../../conformance/` is the cross-implementation parse contract; adding a + case there forces every profile in `profiles.json` to declare its position. This package reads that + corpus as test *input* and keeps its own fixtures for playback behaviour. +- Session / co-DJ / ownership, DJ mixer crossfading, cue outputs, scratch platters — all dropped from + the Deckard port on purpose. +- Instrument catalogs beyond the generators listed below. + +## Generators + +Ported from Deckard (`tish-midi/src/generators/`), which is the reference host. The port is +source-level: those modules are already Tish and already pure +(`play*(ctx, bus, t, midi, vel, durSec, ch, bendSemis)`), so a fidelity difference is a porting bug, +not a design choice. + +| Tier | Generators | State | +|------|-----------|-------| +| 1 | `gameBoyDmg`, `gbaDirectSound`, `basicOsc` | ported | +| 2 | the other node-graph voices (`chiptune`, `nes2a03`, `c64sid`, `ym2612`, `sn76489`, `spc700`, `noiseBurst`, `fmTone`, `pad`, `bell`, `drumSynth`, …) | not yet ported | +| 3 | `patch`, `matrixFm` — need the gen_block graph parsers + the sync worklet | not yet ported | +| — | `ttsVocal`, `meSpeakVocal` | **excluded**: Web Speech API / `mespeak` dependency | + +Anything unported falls back to `basicOsc` so a song still plays; `unsupportedGenerators()` reports +what was substituted. + +## Why `element/` is not Tish + +`element/deck-player-element.js` is hand-written JavaScript, shipped as authored. A custom element +must be `class X extends HTMLElement`, and **Tish has no class syntax** — `tish build` parses the +declaration as an identifier expression and emits JS that doesn't parse. Everything with behaviour +stays in `src/*.tish`; that file is only the DOM shell around it. Don't try to move it back. + +## Notes for editors + +- **Per-instance state only.** Deckard keeps loop counters in module-level maps + (`deckfile/LoopState.tish`); here they live on the player instance, because two `` + elements can share a page. +- The deck package's registries **are** process-wide singletons. Boot is idempotent and runs once. +- The clock worklet is loaded from an inline Blob URL, not a file — consumers must not have to copy + assets. Keep the `setTimeout` fallback for contexts where `addModule` fails. diff --git a/packages/player/README.md b/packages/player/README.md new file mode 100644 index 0000000..e5ee345 --- /dev/null +++ b/packages/player/README.md @@ -0,0 +1,143 @@ +# @spacedevin/deck-player + +Web Audio playback for **`.deck`** — chip-tune synths, a lookahead transport, and a `` +element. + +[`@spacedevin/deck`](../..) parses the language. This package is the **host**: it applies the +defaults and clamps the parser deliberately leaves out, and it makes sound. + +## Install + +```bash +npm install @spacedevin/deck-player +``` + +## Use + +```js +import { createDeckPlayer } from '@spacedevin/deck-player' + +const player = createDeckPlayer() +const song = player.load(` +deck 1 +bpm 120 + +track Lead id lead gen gameBoyDmg + gen type pulse duty 50 vol 12 + note 60 0 0.5 v 100 + note 64 0.5 0.5 v 90 + note 67 1 1 v 100 +`) + +if (song.errors.length) console.warn(song.errors) +button.onclick = () => player.play() // an AudioContext needs a user gesture +``` + +Or drop in the element — no framework, no build step: + +```html + + + +deck 1 +bpm 120 +track Lead id lead gen gameBoyDmg + note 60 0 0.5 v 100 + + + +``` + +## Hear it + +A whole song is three kinds of line: a tempo, a track, and some notes. On the docs site this block +has a play button — the synths below are doing the work. + +```deck +deck 1 +bpm 132 + +track Lead id lead gen gameBoyDmg + gen type pulse duty 25 vol 11 + note 72 0 0.5 v 110 + note 76 0.5 0.5 v 95 + note 79 1 0.5 v 105 + note 76 1.5 0.5 v 90 + note 72 2 1 v 110 + note 74 3 1 v 95 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape saw vol 15 + note 36 0 1 v 120 + note 36 1 1 v 100 + note 43 2 1 v 115 + note 41 3 1 v 100 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -14 + adsr a 0 d 0.08 s 0 r 0 + note 36 0 0.25 v 127 + note 36 1 0.25 v 110 + note 36 2 0.25 v 127 + note 36 3 0.25 v 110 +``` + +## API + +| | | +|---|---| +| `createDeckPlayer(opts?)` | `load` · `play` · `pause` · `stop` · `seek(beat)` · `position()` · `duration()` · `setIntensity(0..3)` · `analyser()` · `on(event, fn)` · `dispose()` | +| `renderDeckToBuffer(src, opts?)` | offline render through the same graph → `Promise` | +| `parseSong(src)` | `.deck` → Song IR. No AudioContext, no sound | +| `stepTriggers(song, step)` | which notes sound at a 16th step. Pure | +| `buildAudioGraph` · `createTransport` · `playStep` | the pieces, if you want your own loop | + +`load()` returns the Song, including three things worth showing a user: + +- **`errors`** — parse errors plus host errors (a malformed `gen` line, a bad `wave` table) +- **`substitutions`** — generators that were swapped for `basicOsc` (see below) +- **`ignored`** — language features present in the source that this package doesn't sequence yet: + clips/session, `song`/`follow` arrangement, `auto` automation, `master_mix`, `@` directives + +## Sound + +The synths are a source-level port of [Deckard](https://deckard.lol)'s, which are themselves checked +against tish-gba's build-time bake — so a `.deck` sounds the same in a browser as it does on a GBA. +That means real hardware behaviour, not an impression of it: + +- **`gameBoyDmg`** — the four duty tables in an 8-sample buffer pitched by `playbackRate`; a genuine + 15/7-bit LFSR for noise; wave RAM quantized to 4 bits; the 64 Hz / 32 Hz frequency floors; the + 15-step volume envelope +- **`gbaDirectSound`** — a 32-sample table (so high notes alias like the real software mixer), an + 8-bit DAC as a 256-step staircase, and the ~16 kHz mixing roll-off +- **`wave <32 hex nibbles>`** — named wave RAM tables, and `layer` stem gating via + `setIntensity()` + +Everything else — `matrixFm`, `patch`, `nes2a03`, `c64sid`, and the rest — falls back to a plain +oscillator so a song still plays, and says so in `song.substitutions`. `ttsVocal` / `meSpeakVocal` +are out of scope: they need the Web Speech API. + +## Notes + +- **Players are aware of each other.** Starting one stops any other that's playing — two chip songs + at once is noise, and a page like this one has several players on it. Pass `{ exclusive: false }` + to layer them deliberately. +- **One AudioContext per page.** Players share a single lazily-created context unless you pass your + own, because a context is a page-level resource and Safari has historically refused past about + four. +- **No assets to copy.** The clock worklet is compiled from an inline string into a Blob URL, so + installing the package is the whole install. +- **Deterministic.** Probability locks, arpeggiator shuffles and the reverb impulse are all seeded, so + two renders of one song are identical. +- **Pause is real pause.** It suspends the AudioContext, so notes and the scheduler resume exactly + where they stopped. +- Requires `AudioContext`; playback must start from a user gesture. + +## Scope + +See [AGENTS.md](AGENTS.md). Grammar changes belong upstream in `@spacedevin/deck` — never re-tokenize +`.deck` text here. + +## License + +Pay It Forward (PIF) — see [LICENSE](../../LICENSE). diff --git a/packages/player/element/deck-player-element.js b/packages/player/element/deck-player-element.js new file mode 100644 index 0000000..fd69275 --- /dev/null +++ b/packages/player/element/deck-player-element.js @@ -0,0 +1,194 @@ +// — a framework-agnostic custom element wrapping createDeckPlayer. +// +// +// +// deck 1 +// bpm 120 +// track Lead id lead gen gameBoyDmg +// note 60 0 0.5 v 100 +// +// +// +// +// Source comes from the element's text content, or from a `src` attribute. Everything renders into a +// shadow root, so the host page's CSS can't reshape the controls and the controls can't leak out. +// The AudioContext is created on the first click, which is what the autoplay policy wants. +// +// THIS FILE IS PLAIN JAVASCRIPT, not Tish, and is shipped as authored rather than built. A custom +// element has to be `class X extends HTMLElement`, and Tish has no class syntax — `tish build` parses +// the declaration as an identifier expression and emits JS that doesn't even load. Everything with +// actual behaviour lives in the Tish source; this is the DOM shell around it. + +import { createDeckPlayer } from '../dist/deck-player.js' + +const STYLES = ` +:host { display: inline-flex; align-items: center; gap: 8px; font: inherit; color: inherit; + vertical-align: middle; } +:host([hidden]) { display: none; } +button { font: inherit; font-size: 0.85em; line-height: 1; cursor: pointer; color: inherit; + background: color-mix(in srgb, currentColor 10%, transparent); + border: 1px solid color-mix(in srgb, currentColor 35%, transparent); + border-radius: 6px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 6px; } +button:hover:not(:disabled) { background: color-mix(in srgb, currentColor 20%, transparent); } +button:disabled { opacity: 0.45; cursor: default; } +button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.bar { position: relative; width: 72px; height: 4px; border-radius: 2px; overflow: hidden; + background: color-mix(in srgb, currentColor 20%, transparent); } +.bar > i { position: absolute; inset: 0 auto 0 0; width: 0; background: currentColor; + border-radius: 2px; } +.err { font-size: 0.75em; opacity: 0.8; } +@media (prefers-reduced-motion: no-preference) { .bar > i { transition: width 90ms linear; } } +` + +export class DeckPlayerElement extends HTMLElement { + static get observedAttributes () { + return ['src'] + } + + constructor () { + super() + this._player = null + this._raf = 0 + this._song = null + this._built = false + this.attachShadow({ mode: 'open' }) + } + + /** The parsed Song, so a page can surface `errors` or `substitutions`. */ + get song () { + return this._song + } + + connectedCallback () { + if (!this._built) this._build() + const src = this.getAttribute('src') + if (src) this._loadFromUrl(src) + else this._setSource(this.textContent) + } + + disconnectedCallback () { + this._stopTicking() + if (this._player) { + this._player.dispose() + this._player = null + } + } + + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'src' && newValue && newValue !== oldValue && this._built) { + this._loadFromUrl(newValue) + } + } + + _build () { + this._built = true + const style = document.createElement('style') + style.textContent = STYLES + + this._button = document.createElement('button') + this._button.type = 'button' + this._button.addEventListener('click', () => this._toggle()) + this._icon = document.createElement('span') + this._icon.setAttribute('aria-hidden', 'true') + this._label = document.createElement('span') + this._button.append(this._icon, this._label) + + this._bar = document.createElement('div') + this._bar.className = 'bar' + this._fill = document.createElement('i') + this._bar.append(this._fill) + + this._note = document.createElement('span') + this._note.className = 'err' + + this.shadowRoot.append(style, this._button, this._bar, this._note) + this._paint('idle') + } + + _loadFromUrl (url) { + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(String(res.status)) + return res.text() + }) + .then((text) => this._setSource(text)) + .catch(() => { + this._note.textContent = `could not load ${url}` + this._button.disabled = true + }) + } + + _setSource (text) { + if (!this._player) { + this._player = createDeckPlayer() + this._player.on('stop', () => { + this._stopTicking() + this._paint('idle') + }) + } + this._song = this._player.load(text ? String(text) : '') + if (this._song && this._song.errors.length) { + const e = this._song.errors[0] + this._note.textContent = `line ${e.line}: ${e.msg}` + } else if (this._song && this._song.substitutions.length) { + const ids = [...new Set(this._song.substitutions.map((s) => s.generatorId))] + this._note.textContent = `approximating ${ids.join(', ')}` + } else { + this._note.textContent = '' + } + this._paint('idle') + this.dispatchEvent(new CustomEvent('deck-load', { detail: this._song })) + } + + _toggle () { + if (!this._player || !this._song) return + if (this._player.isPlaying()) { + this._player.pause() + this._stopTicking() + this._paint('paused') + return + } + this._player.play() + this._paint('playing') + this._startTicking() + } + + _paint (state) { + const playable = !!this._song && this._song.channels.length > 0 + this._button.disabled = !playable + const playing = state === 'playing' + const label = playing ? 'Pause' : state === 'paused' ? 'Resume' : 'Play' + this._icon.textContent = playing ? '❚❚' : '▶' + this._label.textContent = label + this._button.setAttribute('aria-label', label) + if (state === 'idle') this._fill.style.width = '0%' + } + + _startTicking () { + this._stopTicking() + const tick = () => { + if (!this._player || !this._player.isPlaying()) return + const span = this._song.totalBeats ?? this._song.loopBeats + const pos = this._player.position() + this._fill.style.width = `${span > 0 ? ((pos % span) / span) * 100 : 0}%` + this._raf = requestAnimationFrame(tick) + } + this._raf = requestAnimationFrame(tick) + } + + _stopTicking () { + if (this._raf) { + cancelAnimationFrame(this._raf) + this._raf = 0 + } + } +} + +/** Define ``. Idempotent, and a no-op where there is no `customElements`. */ +export function defineDeckPlayerElement () { + if (typeof customElements === 'undefined') return + if (customElements.get('deck-player')) return + customElements.define('deck-player', DeckPlayerElement) +} + +defineDeckPlayerElement() diff --git a/packages/player/package.json b/packages/player/package.json new file mode 100644 index 0000000..9fe2ced --- /dev/null +++ b/packages/player/package.json @@ -0,0 +1,74 @@ +{ + "name": "@spacedevin/deck-player", + "version": "0.1.0", + "type": "module", + "description": "Web Audio player for the .deck language — chip-tune synths, transport, and a element", + "license": "PIF", + "author": "spacedevin", + "repository": { + "type": "git", + "url": "https://github.com/spacedevin/deck.git", + "directory": "packages/player" + }, + "publishConfig": { + "access": "public" + }, + "tish": { + "module": "./src/index.tish" + }, + "main": "./dist/deck-player.js", + "module": "./dist/deck-player.js", + "types": "./types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "tish": "./src/index.tish", + "import": "./dist/deck-player.js", + "require": "./dist/deck-player.js", + "default": "./dist/deck-player.js" + }, + "./element": { + "types": "./types/element.d.ts", + "import": "./element/deck-player-element.js", + "default": "./element/deck-player-element.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src/", + "dist/", + "element/", + "types/", + "README.md", + "AGENTS.md" + ], + "scripts": { + "build": "tish build src/index.tish -o dist/deck-player.js --target js", + "test": "npm run build && node --test test/schedule.mjs test/song.mjs test/voices.mjs", + "prepack": "npm run build", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "deck", + "tish", + "web-audio", + "chiptune", + "player", + "gameboy", + "gba" + ], + "dependencies": { + "@spacedevin/deck": "file:../.." + }, + "peerDependencies": { + "@tishlang/tish": ">=3.2.2" + }, + "peerDependenciesMeta": { + "@tishlang/tish": { + "optional": true + } + } +} diff --git a/packages/player/src/audio/Engine.tish b/packages/player/src/audio/Engine.tish new file mode 100644 index 0000000..b8c990d --- /dev/null +++ b/packages/player/src/audio/Engine.tish @@ -0,0 +1,291 @@ +// The Web Audio graph. +// +// voice → bus.input (drive WaveShaper) → BiquadFilter → 3-band EQ → gain → StereoPanner +// → masterSum → master gain → DynamicsCompressor → tanh soft-clip → Analyser → destination +// +// plus a shared convolution reverb bus fed by per-channel aux sends. +// +// Ported from Deckard (tish-midi/src/audio/Engine.tish, 1177 lines) with the DJ application removed: +// no co-DJ gain overlay, no cue/PFL second output, no scratch platters, no deck A–D crossfader, no +// actor mixer lanes. What is left is the per-channel strip and the master chain — the parts that +// exist to make a song sound right rather than to run a live set. + +import { dispatchPlayNote } from '../generators/Dispatch.tish' + +fn makeDriveCurve(ctx, amount) { + let k = amount * 120 + let n = 1024 + let curve = new Float32Array(n) + let i = 0 + while (i < n) { + let x = (i * 2) / n - 1 + curve[i] = ((3 + k) * x * 0.5) / (Math.PI + k * Math.abs(x)) + i = i + 1 + } + return curve +} + +/** + * Smooth synthetic convolution-reverb impulse (a noise burst with an exponential decay tail) — a real + * room wash, NOT a feedback delay, which slaps and pops on transients. + * + * Deckard seeds this with `Math.random()`. Here it is a deterministic LCG: an offline render of the + * same song must produce the same samples every time, or the render API can't be tested and two + * renders of one song differ in their reverb tail for no reason the user can see. + */ +fn makeReverbImpulse(ctx, duration, decay) { + let len = Math.floor(ctx.sampleRate * duration) + if (len < 1) { + len = 1 + } + let impulse = ctx.createBuffer(2, len, ctx.sampleRate) + let seed = 22222 + let c = 0 + while (c < 2) { + let data = impulse.getChannelData(c) + let j = 0 + while (j < len) { + seed = (seed * 1103515245 + 12345) % 2147483648 + if (seed < 0) { + seed = -seed + } + let rnd = (seed / 2147483648) * 2 - 1 + data[j] = rnd * Math.pow(1 - j / len, decay) + j = j + 1 + } + c = c + 1 + } + return impulse +} + +/// tanh: unity slope at 0 (transparent at low level), gently saturating toward the rails so a peak +/// bends instead of clipping into a click. NOT normalized — that would add ~2x low-level makeup gain. +fn makeSoftClipCurve() { + let n = 1024 + let curve = new Float32Array(n) + let i = 0 + while (i < n) { + let x = (i * 2) / (n - 1) - 1 + curve[i] = Math.tanh(x) + i = i + 1 + } + return curve +} + +fn makeShelf(ctx, t, freq, q) { + let f = ctx.createBiquadFilter() + f.type = t + f.frequency.value = freq + f.Q.value = q + f.gain.value = 0 + return f +} + +fn connectTrackEq(ctx, filt, ch, gainAfterEq) { + let tLo = makeShelf(ctx, "lowshelf", 220, 0.85) + let tMid = ctx.createBiquadFilter() + tMid.type = "peaking" + tMid.frequency.value = 1400 + tMid.Q.value = 0.85 + tMid.gain.value = ch.eqMid + let tHi = makeShelf(ctx, "highshelf", 5200, 0.7) + tLo.gain.value = ch.eqLo + tHi.gain.value = ch.eqHi + filt.connect(tLo) + tLo.connect(tMid) + tMid.connect(tHi) + tHi.connect(gainAfterEq) + return { lo: tLo, mid: tMid, hi: tHi } +} + +/// Per-channel strip: drive → filter → 3-band EQ → trim → pan → master sum (+ reverb send). +export fn buildChannelBus(ctx, ch, masterSum, reverbIn) { + let pan = ctx.createStereoPanner() + pan.pan.value = ch.pan + let g = ctx.createGain() + g.gain.value = ch.gain + + let filt = ctx.createBiquadFilter() + filt.type = ch.filterType + // Default OPEN (20 kHz ≈ transparent). The channel filter is an opt-in effect, not an always-on + // dulling insert — an unset cutoff must never collapse it toward silence. + filt.frequency.value = ch.cutoff + filt.Q.value = ch.res > 0 ? ch.res : 0.7 + + let drive = ctx.createWaveShaper() + drive.oversample = "2x" + if (ch.drive > 0) { + drive.curve = makeDriveCurve(ctx, ch.drive) + } + drive.connect(filt) + + // Filter-cutoff LFO — movement / wobble. Started only when it would do something, so an unused + // oscillator isn't left running per channel. + let lfo = null + let lfoGain = null + if (ch.lfoRate > 0 && ch.lfoDepth > 0) { + lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = ch.lfoRate + lfoGain = ctx.createGain() + // lfoDepth is 0..1; scale it into Hz around the cutoff so it modulates audibly. + lfoGain.gain.value = ch.lfoDepth * ch.cutoff * 0.5 + lfo.connect(lfoGain) + lfoGain.connect(filt.frequency) + lfo.start() + } + + let teq = connectTrackEq(ctx, filt, ch, g) + g.connect(pan) + pan.connect(masterSum) + + let revSend = ctx.createGain() + revSend.gain.value = ch.reverbSend + pan.connect(revSend) + if (reverbIn) { + revSend.connect(reverbIn) + } + + return { + chId: ch.id, + input: drive, + gainNode: g, + panNode: pan, + filterNode: filt, + eqLo: teq.lo, + eqMid: teq.mid, + eqHi: teq.hi, + reverbSend: revSend, + driveNode: drive, + lfo: lfo, + lfoGain: lfoGain + } +} + +/** + * Build the whole graph for a song: one bus per channel plus the master chain. + * + * `destination` lets an OfflineAudioContext render use the same code path as live playback — the only + * difference between hearing a song and rendering it is which node the master chain lands on. + */ +export fn buildAudioGraph(ctx, song, opts) { + let o = opts ? opts : {} + let masterGainValue = (o.gain !== null && o.gain !== undefined) ? o.gain : 0.9 + let withReverb = o.reverb !== false + + let masterSum = ctx.createGain() + masterSum.gain.value = 1 + + let reverbIn = null + let convolver = null + if (withReverb) { + reverbIn = ctx.createGain() + reverbIn.gain.value = 1 + convolver = ctx.createConvolver() + convolver.buffer = makeReverbImpulse(ctx, 2.2, 2.6) + reverbIn.connect(convolver) + convolver.connect(masterSum) + } + + let masterGain = ctx.createGain() + masterGain.gain.value = masterGainValue + + // Glue compression, then a soft clip so a stacked transient bends instead of cracking. + let glue = ctx.createDynamicsCompressor() + glue.threshold.value = -12 + glue.knee.value = 12 + glue.ratio.value = 3 + glue.attack.value = 0.006 + glue.release.value = 0.16 + + let limiter = ctx.createWaveShaper() + limiter.curve = makeSoftClipCurve() + limiter.oversample = "2x" + + masterSum.connect(masterGain) + masterGain.connect(glue) + glue.connect(limiter) + + let analyser = null + if (ctx.createAnalyser) { + analyser = ctx.createAnalyser() + analyser.fftSize = 2048 + limiter.connect(analyser) + analyser.connect(ctx.destination) + } else { + limiter.connect(ctx.destination) + } + + let buses = [] + let i = 0 + while (i < song.channels.length) { + buses.push(buildChannelBus(ctx, song.channels[i], masterSum, reverbIn)) + i = i + 1 + } + + return { + buses: buses, + masterSum: masterSum, + masterGain: masterGain, + glue: glue, + limiter: limiter, + analyser: analyser, + convolver: convolver, + reverbIn: reverbIn + } +} + +/// Tear the graph down. Called on stop/dispose so a re-`load()` doesn't leak a whole second graph. +export fn disposeAudioGraph(graph) { + if (!graph) { + return + } + let i = 0 + while (i < graph.buses.length) { + let b = graph.buses[i] + if (b.lfo) { + b.lfo.stop() + b.lfo.disconnect() + } + if (b.lfoGain) { b.lfoGain.disconnect() } + b.input.disconnect() + b.filterNode.disconnect() + b.eqLo.disconnect() + b.eqMid.disconnect() + b.eqHi.disconnect() + b.gainNode.disconnect() + b.panNode.disconnect() + b.reverbSend.disconnect() + i = i + 1 + } + if (graph.reverbIn) { graph.reverbIn.disconnect() } + if (graph.convolver) { graph.convolver.disconnect() } + graph.masterSum.disconnect() + graph.masterGain.disconnect() + graph.glue.disconnect() + graph.limiter.disconnect() + if (graph.analyser) { graph.analyser.disconnect() } +} + +/// Is this channel heard right now? Mute wins; a solo anywhere silences every non-soloed channel; a +/// `layer` track is silent below its intensity level. +export fn channelAudible(song, idx) { + let ch = song.channels[idx] + if (!ch) { + return false + } + if (ch.mute) { + return false + } + if (song.anySolo && !ch.solo) { + return false + } + if (ch.minIntensity > song.intensity) { + return false + } + return true +} + +export fn playHitAt(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + return dispatchPlayNote(ctx, bus, t, midi, vel, durSec, ch, bendSemis) +} diff --git a/packages/player/src/audio/NoteExpand.tish b/packages/player/src/audio/NoteExpand.tish new file mode 100644 index 0000000..8a1d103 --- /dev/null +++ b/packages/player/src/audio/NoteExpand.tish @@ -0,0 +1,183 @@ +// Per-channel note transforms applied at trigger time: CHORD (one trigger → an interval stack) and +// ARPEGGIATOR (ripple that stack as a short internal arpeggio within the note). The result is a list +// of {pitch, offset, dur}: offset = seconds after the trigger this note starts, dur = its length. A +// plain chord plays together (guitar strums slightly); an arp spreads the chord across the note in +// order. `voice chord` / `voice arp` set these; off/unset = passthrough. +// +// Ported verbatim from Deckard (tish-midi/src/audio/NoteExpand.tish). + +fn chordIntervals(name) { + if (name === "major") { + return [0, 4, 7] + } + if (name === "minor") { + return [0, 3, 7] + } + if (name === "dom7") { + return [0, 4, 7, 10] + } + if (name === "maj7") { + return [0, 4, 7, 11] + } + if (name === "min7") { + return [0, 3, 7, 10] + } + if (name === "dim") { + return [0, 3, 6] + } + if (name === "aug") { + return [0, 4, 8] + } + if (name === "sus4") { + return [0, 5, 7] + } + if (name === "add9") { + return [0, 4, 7, 14] + } + return [0] +} + +fn applyInversion(intervals, inv) { + if (inv === "1st" && intervals.length > 1) { + let out = [] + let i = 1 + while (i < intervals.length) { out.push(intervals[i]); i = i + 1 } + out.push(intervals[0] + 12) + return out + } + if (inv === "2nd" && intervals.length > 2) { + let out = [] + let i = 2 + while (i < intervals.length) { out.push(intervals[i]); i = i + 1 } + out.push(intervals[0] + 12) + out.push(intervals[1] + 12) + return out + } + return intervals +} + +fn isOff(v) { + return !v || v === "off" || v === "Off" +} + +/// Seconds of strum spread between successive chord notes (a guitar chord is gently strummed; a +/// keyboard plays a block chord). Kept subtle — a wide spread reads as sloppy, not strummed. +export fn chordStrumDelta(ch) { + if (ch.strum !== null && ch.strum !== undefined) { + return ch.strum / 1000.0 + } + if (ch.generatorId === "guitar") { + return 0.008 + } + return 0 +} + +/// Order the chord pitches for the arpeggiator. `random` uses a deterministic shuffle (seeded by the +/// note) so replays and offline renders agree — true randomness would make playback unreproducible. +fn arpReorder(pitches, arp, basePitch) { + let n = pitches.length + let out = [] + if (arp === "down") { + let i = n - 1 + while (i >= 0) { + out.push(pitches[i]) + i = i - 1 + } + return out + } + if (arp === "updown") { + let i = 0 + while (i < n) { + out.push(pitches[i]) + i = i + 1 + } + let j = n - 2 + while (j >= 1) { + out.push(pitches[j]) + j = j - 1 + } + return out + } + if (arp === "random") { + let i = 0 + while (i < n) { + out.push(pitches[i]) + i = i + 1 + } + let s = (basePitch + 7) * 2654435761 % 2147483647 + if (s < 0) { + s = -s + } + let m = n - 1 + while (m > 0) { + s = (s * 1103515245 + 12345) % 2147483647 + if (s < 0) { + s = -s + } + let jj = s % (m + 1) + let tmp = out[m] + out[m] = out[jj] + out[jj] = tmp + m = m - 1 + } + return out + } + let i = 0 + while (i < n) { + out.push(pitches[i]) + i = i + 1 + } + return out +} + +/// Notes to actually sound for one trigger of `basePitch` on this channel: {pitch, offset, dur}. +export fn expandTriggerNotes(ch, basePitch, durSec, bpm) { + let intervals = isOff(ch.chord) ? [0] : chordIntervals(ch.chord) + if ((ch.inversion !== null && ch.inversion !== undefined) && ch.inversion !== "root") { + intervals = applyInversion(intervals, ch.inversion) + } + let pitches = [] + let i = 0 + while (i < intervals.length) { + pitches.push(basePitch + intervals[i]) + i = i + 1 + } + if (pitches.length <= 1) { + return [{ pitch: pitches[0], offset: 0, dur: durSec }] + } + // Arpeggiator: ripple the chord across the note, one pitch at a time, in arp order. + if (!isOff(ch.arp)) { + let order = arpReorder(pitches, ch.arp, basePitch) + let d = durSec / order.length + if (ch.arpRate === "1/8") { + d = (60 / bpm) / 2 + } else if (ch.arpRate === "1/16") { + d = (60 / bpm) / 4 + } else if (ch.arpRate === "1/32") { + d = (60 / bpm) / 8 + } else { + if (d > 0.13) { + d = 0.13 + } + if (d < 0.05) { + d = 0.05 + } + } + let out = [] + let k = 0 + while (k < order.length) { + out.push({ pitch: order[k], offset: k * d, dur: d * 1.4 }) + k = k + 1 + } + return out + } + // Plain chord: notes together (guitar strums slightly). + let strumD = chordStrumDelta(ch) + let out = [] + let k = 0 + while (k < pitches.length) { + out.push({ pitch: pitches[k], offset: k * strumD, dur: durSec }) + k = k + 1 + } + return out +} diff --git a/packages/player/src/audio/Playback.tish b/packages/player/src/audio/Playback.tish new file mode 100644 index 0000000..4da3634 --- /dev/null +++ b/packages/player/src/audio/Playback.tish @@ -0,0 +1,171 @@ +// The sequencer: which notes fire at a given 16th step. +// +// Ported from Deckard (tish-midi/src/audio/Playback.tish), dropping the session/clip selection, +// deck gating and co-DJ cue paths. +// +// `stepTriggers` is pure — song + step in, triggers out, no audio and no mutable state. Deckard keeps +// loop-cap progress in module-level maps (deckfile/LoopState.tish), which would mean two +// elements on one page fighting over the same counters. Here the loop index is derived +// from globalStep instead, so there is no state to share and no state to reset. + +import { sixteenthSeconds } from '../schedule/Engine.tish' +import { channelAudible, playHitAt } from './Engine.tish' +import { expandTriggerNotes } from './NoteExpand.tish' +import { songSnapPitch } from '../song/Scale.tish' + +/** + * Deterministic 0..1 from (globalStep, busIndex, seed). The probability lock gates a step on this, so + * every replay and offline re-render rolls IDENTICALLY — the seed rides `song_seed`, and globalStep is + * cumulative so a 50% step varies bar-to-bar instead of being stuck on or off. Classic fract(sin·k) + * hash: no integer overflow (stays in double range), uniform enough for a per-step coin flip. + */ +export fn stepRand01(step, bus, seed) { + let x = Math.sin((step + 1) * 12.9898 + (bus + 1) * 78.233 + (seed + 1) * 37.719) * 43758.5453 + return x - Math.floor(x) +} + +/// `loops N` is a finite play cap: the channel plays N passes of its pattern, then falls silent. +fn channelExhausted(ch, globalStep) { + if (ch.loopCap === null || ch.loopCap === undefined) { + return false + } + let patSpan = ch.patternBars * 16 + if (patSpan < 1) { + patSpan = 16 + } + return Math.floor(globalStep / patSpan) >= ch.loopCap +} + +/** + * Which notes sound at `globalStep`. Returns [{busIndex, pitch, vel, durSec, beat, noteOffset, lyric}]. + * No audio, no state — the single source of truth for "what sounds", shared by live playback and the + * offline render. + */ +export fn stepTriggers(song, globalStep) { + let out = [] + let bi = 0 + while (bi < song.channels.length) { + let ch = song.channels[bi] + if (!channelAudible(song, bi) || channelExhausted(ch, globalStep)) { + bi = bi + 1 + continue + } + + let nbars = ch.patternBars >= 1 ? ch.patternBars : 1 + let patSpan = nbars * 16 + let stepSec = sixteenthSeconds(song.bpm) + + if (ch.pianoNotes && ch.pianoNotes.length > 0) { + // Melodic: notes are positioned by ABSOLUTE beat across all bars (bar-selected notes were + // expanded to absolute at apply time), so the span is the full pattern and bars 2+ are not + // skipped. + let stepInSpan = globalStep % patSpan + let pi = 0 + while (pi < ch.pianoNotes.length) { + let n = ch.pianoNotes[pi] + let noteStep = Math.floor(n.startBeat * 4 + 0.0001) + let fires = noteStep >= 0 && noteStep < patSpan && stepInSpan === noteStep + // Per-note probability, seeded by the note's BEAT (bi*1000 + noteStep) so notes sharing a + // beat — a chord — roll together and reordering the lines can't change the outcome. + if (fires && n.prob < 0.999) { + if (stepRand01(globalStep, bi * 1000 + noteStep, song.songSeed) >= n.prob) { + fires = false + } + } + if (fires) { + let sec = (n.durBeats * 60) / song.bpm + // NUDGE shifts timing by a fraction of a STEP. RATCHET is R evenly-spaced sub-hits filling + // the note's own duration, each shortened to sec/R so they don't overlap. + let nudgeSec = n.nudge * stepSec + let ratchet = n.ratchet >= 1 ? n.ratchet : 1 + let notes = expandTriggerNotes(ch, songSnapPitch(song, n.pitch), ratchet > 1 ? sec / ratchet : sec, song.bpm) + let rj = 0 + while (rj < ratchet) { + let ratSec = ratchet > 1 ? (rj * sec / ratchet) : 0 + let pk = 0 + while (pk < notes.length) { + out.push({ + busIndex: bi, pitch: notes[pk].pitch, vel: n.vel, durSec: notes[pk].dur, + beat: n.startBeat, noteOffset: notes[pk].offset + nudgeSec + ratSec, lyric: n.lyric + }) + pk = pk + 1 + } + rj = rj + 1 + } + } + pi = pi + 1 + } + } else if (ch.steps) { + // Steps: the rhythm loops over its OWN array length — a 16-step pattern repeats every bar, + // while a captured N*16 array plays distinct per-bar steps. The per-bar step pitch is chosen by + // the global bar index so multi-bar pitch progressions actually change across bars. + let rlen = ch.steps.length >= 1 ? ch.steps.length : 16 + let stepInRhythm = globalStep % rlen + let st = ch.steps[stepInRhythm] + let fires = (st !== null && st !== undefined) && st.on + if (fires && st.prob < 0.999) { + if (stepRand01(globalStep, bi, song.songSeed) >= st.prob) { + fires = false + } + } + if (fires) { + let dur = stepSec * 0.85 + let basePitch = ch.stepPitch + if (ch.stepPitchByBar) { + let barIdx = Math.floor(globalStep / 16) % nbars + if (barIdx >= 0 && barIdx < ch.stepPitchByBar.length) { + basePitch = ch.stepPitchByBar[barIdx] + } + } + let nudgeSec = st.nudge * stepSec + let ratchet = st.ratchet >= 1 ? st.ratchet : 1 + let notes = expandTriggerNotes(ch, songSnapPitch(song, basePitch), ratchet > 1 ? dur / ratchet : dur, song.bpm) + let rj = 0 + while (rj < ratchet) { + let ratSec = ratchet > 1 ? (rj * stepSec / ratchet) : 0 + let pk = 0 + while (pk < notes.length) { + out.push({ + busIndex: bi, pitch: notes[pk].pitch, vel: st.vel, durSec: notes[pk].dur, + beat: globalStep * 0.25, noteOffset: notes[pk].offset + nudgeSec + ratSec, lyric: st.lyric + }) + pk = pk + 1 + } + rj = rj + 1 + } + } + } + bi = bi + 1 + } + return out +} + +/** + * Sound one step: the thin impure wrapper over `stepTriggers`. Returns the voices it started so the + * caller can retire their nodes — see the cleanup note in Dispatch.tish. + */ +export fn playStep(ctx, song, graph, globalStep, tWhen) { + let trigs = stepTriggers(song, globalStep) + let voices = [] + let i = 0 + while (i < trigs.length) { + let tr = trigs[i] + let bus = graph.buses[tr.busIndex] + if (bus) { + let v = playHitAt(ctx, bus, tWhen + tr.noteOffset, tr.pitch, tr.vel, tr.durSec, song.channels[tr.busIndex], 0) + if (v) { + voices.push(v) + } + } + i = i + 1 + } + return voices +} + +/// Steps until every channel's `loops` cap is spent. `null` = at least one channel loops forever. +export fn songStepCount(song) { + if (song.totalBeats === null || song.totalBeats === undefined) { + return null + } + return Math.ceil(song.totalBeats * 4) +} diff --git a/packages/player/src/audio/Transport.tish b/packages/player/src/audio/Transport.tish new file mode 100644 index 0000000..00315e7 --- /dev/null +++ b/packages/player/src/audio/Transport.tish @@ -0,0 +1,199 @@ +// The lookahead ("two clocks") transport. +// +// Ported from Deckard (tish-midi/src/audio/Transport.tish). Three changes, all because this is a +// library rather than an app: +// +// 1. No signal bus. Deckard publishes to a global `signals` object; here the caller passes +// callbacks, so two players on one page don't cross-talk. +// 2. Pause exists. Deckard only has stop/restart, which is fine for a DJ rig and wrong for a play +// button. Pause is `ctx.suspend()` — it freezes the audio clock itself, so every scheduled note +// and the scheduler's own cursor resume exactly where they were. Nothing to reconcile. +// 3. The clock worklet is compiled from an inline string into a Blob URL rather than fetched from +// `/clock-worklet.js`. A consumer must not have to copy an asset into their web root to make an +// npm package work. + +import { swingOffsetSec, stepsToScheduleInWindow, underrunsInBatch } from '../schedule/Scheduler.tish' + +/// Runs on the audio thread and pings the main thread every ~20 ms. An audio-thread timer is immune +/// to the background-tab throttling that would starve a `setTimeout` scheduler and drop notes. +fn clockWorkletSource() { + return "class DeckClockProcessor extends AudioWorkletProcessor {\n" + + " constructor () { super(); this._acc = 0 }\n" + + " process () {\n" + + " this._acc += 128\n" + + " if (this._acc >= sampleRate * 0.02) { this._acc = 0; this.port.postMessage(0) }\n" + + " return true\n" + + " }\n" + + "}\n" + + "registerProcessor('deck-clock', DeckClockProcessor)\n" +} + +/** + * cfg: { + * secPerStep() -> seconds per 16th step + * swing() -> 0..1 + * onStep(step, when) -> true to stop after this step + * onStopped() -> called once when the transport stops on its own + * } + */ +export fn createTransport(ctx, cfg) { + let LOOKAHEAD_SEC = 0.1 + let MAX_BATCH = 256 + + let active = false + let paused = false + let globalTick = 0 + let nextStepSec = 0 + let underruns = 0 + let node = null + let timer = null + let workletUrl = null + + fn runScheduler() { + if (!active || paused) { + return + } + let sp = cfg.secPerStep() + let sw = cfg.swing ? cfg.swing() : 0 + let batch = stepsToScheduleInWindow(globalTick, nextStepSec, ctx.currentTime, LOOKAHEAD_SEC, sp, MAX_BATCH) + underruns = underruns + underrunsInBatch(batch.items, ctx.currentTime) + let i = 0 + while (i < batch.items.length) { + let it = batch.items[i] + // Swing offsets the PLAYED time only; the base grid stays un-swung so nothing accumulates. + let playAt = it.when + swingOffsetSec(it.step, sp, sw) + let stop = cfg.onStep(it.step, playAt) + if (stop) { + globalTick = it.step + 1 + nextStepSec = it.when + sp + stopInternal(true) + return + } + i = i + 1 + } + globalTick = batch.nextStep + nextStepSec = batch.nextSec + } + + fn onClockTick(ev) { + runScheduler() + } + + fn startPump() { + if (ctx.audioWorklet && !node) { + let src = clockWorkletSource() + let blob = new Blob([src], { type: "application/javascript" }) + workletUrl = URL.createObjectURL(blob) + // Two-arg `then`: the rejection handler is a no-op because the timer below already covers a + // browser where `addModule` fails (old engine, blocked blob URL). + ctx.audioWorklet.addModule(workletUrl).then(fn () { + if (!active) { + return + } + node = new AudioWorkletNode(ctx, "deck-clock") + node.port.onmessage = onClockTick + // Never connected to the destination: it produces no audio, it only ticks. + }, fn () { }) + } + // The timer runs regardless. It is the fallback when `addModule` fails or is still pending, and + // a harmless no-op alongside the worklet — `stepsToScheduleInWindow` is idempotent over a window + // that has already been consumed. + if (!timer) { + timer = setInterval(() => { runScheduler() }, 25) + } + } + + fn stopPump() { + if (timer) { + clearInterval(timer) + timer = null + } + if (node) { + node.port.onmessage = null + node.disconnect() + node = null + } + if (workletUrl) { + URL.revokeObjectURL(workletUrl) + workletUrl = null + } + } + + fn stopInternal(notify) { + if (!active) { + return + } + active = false + paused = false + stopPump() + if (notify && cfg.onStopped) { + cfg.onStopped() + } + } + + return { + start: fn (startStep) { + if (active) { + return + } + active = true + paused = false + underruns = 0 + globalTick = (startStep !== null && startStep !== undefined) ? Math.floor(startStep) : 0 + // A beat of headroom so the first step is scheduled ahead rather than already late. + nextStepSec = ctx.currentTime + 0.06 + startPump() + runScheduler() + }, + + pause: fn () { + if (!active || paused) { + return + } + paused = true + // Freezing the clock freezes everything: scheduled notes, the lookahead cursor, all of it. + if (ctx.suspend) { + ctx.suspend() + } + }, + + resume: fn () { + if (!active || !paused) { + return + } + paused = false + if (ctx.resume) { + ctx.resume().then(() => { runScheduler() }) + } else { + runScheduler() + } + }, + + stop: fn () { + stopInternal(false) + }, + + isActive: fn () { return active }, + isPaused: fn () { return paused }, + getStep: fn () { return globalTick }, + underruns: fn () { return underruns }, + + /// The step the listener is hearing right now, as opposed to the one being scheduled. + audibleStep: fn () { + let sp = cfg.secPerStep() + if (sp <= 0) { + return globalTick + } + let ahead = Math.ceil((nextStepSec - ctx.currentTime) / sp) + if (ahead < 0) { + ahead = 0 + } + let s = globalTick - ahead + return s > 0 ? s : 0 + }, + + dispose: fn () { + stopInternal(false) + } + } +} diff --git a/packages/player/src/generators/AdsrAmpSchedule.tish b/packages/player/src/generators/AdsrAmpSchedule.tish new file mode 100644 index 0000000..71ba134 --- /dev/null +++ b/packages/player/src/generators/AdsrAmpSchedule.tish @@ -0,0 +1,19 @@ +// Schedules gain ADSR so AudioParam times never go backwards (short gates + long A/D would throw). +// +// Ported verbatim from Deckard (tish-midi/src/generators/AdsrAmpSchedule.tish). + +export fn scheduleAdsrAmpEnvelope(env, t, peak, slev, a, d, r, durSec) { + let gate = durSec > 0 ? durSec : 0.001 + let tDecayEnd = t + a + d + let susPlateau = Math.max(tDecayEnd, t + gate - r) + let releaseEnd = t + gate + r + if (releaseEnd <= susPlateau) { + releaseEnd = susPlateau + Math.max(r, 0.02) + } + env.gain.setValueAtTime(0, t) + env.gain.linearRampToValueAtTime(peak, t + a) + env.gain.linearRampToValueAtTime(slev, tDecayEnd) + env.gain.setValueAtTime(slev, susPlateau) + env.gain.linearRampToValueAtTime(0, releaseEnd) + return releaseEnd +} diff --git a/packages/player/src/generators/BasicOsc.tish b/packages/player/src/generators/BasicOsc.tish new file mode 100644 index 0000000..aa7ee6a --- /dev/null +++ b/packages/player/src/generators/BasicOsc.tish @@ -0,0 +1,83 @@ +// The fallback voice: a plain oscillator + ADSR. Any generator id this package hasn't ported lands +// here, so an unknown `gen` still makes a sound instead of silence. +// +// Ported from Deckard (tish-midi/src/generators/BasicOsc.tish). + +import { midiToHz } from '../schedule/Engine.tish' +import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' + +export fn normalizeBasicOscWaveform(raw) { + if (!raw) { + return "sine" + } + let s = String(raw).toLowerCase() + if (s === "saw" || s === "sawtooth") { + return "sawtooth" + } + if (s === "square" || s === "sqr" || s === "pulse") { + return "square" + } + if (s === "triangle" || s === "tri") { + return "triangle" + } + return "sine" +} + +export fn defaultParamsForBasicOsc() { + return { + waveform: "sine", + attack: 0.005, + decay: 0.08, + sustain: 0.4, + release: 0.12 + } +} + +export fn playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams + let wave = "sine" + let a = 0.005 + let d = 0.08 + let sus = 0.4 + let r = 0.12 + if (p) { + wave = normalizeBasicOscWaveform(p.waveform) + if (p.attack > 0) { + a = p.attack + } + if (p.decay > 0) { + d = p.decay + } + if (p.sustain >= 0 && p.sustain <= 1) { + sus = p.sustain + } + if (p.release > 0) { + r = p.release + } + } + let osc = ctx.createOscillator() + osc.type = wave + let n = Math.floor(midi + bendSemis) + let hz = 440 + if (n >= 0 && n <= 127) { + hz = midiToHz(n) + } + osc.frequency.value = hz + let env = ctx.createGain() + env.gain.value = 0 + osc.connect(env) + env.connect(bus.input) + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + let peak = v + let slev = sus * v + let tEnd = scheduleAdsrAmpEnvelope(env, t, peak, slev, a, d, r, durSec) + osc.start(t) + osc.stop(tEnd + 0.05) + return { stopTime: tEnd + 0.05, disconnects: [osc, env] } +} diff --git a/packages/player/src/generators/Dispatch.tish b/packages/player/src/generators/Dispatch.tish new file mode 100644 index 0000000..aa6fc58 --- /dev/null +++ b/packages/player/src/generators/Dispatch.tish @@ -0,0 +1,32 @@ +// Generator id → voice. One flat branch, uniform signature, same as Deckard's Dispatch.tish. +// +// Two differences from Deckard, both because this is a library and not an app: +// - voices RETURN `{stopTime, disconnects}` instead of arming a per-note `setTimeout`. The caller +// decides the cleanup policy, which is what lets an OfflineAudioContext render work at all. +// - an unported id falls back to `basicOsc` and is recorded, so the player can tell the user which +// generators were substituted instead of quietly sounding wrong. + +import { playGameBoyDmg } from './GameBoyDmg.tish' +import { playGbaDirectSound } from './GbaDirectSound.tish' +import { playBasicOsc } from './BasicOsc.tish' + +export fn dispatchPlayNote(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + if (!bus || !bus.input) { + return null + } + let id = ch.generatorId + if (!id) { + id = "basicOsc" + } + // `voice octave N` shifts the whole channel before the voice sees the pitch. + if (ch.octave !== null && ch.octave !== undefined && ch.octave !== 0) { + midi = midi + ch.octave * 12 + } + if (id === "gameBoyDmg") { + return playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + } + if (id === "gbaDirectSound") { + return playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + } + return playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) +} diff --git a/packages/player/src/generators/Duty.tish b/packages/player/src/generators/Duty.tish new file mode 100644 index 0000000..86861ff --- /dev/null +++ b/packages/player/src/generators/Duty.tish @@ -0,0 +1,31 @@ +// Pulse duty spelling, shared by both chip voices. +// +// Its own module because Registry needs the generators' default-param functions and the generators +// need this — putting it in Registry makes that a cycle. + +/** + * Canonical duty spelling. Matches `duty_code` / `pcm_table` in tish-gba's bake + * (crates/tish-gba-scenepack/src/deckpack.rs): only `12_5`/`12.5`, `25` and `75` are distinct, and + * ANYTHING else is 50%. + * + * The fallback is load-bearing. Deckard's `getDmgPulseBuffer` starts from an all-zero sequence and + * only fills it on an exact string match, so an unrecognised duty — e.g. the numeric `duty 12.5`, + * which stringifies to "12.5", not "12_5" — yields a buffer of constant -1.0, a silent channel. The + * GBA bake falls back to 50 there, so a song that sounds right on hardware went quiet in the browser. + */ +export fn normalizeDuty(raw) { + if (raw === null || raw === undefined) { + return "50" + } + let s = String(raw) + if (s === "12_5" || s === "12.5") { + return "12_5" + } + if (s === "25") { + return "25" + } + if (s === "75") { + return "75" + } + return "50" +} diff --git a/packages/player/src/generators/GameBoyDmg.tish b/packages/player/src/generators/GameBoyDmg.tish new file mode 100644 index 0000000..0322395 --- /dev/null +++ b/packages/player/src/generators/GameBoyDmg.tish @@ -0,0 +1,321 @@ +// Game Boy (LR35902 / DMG PSG) emulation. +// +// Ported from Deckard (tish-midi/src/generators/GameBoyDmg.tish). The waveform tables are baked into +// tiny looping AudioBuffers and pitched with `playbackRate` — that is what gives the aliasing and +// the hard edges a band-limited OscillatorNode would smooth away. +// +// Two things here are real hardware, not approximations: the noise channel is an actual 15/7-bit +// LFSR rendered to a buffer, and the wave channel is quantized to 4 bits (16 levels) like wave RAM. +// +// One deliberate deviation from Deckard: node cleanup is RETURNED rather than done in a per-note +// `setTimeout`. Wall-clock timers are wrong for an OfflineAudioContext render (which runs faster than +// realtime) and untestable in Node. The dispatcher owns the cleanup policy — see Dispatch.tish. The +// scheduled audio is byte-for-byte the same. + +import { normalizeDuty } from './Duty.tish' + +export fn defaultParamsForGameBoyDmg() { + return { + type: "pulse", + duty: "50", + envMode: "step", + vol: 15, + sweep: 0, + noiseMode: "long", + waveShape: "saw", + attack: 0, + decay: 0, + sustain: 15, + release: 0, + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0, + // Hardware surface (round-trips to the GBA bake) + len: 0, + envStep: 0, + envUp: false, + sweepShift: 0, + sweepPeriod: 0, + sweepDown: false, + noiseShift: null, + noiseRatio: 0 + } +} + +fn getDmgPulseBuffer(ctx, duty) { + // `duty` is already canonical (see normalizeDuty), so 50% is the real default rather than the + // all-off sequence Deckard falls through to. + let seq = [0, 1, 1, 1, 1, 0, 0, 0] + if (duty === "12_5") seq = [0, 1, 0, 0, 0, 0, 0, 0] + if (duty === "25") seq = [0, 1, 1, 0, 0, 0, 0, 0] + if (duty === "75") seq = [1, 0, 0, 1, 1, 1, 1, 1] + + let buf = ctx.createBuffer(1, 8, ctx.sampleRate) + let data = buf.getChannelData(0) + let i = 0 + while (i < 8) { + data[i] = (seq[i] === 1) ? 1.0 : -1.0 + i = i + 1 + } + return buf +} + +fn getDmgLfsrBuffer(ctx, mode) { + let steps = mode === 15 ? 32767 : 127 + let buf = ctx.createBuffer(1, steps, ctx.sampleRate) + let data = buf.getChannelData(0) + let reg = 1 + let i = 0 + while (i < steps) { + let bit0 = reg & 1 + let bitOther = (reg >> 1) & 1 + let feedback = bit0 ^ bitOther + reg = (reg >> 1) | (feedback << 14) + if (mode === 7) { + reg = (reg & ~(1 << 6)) | (feedback << 6) + } + data[i] = (reg & 1) === 0 ? 1.0 : -1.0 + i = i + 1 + } + return buf +} + +/// A `wave <32 hex nibbles>` table, already decoded to -1..1 by Apply. Straight into the +/// buffer — it is wave RAM, so it is 4-bit by construction and needs no further quantization. +fn getNamedWaveBuffer(ctx, table) { + let buf = ctx.createBuffer(1, 32, ctx.sampleRate) + let data = buf.getChannelData(0) + let i = 0 + while (i < 32) { + data[i] = table[i] + i = i + 1 + } + return buf +} + +fn getDmgWaveBuffer(ctx, shape) { + let buf = ctx.createBuffer(1, 32, ctx.sampleRate) + let data = buf.getChannelData(0) + let i = 0 + while (i < 32) { + let v = 0 + let phase = i / 32 + if (shape === "saw") { + v = (phase * 2) - 1 + } else if (shape === "square") { + v = phase < 0.5 ? 1 : -1 + } else { + v = Math.sin(phase * Math.PI * 2) + } + // Quantize to 4-bit (16 levels), as wave RAM does. + v = Math.round(v * 7.5) / 7.5 + data[i] = v + i = i + 1 + } + return buf +} + +export fn playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let type = gp.type ? gp.type : "pulse" + let duty = normalizeDuty(gp.duty) + let envMode = gp.envMode ? gp.envMode : "step" + let vol = gp.vol !== null && gp.vol !== undefined ? Math.round(gp.vol) : 10 + let sweep = gp.sweep !== null && gp.sweep !== undefined ? Math.round(gp.sweep) : 0 + let noiseMode = gp.noiseMode ? gp.noiseMode : "long" + let waveShape = gp.waveShape ? gp.waveShape : "saw" + + let attack = gp.attack !== null && gp.attack !== undefined ? gp.attack : 0 + let decay = gp.decay !== null && gp.decay !== undefined ? gp.decay : 0 + let sustain = gp.sustain !== null && gp.sustain !== undefined ? gp.sustain : 15 + let release = gp.release !== null && gp.release !== undefined ? gp.release : 0 + let pitchDrop = gp.pitchDrop !== null && gp.pitchDrop !== undefined ? Math.round(gp.pitchDrop) : 0 + let pitchDec = gp.pitchDec !== null && gp.pitchDec !== undefined ? gp.pitchDec : 0.05 + let vibRate = gp.vibRate !== null && gp.vibRate !== undefined ? gp.vibRate : 0 + let vibAmt = gp.vibAmt !== null && gp.vibAmt !== undefined ? gp.vibAmt : 0 + let arpRate = gp.arpRate !== null && gp.arpRate !== undefined ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null && gp.arpSemis !== undefined ? Math.round(gp.arpSemis) : 0 + let len = gp.len !== null && gp.len !== undefined ? Math.round(gp.len) : 0 + let envStep = gp.envStep !== null && gp.envStep !== undefined ? Math.round(gp.envStep) : 0 + let envUp = gp.envUp === true + let sweepShift = gp.sweepShift !== null && gp.sweepShift !== undefined ? Math.round(gp.sweepShift) : 0 + let sweepPeriod = gp.sweepPeriod !== null && gp.sweepPeriod !== undefined ? Math.round(gp.sweepPeriod) : 0 + let sweepDown = gp.sweepDown === true || sweep < 0 + let noiseShift = gp.noiseShift !== null && gp.noiseShift !== undefined ? Math.round(gp.noiseShift) : null + let noiseRatio = gp.noiseRatio !== null && gp.noiseRatio !== undefined ? Math.round(gp.noiseRatio) : 0 + + let isNoise = (type === "noise") + let isWave = (type === "wave") + let isPulse = (type === "pulse") + + let f0 = 440 * Math.pow(2, (midi + bendSemis - 69) / 12) + // Soft sweep (±semis) or an NR10-ish approximation via sweepShift/sweepPeriod. + let sweepSemis = sweep + if (sweepShift > 0 && sweepPeriod > 0 && isPulse) { + sweepSemis = sweepDown ? -(sweepShift) : sweepShift + } + let f1 = f0 * Math.pow(2, sweepSemis / 12) + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + // Hardware frequency floors. + if (isWave) { + if (f0 < 32.0) f0 = 32.0 + if (f1 < 32.0) f1 = 32.0 + } else if (isPulse) { + if (f0 < 64.0) f0 = 64.0 + if (f1 < 64.0) f1 = 64.0 + } + + let src = ctx.createBufferSource() + src.loop = true + + if (isWave) { + // A named `wave` table wins over the built-in shapes, same precedence as the GBA bake. + src.buffer = ch.waveTable ? getNamedWaveBuffer(ctx, ch.waveTable) : getDmgWaveBuffer(ctx, waveShape) + } else if (isNoise) { + src.buffer = getDmgLfsrBuffer(ctx, noiseMode === "short" ? 7 : 15) + } else { + src.buffer = getDmgPulseBuffer(ctx, duty) + } + + let rate0 = 1.0 + let rate1 = 1.0 + if (isNoise) { + // GB noise lacks the NES's rigid 16-period table (it uses an expanding divider tree), so modern + // trackers treat it continuously. Scale to MIDI; noise_shift/ratio nudge the divider. + let shift = noiseShift !== null ? noiseShift : Math.floor(midi / 8) + let ratioMul = 1 + (noiseRatio / 14) + let pitchScale = Math.pow(2, -(shift - 7) / 4) / ratioMul + rate0 = (f0 * 100 * pitchScale) / ctx.sampleRate + rate1 = (f1 * 100 * pitchScale) / ctx.sampleRate + } else { + rate0 = (f0 * src.buffer.length) / ctx.sampleRate + rate1 = (f1 * src.buffer.length) / ctx.sampleRate + } + + let safeDur = Math.max(durSec, 0.01) + // Hardware length counter approximation: (64-len)/256 s for pulse/noise. + if (len > 0 && !isWave) { + let hwDur = (64 - Math.min(len, 63)) / 256 + if (hwDur < safeDur) safeDur = Math.max(hwDur, 0.01) + } + + // The step envelope decays over 15 steps, max 7/64 s per step. Prefer an authored env_step; else + // map vol (0-15) onto a playable range. + let stepLen = envStep > 0 ? envStep : Math.max(1, Math.floor(vol / 2)) + let decTime = (stepLen / 7) * 1.64 + if (decTime < 0.05) decTime = 0.05 + + let totalTime = safeDur + 0.1 + if (envMode === "adsr") { + let tD = attack + decay + 0.01 + let tOff = Math.max(safeDur, tD) + totalTime = tOff + release + 0.1 + } else if (envMode === "step") { + totalTime = Math.max(safeDur, decTime) + 0.1 + } + + let stopTime = t + totalTime + let disconnects = [] + + src.playbackRate.setValueAtTime(rate0, t) + + if (pitchDrop !== 0) { + let rateDrop = rate0 * Math.pow(2, pitchDrop / 12) + src.playbackRate.setValueAtTime(rateDrop, t) + src.playbackRate.setTargetAtTime(rate0, t, Math.max(pitchDec, 0.001)) + } else if (arpRate > 0 && arpSemis !== 0) { + let arpStep = 1.0 / arpRate + let steps = Math.ceil(totalTime / arpStep) + let ai = 0 + while (ai < steps) { + let semi = (ai % 2 === 1) ? arpSemis : 0 + src.playbackRate.setValueAtTime(rate0 * Math.pow(2, semi / 12), t + ai * arpStep) + ai = ai + 1 + } + } else if (sweep !== 0 && isPulse) { + src.playbackRate.exponentialRampToValueAtTime(rate1, t + decTime) + } + + if (vibRate > 0 && vibAmt > 0 && !isNoise) { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = vibRate + let lfoGain = ctx.createGain() + lfoGain.gain.value = rate0 * (vibAmt / 1200) + lfo.connect(lfoGain) + lfoGain.connect(src.playbackRate) + lfo.start(t) + lfo.stop(stopTime) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + let maxAmp = (vol / 15.0) * v * 0.8 + if (maxAmp < 0) maxAmp = 0 + if (maxAmp > 0.8) maxAmp = 0.8 + + let finalGain = ctx.createGain() + + if (envMode === "adsr") { + let a = attack > 0 ? attack : 0.005 + let d = decay > 0 ? decay : 0.005 + let s = (sustain / 15.0) * maxAmp + let r = release > 0 ? release : 0.005 + let tA = t + a + let tD = tA + d + let tOff = Math.max(t + safeDur, tD) + + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(maxAmp, tA) + finalGain.gain.linearRampToValueAtTime(s, tD) + finalGain.gain.setValueAtTime(s, tOff) + finalGain.gain.linearRampToValueAtTime(0, tOff + r) + } else { + let tOff = t + safeDur + if (envMode === "step" && envUp) { + // Amplify envelope: start low, climb toward maxAmp over decTime. + finalGain.gain.setValueAtTime(0.001, t) + let tDec = t + 0.005 + decTime + if (tOff < tDec) { + finalGain.gain.linearRampToValueAtTime(maxAmp, tOff) + } else { + finalGain.gain.linearRampToValueAtTime(maxAmp, tDec) + finalGain.gain.setValueAtTime(maxAmp, tOff) + } + finalGain.gain.linearRampToValueAtTime(0, tOff + 0.01) + } else { + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(maxAmp, t + 0.005) + if (envMode === "step") { + let tDec = t + 0.005 + decTime + if (tOff < tDec) { + finalGain.gain.linearRampToValueAtTime(0, tOff) + } else { + finalGain.gain.linearRampToValueAtTime(0, tDec) + } + } else { + finalGain.gain.setValueAtTime(maxAmp, tOff) + finalGain.gain.linearRampToValueAtTime(0, tOff + 0.01) + } + } + } + + src.start(t) + src.stop(stopTime) + disconnects.push(src) + + src.connect(finalGain) + finalGain.connect(bus.input) + disconnects.push(finalGain) + + return { stopTime: stopTime, disconnects: disconnects } +} diff --git a/packages/player/src/generators/GbaDirectSound.tish b/packages/player/src/generators/GbaDirectSound.tish new file mode 100644 index 0000000..40961ff --- /dev/null +++ b/packages/player/src/generators/GbaDirectSound.tish @@ -0,0 +1,215 @@ +// Game Boy Advance DirectSound. +// +// Ported from Deckard (tish-midi/src/generators/GbaDirectSound.tish). Emulates the GBA's software +// mixer: an 8-bit DAC (a 256-step staircase WaveShaper) feeding a ~16 kHz lowpass, with the source +// itself a 32-sample buffer so high notes alias the way a low mixing rate makes them. +// +// Same cleanup deviation as GameBoyDmg — the voice returns its nodes instead of arming a setTimeout. + +import { normalizeDuty } from './Duty.tish' + +export fn defaultParamsForGbaDirectSound() { + return { + waveform: "pulse", // GBA software synths commonly used pulse/saw + duty: "50", + vol: 15, + attack: 0, + decay: 2, + sustain: 15, + release: 0, + bitcrush: true, // 8-bit DAC simulation + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0 + } +} + +fn getGbaDacCurve(ctx) { + if (ctx.gbaDacCurve) return ctx.gbaDacCurve + // A staircase curve that forces the signal into 8 bits (256 steps). + let steps = 8192 + let curve = new Float32Array(steps) + let i = 0 + while (i < steps) { + let norm = i / (steps - 1) + let quant = Math.round(norm * 255) / 255.0 + curve[i] = quant * 2.0 - 1.0 + i = i + 1 + } + ctx.gbaDacCurve = curve + return curve +} + +fn getGbaBuffer(ctx, shape, duty) { + let cacheKey = "gba_" + shape + "_" + duty + if (ctx[cacheKey]) return ctx[cacheKey] + + // A tiny 32-sample buffer mimics the GBA software mixer's aliasing at high frequencies. + let len = 32 + let buf = ctx.createBuffer(1, len, ctx.sampleRate) + let data = buf.getChannelData(0) + + let dThresh = 0.5 + if (duty === "12_5") dThresh = 0.125 + if (duty === "25") dThresh = 0.25 + if (duty === "75") dThresh = 0.75 + + let i = 0 + while (i < len) { + let phase = i / len + let v = 0 + if (shape === "pulse") { + v = phase < dThresh ? 1.0 : -1.0 + } else if (shape === "sawtooth" || shape === "saw") { + v = (phase * 2.0) - 1.0 + } else if (shape === "triangle") { + v = phase < 0.5 ? (phase * 4.0 - 1.0) : (3.0 - phase * 4.0) + } else if (shape === "square") { + v = phase < 0.5 ? 1.0 : -1.0 + } else { + v = Math.sin(phase * Math.PI * 2) + } + data[i] = v + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +export fn playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let waveform = gp.waveform ? gp.waveform : "pulse" + let duty = normalizeDuty(gp.duty) + let vol = (gp.vol !== null && gp.vol !== undefined) ? Math.round(gp.vol) : 15 + let attack = (gp.attack !== null && gp.attack !== undefined) ? gp.attack : 0 + let decay = (gp.decay !== null && gp.decay !== undefined) ? gp.decay : 2 + let sustain = (gp.sustain !== null && gp.sustain !== undefined) ? gp.sustain : 15 + let release = (gp.release !== null && gp.release !== undefined) ? gp.release : 0 + + // `bitcrush 16bit` reads as "no crush" — matches the GBA bake's truthiness quirk. + let bitcrush = gp.bitcrush !== false && gp.bitcrush !== "16bit" + let pitchDrop = (gp.pitchDrop !== null && gp.pitchDrop !== undefined) ? Math.round(gp.pitchDrop) : 0 + let pitchDec = (gp.pitchDec !== null && gp.pitchDec !== undefined) ? gp.pitchDec : 0.05 + let vibRate = (gp.vibRate !== null && gp.vibRate !== undefined) ? gp.vibRate : 0 + let vibAmt = (gp.vibAmt !== null && gp.vibAmt !== undefined) ? gp.vibAmt : 0 + let arpRate = (gp.arpRate !== null && gp.arpRate !== undefined) ? gp.arpRate : 0 + let arpSemis = (gp.arpSemis !== null && gp.arpSemis !== undefined) ? Math.round(gp.arpSemis) : 0 + + let f0 = 440 * Math.pow(2, (midi + bendSemis - 69) / 12) + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let buf = getGbaBuffer(ctx, waveform, duty) + let src = ctx.createBufferSource() + src.buffer = buf + src.loop = true + + let baseFreq = (ctx.sampleRate / buf.length) + let rate0 = f0 / baseFreq + src.playbackRate.value = rate0 + + let disconnects = [] + + if (pitchDrop !== 0) { + let rateDrop = rate0 * Math.pow(2, pitchDrop / 12) + src.playbackRate.setValueAtTime(rateDrop, t) + src.playbackRate.setTargetAtTime(rate0, t, Math.max(pitchDec, 0.001)) + } else if (arpRate > 0 && arpSemis !== 0) { + let arpStep = 1.0 / arpRate + let steps = Math.ceil((durSec + release + 2.0) / arpStep) + let ai = 0 + src.playbackRate.setValueAtTime(rate0, t) + while (ai < steps) { + let semi = (ai % 2 === 1) ? arpSemis : 0 + src.playbackRate.setValueAtTime(rate0 * Math.pow(2, semi / 12), t + ai * arpStep) + ai = ai + 1 + } + } + + if (vibRate > 0 && vibAmt > 0) { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = vibRate + let lfoGain = ctx.createGain() + lfoGain.gain.value = rate0 * (vibAmt / 1200) + lfo.connect(lfoGain) + lfoGain.connect(src.playbackRate) + lfo.start(t) + lfo.stop(t + durSec + release + 2.0) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + let maxAmp = (vol / 15.0) * v * 0.8 + + let finalGain = ctx.createGain() + finalGain.gain.value = maxAmp + + let safeDur = Math.max(durSec, 0.01) + let envGain = ctx.createGain() + envGain.gain.value = 0 + + let tA = t + attack + let tD = tA + (decay / 10.0) + let tOff = Math.max(t + safeDur, tD) + + let envSLevel = sustain / 15.0 + if (attack > 0) { + envGain.gain.linearRampToValueAtTime(1.0, tA) + } else { + envGain.gain.setValueAtTime(1.0, tA) + } + + if (decay > 0) { + envGain.gain.setTargetAtTime(envSLevel, tA, decay / 30.0) + } else { + envGain.gain.setValueAtTime(envSLevel, tA) + } + + envGain.gain.setValueAtTime(envSLevel, tOff) + if (release > 0) { + envGain.gain.setTargetAtTime(0, tOff, release / 30.0) + } else { + envGain.gain.setValueAtTime(0, tOff) + } + + let stopTime = tOff + release + 0.1 + src.start(t) + src.stop(stopTime) + + src.connect(envGain) + + disconnects.push(src) + disconnects.push(envGain) + disconnects.push(finalGain) + + if (bitcrush) { + let shaper = ctx.createWaveShaper() + shaper.curve = getGbaDacCurve(ctx) + // No interpolation — the staircase is the point. + shaper.oversample = "none" + + // The software mixer rolled off hard at its Nyquist limit. + let mixFilter = ctx.createBiquadFilter() + mixFilter.type = "lowpass" + mixFilter.frequency.value = 16000 + envGain.connect(shaper) + shaper.connect(mixFilter) + mixFilter.connect(finalGain) + disconnects.push(shaper) + disconnects.push(mixFilter) + } else { + envGain.connect(finalGain) + } + + finalGain.connect(bus.input) + + return { stopTime: tOff + release + 0.5, disconnects: disconnects } +} diff --git a/packages/player/src/generators/Registry.tish b/packages/player/src/generators/Registry.tish new file mode 100644 index 0000000..cd13c49 --- /dev/null +++ b/packages/player/src/generators/Registry.tish @@ -0,0 +1,49 @@ +// What this package can play, and what each generator's parameters default to. +// +// The tiers here mirror the port state documented in AGENTS.md. `PORTED` is the honest list; anything +// else is substituted with `basicOsc` and reported by `unsupportedGenerators()` rather than silently +// producing the wrong sound. + +import { defaultParamsForGameBoyDmg } from './GameBoyDmg.tish' +import { defaultParamsForGbaDirectSound } from './GbaDirectSound.tish' +import { defaultParamsForBasicOsc } from './BasicOsc.tish' + +/// Generator ids this package synthesizes faithfully. +export fn portedGeneratorIds() { + return ["gameBoyDmg", "gbaDirectSound", "basicOsc"] +} + +/// Generator ids that exist in the wider `.deck` ecosystem but are not ported here yet. Kept explicit +/// so `unsupportedGenerators()` can say "known but not ported" rather than "unknown id". +export fn knownUnportedGeneratorIds() { + return [ + "chiptune", "nes2a03", "c64sid", "ym2612", "sn76489", "spc700", + "noiseBurst", "fmTone", "matrixFm", "patch", "pad", "bell", "drumSynth", + "guitar", "clap", "arco", "tine", "aether", "halo", "acid303", "sub808", + "cymbal", "reeseBass", "syncLead", "syncChoir", "obSync", "laserSync", + "formantVocal", "ttsVocal", "meSpeakVocal" + ] +} + +export fn isPortedGeneratorId(id) { + let ids = portedGeneratorIds() + let i = 0 + while (i < ids.length) { + if (ids[i] === id) { + return true + } + i = i + 1 + } + return false +} + +/// Default `gen` params for a generator id. Unknown ids get the fallback voice's defaults. +export fn defaultParamsForGeneratorId(id) { + if (id === "gameBoyDmg") { + return defaultParamsForGameBoyDmg() + } + if (id === "gbaDirectSound") { + return defaultParamsForGbaDirectSound() + } + return defaultParamsForBasicOsc() +} diff --git a/packages/player/src/index.tish b/packages/player/src/index.tish new file mode 100644 index 0000000..e80ae5b --- /dev/null +++ b/packages/player/src/index.tish @@ -0,0 +1,446 @@ +// @spacedevin/deck-player — Web Audio playback for the `.deck` language. +// +// import { createDeckPlayer } from '@spacedevin/deck-player' +// +// let player = createDeckPlayer() +// player.load(deckSource) +// player.play() +// +// The parsing half is @spacedevin/deck, which is language-only by design. This package is the host: +// defaults, clamps, synthesis, transport. See AGENTS.md for the boundary. + +import { parseSong } from './song/Apply.tish' +import { bootDeckRegistries } from './song/DeckIds.tish' +import { buildAudioGraph, disposeAudioGraph } from './audio/Engine.tish' +import { stepTriggers, playStep, songStepCount } from './audio/Playback.tish' +import { createTransport } from './audio/Transport.tish' +import { secondsPerStep, swingOffsetSec, stepsToScheduleInWindow, underrunsInBatch } from './schedule/Scheduler.tish' +import { midiToHz, automationAt, sixteenthSeconds } from './schedule/Engine.tish' +import { portedGeneratorIds, knownUnportedGeneratorIds, isPortedGeneratorId, defaultParamsForGeneratorId } from './generators/Registry.tish' +import { normalizeDuty } from './generators/Duty.tish' +import { dispatchPlayNote } from './generators/Dispatch.tish' +import { expandTriggerNotes } from './audio/NoteExpand.tish' +import { snapToScale, songSnapPitch } from './song/Scale.tish' + +fn emptyEvents() { + return { step: [], stop: [], load: [], error: [] } +} + +fn emit(events, name, payload) { + let list = events[name] + if (!list) { + return + } + let i = 0 + while (i < list.length) { + list[i](payload) + i = i + 1 + } +} + +/** + * Every exclusive player alive in this realm. + * + * Two chip songs at once is noise, not a demo, and a page that documents the language tends to have + * several players on it — the examples page has eight. So starting one stops the others rather than + * layering them. This lives here rather than in each consumer because otherwise every consumer + * reimplements it, and two of them on one page still wouldn't know about each other. + * + * Entries are removed on `dispose()`. A player that is never disposed stays listed, same as the + * AudioContext it holds — the element disposes on `disconnectedCallback`. + */ +let LIVE_PLAYERS = [] + +fn registerLivePlayer(player) { + LIVE_PLAYERS.push(player) +} + +fn unregisterLivePlayer(player) { + let kept = [] + let i = 0 + while (i < LIVE_PLAYERS.length) { + if (LIVE_PLAYERS[i] !== player) { + kept.push(LIVE_PLAYERS[i]) + } + i = i + 1 + } + LIVE_PLAYERS = kept +} + +/// Stop every other exclusive player. Stop, not pause — the others' `stop` listeners fire, so their +/// UI resets to idle instead of being left showing a paused transport nobody can see. +fn stopOtherPlayers(self) { + let i = 0 + while (i < LIVE_PLAYERS.length) { + let other = LIVE_PLAYERS[i] + if (other !== self && (other.isPlaying() || other.isPaused())) { + other.stop() + } + i = i + 1 + } +} + +/// How many players are currently registered as exclusive. Exposed for tests and debugging. +export fn livePlayerCount() { + return LIVE_PLAYERS.length +} + +/** + * One AudioContext for every player that didn't bring its own. + * + * An AudioContext is a page-level resource, not a per-widget one: each carries its own graph and + * hardware stream, and Safari has historically refused past about four. A docs page with eight + * playable examples would otherwise open eight. Players are exclusive by default anyway, so they + * never contend for it. + * + * Pass `opts.context` to opt out and manage your own. + */ +let SHARED_CONTEXT = null + +fn sharedContext() { + if (!SHARED_CONTEXT) { + SHARED_CONTEXT = new AudioContext() + } + return SHARED_CONTEXT +} + +/** + * A player bound to one AudioContext. + * + * opts: { + * context — an existing AudioContext. Without one the player uses a single page-shared context, + * created on first play so the browser's user-gesture requirement is satisfied. + * gain — master gain, default 0.9 + * reverb — false to skip the convolution bus + * loop — keep looping past the song's natural end (default true; a song with no `loops` cap + * has no end anyway) + * exclusive — starting this player stops any other one that is playing. Default true. Pass false + * to layer players deliberately (a stem player, an A/B comparison). + * } + */ +export fn createDeckPlayer(opts) { + let o = opts ? opts : {} + let ctx = o.context ? o.context : null + // Nothing here ever owns a context: a caller-supplied one is theirs, and the shared one belongs to + // the page. Closing either on dispose would silence the other players still using it. + let loop = o.loop !== false + let exclusive = o.exclusive !== false + + let song = null + let graph = null + let transport = null + let voices = [] + let events = emptyEvents() + let disposed = false + + fn ensureContext() { + if (!ctx) { + ctx = sharedContext() + } + return ctx + } + + /// Retire voices whose tail has passed. Called per step, so there is no timer to leak and an + /// offline render (which has no wall clock) never depends on one. + fn pruneVoices(now) { + let keep = [] + let i = 0 + while (i < voices.length) { + let v = voices[i] + if (v.stopTime > now) { + keep.push(v) + } else { + let d = 0 + while (d < v.disconnects.length) { + v.disconnects[d].disconnect() + d = d + 1 + } + } + i = i + 1 + } + voices = keep + } + + fn killVoices() { + let i = 0 + while (i < voices.length) { + let v = voices[i] + let d = 0 + while (d < v.disconnects.length) { + v.disconnects[d].disconnect() + d = d + 1 + } + i = i + 1 + } + voices = [] + } + + fn teardownGraph() { + killVoices() + if (graph) { + disposeAudioGraph(graph) + graph = null + } + } + + fn ensureGraph() { + if (!graph && song) { + graph = buildAudioGraph(ensureContext(), song, { gain: o.gain, reverb: o.reverb }) + } + return graph + } + + fn onStep(step, when) { + let total = songStepCount(song) + if (total !== null && !loop && step >= total) { + return true + } + let started = playStep(ctx, song, graph, step, when) + let i = 0 + while (i < started.length) { + voices.push(started[i]) + i = i + 1 + } + pruneVoices(ctx.currentTime) + emit(events, "step", { step: step, beat: step * 0.25, when: when }) + return false + } + + fn ensureTransport() { + if (!transport) { + transport = createTransport(ensureContext(), { + secPerStep: fn () { return secondsPerStep(song ? song.bpm : 120) }, + swing: fn () { return song ? song.swing : 0 }, + onStep: onStep, + onStopped: fn () { + killVoices() + emit(events, "stop", { reason: "end" }) + } + }) + } + return transport + } + + // Built into a binding first so the methods can refer to the player itself — `stopOtherPlayers` + // needs an identity to skip, and `dispose` needs one to unregister. + let api = { + /// Parse and prepare. Returns the Song IR (with `errors`, `substitutions` and `ignored`) so a + /// caller can surface problems without re-parsing. + load: fn (source) { + if (disposed) { + return null + } + if (transport) { + transport.stop() + } + teardownGraph() + song = parseSong(source) + emit(events, "load", song) + return song + }, + + play: fn () { + if (disposed || !song) { + return + } + // Before anything else, including a resume — starting is starting. + if (exclusive) { + stopOtherPlayers(api) + } + ensureContext() + // A context created before a user gesture starts suspended; resuming inside the click handler + // is what actually makes sound. + if (ctx.state === "suspended" && transport && transport.isPaused()) { + transport.resume() + return + } + if (ctx.resume) { + ctx.resume() + } + ensureGraph() + let tr = ensureTransport() + if (tr.isPaused()) { + tr.resume() + return + } + if (!tr.isActive()) { + tr.start(0) + } + }, + + pause: fn () { + if (transport) { + transport.pause() + } + }, + + stop: fn () { + if (transport) { + transport.stop() + } + killVoices() + emit(events, "stop", { reason: "user" }) + }, + + /// Jump to a beat. Restarts the transport there; the pattern position is derived from the step + /// index, so seeking is exact rather than approximate. + seek: fn (beat) { + if (!song) { + return + } + let step = Math.floor(Number(beat) * 4) + if (step < 0) { + step = 0 + } + let wasActive = transport && transport.isActive() + if (transport) { + transport.stop() + } + killVoices() + if (wasActive) { + ensureGraph() + ensureTransport().start(step) + } + }, + + isPlaying: fn () { + return (transport !== null && transport !== undefined) && transport.isActive() && !transport.isPaused() + }, + isPaused: fn () { + return (transport !== null && transport !== undefined) && transport.isPaused() + }, + + /// Beat the listener is hearing, not the one being scheduled. + position: fn () { + if (!transport || !transport.isActive()) { + return 0 + } + return transport.audibleStep() * 0.25 + }, + + /// Beats until the song ends, or null when a channel loops forever. + duration: fn () { + if (!song) { + return 0 + } + return song.totalBeats + }, + + song: fn () { return song }, + + /** + * Stem gating, 0..3. Tracks carrying `layer N` fall silent below N, so a soundtrack can duck to + * its core parts and build back up. Everything plays at 3, the default. + */ + setIntensity: fn (level) { + if (!song) { + return + } + let n = Math.round(Number(level)) + if (n !== n || n < 0) { + n = 0 + } + if (n > 3) { + n = 3 + } + song.intensity = n + }, + intensity: fn () { return song ? song.intensity : 3 }, + analyser: fn () { return graph ? graph.analyser : null }, + context: fn () { return ctx }, + + on: fn (name, handler) { + if (events[name]) { + events[name].push(handler) + } + }, + + dispose: fn () { + disposed = true + unregisterLivePlayer(api) + if (transport) { + transport.dispose() + transport = null + } + teardownGraph() + ctx = null + song = null + events = emptyEvents() + } + } + + if (exclusive) { + registerLivePlayer(api) + } + return api +} + +/** + * Render a song offline to an AudioBuffer. Uses the same graph and the same sequencer as live + * playback — the only difference is the destination — so what you render is what you hear. + * + * Needs a song with a finite length: every channel must carry a `loops` cap, or pass `opts.beats`. + * Returns a Promise. + */ +export fn renderDeckToBuffer(source, opts) { + let o = opts ? opts : {} + let song = parseSong(source) + let sampleRate = o.sampleRate ? o.sampleRate : 44100 + + let beats = o.beats + if (beats === null || beats === undefined) { + beats = song.totalBeats + } + if (beats === null || beats === undefined) { + beats = song.loopBeats + } + + let secPerStep = secondsPerStep(song.bpm) + let steps = Math.ceil(beats * 4) + // A tail so the last note's release isn't cut off mid-decay. + let seconds = steps * secPerStep + 2.0 + let ctx = new OfflineAudioContext(2, Math.ceil(seconds * sampleRate), sampleRate) + let graph = buildAudioGraph(ctx, song, { gain: o.gain, reverb: o.reverb }) + + let t0 = 0.05 + let s = 0 + while (s < steps) { + let when = t0 + s * secPerStep + swingOffsetSec(s, secPerStep, song.swing) + playStep(ctx, song, graph, s, when) + s = s + 1 + } + return ctx.startRendering() +} + +export { + // Song IR + livePlayerCount, + parseSong, + bootDeckRegistries, + // sequencing (pure — usable without an AudioContext) + stepTriggers, + songStepCount, + expandTriggerNotes, + snapToScale, + songSnapPitch, + // timing math + secondsPerStep, + sixteenthSeconds, + swingOffsetSec, + stepsToScheduleInWindow, + underrunsInBatch, + midiToHz, + automationAt, + // generators + portedGeneratorIds, + knownUnportedGeneratorIds, + isPortedGeneratorId, + defaultParamsForGeneratorId, + normalizeDuty, + dispatchPlayNote, + // graph + buildAudioGraph, + disposeAudioGraph, + createTransport, + playStep +} diff --git a/packages/player/src/schedule/Engine.tish b/packages/player/src/schedule/Engine.tish new file mode 100644 index 0000000..73ded1f --- /dev/null +++ b/packages/player/src/schedule/Engine.tish @@ -0,0 +1,34 @@ +// Project time → playback parameters. Pure math: no DOM, no audio nodes. +// +// Ported from Deckard (tish-midi/src/schedule/Engine.tish). + +/// Seconds per 16th-note step at `bpm`, unclamped. See `secondsPerStep` for the transport's clamped +/// version — this one is the raw musical conversion. +export fn sixteenthSeconds(bpm) { + return (60 / bpm) / 4 +} + +/// Linear automation value at `beat`. `points` must be sorted by beat. +export fn automationAt(points, beat) { + if (points.length === 0) { + return 0 + } + if (beat <= points[0].beat) { + return points[0].value + } + let i = 1 + while (i < points.length) { + if (beat < points[i].beat) { + let p0 = points[i - 1] + let p1 = points[i] + let t = (beat - p0.beat) / (p1.beat - p0.beat) + return p0.value + t * (p1.value - p0.value) + } + i = i + 1 + } + return points[points.length - 1].value +} + +export fn midiToHz(midi) { + return 440 * Math.pow(2, (midi - 69) / 12) +} diff --git a/packages/player/src/schedule/Scheduler.tish b/packages/player/src/schedule/Scheduler.tish new file mode 100644 index 0000000..b7d3eee --- /dev/null +++ b/packages/player/src/schedule/Scheduler.tish @@ -0,0 +1,92 @@ +// Pure helpers for the lookahead ("two clocks") transport scheduler. No DOM / no audio nodes — these +// are unit-testable. The scheduler schedules every step whose audio-clock time falls within a +// lookahead window, advancing self-correctingly by the per-step duration so there is no accumulated +// drift. +// +// Ported from Deckard (tish-midi/src/audio/Scheduler.tish). Deckard extracted these as pure but its +// Transport still inlined the same logic; here the transport actually calls them, which is what makes +// the timing testable in Node. + +/// Seconds per 16th-note step at `bpm`, clamped to the transport's 40..300 range. +export fn secondsPerStep(bpm) { + let b = typeof bpm === "number" ? bpm : 120 + if (b < 40) { + b = 40 + } + if (b > 300) { + b = 300 + } + return 60 / b / 4 +} + +/** + * Swing: delay each ODD (off-beat) 16th step so the groove shuffles. Even steps (on-beats) are + * unchanged (returns 0); odd steps are delayed by `swing * secPerStep * 0.5` (so swing=1 = a + * half-step max). `swing` is clamped 0..1. The caller keeps the base grid un-swung, so this adds + * groove with ZERO accumulated drift. + */ +export fn swingOffsetSec(step, secPerStep, swing) { + let s = typeof swing === "number" ? swing : 0 + if (s < 0) { + s = 0 + } + if (s > 1) { + s = 1 + } + if (s <= 0) { + return 0 + } + let st = Math.floor(Number(step)) + if (st % 2 !== 1) { + return 0 + } + let sp = Number(secPerStep) + if (sp <= 0 || sp !== sp) { + return 0 + } + return s * sp * 0.5 +} + +/** + * Given the next step to play (`startStep` at audio-time `nextStepSec`), the current audio clock + * (`ctxNow`), a `lookahead` window, and the per-step duration `secPerStep`, return the batch of + * { step, when } to schedule now plus the advanced cursor. Self-correcting: `when` accumulates by + * `secPerStep` from the audio clock, never from wall-clock timer jitter. `maxBatch` caps a catch-up + * burst if the clock jumps (e.g. after a stall / tab resume) so we never loop unbounded. + * + * Returns { items: [{step, when}], nextStep, nextSec }. + */ +export fn stepsToScheduleInWindow(startStep, nextStepSec, ctxNow, lookahead, secPerStep, maxBatch) { + let out = [] + let step = Math.floor(Number(startStep)) + let when = Number(nextStepSec) + let sp = Number(secPerStep) + let limit = (maxBatch !== null && maxBatch !== undefined) && Math.floor(Number(maxBatch)) > 0 ? Math.floor(Number(maxBatch)) : 256 + if (sp <= 0 || sp !== sp) { + return { items: out, nextStep: step, nextSec: when } + } + let horizon = Number(ctxNow) + Number(lookahead) + while (when < horizon && out.length < limit) { + out.push({ step: step, when: when }) + step = step + 1 + when = when + sp + } + return { items: out, nextStep: step, nextSec: when } +} + +/// Count of scheduled items whose audio-time is already in the past relative to `ctxNow` (we were too +/// late to schedule them ahead — an underrun signal for the health readout). +export fn underrunsInBatch(items, ctxNow) { + let n = 0 + if (!items) { + return 0 + } + let i = 0 + while (i < items.length) { + if (Number(items[i].when) < Number(ctxNow)) { + n = n + 1 + } + i = i + 1 + } + return n +} diff --git a/packages/player/src/song/Apply.tish b/packages/player/src/song/Apply.tish new file mode 100644 index 0000000..841764b --- /dev/null +++ b/packages/player/src/song/Apply.tish @@ -0,0 +1,701 @@ +// AST → Song IR. This is the host-policy layer. +// +// @spacedevin/deck is deliberately parse-only: "an absent optional is null so the host applies its own +// default, and there is no clamping or range checking — that is host policy, and hosts differ" +// (docs/DECK_GRAMMAR.md). Everything that sentence excludes happens in this file: defaults, clamps, +// the steps-vs-notes rule, bar-selector expansion, and per-bar step pitch. +// +// The defaults come from the grammar's own tables; the generator param vocabulary is cross-checked +// against tish-gba's build-time bake (crates/tish-gba-scenepack/src/deckpack.rs), which is the +// canonical list for the two chip synths. + +import { parseProgram, parseTrackBody, barSelectorMatches, snakeToCamel } from '@spacedevin/deck' +import { defaultParamsForGeneratorId, isPortedGeneratorId, knownUnportedGeneratorIds } from '../generators/Registry.tish' +import { bootDeckRegistries } from './DeckIds.tish' + +// ── clamps ──────────────────────────────────────────────────────────────────── + +fn clampNum(v, lo, hi) { + let n = Number(v) + if (n !== n) { + return lo + } + if (n < lo) { + return lo + } + if (n > hi) { + return hi + } + return n +} + +fn clampVel(v) { + if (v === null || v === undefined) { + return 100 + } + return Math.round(clampNum(v, 1, 127)) +} + +fn clampProb(v) { + if (v === null || v === undefined) { + return 1 + } + return clampNum(v, 0, 1) +} + +fn clampRatchet(v) { + if (v === null || v === undefined) { + return 1 + } + return Math.round(clampNum(v, 1, 8)) +} + +fn clampNudge(v) { + if (v === null || v === undefined) { + return 0 + } + return clampNum(v, -0.5, 0.5) +} + +// ── channel shell ───────────────────────────────────────────────────────────── + +fn emptyChannel(index, id, name, generatorId) { + return { + index: index, + id: id, + name: name, + generatorId: generatorId, + generatorParams: {}, + generatorSpec: null, + waveTable: null, + minIntensity: 0, + patternBars: 1, + loopCap: null, + pianoNotes: [], + steps: null, + stepPitch: 36, + stepPitchByBar: null, + transpose: 0, + // mix + gain: 1, + pan: 0, + mute: false, + solo: false, + eqLo: 0, + eqMid: 0, + eqHi: 0, + // fx + reverbSend: 0, + drive: 0, + lfoRate: 0, + lfoDepth: 0, + cutoff: 20000, + res: 0, + filterType: "lowpass", + // voice + octave: 0, + arp: null, + arpRate: null, + chord: null, + inversion: null, + strum: null + } +} + +// ── body rows → channel ─────────────────────────────────────────────────────── + +fn applyMixRow(ch, row) { + if (row.gain !== null && row.gain !== undefined) { + ch.gain = clampNum(row.gain, 0, 2) + } + if (row.pan !== null && row.pan !== undefined) { + ch.pan = clampNum(row.pan, -1, 1) + } + if (row.mute !== null && row.mute !== undefined) { + ch.mute = row.mute === true + } + if (row.solo !== null && row.solo !== undefined) { + ch.solo = row.solo === true + } + if (row.eqLo !== null && row.eqLo !== undefined) { + ch.eqLo = clampNum(row.eqLo, -24, 24) + } + if (row.eqMid !== null && row.eqMid !== undefined) { + ch.eqMid = clampNum(row.eqMid, -24, 24) + } + if (row.eqHi !== null && row.eqHi !== undefined) { + ch.eqHi = clampNum(row.eqHi, -24, 24) + } +} + +fn applyFxRow(ch, row) { + let p = row.params + if (!p) { + return + } + if (p.reverbSend !== null && p.reverbSend !== undefined) { + ch.reverbSend = clampNum(p.reverbSend, 0, 1) + } + if (p.drive !== null && p.drive !== undefined) { + ch.drive = clampNum(p.drive, 0, 10) + } + if (p.lfoRate !== null && p.lfoRate !== undefined) { + ch.lfoRate = clampNum(p.lfoRate, 0, 40) + } + if (p.lfoDepth !== null && p.lfoDepth !== undefined) { + ch.lfoDepth = clampNum(p.lfoDepth, 0, 1) + } + if (p.cutoff !== null && p.cutoff !== undefined) { + ch.cutoff = clampNum(p.cutoff, 20, 20000) + } + if (p.res !== null && p.res !== undefined) { + ch.res = clampNum(p.res, 0, 30) + } + if (p.filterType !== null && p.filterType !== undefined) { + ch.filterType = String(p.filterType) + } +} + +fn applyVoiceRow(ch, row) { + let p = row.params + if (!p) { + return + } + if (p.octave !== null && p.octave !== undefined) { + ch.octave = Math.round(clampNum(p.octave, -4, 4)) + } + if (p.arp !== null && p.arp !== undefined) { + ch.arp = String(p.arp) + } + if (p.chord !== null && p.chord !== undefined) { + ch.chord = String(p.chord) + } + if (p.inversion !== null && p.inversion !== undefined) { + ch.inversion = String(p.inversion) + } + if (p.strum !== null && p.strum !== undefined) { + ch.strum = clampNum(p.strum, 0, 200) + } + // The grammar spells this `arprate`, one word, so the parser's camelCasing leaves it alone. + if (p.arprate !== null && p.arprate !== undefined) { + ch.arpRate = String(p.arprate) + } + if (p.arpRate !== null && p.arpRate !== undefined) { + ch.arpRate = String(p.arpRate) + } +} + +fn mergeParams(target, src) { + if (!src) { + return + } + let keys = Object.keys(src) + let i = 0 + while (i < keys.length) { + target[keys[i]] = src[keys[i]] + i = i + 1 + } +} + +/** + * `gen k v k v …` pairs positionally, so an odd token count silently shifts every pair by one and a + * VALUE becomes a key. The tell is a numeric key: `gen adsr attack 0 decay 0.1` yields + * `{adsr:"attack", 0:"decay", 0.1:"sustain"}`, which would otherwise be merged as three junk params + * and quietly ignored by the voice. Report it instead — a malformed `gen` line is worth a message. + */ +fn checkGenParams(params, lineNo, errors) { + if (!params) { + return false + } + let keys = Object.keys(params) + let i = 0 + while (i < keys.length) { + let n = Number(keys[i]) + if (n === n && keys[i].length > 0) { + errors.push({ + line: lineNo, + msg: "malformed `gen` line: expected `gen …` pairs, got a value where a key belongs (near `" + keys[i] + "`)" + }) + return false + } + i = i + 1 + } + return true +} + +// ── steps ───────────────────────────────────────────────────────────────────── + +fn makeSteps(on) { + let out = [] + let i = 0 + while (i < on.length) { + out.push({ on: on[i] === true, vel: 100, prob: 1, ratchet: 1, nudge: 0, lyric: null }) + i = i + 1 + } + return out +} + +/// A bare `steps` line resets the locks; the lanes that follow restore deviations. Values run out ⇒ +/// the rest of the pattern keeps the default, which is how a short lane line covers a long pattern. +fn applyStepLane(steps, row) { + if (!steps) { + return + } + let vals = row.values + if (!vals) { + return + } + let i = 0 + while (i < vals.length && i < steps.length) { + let v = vals[i] + if (v !== null && v !== undefined) { + if (row.lane === "vel") { + steps[i].vel = clampVel(v) + } else if (row.lane === "prob") { + steps[i].prob = clampProb(v) + } else if (row.lane === "ratchet") { + steps[i].ratchet = clampRatchet(v) + } else if (row.lane === "nudge") { + steps[i].nudge = clampNudge(v) + } else if (row.lane === "lyric") { + steps[i].lyric = String(v) + } + } + i = i + 1 + } +} + +// ── notes ───────────────────────────────────────────────────────────────────── + +/** + * Expand one `note` row to absolute positions. + * + * Without a bar selector the note keeps its absolute beat — including past `* N`. That is not a + * mistake in the source: `* N` is the declared pattern length, but tish-gba's bake computes the real + * span as `max(every note end, bars * 4)` (deckpack.rs), so a track that writes 8 bars of notes under + * a bare header is an 8-bar track. Rejecting those would have silenced 10 of the 62 songs in the + * tish-gba corpus. + * + * With a selector the grammar keeps `startBeat < 4` and the note repeats on every matching bar of the + * DECLARED length — so expansion happens here, at apply time, and the sequencer only ever sees + * absolute beats. + */ +fn expandNoteRow(row, declaredBars, errors) { + let out = [] + let dur = Number(row.durBeats) + if (dur !== dur || dur <= 0) { + dur = 0.25 + } + let base = { + pitch: Math.round(Number(row.midi)), + durBeats: dur, + vel: clampVel(row.vel), + prob: clampProb(row.prob), + ratchet: clampRatchet(row.ratchet), + nudge: clampNudge(row.nudge), + lyric: (row.lyric !== null && row.lyric !== undefined) ? String(row.lyric) : null + } + if (row.bar) { + let inBar = Number(row.startBeat) + if (inBar !== inBar || inBar < 0) { + inBar = 0 + } + if (inBar >= 4) { + errors.push({ line: row.lineNo, msg: "note with `bar` must start before beat 4 of its bar" }) + return out + } + let b = 0 + while (b < declaredBars) { + if (barSelectorMatches(row.bar, b)) { + let d = base.durBeats + // A bar-selected note belongs to its bar; don't let it bleed past the bar line. + if (inBar + d > 4) { + d = 4 - inBar + } + out.push({ + pitch: base.pitch, startBeat: b * 4 + inBar, durBeats: d, vel: base.vel, + prob: base.prob, ratchet: base.ratchet, nudge: base.nudge, lyric: base.lyric + }) + } + b = b + 1 + } + return out + } + let start = Number(row.startBeat) + if (start !== start || start < 0) { + start = 0 + } + out.push({ + pitch: base.pitch, startBeat: start, durBeats: base.durBeats, vel: base.vel, + prob: base.prob, ratchet: base.ratchet, nudge: base.nudge, lyric: base.lyric + }) + return out +} + +/** + * `wave <32 hex nibbles>` → 32 samples in -1..1. + * + * Each nibble is a 4-bit wave RAM level, so 0 → -1, 7.5 → 0, 15 → +1. This is the inverse of the + * bake's `wave_nibbles` (deckpack.rs), which writes `(phase * 15).round()` for a saw — so a table + * decoded here and a table baked to a ROM describe the same waveform. + */ +fn decodeWaveTable(hex) { + let s = String(hex) + if (s.length !== 32) { + return null + } + let out = [] + let i = 0 + while (i < 32) { + let n = parseInt(s.charAt(i), 16) + if (n !== n) { + return null + } + out.push(n / 7.5 - 1) + i = i + 1 + } + return out +} + +fn collectWaveTables(ast, errors) { + let tables = {} + let entries = ast.hostStatements ? ast.hostStatements.wave : null + if (!entries) { + return tables + } + let i = 0 + while (i < entries.length) { + let toks = entries[i].value + if (toks && toks.length >= 3) { + let name = String(toks[1]) + let table = decodeWaveTable(toks[2]) + if (table) { + tables[name] = table + } else { + errors.push({ line: entries[i].lineNo, msg: "wave `" + name + "` needs 32 hex digits" }) + } + } else { + errors.push({ line: entries[i].lineNo, msg: "wave needs a name and 32 hex digits" }) + } + i = i + 1 + } + return tables +} + +fn maxNoteEnd(notes) { + let m = 0 + let i = 0 + while (i < notes.length) { + let end = notes[i].startBeat + notes[i].durBeats + if (end > m) { + m = end + } + i = i + 1 + } + return m +} + +// ── track → channel ─────────────────────────────────────────────────────────── + +fn clampIntensity(v) { + return Math.round(clampNum(v, 0, 3)) +} + +fn applyTrack(track, index, errors, substitutions, waveTables) { + let generatorId = track.generatorId ? track.generatorId : "basicOsc" + if (!isPortedGeneratorId(generatorId)) { + let known = knownUnportedGeneratorIds() + let isKnown = false + let k = 0 + while (k < known.length) { + if (known[k] === generatorId) { + isKnown = true + } + k = k + 1 + } + substitutions.push({ + trackId: track.id, + generatorId: generatorId, + reason: isKnown ? "not ported to deck-player yet" : "unknown generator id" + }) + } + + let ch = emptyChannel(index, track.id, track.name, generatorId) + + // `* N` is the pattern LENGTH in bars; `* inf` / unset means a one-bar pattern that loops. + if (track.loopBars !== null && track.loopBars !== undefined && Math.floor(Number(track.loopBars)) >= 1) { + ch.patternBars = Math.floor(Number(track.loopBars)) + } + + // Generator defaults, then macro overrides from the track header, then `gen` body rows. + ch.generatorParams = defaultParamsForGeneratorId(generatorId) + mergeParams(ch.generatorParams, track.genParams) + + let parsed = parseTrackBody(track.body) + let i = 0 + while (i < parsed.errors.length) { + errors.push(parsed.errors[i]) + i = i + 1 + } + + let noteRows = [] + let r = 0 + while (r < parsed.rows.length) { + let row = parsed.rows[r] + let kind = row.kind + if (kind === "mix") { + applyMixRow(ch, row) + } else if (kind === "fx") { + applyFxRow(ch, row) + } else if (kind === "voice") { + applyVoiceRow(ch, row) + } else if (kind === "gen") { + if (checkGenParams(row.params, row.lineNo, errors)) { + mergeParams(ch.generatorParams, row.params) + } + } else if (kind === "adsr") { + if (row.a !== null && row.a !== undefined) { ch.generatorParams.attack = row.a } + if (row.d !== null && row.d !== undefined) { ch.generatorParams.decay = row.d } + if (row.s !== null && row.s !== undefined) { ch.generatorParams.sustain = row.s } + if (row.r !== null && row.r !== undefined) { ch.generatorParams.release = row.r } + } else if (kind === "steps") { + ch.steps = makeSteps(row.on) + } else if (kind === "stepLane") { + applyStepLane(ch.steps, row) + } else if (kind === "stepPitch") { + if (row.bar) { + if (!ch.stepPitchByBar) { + ch.stepPitchByBar = [] + let b = 0 + while (b < ch.patternBars) { + ch.stepPitchByBar.push(ch.stepPitch) + b = b + 1 + } + } + let b2 = 0 + while (b2 < ch.patternBars) { + if (barSelectorMatches(row.bar, b2)) { + ch.stepPitchByBar[b2] = Math.round(Number(row.midi)) + } + b2 = b2 + 1 + } + } else { + ch.stepPitch = Math.round(Number(row.midi)) + } + } else if (kind === "note") { + noteRows.push(row) + } else if (kind === "notesClear") { + noteRows = [] + } else if (kind === "transpose") { + ch.transpose = Math.round(Number(row.semitones)) + } else if (kind === "layer") { + ch.minIntensity = clampIntensity(row.level) + } else if (kind === "loops") { + // `loops inf` parses to cap null — an uncapped channel. + if (row.cap !== null && row.cap !== undefined) { + ch.loopCap = Math.max(1, Math.floor(Number(row.cap))) + } else { + ch.loopCap = null + } + } + r = r + 1 + } + + // Expansion needs the final declared bar count, so it runs after the whole body. + let declaredBars = ch.patternBars + let anyBarSelector = false + let n = 0 + while (n < noteRows.length) { + if (noteRows[n].bar) { + anyBarSelector = true + } + let expanded = expandNoteRow(noteRows[n], declaredBars, errors) + let e = 0 + while (e < expanded.length) { + ch.pianoNotes.push(expanded[e]) + e = e + 1 + } + n = n + 1 + } + + // `* N` with everything written inside bar 0 means "play this bar N times" — the bake replicates it + // (deckpack.rs: "Repeat pattern for * N bars if notes only cover bar 0"). Skipped when a bar + // selector is present, since those notes already said which bars they belong to. + let noteEnd = maxNoteEnd(ch.pianoNotes) + if (declaredBars > 1 && ch.pianoNotes.length > 0 && !anyBarSelector && noteEnd <= 4 + 1e-6) { + let base = ch.pianoNotes + let copies = [] + let b = 1 + while (b < declaredBars) { + let k = 0 + while (k < base.length) { + let src = base[k] + copies.push({ + pitch: src.pitch, startBeat: src.startBeat + b * 4, durBeats: src.durBeats, vel: src.vel, + prob: src.prob, ratchet: src.ratchet, nudge: src.nudge, lyric: src.lyric + }) + k = k + 1 + } + b = b + 1 + } + let c = 0 + while (c < copies.length) { + ch.pianoNotes.push(copies[c]) + c = c + 1 + } + noteEnd = maxNoteEnd(ch.pianoNotes) + } + + // The looping span is whichever is longer: the declared length, or what the notes actually need. + let neededBars = Math.ceil(noteEnd / 4) + if (neededBars > ch.patternBars) { + ch.patternBars = neededBars + } + + // "Integer shift applied to collected `note` pitches for that block" — notes only, per the grammar. + if (ch.transpose !== 0) { + let ti = 0 + while (ti < ch.pianoNotes.length) { + ch.pianoNotes[ti].pitch = ch.pianoNotes[ti].pitch + ch.transpose + ti = ti + 1 + } + } + + // Steps vs notes: notes win. "If a track block contains any `note` lines, steps for that channel + // are cleared." (docs/DECK_GRAMMAR.md) + if (ch.pianoNotes.length > 0) { + ch.steps = null + } + + // The bake also accepts the level as a track-header param (`… gen gameBoyDmg layer 2`). + let hdr = track.genParams + if (hdr) { + if (hdr.layer !== null && hdr.layer !== undefined) { + ch.minIntensity = clampIntensity(hdr.layer) + } else if (hdr.intensity !== null && hdr.intensity !== undefined) { + ch.minIntensity = clampIntensity(hdr.intensity) + } else if (hdr.minIntensity !== null && hdr.minIntensity !== undefined) { + ch.minIntensity = clampIntensity(hdr.minIntensity) + } + } + + // A `wave` table named by this channel's wave_shape wins over the built-in shapes — the same + // `named_waves.get(wave_shape).unwrap_or_else(built_in)` order the bake uses. + if (ch.generatorParams.waveShape !== null && ch.generatorParams.waveShape !== undefined) { + let named = waveTables[String(ch.generatorParams.waveShape)] + if (named) { + ch.waveTable = named + } + } + + if (track.genBlocks && track.genBlocks.length > 0) { + ch.generatorSpec = track.genBlocks[track.genBlocks.length - 1] + } + + return ch +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +/** + * Parse `.deck` source into a Song the player can sequence. Never throws: malformed lines land in + * `errors`, matching the parser's own error-tolerant contract. + * + * Not yet interpreted (collected on the AST, ignored here): session/clips, `song`/`follow` + * arrangement, `auto` automation, `master_mix`/`actor_mix`, `@` directives, `deck` routing. They are + * reported in `ignored` so a caller can say so rather than silently dropping them. + */ +export fn parseSong(source) { + bootDeckRegistries() + + let ast = parseProgram(source) + let errors = [] + let substitutions = [] + let i = 0 + while (i < ast.errors.length) { + errors.push(ast.errors[i]) + i = i + 1 + } + + let waveTables = collectWaveTables(ast, errors) + + let channels = [] + let removed = ast.removeTrackIds ? ast.removeTrackIds : [] + let t = 0 + while (t < ast.tracks.length) { + let track = ast.tracks[t] + let isRemoved = false + let ri = 0 + while (ri < removed.length) { + if (removed[ri] === track.id) { + isRemoved = true + } + ri = ri + 1 + } + if (!isRemoved) { + channels.push(applyTrack(track, channels.length, errors, substitutions, waveTables)) + } + t = t + 1 + } + + let ignored = [] + if (ast.clipBlocks && ast.clipBlocks.length > 0) { ignored.push("clips") } + if (ast.song) { ignored.push("song") } + if (ast.follow) { ignored.push("follow") } + if (ast.autos && ast.autos.length > 0) { ignored.push("auto") } + if (ast.masterMixTokens) { ignored.push("master_mix") } + if (ast.actorMixRows && ast.actorMixRows.length > 0) { ignored.push("actor_mix") } + if (ast.directives && ast.directives.length > 0) { ignored.push("directives") } + if (ast.sessionSlots && ast.sessionSlots.length > 0) { ignored.push("session") } + + // Any solo anywhere mutes every non-soloed channel — the usual mixer rule. + let anySolo = false + let s = 0 + while (s < channels.length) { + if (channels[s].solo) { + anySolo = true + } + s = s + 1 + } + + // Loop span: the longest pattern. Total length is finite only if every channel has a `loops` cap. + let loopBeats = 4 + let totalBeats = 0 + let allCapped = channels.length > 0 + let c = 0 + while (c < channels.length) { + let span = channels[c].patternBars * 4 + if (span > loopBeats) { + loopBeats = span + } + if (channels[c].loopCap === null) { + allCapped = false + } else { + let end = channels[c].loopCap * span + if (end > totalBeats) { + totalBeats = end + } + } + c = c + 1 + } + + return { + version: ast.tplVersion, + bpm: (ast.bpm !== null && ast.bpm !== undefined) ? clampNum(ast.bpm, 40, 300) : 120, + swing: (ast.swing !== null && ast.swing !== undefined) ? clampNum(ast.swing, 0, 1) : 0, + songSeed: (ast.songSeed !== null && ast.songSeed !== undefined) ? Math.floor(ast.songSeed) : 0, + scaleRoot: ast.scaleRoot, + scaleMode: ast.scaleMode, + channels: channels, + anySolo: anySolo, + waveTables: waveTables, + // Stem gating level, 0..3. Everything plays at 3; lowering it drops the higher layers, which is + // how the GBA host ducks a soundtrack down to its core parts. + intensity: 3, + loopBeats: loopBeats, + totalBeats: allCapped ? totalBeats : null, + substitutions: substitutions, + ignored: ignored, + errors: errors + } +} diff --git a/packages/player/src/song/DeckIds.tish b/packages/player/src/song/DeckIds.tish new file mode 100644 index 0000000..c4090f5 --- /dev/null +++ b/packages/player/src/song/DeckIds.tish @@ -0,0 +1,129 @@ +// Host registrations for @spacedevin/deck: generator id aliases, param key aliases, legacy body +// lines, highlight vocabulary. +// +// Boot order and the fact that this is the host's job come from docs/HOST.md. Ported from Deckard +// (tish-midi/src/generators/DeckIds.tish), minus the `patch` / `matrix_fm` gen_block dialects — those +// need graph parsers this package hasn't ported, and registering a dialect we can't play would turn +// "gen_block kept as raw lines" into a half-parsed graph nobody consumes. +// +// IMPORTANT: the deck package's registries are module-level singletons, shared by everything that +// imports it in this realm. Registration must be idempotent, and it must not contradict another +// host's — so this only registers aliases that are unambiguous across hosts. + +import { + registerGeneratorIdAliases, + registerParamKeyAliases, + registerBodyLineDialect, + registerTopLevelStatement, + registerHighlightKeywords, + paramKeyToCamel +} from '@spacedevin/deck' + +/// `noise` / `fm` / `osc` — legacy one-line generator params, same shape as `gen`: trailing +/// `key value` pairs, snake keys camelised. Numbers become numbers, everything else stays a string. +fn parseLegacyGenLine(head, toks) { + let params = {} + let i = 1 + while (i + 1 < toks.length) { + let key = paramKeyToCamel(toks[i]) + let val = toks[i + 1] + let num = Number(val) + params[key] = (num === num && val.length > 0) ? num : val + i = i + 2 + } + return { kind: "gen", params: params } +} + +/// `wave <32 hex nibbles>` — a named PSG wavetable. Kept as raw tokens; Apply validates. +fn parseWaveStatement(head, toks) { + return toks +} + +/// `layer|intensity|min_intensity <0..3>` — stem gating. A track sounds only at or above its level. +fn parseLayerLine(head, toks) { + return { kind: "layer", level: toks[1] } +} + +let registered = false + +/// Idempotent. Called by `parseSong`, so a consumer never has to think about boot order. +export fn bootDeckRegistries() { + if (registered) { + return + } + registered = true + + registerGeneratorIdAliases( + { + game_boy_dmg: "gameBoyDmg", + gameboydmg: "gameBoyDmg", + gameboy_dmg: "gameBoyDmg", + dmg: "gameBoyDmg", + gba_direct_sound: "gbaDirectSound", + gbadirectsound: "gbaDirectSound", + directsound: "gbaDirectSound", + basic_osc: "basicOsc", + basicosc: "basicOsc", + osc: "basicOsc", + noise_burst: "noiseBurst", + noiseburst: "noiseBurst", + fm: "fmTone", + fm_tone: "fmTone", + fmtone: "fmTone", + matrix_fm: "matrixFm", + matrixfm: "matrixFm", + drum: "drumSynth", + drum_synth: "drumSynth", + drumsynth: "drumSynth", + patch: "patch", + modular: "patch", + synth: "patch" + }, + { + gameBoyDmg: "gameBoyDmg", + gbaDirectSound: "gbaDirectSound", + basicOsc: "basic_osc", + noiseBurst: "noise_burst", + fmTone: "fm", + drumSynth: "drum", + matrixFm: "matrix_fm" + } + ) + + // Snake spellings the chip synths accept that aren't a plain snake→camel of a known key. + registerParamKeyAliases({ + wave_shape: "waveShape", + noise_mode: "noiseMode", + noise_shift: "noiseShift", + noise_ratio: "noiseRatio", + pitch_drop: "pitchDrop", + pitch_dec: "pitchDec", + vib_rate: "vibRate", + vib_amt: "vibAmt", + arp_rate: "arpRate", + arp_semis: "arpSemis", + env_mode: "envMode", + env_step: "envStep", + env_up: "envUp", + sweep_shift: "sweepShift", + sweep_period: "sweepPeriod", + sweep_down: "sweepDown" + }) + + // Legacy per-engine one-liners: `noise attack 0.01 decay 0.2`, `fm ratio 2 mod_index 3`, + // `osc waveform saw`. They are `gen`-shaped, so they register as a body dialect rather than being + // re-scanned in Apply. + registerBodyLineDialect(["noise", "fm", "osc"], parseLegacyGenLine) + + // The GBA host extensions. `conformance/profiles.json` declares them as the `gba` profile's + // extensions (topLevel: ["wave"], body: ["layer","intensity","min_intensity"]), and 10 of the 62 + // songs in the tish-gba corpus use them — without these, a sixth of the real corpus reports + // "unexpected top-level: wave" and its wave channels play a generic saw instead of their own table. + registerTopLevelStatement("wave", parseWaveStatement) + registerBodyLineDialect(["layer", "intensity", "min_intensity"], parseLayerLine) + + registerHighlightKeywords({ + top: ["wave"], + body: ["noise", "fm", "osc", "layer", "intensity", "min_intensity"] + }) +} diff --git a/packages/player/src/song/Scale.tish b/packages/player/src/song/Scale.tish new file mode 100644 index 0000000..17eb512 --- /dev/null +++ b/packages/player/src/song/Scale.tish @@ -0,0 +1,70 @@ +// Scale lock: snap pitches at play time. The vocabulary (`scaleIntervals`, root names, modes) is +// language, and lives in @spacedevin/deck — only the snapping policy is ours. +// +// Ported from Deckard (tish-midi/src/model/Scale.tish). + +import { scaleIntervals } from '@spacedevin/deck' + +/// Snap one MIDI pitch onto the scale (nearest degree; ties round down so the key's character holds). +/// A pitch already in the scale is returned unchanged; octaves are preserved. +export fn snapToScale(pitch, root, mode) { + if (root === null || root === undefined) { + return pitch + } + let ivs = scaleIntervals(mode) + if (ivs.length >= 12) { + return pitch + } + let p = Math.round(pitch) + let rel = ((p - root) % 12 + 12) % 12 + let i = 0 + while (i < ivs.length) { + if (ivs[i] === rel) { + return p + } + i = i + 1 + } + let best = ivs[0] + let bestDist = 99 + let j = 0 + while (j < ivs.length) { + let d0 = Math.abs(ivs[j] - rel) + let d1 = Math.abs(ivs[j] - rel + 12) + let d2 = Math.abs(ivs[j] - rel - 12) + let d = d0 < d1 ? d0 : d1 + if (d2 < d) { + d = d2 + } + if (d < bestDist) { + bestDist = d + best = ivs[j] + } + j = j + 1 + } + let snapped = p - rel + best + if (snapped - p > 6) { + snapped = snapped - 12 + } + if (p - snapped > 6) { + snapped = snapped + 12 + } + return snapped +} + +/// `scale off` parses to scaleRoot -1 / scaleMode "off"; both mean "don't snap". +export fn songScaleActive(song) { + if (!song) { + return false + } + if (song.scaleRoot === null || song.scaleRoot === undefined || song.scaleRoot < 0) { + return false + } + return (song.scaleMode !== null && song.scaleMode !== undefined) && song.scaleMode !== "" && song.scaleMode !== "off" +} + +export fn songSnapPitch(song, pitch) { + if (!songScaleActive(song)) { + return pitch + } + return snapToScale(pitch, song.scaleRoot, song.scaleMode) +} diff --git a/packages/player/test/fake-audio.mjs b/packages/player/test/fake-audio.mjs new file mode 100644 index 0000000..f8bf0f6 --- /dev/null +++ b/packages/player/test/fake-audio.mjs @@ -0,0 +1,151 @@ +// A recording stand-in for AudioContext. +// +// The voices are the part of this package most worth testing and the part hardest to test: their +// whole output is a schedule of AudioParam automation, which a real browser turns into sound and +// throws away. So instead of rendering audio and analysing it, we record every node created and every +// automation call, and assert on the schedule itself — the duty table actually written into the +// buffer, the exact playbackRate for a MIDI note, the shape of a pitch drop. +// +// Every node also records its `connect` targets, so a test can walk the graph. + +let nextId = 1 + +class FakeParam { + constructor (node, name, value = 0) { + this.node = node + this.name = name + this.value = value + this.calls = [] + } + setValueAtTime (v, t) { this.calls.push({ m: 'setValueAtTime', v, t }); return this } + linearRampToValueAtTime (v, t) { this.calls.push({ m: 'linearRampToValueAtTime', v, t }); return this } + exponentialRampToValueAtTime (v, t) { this.calls.push({ m: 'exponentialRampToValueAtTime', v, t }); return this } + setTargetAtTime (v, t, tc) { this.calls.push({ m: 'setTargetAtTime', v, t, tc }); return this } + cancelScheduledValues (t) { this.calls.push({ m: 'cancelScheduledValues', t }); return this } + /** Times at which this param was given `v`, in call order. */ + timesFor (v) { return this.calls.filter(c => c.v === v).map(c => c.t) } +} + +class FakeNode { + constructor (ctx, kind) { + this.ctx = ctx + this.kind = kind + this.id = nextId++ + this.outputs = [] + this.disconnected = false + ctx.nodes.push(this) + } + connect (dst) { this.outputs.push(dst); return dst } + disconnect () { this.disconnected = true } +} + +class FakeBufferSource extends FakeNode { + constructor (ctx) { + super(ctx, 'bufferSource') + this.buffer = null + this.loop = false + this.playbackRate = new FakeParam(this, 'playbackRate', 1) + this.started = null + this.stopped = null + } + start (t) { this.started = t } + stop (t) { this.stopped = t } +} + +class FakeOscillator extends FakeNode { + constructor (ctx) { + super(ctx, 'oscillator') + this.type = 'sine' + this.frequency = new FakeParam(this, 'frequency', 440) + this.detune = new FakeParam(this, 'detune', 0) + this.started = null + this.stopped = null + } + start (t) { this.started = t === undefined ? 0 : t } + stop (t) { this.stopped = t } +} + +class FakeGain extends FakeNode { + constructor (ctx) { super(ctx, 'gain'); this.gain = new FakeParam(this, 'gain', 1) } +} + +class FakeBiquad extends FakeNode { + constructor (ctx) { + super(ctx, 'biquad') + this.type = 'lowpass' + this.frequency = new FakeParam(this, 'frequency', 350) + this.Q = new FakeParam(this, 'Q', 1) + this.gain = new FakeParam(this, 'gain', 0) + } +} + +class FakeWaveShaper extends FakeNode { + constructor (ctx) { super(ctx, 'waveShaper'); this.curve = null; this.oversample = 'none' } +} + +class FakePanner extends FakeNode { + constructor (ctx) { super(ctx, 'panner'); this.pan = new FakeParam(this, 'pan', 0) } +} + +class FakeConvolver extends FakeNode { + constructor (ctx) { super(ctx, 'convolver'); this.buffer = null } +} + +class FakeCompressor extends FakeNode { + constructor (ctx) { + super(ctx, 'compressor') + this.threshold = new FakeParam(this, 'threshold', -24) + this.knee = new FakeParam(this, 'knee', 30) + this.ratio = new FakeParam(this, 'ratio', 12) + this.attack = new FakeParam(this, 'attack', 0.003) + this.release = new FakeParam(this, 'release', 0.25) + } +} + +class FakeAnalyser extends FakeNode { + constructor (ctx) { super(ctx, 'analyser'); this.fftSize = 2048 } +} + +class FakeBuffer { + constructor (channels, length, sampleRate) { + this.numberOfChannels = channels + this.length = length + this.sampleRate = sampleRate + this._data = [] + for (let i = 0; i < channels; i++) this._data.push(new Float32Array(length)) + } + getChannelData (i) { return this._data[i] } +} + +export class FakeAudioContext { + constructor (sampleRate = 44100) { + this.sampleRate = sampleRate + this.currentTime = 0 + this.state = 'running' + this.nodes = [] + this.destination = new FakeNode(this, 'destination') + // Deliberately absent: `audioWorklet`. The transport must fall back to its timer without one. + } + createBuffer (c, l, sr) { return new FakeBuffer(c, l, sr) } + createBufferSource () { return new FakeBufferSource(this) } + createOscillator () { return new FakeOscillator(this) } + createGain () { return new FakeGain(this) } + createBiquadFilter () { return new FakeBiquad(this) } + createWaveShaper () { return new FakeWaveShaper(this) } + createStereoPanner () { return new FakePanner(this) } + createConvolver () { return new FakeConvolver(this) } + createDynamicsCompressor () { return new FakeCompressor(this) } + createAnalyser () { return new FakeAnalyser(this) } + resume () { this.state = 'running'; return Promise.resolve() } + suspend () { this.state = 'suspended'; return Promise.resolve() } + close () { this.state = 'closed'; return Promise.resolve() } + + /** Nodes of one kind, in creation order. */ + of (kind) { return this.nodes.filter(n => n.kind === kind) } +} + +/** A minimal channel bus for driving a voice in isolation. */ +export function fakeBus (ctx) { + const input = ctx.createGain() + return { input, chId: 'test' } +} diff --git a/packages/player/test/schedule.mjs b/packages/player/test/schedule.mjs new file mode 100644 index 0000000..defd2b1 --- /dev/null +++ b/packages/player/test/schedule.mjs @@ -0,0 +1,299 @@ +// Pure timing math + the sequencer. No AudioContext involved — if this file needs one, something has +// leaked out of the pure layer. + +import assert from 'node:assert/strict' +import test from 'node:test' +import { + secondsPerStep, sixteenthSeconds, swingOffsetSec, stepsToScheduleInWindow, underrunsInBatch, + midiToHz, automationAt, parseSong, stepTriggers, songStepCount, normalizeDuty +} from '../dist/deck-player.js' + +const DOCS_EXAMPLE = `deck 1 +bpm 120 + +track Lead id lead gen gameBoyDmg + gen type pulse duty 50 vol 12 + note 60 0 0.5 v 100 + note 64 0.5 0.5 v 90 + note 67 1 1 v 100 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape saw vol 15 + note 36 0 2 v 110 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -12 + adsr a 0 d 0.1 s 0 r 0 + note 36 0 0.25 v 127 + note 36 1 0.25 v 127 +` + +test('secondsPerStep is a 16th note, clamped to 40..300 bpm', () => { + assert.equal(secondsPerStep(120), 0.125) + assert.equal(secondsPerStep(60), 0.25) + assert.equal(sixteenthSeconds(120), 0.125) + assert.equal(secondsPerStep(10), secondsPerStep(40), 'below 40 clamps up') + assert.equal(secondsPerStep(9999), secondsPerStep(300), 'above 300 clamps down') + assert.equal(secondsPerStep('nope'), 0.125, 'non-numeric falls back to 120') +}) + +test('swing delays odd steps only, and never past a half step', () => { + const sp = 0.125 + assert.equal(swingOffsetSec(0, sp, 1), 0) + assert.equal(swingOffsetSec(2, sp, 1), 0) + assert.equal(swingOffsetSec(1, sp, 1), sp * 0.5) + assert.equal(swingOffsetSec(3, sp, 0.5), sp * 0.25) + assert.equal(swingOffsetSec(1, sp, 0), 0, 'swing 0 is straight') + assert.equal(swingOffsetSec(1, sp, 5), sp * 0.5, 'swing clamps to 1') +}) + +test('lookahead batches every step inside the window and self-corrects', () => { + // A 0.1s window at 0.125s/step reaches exactly one step: 1.125 is past the 1.1 horizon. + const one = stepsToScheduleInWindow(0, 1.0, 1.0, 0.1, 0.125, 256) + assert.deepEqual(one.items.map(i => i.step), [0]) + assert.equal(one.nextStep, 1) + assert.equal(one.nextSec, 1.125) + + const two = stepsToScheduleInWindow(0, 1.0, 1.0, 0.2, 0.125, 256) + assert.deepEqual(two.items.map(i => i.step), [0, 1]) + assert.deepEqual(two.items.map(i => i.when), [1.0, 1.125]) + assert.equal(two.nextStep, 2) + assert.equal(two.nextSec, 1.25) + + // The cursor advances from the previous `when`, not from the wall clock, so drift can't accumulate. + let sec = 0 + for (let i = 0; i < 1000; i++) sec = stepsToScheduleInWindow(i, sec, sec, 0.0001, 0.125, 1).nextSec + assert.equal(sec, 125, '1000 steps of 0.125s land exactly on 125s') + + assert.equal(stepsToScheduleInWindow(0, 0, 0, 10, 0.125, 4).items.length, 4, 'maxBatch caps a burst') + assert.equal(stepsToScheduleInWindow(0, 0, 0, 1, 0, 256).items.length, 0, 'a zero step size cannot loop forever') +}) + +test('underruns count steps already in the past', () => { + const items = [{ when: 0.5 }, { when: 1.5 }, { when: 2.5 }] + assert.equal(underrunsInBatch(items, 2.0), 2) + assert.equal(underrunsInBatch(null, 2.0), 0) +}) + +test('midiToHz and linear automation', () => { + assert.equal(midiToHz(69), 440) + assert.equal(midiToHz(81), 880) + const pts = [{ beat: 0, value: 0 }, { beat: 4, value: 1 }] + assert.equal(automationAt(pts, 0), 0) + assert.equal(automationAt(pts, 2), 0.5) + assert.equal(automationAt(pts, 99), 1, 'past the last point holds') + assert.equal(automationAt([], 1), 0) +}) + +test('duty normalizes the way the GBA bake does', () => { + assert.equal(normalizeDuty('12_5'), '12_5') + assert.equal(normalizeDuty(12.5), '12_5', 'numeric 12.5 stringifies to "12.5", not "12_5"') + assert.equal(normalizeDuty('25'), '25') + assert.equal(normalizeDuty(50), '50') + assert.equal(normalizeDuty('75'), '75') + // Anything unrecognised is 50%, matching duty_code() — not silence. + assert.equal(normalizeDuty(12), '50') + assert.equal(normalizeDuty('wat'), '50') + assert.equal(normalizeDuty(null), '50') +}) + +test('the docs example sequences the notes it prints', () => { + const song = parseSong(DOCS_EXAMPLE) + assert.deepEqual(song.errors, [], 'the example must parse clean') + assert.deepEqual(song.substitutions, [], 'both its generators are ported') + assert.equal(song.bpm, 120) + assert.equal(song.channels.length, 3) + + // Step 0 (beat 0): lead 60, bass 36, kick 36. + const s0 = stepTriggers(song, 0) + assert.deepEqual(s0.map(t => t.pitch).sort((a, b) => a - b), [36, 36, 60]) + assert.deepEqual(s0.map(t => t.vel).sort((a, b) => a - b), [100, 110, 127]) + + // Step 2 (beat 0.5): lead 64 only. + assert.deepEqual(stepTriggers(song, 2).map(t => t.pitch), [64]) + // Step 4 (beat 1): lead 67 and the second kick. + assert.deepEqual(stepTriggers(song, 4).map(t => t.pitch).sort((a, b) => a - b), [36, 67]) + // Step 1 is empty. + assert.deepEqual(stepTriggers(song, 1), []) + + // Durations are beats converted at this tempo: 0.5 beats at 120bpm = 0.25s. + assert.equal(stepTriggers(song, 2)[0].durSec, 0.25) + + // A 1-bar pattern with no `loops` cap repeats forever. + assert.equal(song.totalBeats, null) + assert.equal(songStepCount(song), null) + assert.deepEqual(stepTriggers(song, 16).map(t => t.pitch).sort((a, b) => a - b), [36, 36, 60], + 'bar 2 repeats bar 1') +}) + +test('steps, locks and euclid', () => { + const song = parseSong(`deck 1 +bpm 120 +track Drum id d gen gameBoyDmg + steps x . . . x . . . x . . . x . . . + step_vel 120 . . . 60 . . . . . . . . . . . + step_pitch 40 +`) + assert.deepEqual(song.errors, []) + const ch = song.channels[0] + assert.equal(ch.steps.length, 16) + assert.deepEqual(ch.steps.map(s => s.on).map(Number), [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]) + assert.equal(ch.steps[0].vel, 120) + assert.equal(ch.steps[4].vel, 60) + assert.equal(ch.steps[8].vel, 100, 'unwritten steps keep the default of 100') + assert.equal(ch.stepPitch, 40) + + assert.deepEqual(stepTriggers(song, 0).map(t => t.pitch), [40]) + assert.deepEqual(stepTriggers(song, 0).map(t => t.vel), [120]) + assert.deepEqual(stepTriggers(song, 1), []) + assert.deepEqual(stepTriggers(song, 4).map(t => t.vel), [60]) + + const euclid = parseSong(`deck 1 +track E id e gen gameBoyDmg + steps euclid 5 16 +`) + assert.equal(euclid.channels[0].steps.length, 16) + assert.equal(euclid.channels[0].steps.filter(s => s.on).length, 5, 'euclid arrives already expanded') +}) + +test('notes win over steps, and transpose shifts only notes', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + steps x x x x x x x x x x x x x x x x + note 60 0 1 v 100 + transpose 12 +`) + assert.equal(song.channels[0].steps, null, 'a block with notes clears its steps') + assert.equal(song.channels[0].pianoNotes[0].pitch, 72) +}) + +test('bar selectors expand onto matching bars at apply time', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg * 4 + note 60 0 1 v 100 bar even + note 67 2 1 v 90 bar 1 +`) + assert.deepEqual(song.errors, []) + const notes = song.channels[0].pianoNotes + // `even` = bars 0 and 2 of a 4-bar pattern. + assert.deepEqual(notes.filter(n => n.pitch === 60).map(n => n.startBeat), [0, 8]) + // `bar 1` = the second bar only; beat 2 within it is absolute beat 6. + assert.deepEqual(notes.filter(n => n.pitch === 67).map(n => n.startBeat), [6]) + + assert.deepEqual(stepTriggers(song, 0).map(t => t.pitch), [60]) + assert.deepEqual(stepTriggers(song, 24).map(t => t.pitch), [67], 'step 24 = beat 6, the bar-1 note') + assert.deepEqual(stepTriggers(song, 32).map(t => t.pitch), [60], 'step 32 = beat 8, bar 2 of `even`') + assert.deepEqual(stepTriggers(song, 16), [], 'bar 1 beat 0 is silent — `even` skips it') + assert.deepEqual(stepTriggers(song, 4), [], 'bar 0 beat 1 is silent') +}) + +test('probability is deterministic across runs and seeds', () => { + const src = seed => `deck 1 +song_seed ${seed} +track T id t gen gameBoyDmg + steps x x x x x x x x x x x x x x x x + step_prob 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 0.5 +` + const fire = (song, n) => Array.from({ length: n }, (_, i) => stepTriggers(song, i).length > 0) + const a = fire(parseSong(src(1)), 32) + const b = fire(parseSong(src(1)), 32) + assert.deepEqual(a, b, 'the same seed must roll the same every time') + const c = fire(parseSong(src(2)), 32) + assert.notDeepEqual(a, c, 'a different seed must roll differently') + const on = a.filter(Boolean).length + assert.ok(on > 6 && on < 26, `p=0.5 over 32 steps should land near half, got ${on}`) +}) + +test('ratchet and nudge subdivide and shift a hit', () => { + const song = parseSong(`deck 1 +bpm 120 +track T id t gen gameBoyDmg + steps x . . . . . . . . . . . . . . . + step_ratchet 4 . . . . . . . . . . . . . . . + step_nudge 0.25 . . . . . . . . . . . . . . . +`) + const trigs = stepTriggers(song, 0) + assert.equal(trigs.length, 4, 'ratchet 4 = four sub-hits') + const stepSec = 0.125 + // Each sub-hit is offset by a quarter step, and all four carry the same 0.25-step nudge. + assert.deepEqual(trigs.map(t => Number((t.noteOffset - 0.25 * stepSec).toFixed(6))), + [0, stepSec / 4, stepSec / 2, (stepSec / 4) * 3]) +}) + +test('loops caps a channel, and an uncapped channel makes the song endless', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + steps x x x x x x x x x x x x x x x x + loops 2 +`) + assert.equal(song.channels[0].loopCap, 2) + assert.equal(song.totalBeats, 8, '2 loops of a 1-bar pattern = 8 beats') + assert.equal(songStepCount(song), 32) + assert.equal(stepTriggers(song, 31).length, 1, 'last step of the cap still plays') + assert.equal(stepTriggers(song, 32).length, 0, 'past the cap the channel is silent') + + const inf = parseSong(`deck 1 +track T id t gen gameBoyDmg + steps x x x x x x x x x x x x x x x x + loops inf +`) + assert.equal(inf.channels[0].loopCap, null) + assert.equal(inf.totalBeats, null) + assert.equal(stepTriggers(inf, 1000).length, 1) +}) + +test('mute and solo gate channels', () => { + const song = parseSong(`deck 1 +track A id a gen gameBoyDmg + steps x x x x x x x x x x x x x x x x +track B id b gen gameBoyDmg + mix gain 1 pan 0 mute 1 + steps x x x x x x x x x x x x x x x x +`) + assert.equal(stepTriggers(song, 0).length, 1, 'the muted channel is silent') + + const soloed = parseSong(`deck 1 +track A id a gen gameBoyDmg + steps x x x x x x x x x x x x x x x x +track B id b gen gameBoyDmg + mix gain 1 pan 0 solo 1 + steps x x x x x x x x x x x x x x x x +`) + assert.equal(soloed.anySolo, true) + const t = stepTriggers(soloed, 0) + assert.equal(t.length, 1) + assert.equal(t[0].busIndex, 1, 'only the soloed channel sounds') +}) + +test('scale lock snaps pitches', () => { + const song = parseSong(`deck 1 +scale C minor +track T id t gen gameBoyDmg + note 61 0 1 v 100 +`) + assert.equal(song.scaleRoot, 0) + assert.equal(song.scaleMode, 'minor') + // C# is not in C minor; it snaps to a scale degree. + const p = stepTriggers(song, 0)[0].pitch + assert.notEqual(p, 61) + assert.ok([60, 62].includes(p), `expected a neighbouring degree, got ${p}`) + + const off = parseSong(`deck 1 +scale off +track T id t gen gameBoyDmg + note 61 0 1 v 100 +`) + assert.equal(stepTriggers(off, 0)[0].pitch, 61, 'scale off leaves pitches alone') +}) + +test('voice chord expands one trigger into a stack', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + voice chord major octave 1 + note 60 0 1 v 100 +`) + const trigs = stepTriggers(song, 0) + assert.deepEqual(trigs.map(t => t.pitch), [60, 64, 67], 'a major triad') + // `octave` is applied by the dispatcher at play time, not baked into the trigger. + assert.equal(song.channels[0].octave, 1) +}) diff --git a/packages/player/test/song.mjs b/packages/player/test/song.mjs new file mode 100644 index 0000000..4de2d9b --- /dev/null +++ b/packages/player/test/song.mjs @@ -0,0 +1,260 @@ +// The Song IR: defaults, clamps, and the whole conformance corpus. +// +// The corpus at ../../conformance is the language package's cross-implementation PARSE contract. This +// package reads it as input — every case must survive the host layer without throwing and without +// inventing errors the parser didn't report. It must not add cases there: a new case forces every +// profile in profiles.json to declare its position. See AGENTS.md. + +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { parseSong, stepTriggers } from '../dist/deck-player.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const repo = join(here, '..', '..', '..') +const conformanceDir = join(repo, 'conformance') + +test('every conformance case survives the host layer', () => { + const cases = readdirSync(conformanceDir).filter(f => f.endsWith('.deck')).sort() + assert.ok(cases.length >= 12, `expected the corpus, found ${cases.length} files`) + for (const f of cases) { + const src = readFileSync(join(conformanceDir, f), 'utf8') + const song = parseSong(src) + assert.ok(song, `${f}: parseSong returned nothing`) + assert.ok(Array.isArray(song.channels), `${f}: no channels array`) + // 010 is the corpus's error case and is SUPPOSED to report errors; nothing else may. + if (f.startsWith('010')) { + assert.ok(song.errors.length > 0, `${f}: the error case should report errors`) + } else { + assert.deepEqual(song.errors, [], `${f}: clean input must not produce host errors`) + } + // Sequencing a few steps must not throw on any corpus input. + for (const step of [0, 1, 7, 16, 63]) { + assert.ok(Array.isArray(stepTriggers(song, step)), `${f}: step ${step} did not sequence`) + } + } +}) + +test('the GBA subset needs no substitutions', () => { + // 012-gba-subset pins the gameBoyDmg / gbaDirectSound subset — the tier this package plays for real. + const song = parseSong(readFileSync(join(conformanceDir, '012-gba-subset.deck'), 'utf8')) + assert.deepEqual(song.errors, []) + assert.deepEqual(song.substitutions, [], 'both chip generators must be ported') + assert.ok(song.channels.length >= 1) + for (const ch of song.channels) { + assert.ok(['gameBoyDmg', 'gbaDirectSound'].includes(ch.generatorId)) + } +}) + +test('the golden fixture exercises the whole language without host errors', () => { + const song = parseSong(readFileSync(join(repo, 'fixtures', 'golden.deck'), 'utf8')) + assert.deepEqual(song.errors, []) + assert.ok(song.channels.length > 0) + // It uses features this package does not sequence yet; they are reported, not silently dropped. + for (const k of ['clips', 'song', 'follow', 'auto']) { + assert.ok(song.ignored.includes(k), `golden.deck uses ${k}; it should be reported as ignored`) + } +}) + +test('defaults come from the grammar tables', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + steps x . . . . . . . . . . . . . . . +`) + const ch = song.channels[0] + assert.equal(ch.steps[0].vel, 100, 'step_vel default') + assert.equal(ch.steps[0].prob, 1, 'step_prob default') + assert.equal(ch.steps[0].ratchet, 1, 'step_ratchet default') + assert.equal(ch.steps[0].nudge, 0, 'step_nudge default') + assert.equal(ch.stepPitch, 36, 'step_pitch default') + assert.equal(ch.patternBars, 1, 'no `* N` is a one-bar pattern') + assert.equal(ch.gain, 1) + assert.equal(ch.pan, 0) + assert.equal(song.bpm, 120, 'no bpm line') + assert.equal(song.swing, 0) +}) + +test('out-of-range locks are clamped, not rejected', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + steps x x x x . . . . . . . . . . . . + step_vel 999 -5 100 100 . . . . . . . . . . . . + step_prob 5 -1 0.5 1 . . . . . . . . . . . . + step_ratchet 99 0 3 1 . . . . . . . . . . . . + step_nudge 9 -9 0.1 0 . . . . . . . . . . . . +`) + assert.deepEqual(song.errors, [], 'clamping is not an error') + const s = song.channels[0].steps + assert.deepEqual([s[0].vel, s[1].vel], [127, 1], 'velocity clamps to 1..127') + assert.deepEqual([s[0].prob, s[1].prob], [1, 0], 'probability clamps to 0..1') + assert.deepEqual([s[0].ratchet, s[1].ratchet], [8, 1], 'ratchet clamps to 1..8') + assert.deepEqual([s[0].nudge, s[1].nudge], [0.5, -0.5], 'nudge clamps to ±0.5') +}) + +test('mix and fx values are clamped to sane ranges', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg + mix gain 99 pan -9 eq_lo 99 eq_hi -99 + fx reverb_send 9 cutoff 99999 res 999 drive 99 + note 60 0 1 v 100 +`) + const ch = song.channels[0] + assert.equal(ch.gain, 2) + assert.equal(ch.pan, -1) + assert.equal(ch.eqLo, 24) + assert.equal(ch.eqHi, -24) + assert.equal(ch.reverbSend, 1) + assert.equal(ch.cutoff, 20000) + assert.equal(ch.res, 30) + assert.equal(ch.drive, 10) +}) + +test('notes define the pattern length when they run past `* N`', () => { + // The bake computes the span as max(every note end, bars*4), so a bare header with 8 bars of notes + // is an 8-bar track — not 31 rejected notes. 10 of the 62 songs in the tish-gba corpus rely on it. + const long = parseSong(`deck 1 +track T id t gen gameBoyDmg + note 60 0 1 v 100 + note 64 28 1 v 100 +`) + assert.deepEqual(long.errors, []) + assert.equal(long.channels[0].pianoNotes.length, 2) + assert.equal(long.channels[0].patternBars, 8, 'a note ending at beat 29 needs 8 bars') + assert.deepEqual(stepTriggers(long, 112).map(t => t.pitch), [64], 'beat 28 = step 112') + + const over = parseSong(`deck 1 +track T id t gen gameBoyDmg + note 60 0 99 v 100 +`) + assert.deepEqual(over.errors, []) + assert.equal(over.channels[0].pianoNotes[0].durBeats, 99, 'a long note keeps its duration') + assert.equal(over.channels[0].patternBars, 25) + + const badBar = parseSong(`deck 1 +track T id t gen gameBoyDmg * 4 + note 60 5 1 v 100 bar even +`) + assert.equal(badBar.errors.length, 1, 'a bar-selected note must still fit inside its bar') + assert.match(badBar.errors[0].msg, /must start before beat 4/) +}) + +test('`* N` replicates a bar-0-only pattern across N bars', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg * 4 + note 60 0 1 v 100 + note 64 2 1 v 90 +`) + assert.deepEqual(song.errors, []) + assert.equal(song.channels[0].patternBars, 4) + assert.equal(song.channels[0].pianoNotes.length, 8, '2 notes × 4 bars') + assert.deepEqual(stepTriggers(song, 0).map(t => t.pitch), [60]) + assert.deepEqual(stepTriggers(song, 16).map(t => t.pitch), [60], 'bar 1 repeats bar 0') + assert.deepEqual(stepTriggers(song, 56).map(t => t.pitch), [64], 'bar 3, beat 2') + + // Not replicated when the notes already span more than one bar. + const spans = parseSong(`deck 1 +track T id t gen gameBoyDmg * 4 + note 60 0 1 v 100 + note 64 5 1 v 90 +`) + assert.equal(spans.channels[0].pianoNotes.length, 2) +}) + +test('the GBA host extensions: named wave tables and layer gating', () => { + const song = parseSong(`deck 1 +bpm 120 +wave round 8acdefffffedba988765421000001235 +track Bass id bass gen gameBoyDmg + gen type wave wave_shape round + note 36 0 1 v 100 +track Extra id extra gen gameBoyDmg + layer 3 + gen type pulse + note 60 0 1 v 100 +`) + assert.deepEqual(song.errors, [], '`wave` and `layer` must parse, not report "unexpected top-level"') + assert.deepEqual(Object.keys(song.waveTables), ['round']) + assert.equal(song.waveTables.round.length, 32) + // Nibble 8 is just above centre, nibble 0 is the floor, f is the ceiling. + assert.ok(Math.abs(song.waveTables.round[0] - (8 / 7.5 - 1)) < 1e-9) + assert.equal(Math.min(...song.waveTables.round), -1) + assert.equal(Math.max(...song.waveTables.round), 1) + + assert.ok(song.channels[0].waveTable, 'wave_shape round binds the named table') + assert.equal(song.channels[1].minIntensity, 3) + + // Everything plays at the default intensity of 3. + assert.equal(song.intensity, 3) + assert.equal(stepTriggers(song, 0).length, 2) + song.intensity = 2 + assert.deepEqual(stepTriggers(song, 0).map(t => t.pitch), [36], 'the layer-3 stem drops out below 3') + + const bad = parseSong('deck 1\nwave short abc\n') + assert.equal(bad.errors.length, 1) + assert.match(bad.errors[0].msg, /32 hex digits/) +}) + +test('a malformed gen line is reported instead of merging junk params', () => { + // `gen adsr attack 0 decay 0.1` pairs positionally, so `0` becomes a key. Silently merging that + // leaves the envelope at its defaults and the author with no idea why. + const song = parseSong(`deck 1 +track T id t gen gbaDirectSound + gen adsr attack 0 decay 0.1 sustain 0 + note 36 0 0.25 v 127 +`) + assert.equal(song.errors.length, 1) + assert.match(song.errors[0].msg, /malformed `gen` line/) + assert.equal(song.errors[0].line, 3) + assert.equal(song.channels[0].generatorParams.decay, 2, 'the junk was not merged') + + // The correct spelling is the `adsr` body head. + const ok = parseSong(`deck 1 +track T id t gen gbaDirectSound + adsr a 0 d 0.1 s 0 r 0 + note 36 0 0.25 v 127 +`) + assert.deepEqual(ok.errors, []) + assert.equal(ok.channels[0].generatorParams.decay, 0.1) + assert.equal(ok.channels[0].generatorParams.sustain, 0) +}) + +test('generator params layer: defaults, then header macro overrides, then gen rows', () => { + const song = parseSong(`deck 1 +track T id t gen gameBoyDmg vol 3 + gen duty 25 + note 60 0 1 v 100 +`) + const p = song.channels[0].generatorParams + assert.equal(p.vol, 3, 'the track header overrides the default') + assert.equal(p.duty, 25, 'a gen row is applied on top') + assert.equal(p.envMode, 'step', 'untouched defaults survive') +}) + +test('remove_track drops a channel', () => { + const song = parseSong(`deck 1 +track A id a gen gameBoyDmg + note 60 0 1 v 100 +track B id b gen gameBoyDmg + note 64 0 1 v 100 +remove_track a +`) + assert.equal(song.channels.length, 1) + assert.equal(song.channels[0].id, 'b') +}) + +test('parse errors from the language package are passed through, not swallowed', () => { + const song = parseSong('deck 1\nzzz nonsense\n') + assert.ok(song.errors.length > 0) + assert.equal(song.errors[0].line, 2) +}) + +test('an empty or comment-only program is a valid, silent song', () => { + for (const src of ['', '# just a comment\n', 'deck 1\n']) { + const song = parseSong(src) + assert.deepEqual(song.channels, []) + assert.deepEqual(stepTriggers(song, 0), []) + assert.equal(song.bpm, 120) + } +}) diff --git a/packages/player/test/voices.mjs b/packages/player/test/voices.mjs new file mode 100644 index 0000000..31fc25d --- /dev/null +++ b/packages/player/test/voices.mjs @@ -0,0 +1,256 @@ +// What the chip voices actually schedule. +// +// These assert the hardware details that make the port worth doing — the duty table written into the +// buffer, the 4-bit wave quantization, the real LFSR, the 8-bit DAC staircase. A rewrite that merely +// "sounds chiptune-ish" passes none of them. + +import assert from 'node:assert/strict' +import test from 'node:test' +import { FakeAudioContext, fakeBus } from './fake-audio.mjs' +import { + dispatchPlayNote, midiToHz, parseSong, buildAudioGraph, playStep, + createDeckPlayer, livePlayerCount +} from '../dist/deck-player.js' + +const chan = (generatorId, generatorParams) => ({ + id: 'test', generatorId, generatorParams, octave: 0, + chord: null, arp: null, arpRate: null, inversion: null, strum: null +}) + +/** Play one note into a fresh fake context and hand back everything it built. */ +function play (generatorId, params, { midi = 60, vel = 127, dur = 0.5, t = 1.0 } = {}) { + const ctx = new FakeAudioContext(44100) + const bus = fakeBus(ctx) + const voice = dispatchPlayNote(ctx, bus, t, midi, vel, dur, chan(generatorId, params), 0) + return { ctx, bus, voice, src: ctx.of('bufferSource')[0] } +} + +test('gameBoyDmg pulse writes the real duty tables', () => { + const table = d => Array.from(play('gameBoyDmg', { type: 'pulse', duty: d }).src.buffer.getChannelData(0)) + assert.deepEqual(table('50'), [-1, 1, 1, 1, 1, -1, -1, -1]) + assert.deepEqual(table('25'), [-1, 1, 1, -1, -1, -1, -1, -1]) + assert.deepEqual(table('12_5'), [-1, 1, -1, -1, -1, -1, -1, -1]) + assert.deepEqual(table('75'), [1, -1, -1, 1, 1, 1, 1, 1]) + // The regression that matters: an unrecognised duty must fall back to 50%, not to a constant -1 + // (a silent channel), which is what an exact-match-only lookup produces. + assert.deepEqual(table(12.5), [-1, 1, -1, -1, -1, -1, -1, -1], 'numeric 12.5 is the 12.5% table') + assert.deepEqual(table('bogus'), [-1, 1, 1, 1, 1, -1, -1, -1], 'unknown duty is 50%, never silence') + assert.ok(table('bogus').some(v => v > 0), 'and is definitely not a DC buffer') +}) + +test('gameBoyDmg pitches by playbackRate over an 8-sample buffer', () => { + const { ctx, src } = play('gameBoyDmg', { type: 'pulse', duty: '50' }, { midi: 60 }) + const expected = (midiToHz(60) * 8) / ctx.sampleRate + const first = src.playbackRate.calls.find(c => c.m === 'setValueAtTime') + assert.ok(Math.abs(first.v - expected) < 1e-12, `rate ${first.v} != ${expected}`) + assert.equal(src.loop, true, 'the waveform table has to loop to be a tone') + assert.equal(src.started, 1.0) +}) + +test('gameBoyDmg clamps to the hardware frequency floors', () => { + // A pulse below 64 Hz and a wave below 32 Hz are pinned by the hardware, so a very low note does + // not keep dropping in pitch. + const pulse = play('gameBoyDmg', { type: 'pulse', duty: '50' }, { midi: 12 }) + const pulseRate = pulse.src.playbackRate.calls[0].v + assert.ok(Math.abs(pulseRate - (64 * 8) / 44100) < 1e-12, 'pulse floors at 64 Hz') + + const wave = play('gameBoyDmg', { type: 'wave', waveShape: 'saw' }, { midi: 12 }) + const waveRate = wave.src.playbackRate.calls[0].v + assert.ok(Math.abs(waveRate - (32 * 32) / 44100) < 1e-12, 'wave floors at 32 Hz') +}) + +test('gameBoyDmg wave RAM is 32 samples quantized to 4 bits', () => { + const data = Array.from(play('gameBoyDmg', { type: 'wave', waveShape: 'saw' }).src.buffer.getChannelData(0)) + assert.equal(data.length, 32) + // Every sample must sit on a 1/7.5 lattice — that is what 16 levels means. + for (const v of data) { + assert.ok(Math.abs(v * 7.5 - Math.round(v * 7.5)) < 1e-6, `${v} is not on the 4-bit lattice`) + } + assert.ok(new Set(data.map(v => v.toFixed(4))).size <= 16, 'at most 16 distinct levels') + assert.ok(data[0] < data[16], 'a saw rises across the table') +}) + +test('gameBoyDmg noise is a real LFSR, long and short', () => { + const long = play('gameBoyDmg', { type: 'noise', noiseMode: 'long' }).src.buffer + const short = play('gameBoyDmg', { type: 'noise', noiseMode: 'short' }).src.buffer + assert.equal(long.length, 32767, '15-bit LFSR period') + assert.equal(short.length, 127, '7-bit LFSR period') + for (const v of long.getChannelData(0).slice(0, 500)) { + assert.ok(v === 1 || v === -1, 'the LFSR output is a two-level square, not noise-shaped') + } + // A 7-bit register really does repeat every 127 samples. + const s = short.getChannelData(0) + assert.equal(s[0], s[0], 'sanity') + const period = play('gameBoyDmg', { type: 'noise', noiseMode: 'short' }).src.buffer.getChannelData(0) + assert.equal(period.length, 127) +}) + +test('gameBoyDmg volume scales the envelope peak', () => { + const peak = (vol, vel) => { + const { ctx } = play('gameBoyDmg', { type: 'pulse', duty: '50', vol }, { vel }) + // The last gain node built by the voice carries the amplitude envelope. + const gains = ctx.of('gain') + const env = gains[gains.length - 1] + return Math.max(...env.gain.calls.map(c => c.v)) + } + assert.ok(Math.abs(peak(15, 127) - 0.8) < 1e-9, 'full vol + full velocity = the 0.8 ceiling') + assert.ok(Math.abs(peak(15, 64) - (64 / 127) * 0.8) < 1e-9, 'velocity scales linearly') + assert.ok(Math.abs(peak(8, 127) - (8 / 15) * 0.8) < 1e-9, 'vol is a 4-bit fraction') + assert.equal(peak(0, 127), 0, 'vol 0 is silent') +}) + +test('gbaDirectSound builds a 32-sample table and an 8-bit DAC', () => { + const { ctx, src } = play('gbaDirectSound', { waveform: 'triangle', bitcrush: true }) + assert.equal(src.buffer.length, 32) + + const shapers = ctx.of('waveShaper') + assert.equal(shapers.length, 1, 'bitcrush adds exactly one shaper') + const curve = shapers[0].curve + assert.equal(curve.length, 8192) + assert.equal(shapers[0].oversample, 'none', 'interpolation would smooth away the staircase') + // A 256-step staircase: 8192 points, 256 distinct output levels. + assert.equal(new Set(Array.from(curve).map(v => v.toFixed(6))).size, 256) + assert.ok(Math.abs(curve[0] + 1) < 1e-6 && Math.abs(curve[8191] - 1) < 1e-6, 'the curve spans -1..1') + + // ...feeding the mixer's Nyquist roll-off. + const lp = ctx.of('biquad').find(b => b.frequency.value === 16000) + assert.ok(lp, 'the 16 kHz mixing lowpass is present') + assert.equal(lp.type, 'lowpass') + + const clean = play('gbaDirectSound', { waveform: 'triangle', bitcrush: false }) + assert.equal(clean.ctx.of('waveShaper').length, 0, 'bitcrush false bypasses the DAC') + const sixteen = play('gbaDirectSound', { waveform: 'triangle', bitcrush: '16bit' }) + assert.equal(sixteen.ctx.of('waveShaper').length, 0, '`bitcrush 16bit` reads as no crush') +}) + +test('gbaDirectSound waveform tables', () => { + const table = w => Array.from(play('gbaDirectSound', { waveform: w }).src.buffer.getChannelData(0)) + const tri = table('triangle') + assert.ok(Math.abs(tri[0] + 1) < 1e-9, 'triangle starts at -1') + assert.ok(Math.abs(tri[16] - 1) < 1e-9, 'and peaks halfway') + const saw = table('sawtooth') + assert.ok(saw[0] < saw[16] && saw[16] < saw[31], 'saw rises monotonically') + assert.deepEqual(table('saw'), saw, '`saw` is accepted alongside `sawtooth`') + const sq = table('square') + assert.ok(sq.every(v => v === 1 || v === -1), 'square is two-level') +}) + +test('gbaDirectSound pitch_drop bends down then recovers', () => { + const { ctx, src } = play('gbaDirectSound', { waveform: 'triangle', pitchDrop: -12 }, { midi: 60 }) + const rate0 = midiToHz(60) / (ctx.sampleRate / 32) + const set = src.playbackRate.calls.find(c => c.m === 'setValueAtTime') + const target = src.playbackRate.calls.find(c => c.m === 'setTargetAtTime') + assert.ok(set && target, 'a drop is a jump plus an exponential recovery') + assert.ok(Math.abs(set.v - rate0 / 2) < 1e-9, '-12 semitones starts an octave down') + assert.ok(Math.abs(target.v - rate0) < 1e-9, 'and settles back on the note') + assert.equal(set.t, target.t, 'both land at the note start') +}) + +test('an unported generator falls back to a plain oscillator', () => { + const { ctx } = play('matrixFm', { waveform: 'saw' }) + assert.equal(ctx.of('bufferSource').length, 0) + assert.equal(ctx.of('oscillator').length, 1, 'basicOsc stands in') + assert.equal(ctx.of('oscillator')[0].type, 'sawtooth') + // The substitution is reported rather than silent — see Registry.knownUnportedGeneratorIds. + const song = parseSong('deck 1\ntrack T id t gen matrix_fm\n note 60 0 1 v 100\n') + assert.equal(song.substitutions.length, 1) + assert.equal(song.substitutions[0].generatorId, 'matrixFm') + assert.match(song.substitutions[0].reason, /not ported/) +}) + +test('voice octave shifts the pitch the generator receives', () => { + const ctx = new FakeAudioContext(44100) + const bus = fakeBus(ctx) + const ch = chan('basicOsc', {}) + ch.octave = 1 + dispatchPlayNote(ctx, bus, 0, 60, 100, 0.5, ch, 0) + assert.ok(Math.abs(ctx.of('oscillator')[0].frequency.value - midiToHz(72)) < 1e-9) +}) + +test('the full graph wires channels through to the destination', () => { + const ctx = new FakeAudioContext(44100) + const song = parseSong(`deck 1 +bpm 120 +track Lead id lead gen gameBoyDmg + mix gain 0.5 pan -1 eq_lo 3 + fx reverb_send 0.4 cutoff 800 res 2 + note 60 0 0.5 v 100 +`) + const graph = buildAudioGraph(ctx, song, {}) + assert.equal(graph.buses.length, 1) + const bus = graph.buses[0] + assert.equal(bus.gainNode.gain.value, 0.5) + assert.equal(bus.panNode.pan.value, -1) + assert.equal(bus.eqLo.gain.value, 3) + assert.equal(bus.filterNode.frequency.value, 800) + assert.equal(bus.filterNode.Q.value, 2) + assert.equal(bus.reverbSend.gain.value, 0.4) + + // drive → filter → eqLo → eqMid → eqHi → gain → pan → masterSum + assert.ok(bus.input.outputs.includes(bus.filterNode)) + assert.ok(bus.filterNode.outputs.includes(bus.eqLo)) + assert.ok(bus.eqHi.outputs.includes(bus.gainNode)) + assert.ok(bus.gainNode.outputs.includes(bus.panNode)) + assert.ok(bus.panNode.outputs.includes(graph.masterSum)) + // masterSum → masterGain → compressor → limiter → analyser → destination + assert.ok(graph.masterSum.outputs.includes(graph.masterGain)) + assert.ok(graph.masterGain.outputs.includes(graph.glue)) + assert.ok(graph.glue.outputs.includes(graph.limiter)) + assert.ok(graph.limiter.outputs.includes(graph.analyser)) + assert.ok(graph.analyser.outputs.includes(ctx.destination)) + + // A step actually reaches the bus input. + const before = ctx.nodes.length + const voices = playStep(ctx, song, graph, 0, 1.0) + assert.equal(voices.length, 1) + assert.ok(ctx.nodes.length > before) + assert.ok(voices[0].stopTime > 1.0, 'a voice reports when it can be retired') + assert.ok(voices[0].disconnects.length > 0, 'and what to retire') +}) + +test('starting one player stops the others', () => { + // Two chip songs at once is noise. A page documenting the language has several players on it, so + // the registry lives in the library rather than in each consumer. + const song = 'deck 1\ntrack T id t gen gameBoyDmg\n note 60 0 1 v 100\n' + const before = livePlayerCount() + + const a = createDeckPlayer({ context: new FakeAudioContext() }) + const b = createDeckPlayer({ context: new FakeAudioContext() }) + const solo = createDeckPlayer({ context: new FakeAudioContext(), exclusive: false }) + a.load(song); b.load(song); solo.load(song) + assert.equal(livePlayerCount(), before + 2, 'exclusive:false players are not registered') + + a.play() + assert.equal(a.isPlaying(), true) + + b.play() + assert.equal(a.isPlaying(), false, 'starting b stopped a') + assert.equal(b.isPlaying(), true) + + // An opted-out player neither stops others nor is stopped by them. + solo.play() + assert.equal(b.isPlaying(), true, 'a non-exclusive player leaves others alone') + assert.equal(solo.isPlaying(), true) + a.play() + assert.equal(solo.isPlaying(), true, 'and is not stopped by an exclusive one') + assert.equal(b.isPlaying(), false) + + // A paused player is stopped too, so its UI resets instead of sitting on an invisible pause. + a.pause() + assert.equal(a.isPaused(), true) + b.play() + assert.equal(a.isPaused(), false, 'the paused player was stopped, not left paused') + + a.dispose(); b.dispose(); solo.dispose() + assert.equal(livePlayerCount(), before, 'dispose unregisters') +}) + +test('a default channel filter is transparent, not a dulling insert', () => { + const ctx = new FakeAudioContext(44100) + const song = parseSong('deck 1\ntrack T id t gen gameBoyDmg\n note 60 0 1 v 100\n') + const graph = buildAudioGraph(ctx, song, {}) + assert.equal(graph.buses[0].filterNode.frequency.value, 20000) + assert.equal(graph.buses[0].gainNode.gain.value, 1) + assert.equal(graph.buses[0].reverbSend.gain.value, 0) + assert.equal(graph.buses[0].lfo, null, 'no LFO node when there is no LFO to run') +}) diff --git a/packages/player/types/element.d.ts b/packages/player/types/element.d.ts new file mode 100644 index 0000000..a92a1bc --- /dev/null +++ b/packages/player/types/element.d.ts @@ -0,0 +1,19 @@ +import type { DeckSong } from './index' + +/** + * `` — source from the element's text content, or from a `src` attribute pointing at a + * `.deck` file. Importing this module defines the element as a side effect. + */ +export declare class DeckPlayerElement extends HTMLElement { + /** The parsed Song, once loaded. Read it to surface `errors` or `substitutions`. */ + readonly song: DeckSong | null +} + +/** Define ``. Idempotent, and a no-op without `customElements`. */ +export function defineDeckPlayerElement (): void + +declare global { + interface HTMLElementTagNameMap { + 'deck-player': DeckPlayerElement + } +} diff --git a/packages/player/types/index.d.ts b/packages/player/types/index.d.ts new file mode 100644 index 0000000..ebe212e --- /dev/null +++ b/packages/player/types/index.d.ts @@ -0,0 +1,278 @@ +// Hand-written: the source is Tish, and `tish build --target js` emits plain ESM with no +// declarations. @spacedevin/deck ships none either, so these are the only types in the chain. + +/** One `note` after bar-selector expansion — always at an absolute beat. */ +export interface DeckNote { + pitch: number + startBeat: number + durBeats: number + /** 1..127 */ + vel: number + /** 0..1 */ + prob: number + /** 1..8 */ + ratchet: number + /** -0.5..0.5, a fraction of a 16th step */ + nudge: number + lyric: string | null +} + +/** One 16th step, with its locks resolved to concrete values. */ +export interface DeckStep { + on: boolean + vel: number + prob: number + ratchet: number + nudge: number + lyric: string | null +} + +export interface DeckChannel { + index: number + id: string + name: string + generatorId: string + generatorParams: Record + /** The last `gen_block` on the track, unparsed (`{ generatorId, lines }`). */ + generatorSpec: { generatorId: string, lines: string[] } | null + /** 32 samples in -1..1 from a named `wave` table, when this channel's `wave_shape` names one. */ + waveTable: number[] | null + /** From `layer` / `intensity`: this channel is silent below this level. */ + minIntensity: number + /** Looping span in bars: the longer of `* N` and what the notes need. */ + patternBars: number + /** `loops N`; null means it never stops. */ + loopCap: number | null + pianoNotes: DeckNote[] + /** null when the channel has notes — notes win over steps. */ + steps: DeckStep[] | null + stepPitch: number + stepPitchByBar: number[] | null + transpose: number + gain: number + pan: number + mute: boolean + solo: boolean + eqLo: number + eqMid: number + eqHi: number + reverbSend: number + drive: number + lfoRate: number + lfoDepth: number + cutoff: number + res: number + filterType: BiquadFilterType + octave: number + arp: string | null + arpRate: string | null + chord: string | null + inversion: string | null + strum: number | null +} + +export interface DeckError { + line: number + msg: string +} + +/** A generator the source asked for that this package substituted `basicOsc` for. */ +export interface DeckSubstitution { + trackId: string + generatorId: string + reason: string +} + +export interface DeckSong { + version: number + bpm: number + swing: number + songSeed: number + scaleRoot: number | null + scaleMode: string | null + channels: DeckChannel[] + anySolo: boolean + waveTables: Record + /** Stem gating, 0..3. Everything plays at 3. */ + intensity: number + /** The longest channel pattern, in beats. */ + loopBeats: number + /** Total length in beats, or null when a channel loops forever. */ + totalBeats: number | null + substitutions: DeckSubstitution[] + /** Language features present in the source that this package does not sequence yet. */ + ignored: string[] + errors: DeckError[] +} + +export interface DeckTrigger { + busIndex: number + pitch: number + vel: number + durSec: number + beat: number + /** Seconds after the step, from nudge / ratchet / chord strum. */ + noteOffset: number + lyric: string | null +} + +export interface DeckPlayerOptions { + /** + * Reuse an existing context. Without one the player uses a single page-shared AudioContext, + * created on the first `play()` — a context is a page-level resource, and Safari has historically + * refused past about four. + */ + context?: AudioContext + /** Master gain, default 0.9. */ + gain?: number + /** false to skip the convolution reverb bus. */ + reverb?: boolean + /** Keep looping past the song's end. Default true. */ + loop?: boolean + /** + * Starting this player stops any other one that is playing. Default true — two chip songs at once + * is noise. Pass false to layer players deliberately. + */ + exclusive?: boolean +} + +export type DeckPlayerEvent = 'step' | 'stop' | 'load' | 'error' + +export interface DeckPlayer { + /** Parse and prepare. Returns the Song so you can surface `errors` / `substitutions`. */ + load (source: string): DeckSong | null + play (): void + pause (): void + stop (): void + seek (beat: number): void + isPlaying (): boolean + isPaused (): boolean + /** The beat currently being heard, not the one being scheduled. */ + position (): number + /** Length in beats, or null when the song loops forever. */ + duration (): number | null + song (): DeckSong | null + /** Stem gating, 0..3. */ + setIntensity (level: number): void + intensity (): number + analyser (): AnalyserNode | null + context (): AudioContext | null + on (event: DeckPlayerEvent, handler: (payload: any) => void): void + dispose (): void +} + +export function createDeckPlayer (opts?: DeckPlayerOptions): DeckPlayer + +/** How many exclusive players are currently registered. For tests and debugging. */ +export function livePlayerCount (): number + +export interface RenderOptions { + sampleRate?: number + /** Length to render. Defaults to the song's own length, else one loop. */ + beats?: number + gain?: number + reverb?: boolean +} + +/** Render offline through the same graph and sequencer as live playback. */ +export function renderDeckToBuffer (source: string, opts?: RenderOptions): Promise + +/** `.deck` source → Song IR. No AudioContext needed. */ +export function parseSong (source: string): DeckSong + +/** Register this package's host vocabulary with @spacedevin/deck. Idempotent; `parseSong` calls it. */ +export function bootDeckRegistries (): void + +/** Which notes sound at a 16th step. Pure. */ +export function stepTriggers (song: DeckSong, globalStep: number): DeckTrigger[] +export function songStepCount (song: DeckSong): number | null + +export function secondsPerStep (bpm: number): number +export function sixteenthSeconds (bpm: number): number +export function swingOffsetSec (step: number, secPerStep: number, swing: number): number +export function stepsToScheduleInWindow ( + startStep: number, nextStepSec: number, ctxNow: number, + lookahead: number, secPerStep: number, maxBatch?: number +): { items: Array<{ step: number, when: number }>, nextStep: number, nextSec: number } +export function underrunsInBatch (items: Array<{ when: number }> | null, ctxNow: number): number +export function midiToHz (midi: number): number +export function automationAt (points: Array<{ beat: number, value: number }>, beat: number): number + +export function portedGeneratorIds (): string[] +export function knownUnportedGeneratorIds (): string[] +export function isPortedGeneratorId (id: string): boolean +export function defaultParamsForGeneratorId (id: string): Record +export function normalizeDuty (raw: unknown): '12_5' | '25' | '50' | '75' + +export function snapToScale (pitch: number, root: number | null, mode: string | null): number +export function songSnapPitch (song: DeckSong, pitch: number): number +export function expandTriggerNotes ( + ch: DeckChannel, basePitch: number, durSec: number, bpm: number +): Array<{ pitch: number, offset: number, dur: number }> + +/** What a voice built, so the caller can retire it. */ +export interface DeckVoice { + stopTime: number + disconnects: AudioNode[] +} + +export interface DeckChannelBus { + chId: string + input: AudioNode + gainNode: GainNode + panNode: StereoPannerNode + filterNode: BiquadFilterNode + eqLo: BiquadFilterNode + eqMid: BiquadFilterNode + eqHi: BiquadFilterNode + reverbSend: GainNode + driveNode: WaveShaperNode + lfo: OscillatorNode | null + lfoGain: GainNode | null +} + +export interface DeckAudioGraph { + buses: DeckChannelBus[] + masterSum: GainNode + masterGain: GainNode + glue: DynamicsCompressorNode + limiter: WaveShaperNode + analyser: AnalyserNode | null + convolver: ConvolverNode | null + reverbIn: GainNode | null +} + +export function buildAudioGraph ( + ctx: BaseAudioContext, song: DeckSong, opts?: { gain?: number, reverb?: boolean } +): DeckAudioGraph +export function disposeAudioGraph (graph: DeckAudioGraph): void +export function playStep ( + ctx: BaseAudioContext, song: DeckSong, graph: DeckAudioGraph, globalStep: number, tWhen: number +): DeckVoice[] +export function dispatchPlayNote ( + ctx: BaseAudioContext, bus: DeckChannelBus, t: number, midi: number, vel: number, + durSec: number, ch: DeckChannel, bendSemis: number +): DeckVoice | null + +export interface DeckTransportConfig { + secPerStep (): number + swing? (): number + /** Return true to stop after this step. */ + onStep (step: number, when: number): boolean + onStopped? (): void +} + +export interface DeckTransport { + start (startStep?: number): void + pause (): void + resume (): void + stop (): void + isActive (): boolean + isPaused (): boolean + getStep (): number + underruns (): number + audibleStep (): number + dispose (): void +} + +export function createTransport (ctx: AudioContext, cfg: DeckTransportConfig): DeckTransport diff --git a/site/build.mjs b/site/build.mjs new file mode 100644 index 0000000..b124d71 --- /dev/null +++ b/site/build.mjs @@ -0,0 +1,475 @@ +// Static site generator for the deck docs → GitHub Pages. +// +// The contract, borrowed from tishlang-web: MARKDOWN DICTATES THE DOCS. Adding a page means adding a +// `.md` file inside one of the SECTIONS below — no registration, no route, no nav entry. Title comes +// from frontmatter if present, otherwise the first `#` heading, otherwise the filename. +// +// The one rule specific to this repo: markdown is read WHERE IT ALREADY LIVES. `docs/*.md` are +// package exports (`@spacedevin/deck/grammar` and friends) and ship in the npm tarball, so copying +// them into a `content/` tree would fork the canonical text — the exact drift the conformance corpus +// exists to prevent. The site is a view over the repo, never a second copy of it. +// +// npm run site # build to site/out +// npm run site:serve # build with base "/" and serve on :4321 +// +// Base path defaults to /deck/ because Pages serves a project site under the repo name. + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { marked } from 'marked' +import { highlight } from './highlight.mjs' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.join(HERE, '..') +const OUT = path.join(HERE, 'out') +const REPO = 'https://github.com/spacedevin/deck' +const BASE = process.env.SITE_BASE ?? '/deck/' + +const SITE = { + title: 'deck', + tagline: 'Streamable .deck patch language for Tish hosts', +} +// Absolute origin, for llms.txt — that file is read out of context, so relative links are useless. +const SITE_URL = (process.env.SITE_URL ?? 'https://spacedevin.github.io/deck').replace(/\/$/, '') + +/** + * Where markdown comes from. `dir` is globbed for `**\/*.md`, so a new file appears on its own. + * `order` front-loads a few filenames; anything unlisted sorts alphabetically after them, which is + * what keeps "just add an .md" true. + */ +const SECTIONS = [ + { label: 'Introduction', dir: '.', slug: '', only: ['README.md'] }, + { + label: 'Language', + dir: 'docs', + slug: 'docs', + order: ['DECK_GRAMMAR.md', 'EXAMPLES.md', 'DECK_EXTENSION.md', 'HOST.md'], + // Every untagged fence in these files is `.deck` — the grammar reference shows the language it + // documents, and the host-facing snippets are all tagged `tish`. Declared rather than guessed. + defaultLang: 'deck', + }, + { + label: 'Playback', + dir: 'packages/player', + slug: 'player', + order: ['README.md', 'AGENTS.md'], + ignore: ['node_modules', 'dist', 'test', 'types', 'element'], + // Both of these open with the same `# @spacedevin/deck-player`, which would put two identical + // entries in the sidebar. Overridden here rather than with frontmatter because both files are + // PUBLISHED — npm renders a README verbatim, and a `---` fence after a text line is a setext H2, + // so frontmatter would show up as a stray rule and a giant "description:" heading on npmjs.com. + titles: { 'README.md': 'Playback', 'AGENTS.md': 'Player scope' }, + descriptions: { + 'README.md': 'Web Audio playback for .deck — chip-tune synths, a transport, and an element.', + 'AGENTS.md': 'What belongs in the playback package, and what has to stay upstream.', + }, + }, +] + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const slugify = (s) => + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +const escapeHtml = (s) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +const stripTags = (s) => s.replace(/<[^>]*>/g, '') + +/** + * A title is plain text, not markdown. The docs' own H1s carry code spans and links + * (`# Hosting \`@spacedevin/deck\``), which would otherwise show their backticks in the sidebar and + * the tag. + */ +const cleanTitle = (s) => + s + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → their text + .replace(/`([^`]*)`/g, '$1') // code spans + .replace(/(\*\*|__|\*|_)/g, '') // emphasis + .trim() + +/** Frontmatter without a YAML dependency: only `key: value` scalars, which is all the docs use. */ +function frontmatter (raw) { + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw) + if (!m) return { data: {}, body: raw } + const data = {} + for (const line of m[1].split(/\r?\n/)) { + const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) + if (kv) data[kv[1]] = kv[2].replace(/^["']|["']$/g, '') + } + return { data, body: raw.slice(m[0].length) } +} + +function walk (dir, ignore = []) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...walk(full, ignore)) + else if (entry.name.endsWith('.md')) out.push(full) + } + return out +} + +// ── collect pages ───────────────────────────────────────────────────────────── + +function collect () { + const pages = [] + for (const section of SECTIONS) { + const dir = path.join(ROOT, section.dir) + let files = section.only + ? section.only.map((f) => path.join(dir, f)).filter((f) => fs.existsSync(f)) + : walk(dir, section.ignore ?? []) + + const rank = (f) => { + const i = (section.order ?? []).indexOf(path.basename(f)) + return i === -1 ? Number.MAX_SAFE_INTEGER : i + } + files = files.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)) + + for (const file of files) { + const rel = path.relative(ROOT, file).split(path.sep).join('/') + const raw = fs.readFileSync(file, 'utf8') + const { data, body } = frontmatter(raw) + + const base = path.basename(file, '.md') + const isIndex = base === 'README' + const leaf = isIndex ? '' : slugify(base) + const route = [section.slug, leaf].filter(Boolean).join('/') + + const name = path.basename(file) + const h1 = /^#\s+(.+)$/m.exec(body) + const title = data.title ?? section.titles?.[name] ?? cleanTitle(h1 ? h1[1] : base) + const description = data.description ?? section.descriptions?.[name] ?? '' + + pages.push({ + section: section.label, + sourcePath: rel, + route, + url: BASE + (route ? route + '/' : ''), + outFile: path.join(OUT, route, 'index.html'), + title, + description, + defaultLang: section.defaultLang ?? '', + body, + }) + } + } + return pages +} + +// ── markdown → html ─────────────────────────────────────────────────────────── + +/** Repo-relative path → site URL, for rewriting the links already in these files. */ +function buildLinkMap (pages) { + const map = new Map() + for (const p of pages) { + map.set(p.sourcePath, p.url) + // A README also answers to its directory, so `[player](packages/player/)` resolves. + if (p.sourcePath.endsWith('README.md')) { + const dir = p.sourcePath.replace(/README\.md$/, '').replace(/\/$/, '') + map.set(dir === '' ? '.' : dir, p.url) + map.set(dir === '' ? './' : dir + '/', p.url) + } + } + return map +} + +function rewriteLink (href, fromDir, linkMap) { + if (!href || /^(https?:|mailto:|#|\/\/)/.test(href)) return href + + const [pathPart, hash = ''] = href.split('#') + if (!pathPart) return href + + // Resolve against the source file's directory, then normalise to repo-relative. + const abs = path.resolve(path.join(ROOT, fromDir), pathPart) + let rel = path.relative(ROOT, abs).split(path.sep).join('/') + if (rel === '') rel = '.' + + const hit = linkMap.get(rel) ?? linkMap.get(rel.replace(/\/$/, '')) + if (hit) return hit + (hash ? '#' + hash : '') + + // Not a page on this site — point at the file on GitHub rather than 404. + if (rel.startsWith('..')) return REPO + const kind = pathPart.endsWith('/') || fs.existsSync(abs) && fs.statSync(abs).isDirectory() ? 'tree' : 'blob' + return `${REPO}/${kind}/main/${rel.replace(/\/$/, '')}${hash ? '#' + hash : ''}` +} + +/** + * Does this block actually play? Decided by PARSING it, not by whether someone tagged the fence. + * + * The docs are full of grammar notation — `note <midi> <startBeat> <durBeats>` — which colours fine + * and plays not at all, and equally full of complete songs sitting in untagged fences. A tag is the + * wrong signal for either. Running the real parser is exact: notation produces errors or no channels, + * a song produces channels that sound. + */ +function isPlayableDeck (text, player) { + if (!player) return false + try { + const song = player.parseSong(text) + if (song.errors.length > 0 || song.channels.length === 0) return false + // A track with neither notes nor an on-step is silent — no point offering Play. + return song.channels.some( + (c) => (c.pianoNotes && c.pianoNotes.length > 0) || (c.steps && c.steps.some((s) => s.on)) + ) + } catch { + return false + } +} + +/** Render one page's markdown. Returns { html, toc }. */ +function render (page, linkMap, deck, player) { + const toc = [] + const fromDir = path.dirname(page.sourcePath) + + const renderer = { + heading ({ tokens, depth }) { + const text = this.parser.parseInline(tokens) + const id = slugify(stripTags(text)) + if (depth === 2 || depth === 3) toc.push({ id, text: stripTags(text), depth }) + return `<h${depth} id="${id}">${text}</h${depth}>\n` + }, + link ({ href, title, tokens }) { + const text = this.parser.parseInline(tokens) + const url = rewriteLink(href, fromDir, linkMap) + const external = /^https?:/.test(url) + const attrs = [ + `href="${escapeHtml(url)}"`, + title ? `title="${escapeHtml(title)}"` : '', + external ? 'rel="noreferrer"' : '', + ].filter(Boolean).join(' ') + return `<a ${attrs}>${text}</a>` + }, + code ({ text, lang }) { + const language = lang || page.defaultLang || '' + const cls = language ? ` class="language-${escapeHtml(language)}"` : '' + const block = `<pre><code${cls}>${highlight(text, language, deck)}</code></pre>` + // A block that parses into something audible gets a play button. The element receives the raw + // source and the <pre> the marked-up copy, so highlighting can never change what is played. + if (language === 'deck' && isPlayableDeck(text, player)) { + return `<div class="deck-block">${block}<deck-player>${escapeHtml(text)}</deck-player></div>\n` + } + return block + '\n' + }, + } + + marked.use({ renderer, gfm: true, breaks: false, async: false }) + // The first H1 is already the page title in the header, so drop it from the body. The leading + // `\s*` matters: stripping frontmatter leaves a newline ahead of the heading, and an anchored + // pattern without it silently leaves a duplicate title on every page that has frontmatter. + const body = page.body.replace(/^\s*#\s+.+\n+/, '') + return { html: marked.parse(body), toc } +} + +// ── page shell ──────────────────────────────────────────────────────────────── + +function sidebar (pages, current) { + let out = '' + for (const section of SECTIONS) { + const items = pages.filter((p) => p.section === section.label) + if (!items.length) continue + out += `<div class="nav-section"><span class="nav-label">${escapeHtml(section.label)}</span><ul>` + for (const p of items) { + const active = p.route === current.route ? ' class="active"' : '' + out += `<li><a${active} href="${p.url}">${escapeHtml(p.title)}</a></li>` + } + out += '</ul></div>' + } + return out +} + +function shell (page, pages, html, toc, prev, next, playerAvailable) { + const tocHtml = toc.length + ? `<nav class="toc" aria-label="On this page"><span class="nav-label">On this page</span><ul>${toc + .map((t) => `<li class="d${t.depth}"><a href="#${t.id}">${escapeHtml(t.text)}</a></li>`) + .join('')}</ul></nav>` + : '' + + const pager = (prev || next) + ? `<nav class="pager">${ + prev ? `<a class="prev" href="${prev.url}"><span>Previous</span>${escapeHtml(prev.title)}</a>` : '<span></span>' + }${ + next ? `<a class="next" href="${next.url}"><span>Next</span>${escapeHtml(next.title)}</a>` : '<span></span>' + }</nav>` + : '' + + const desc = page.description || SITE.tagline + + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${escapeHtml(page.title)}${page.route ? ' · ' + SITE.title : ''} + + + + +${playerAvailable ? `` : ''} + + + +
+ ${SITE.title} + +
+
+ +
+

${escapeHtml(page.title)}

+ ${page.description ? `

${escapeHtml(page.description)}

` : ''} + ${html} + ${pager} + +
+ ${tocHtml} +
+ + +` +} + +// ── llms.txt ────────────────────────────────────────────────────────────────── + +/** First real paragraph of a page, for a one-line summary when there's no `description`. */ +function firstParagraph (body) { + const text = body + .replace(/^\s*#\s+.+\n+/, '') // drop the H1 + .replace(/```[\s\S]*?```/g, '') // and any code + for (const chunk of text.split(/\n\s*\n/)) { + const line = chunk.trim().replace(/\s+/g, ' ') + if (line && !line.startsWith('#') && !line.startsWith('|') && !line.startsWith('-')) { + return cleanTitle(line).replace(/\.$/, '') + } + } + return '' +} + +/** + * llms.txt + llms-full.txt, per llmstxt.org and the shape Deckard uses: a curated index of links with + * one-line summaries, plus one file with every page's markdown in it. + * + * Both are generated from the same pages as the HTML, so they cannot fall behind the docs — which is + * the failure mode a hand-written llms.txt always eventually has. + */ +function writeLlms (pages) { + const abs = (p) => SITE_URL + p.url.replace(BASE, '/') + + const index = [ + `# ${SITE.title}`, + '', + `> ${SITE.tagline}. Two packages: \`@spacedevin/deck\` parses the language, ` + + '`@spacedevin/deck-player` plays it through Web Audio.', + '', + 'Line-oriented, streamable patch text. Times are in quarter-note beats; one bar = 4 beats = 16', + 'sixteenth steps. The parser is deliberately parse-only — absent optionals stay `null` and no', + 'clamping happens, because defaults and ranges are host policy. One Tish source emits three', + 'targets (Tish, JS, Rust) checked against a single conformance corpus.', + '', + '## Docs', + '', + ] + for (const p of pages) { + const summary = p.description || firstParagraph(p.body) + index.push(`- [${p.title}](${abs(p)})${summary ? ': ' + summary : ''}`) + } + index.push( + '', + '## Optional', + '', + `- [Full docs as one file](${SITE_URL}/llms-full.txt): every page above, concatenated`, + `- [Source repository](${REPO}): the markdown on this site lives here`, + `- [Conformance corpus](${REPO}/tree/main/conformance): the cross-implementation contract — ` + + 'the same `.deck` inputs and expected parses, run by the JS build, the Rust crate and any host', + `- [Golden fixture](${REPO}/blob/main/fixtures/golden.deck): one file exercising the whole language`, + '' + ) + fs.writeFileSync(path.join(OUT, 'llms.txt'), index.join('\n')) + + const full = [`# ${SITE.title} — full documentation`, '', `> Generated from the docs. Site: ${SITE_URL}`, ''] + for (const p of pages) { + full.push('', '---', '', `# ${p.title}`, '', `Source: ${p.sourcePath}`, '', p.body.replace(/^\s*#\s+.+\n+/, '').trim(), '') + } + fs.writeFileSync(path.join(OUT, 'llms-full.txt'), full.join('\n')) +} + +// ── build ───────────────────────────────────────────────────────────────────── + +async function build () { + const playerBundle = path.join(ROOT, 'packages/player/dist/deck-player.js') + const playerElement = path.join(ROOT, 'packages/player/element/deck-player-element.js') + const playerAvailable = fs.existsSync(playerBundle) && fs.existsSync(playerElement) + + // The language build supplies `.deck` highlighting from its own keyword tables — see + // site/highlight.mjs. Missing (a clean checkout that hasn't run `npm run build`) just means deck + // blocks render as plain text; the site still builds. + const deckBundle = path.join(ROOT, 'dist/deck.js') + let deck = null + let player = null + if (playerAvailable) { + player = await import(pathToFileURL(playerBundle).href) + } + if (fs.existsSync(deckBundle)) { + deck = await import(pathToFileURL(deckBundle).href) + // Pick up the player's host vocabulary (`wave`, `layer`, …) so those colour too. This is the + // registry mechanism the docs describe, used for real. + if (player) { + player.bootDeckRegistries() + } + // `id` is a structural marker in a track header (`track id gen `) but is + // not in the package's INLINE_KEYS, so `lead` coloured while `id` next to it did not. Added + // through the documented host hook rather than by special-casing it in the tokenizer. + deck.registerHighlightKeywords({ inline: ['id'] }) + } + + fs.rmSync(OUT, { recursive: true, force: true }) + fs.mkdirSync(OUT, { recursive: true }) + + const pages = collect() + const linkMap = buildLinkMap(pages) + + pages.forEach((page, i) => { + const { html, toc } = render(page, linkMap, deck, player) + const out = shell(page, pages, html, toc, pages[i - 1], pages[i + 1], playerAvailable) + fs.mkdirSync(path.dirname(page.outFile), { recursive: true }) + fs.writeFileSync(page.outFile, out) + }) + + fs.copyFileSync(path.join(HERE, 'style.css'), path.join(OUT, 'style.css')) + writeLlms(pages) + + if (playerAvailable) { + // The element imports `../dist/deck-player.js`; flatten that to a sibling for the static site. + fs.copyFileSync(playerBundle, path.join(OUT, 'deck-player.js')) + const el = fs.readFileSync(playerElement, 'utf8').replace('../dist/deck-player.js', './deck-player.js') + fs.writeFileSync(path.join(OUT, 'deck-player-element.js'), el) + } + + // Pages runs Jekyll over the artifact otherwise, which eats files starting with an underscore. + fs.writeFileSync(path.join(OUT, '.nojekyll'), '') + + console.log(`site: ${pages.length} pages → ${path.relative(ROOT, OUT)} (base ${BASE})`) + for (const p of pages) console.log(` ${p.url.padEnd(24)} ${p.sourcePath}`) + if (!playerAvailable) { + console.log(' note: player bundle missing — deck blocks built without play buttons') + console.log(' run `npm run build -w @spacedevin/deck-player` first') + } + if (!deck) { + console.log(' note: dist/deck.js missing — deck blocks built without highlighting') + console.log(' run `npm run build` first') + } +} + +await build() diff --git a/site/highlight.mjs b/site/highlight.mjs new file mode 100644 index 0000000..274b88e --- /dev/null +++ b/site/highlight.mjs @@ -0,0 +1,172 @@ +// Syntax highlighting for the docs site. +// +// Two highlighters, for two different reasons: +// +// `deck` — uses THIS REPO'S OWN highlight exports (`isKeyword`, `isInlineKeyword`, `isStepToken`, +// `classifyLine`). The package ships those precisely so a host can colour `.deck`, so the +// deck docs colouring itself with them is the honest test of that API. It also means the +// vocabulary can never drift from the language: add a keyword to src/deckfile/Highlight +// .tish and the site picks it up, no second keyword list to forget. +// +// everything else — highlight.js, core build with only the languages the docs actually use, so we +// don't pull 190 grammars into the build for four of them. +// +// `tish` is registered by hand: it is JS-shaped but `fn`, `///` doc comments and type annotations are +// its own, and no library ships a grammar for it. + +import hljs from 'highlight.js/lib/core' +import bash from 'highlight.js/lib/languages/bash' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import rust from 'highlight.js/lib/languages/rust' +import xml from 'highlight.js/lib/languages/xml' +import yaml from 'highlight.js/lib/languages/yaml' + +hljs.registerLanguage('bash', bash) +hljs.registerLanguage('javascript', javascript) +hljs.registerLanguage('json', json) +hljs.registerLanguage('rust', rust) +hljs.registerLanguage('xml', xml) +hljs.registerLanguage('yaml', yaml) + +hljs.registerLanguage('tish', (hl) => ({ + name: 'Tish', + aliases: ['tsh'], + keywords: { + keyword: + 'import export from as let const fn return if else while for in of break continue ' + + 'new typeof instanceof delete void throw try catch finally async await yield', + literal: 'true false null undefined', + built_in: + 'Math JSON Object Array String Number Boolean Promise Set Map Date RegExp console ' + + 'Float32Array Float64Array Int8Array Int16Array Int32Array Uint8Array Uint16Array Uint32Array', + }, + contains: [ + hl.QUOTE_STRING_MODE, + hl.APOS_STRING_MODE, + { className: 'string', begin: '`', end: '`', contains: [hl.BACKSLASH_ESCAPE] }, + // `///` doc comments are the house style; C_LINE_COMMENT_MODE covers them since they start `//`. + hl.C_LINE_COMMENT_MODE, + hl.C_BLOCK_COMMENT_MODE, + hl.C_NUMBER_MODE, + { className: 'title.function', begin: /(?<=\bfn\s+)[A-Za-z_$][\w$]*/ }, + { className: 'title.class', begin: /\b[A-Z][\w$]*(?=\s*[({])/ }, + ], +})) + +const ALIASES = { js: 'javascript', ts: 'javascript', sh: 'bash', shell: 'bash', html: 'xml', yml: 'yaml' } + +export const escapeHtml = (s) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +// ── deck ────────────────────────────────────────────────────────────────────── + +/** + * Colour one `.deck` line, preserving its exact whitespace — indentation is syntax here (2+ spaces + * nests a body under the open block), so a tokenizer that drops it would change what the reader sees. + * + * `deck` is passed in rather than imported so the caller controls which build is used, and so this + * module still loads when `dist/` has not been built yet. + */ +function highlightDeckLine (line, deck) { + // A `#` only starts a comment at column 0 or after whitespace — otherwise it is data, which is + // exactly what makes `scale F# minor` and a track named `C#maj` work. + const cut = /(^|\s)#/.exec(line) + const code = cut ? line.slice(0, cut.index + cut[1].length) : line + const comment = cut ? line.slice(cut.index + cut[1].length) : '' + + const info = deck.classifyLine(code) + const stepIdx = new Set(info.stepIndices ?? []) + + // Walk runs of whitespace and non-whitespace so the original spacing survives verbatim. Collected + // first because classifying a `key value` pair needs to see the NEXT token. + const parts = [] + let tokenIndex = 0 + for (const m of code.matchAll(/(\s+)|(\S+)/g)) { + if (m[1]) parts.push({ space: m[1] }) + else parts.push({ tok: m[2], index: tokenIndex++ }) + } + const tokens = parts.filter((p) => p.tok) + const isPlaceholder = (t) => !!t && (/^<.+>$/.test(t) || t === '…') + + // `gen …` — these heads take alternating key/value pairs, per the grammar. That + // is exact, where guessing from the next token's shape is not: in `gen type pulse duty 25`, `type` + // introduces a word and `duty` a number, but both are keys. + const head = tokens[0]?.tok + const isKvLine = head === 'gen' || head === 'fx' || head === 'voice' || head === 'adsr' || head === 'mix' + // A generator id follows `gen` only in a TRACK HEADER (`track X id y gen gameBoyDmg`). In a body + // `gen …` line the same word introduces a param key instead. + const headerGen = head === 'track' || head === 'clip' + + let out = '' + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part.space) { out += part.space; continue } + + const tok = part.tok + const at = tokens.indexOf(part) + const next = tokens[at + 1]?.tok + const prev = tokens[at - 1]?.tok + const first = part.index === 0 + let cls = null + + if (isPlaceholder(tok)) { + // Grammar notation — `note ` — a slot, not a value. + cls = 'dk-ph' + } else if (/^[[\]|…]+$/.test(tok)) { + cls = 'dk-ph' + } else if (first && deck.isKeyword(tok)) { + cls = 'dk-kw' + } else if (info.kind === 'steps' && stepIdx.has(part.index)) { + cls = 'dk-step' + } else if (deck.isInlineKeyword(tok)) { + cls = 'dk-inline' + } else if (deck.isNumberToken(tok)) { + cls = 'dk-num' + } else if (((prev === 'gen' && headerGen) || prev === 'id' || prev === 'gen_block') && /^[A-Za-z_]/.test(tok)) { + cls = 'dk-id' + } else if (!first && deck.isKeyword(tok)) { + // `end gen_block`, and inline heads that follow another keyword. + cls = 'dk-kw' + } else if (isKvLine && at >= 1) { + cls = at % 2 === 1 ? 'dk-param' : 'dk-val' + } else if ( + /^[a-z][a-z0-9_]*$/.test(tok) && + // `next` is undefined at the end of a line, and isNumberToken reads `.length` unguarded. + next !== undefined && + (deck.isNumberToken(next) || isPlaceholder(next)) + ) { + // Grammar notation outside a kv head — `cutoff `. Shape is the only tell there, since the + // parser keeps no list of param keys (they are generator-specific and host-registered). + cls = 'dk-param' + } + + out += cls ? `${escapeHtml(tok)}` : escapeHtml(tok) + } + + if (comment) out += `${escapeHtml(comment)}` + return out +} + +export function highlightDeck (src, deck) { + return src.split('\n').map((line) => highlightDeckLine(line, deck)).join('\n') +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +/** Highlighted HTML for a fenced block. Falls back to escaped plain text for anything unknown. */ +export function highlight (code, lang, deck) { + const name = ALIASES[lang] ?? lang + + if (name === 'deck') { + return deck ? highlightDeck(code, deck) : escapeHtml(code) + } + if (name && hljs.getLanguage(name)) { + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value + } catch { + return escapeHtml(code) + } + } + return escapeHtml(code) +} diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..3f21e41 --- /dev/null +++ b/site/style.css @@ -0,0 +1,297 @@ +/* deck docs. One stylesheet, no build step — the generator copies it verbatim. */ + +:root { + --bg: #0b0d10; + --panel: #0f1216; + --line: #1e242c; + --fg: #d7dde5; + --dim: #8b95a3; + --accent: #00e5a0; + --accent-dim: rgba(0, 229, 160, 0.14); + --code-bg: #0d1117; + --scroll-thumb: rgba(255, 255, 255, 0.16); + --scroll-thumb-hover: rgba(0, 229, 160, 0.55); + --max: 1440px; + /* Both rails carry --nav-pad on each side, so they are widened by it — otherwise adding the + padding would just have made the link text wrap sooner. */ + --sidebar: 268px; + --toc: 224px; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #ffffff; + --panel: #f7f9fa; + --line: #e3e8ee; + --fg: #1c2330; + --dim: #5d6875; + --accent: #00875a; + --accent-dim: rgba(0, 135, 90, 0.1); + --code-bg: #f4f6f8; + --scroll-thumb: rgba(0, 0, 0, 0.22); + --scroll-thumb-hover: rgba(0, 135, 90, 0.55); + } +} + +/* Scrollbars. + A wide code line gets the OS default: a fat light-grey slab sitting on a dark code block, which + ends up the loudest thing on the page. Thin, track-less, in the page's own palette instead. + `scrollbar-width` + `scrollbar-color` covers Firefox and current Chrome; the ::-webkit- rules + cover Safari and older Chromium. Both are needed — neither is universal yet. + + Deliberately scoped to `pre` and tables, NOT the sidebar or TOC: styling ::-webkit-scrollbar opts + a container out of macOS overlay scrollbars, so it shows a bar permanently. On a code block that + is a useful "this scrolls" affordance; on a nav rail it is just clutter, so those keep the native + overlay behaviour. */ +pre, main table { + scrollbar-width: thin; + scrollbar-color: var(--scroll-thumb) transparent; +} +pre::-webkit-scrollbar, +main table::-webkit-scrollbar { + width: 10px; + height: 10px; +} +pre::-webkit-scrollbar-track, +main table::-webkit-scrollbar-track { + background: transparent; +} +pre::-webkit-scrollbar-thumb, +main table::-webkit-scrollbar-thumb { + background: var(--scroll-thumb); + border-radius: 999px; + /* Transparent border + padding-box clip insets the thumb so it doesn't sit flush in the corner. */ + border: 3px solid transparent; + background-clip: padding-box; +} +pre:hover::-webkit-scrollbar-thumb, +main table:hover::-webkit-scrollbar-thumb { + background: var(--scroll-thumb-hover); + background-clip: padding-box; +} +pre::-webkit-scrollbar-corner, +main table::-webkit-scrollbar-corner { + background: transparent; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { scroll-behavior: smooth; scroll-padding-top: 72px; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.skip { + position: absolute; left: -9999px; top: 0; z-index: 10; + background: var(--accent); color: var(--bg); padding: 10px 16px; +} +.skip:focus { left: 0; } + +/* ── top bar ─────────────────────────────────────────────────────────────── */ + +.topbar { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 24px; + padding: 14px 24px; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} +.brand { + font-weight: 700; font-size: 18px; letter-spacing: 0.02em; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.topbar nav { display: flex; gap: 20px; margin-left: auto; font-size: 14px; } +.topbar nav a { color: var(--dim); } +.topbar nav a:hover { color: var(--fg); text-decoration: none; } + +/* ── layout ──────────────────────────────────────────────────────────────── */ + +.layout { + display: grid; + grid-template-columns: var(--sidebar) minmax(0, 1fr) var(--toc); + gap: 40px; + max-width: var(--max); + margin: 0 auto; + padding: 32px 24px 96px; +} + +/* --nav-pad is the single source of the sidebar's horizontal rhythm: links pad by it, and the + section labels indent by the same amount so the column stays aligned. The link previously used a + negative margin to cancel its own padding, which put the highlight box flush against the text. */ +:root { --nav-pad: 14px; } + +.sidebar { position: sticky; top: 72px; align-self: start; max-height: calc(100vh - 90px); overflow-y: auto; } +.nav-section { margin-bottom: 26px; } +.nav-label { + display: block; margin-bottom: 8px; padding: 0 var(--nav-pad); + font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); +} +.sidebar ul, .toc ul { list-style: none; margin: 0; padding: 0; } +.sidebar li { margin: 2px 0; } +.sidebar li a { + display: block; padding: 7px var(--nav-pad); + border-radius: 6px; color: var(--fg); font-size: 14px; line-height: 1.4; +} +.sidebar li a:hover { background: var(--accent-dim); text-decoration: none; } +.sidebar li a.active { color: var(--accent); background: var(--accent-dim); font-weight: 600; } + +/* The TOC pads to match, so both rails read as one system rather than two. Its link padding lives in + the `.toc li a` rule below — setting it here would lose to that rule's `padding` shorthand. */ +.toc .nav-label { padding: 0 var(--nav-pad); } + +.toc { position: sticky; top: 72px; align-self: start; max-height: calc(100vh - 90px); overflow-y: auto; } +.toc li a { display: block; padding: 4px var(--nav-pad); color: var(--dim); font-size: 13px; } +.toc li a:hover { color: var(--fg); text-decoration: none; } +.toc li.d3 a { padding-left: calc(var(--nav-pad) + 12px); font-size: 12.5px; } + +main { min-width: 0; } +main h1 { font-size: 34px; line-height: 1.2; margin: 0 0 8px; letter-spacing: -0.01em; } +main .lede { margin: 0 0 28px; color: var(--dim); font-size: 17px; } +main h2 { + font-size: 24px; margin: 44px 0 14px; padding-top: 14px; + border-top: 1px solid var(--line); letter-spacing: -0.01em; +} +main h3 { font-size: 18px; margin: 28px 0 10px; } +main h4 { font-size: 16px; margin: 22px 0 8px; color: var(--dim); } +main p, main li { overflow-wrap: break-word; } +main ul, main ol { padding-left: 22px; } +main li { margin: 5px 0; } +main hr { border: 0; border-top: 1px solid var(--line); margin: 36px 0; } +main blockquote { + margin: 18px 0; padding: 2px 18px; + border-left: 3px solid var(--accent); color: var(--dim); +} +main img { max-width: 100%; height: auto; } + +/* ── code ────────────────────────────────────────────────────────────────── */ + +code, pre, kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; +} +:not(pre) > code { + background: var(--accent-dim); color: var(--accent); + padding: 1px 6px; border-radius: 4px; font-size: 0.88em; +} +pre { + background: var(--code-bg); + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: 6px; + padding: 14px 18px; + overflow-x: auto; /* wide code scrolls itself; the page never does */ + font-size: 13.5px; line-height: 1.55; + margin: 16px 0; +} +pre code { background: none; color: inherit; padding: 0; font-size: inherit; } + +/* ── syntax highlighting ───────────────────────────────────────────────────── + One palette drives both highlighters — highlight.js for the general languages + and the deck tokenizer built on the package's own keyword tables. Keeping the + two on the same variables is what stops a `.deck` block from looking like it + came off a different site than the `tish` block above it. */ + +:root { + --syn-key: #ff7bd5; /* keywords, statement heads */ + --syn-str: #a5e075; /* strings */ + --syn-num: #d3a0ff; /* numbers */ + --syn-com: #6a7688; /* comments */ + --syn-fn: #6cc4ff; /* functions, titles */ + --syn-typ: #ffd479; /* types, classes, generator ids */ + --syn-var: #8fe6c6; /* params, attributes, inline kw */ + --syn-mut: #7d8794; /* punctuation, placeholders */ +} + +@media (prefers-color-scheme: light) { + :root { + --syn-key: #b3199a; + --syn-str: #2c7a20; + --syn-num: #6b34c9; + --syn-com: #7b8794; + --syn-fn: #0b62c4; + --syn-typ: #8a5a00; + --syn-var: #06776a; + --syn-mut: #8892a0; + } +} + +/* highlight.js */ +.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-doctag { color: var(--syn-key); } +.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string { color: var(--syn-str); } +.hljs-number, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--syn-num); } +.hljs-comment, .hljs-quote { color: var(--syn-com); font-style: italic; } +.hljs-title, .hljs-title.function_, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: var(--syn-fn); } +.hljs-title.class_, .hljs-type, .hljs-built_in, .hljs-class .hljs-title { color: var(--syn-typ); } +.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-params, .hljs-property { color: var(--syn-var); } +.hljs-meta, .hljs-punctuation, .hljs-operator, .hljs-deletion { color: var(--syn-mut); } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: 700; } + +/* deck — classes emitted by site/highlight.mjs */ +.dk-kw { color: var(--syn-key); } +.dk-inline, .dk-param { color: var(--syn-var); } +.dk-num { color: var(--syn-num); } +.dk-id { color: var(--syn-typ); } +.dk-val { color: var(--syn-str); } +.dk-comment { color: var(--syn-com); font-style: italic; } +.dk-ph { color: var(--syn-mut); font-style: italic; } +/* A step grid reads as rhythm, so `x` has to pop off `.` at a glance. */ +.dk-step { color: var(--accent); font-weight: 700; } + +/* ── tables ──────────────────────────────────────────────────────────────── */ + +.table-wrap, main table { display: block; overflow-x: auto; } +main table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 14px; } +main th, main td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; vertical-align: top; } +main th { background: var(--panel); font-weight: 600; } +main tbody tr:nth-child(even) { background: color-mix(in srgb, var(--panel) 50%, transparent); } + +/* ── playable deck blocks ────────────────────────────────────────────────── */ + +.deck-block { position: relative; } +.deck-block > pre { padding-right: 130px; } +.deck-block > deck-player { position: absolute; top: 22px; right: 14px; color: var(--accent); } + +/* ── pager + footer ──────────────────────────────────────────────────────── */ + +.pager { + display: flex; justify-content: space-between; gap: 16px; + margin-top: 56px; padding-top: 22px; border-top: 1px solid var(--line); +} +.pager a { + flex: 0 1 46%; padding: 12px 16px; + border: 1px solid var(--line); border-radius: 8px; + color: var(--fg); font-weight: 600; +} +.pager a:hover { border-color: var(--accent); text-decoration: none; } +.pager .next { text-align: right; } +.pager a span { display: block; font-size: 11px; font-weight: 400; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; } +.edit { margin-top: 40px; font-size: 13px; color: var(--dim); } + +/* ── responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 1100px) { + .layout { grid-template-columns: var(--sidebar) minmax(0, 1fr); } + .toc { display: none; } +} +@media (max-width: 760px) { + .layout { grid-template-columns: minmax(0, 1fr); gap: 24px; padding: 24px 18px 72px; } + .sidebar { + position: static; max-height: none; + border-bottom: 1px solid var(--line); padding-bottom: 16px; + } + main h1 { font-size: 27px; } + .pager { flex-direction: column; } + .pager a { flex: 1 1 auto; } + .deck-block > pre { padding-right: 18px; padding-top: 52px; } + .deck-block > deck-player { top: 12px; right: 12px; } +} From 6da165949203dd66fd95d2f99e1aad9d8854b3be Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 6 Aug 2026 10:42:03 -0700 Subject: [PATCH 2/3] docs: lead the readme with a song you can play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 7 ++++++- README.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1419e14..e13dc56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,11 @@ jobs: # The player releases in lockstep: same tag, same version. Its dependency on the language # package is `file:../..` so the workspace links locally and CI tests the source in this repo — # that has to become the real published version before packing, or the tarball is uninstallable. + # + # Caret, not an exact pin. `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 — and for them a compatible minor is fine. An exact pin would just force npm to + # install a second copy alongside a consumer's own @spacedevin/deck. - name: Set player version and pin its dependency run: | node -e " @@ -203,7 +208,7 @@ jobs: const p = './packages/player/package.json'; const j = JSON.parse(fs.readFileSync(p)); j.version = process.env.VERSION; - j.dependencies['@spacedevin/deck'] = process.env.VERSION; + j.dependencies['@spacedevin/deck'] = '^' + process.env.VERSION; fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); " env: diff --git a/README.md b/README.md index 0c893b4..53e1ceb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,38 @@ Streamable **`.deck`** patch **language** for Tish hosts (e.g. Deckard). +```deck +deck 1 +bpm 132 + +track Lead id lead gen gameBoyDmg + gen type pulse duty 25 vol 11 + note 72 0 0.5 v 110 + note 76 0.5 0.5 v 95 + note 79 1 1 v 105 + note 76 2 0.5 v 100 + note 72 2.5 1.5 v 110 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape saw vol 15 + note 36 0 2 v 120 + note 43 2 2 v 110 + +track Kick id kick gen gbaDirectSound + gen waveform triangle pitch_drop -14 + adsr a 0 d 0.08 s 0 r 0 + step_pitch 36 + steps x . . . x . . x x . . . x . . . +``` + +That is a whole song: a tempo, three tracks, and what each one plays — melody as notes on +a beat grid, drums as a step pattern. **[Press play on it](https://spacedevin.github.io/deck/)** +— the docs site synthesises it in the browser with +[`@spacedevin/deck-player`](packages/player/), the same engine that drives the GBA build. + +More in **[Examples](docs/EXAMPLES.md)**; the full surface in the +**[grammar](docs/DECK_GRAMMAR.md)**. + ## Install ```bash From bcbeedf606165c317c3f9547914cf82c3829e582 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 6 Aug 2026 10:44:57 -0700 Subject: [PATCH 3/3] docs: give the AST shape its own page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- AGENTS.md | 2 + README.md | 3 +- docs/AST.md | 146 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/HOST.md | 35 ++++-------- package.json | 2 + site/build.mjs | 2 +- 6 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 docs/AST.md diff --git a/AGENTS.md b/AGENTS.md index de87d30..3df3d57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,8 @@ Sources are read **in place**. `docs/*.md` are package exports and ship in the t |-----|----------| | [README.md](README.md) | Install + API map | | [docs/DECK_GRAMMAR.md](docs/DECK_GRAMMAR.md) | **Canonical** language reference | +| [docs/AST.md](docs/AST.md) | What `parseProgram` / `parseTrackBody` return | +| [docs/EXAMPLES.md](docs/EXAMPLES.md) | Complete runnable songs | | [docs/DECK_EXTENSION.md](docs/DECK_EXTENSION.md) | gen_block dialect registration + common dialects | | [docs/HOST.md](docs/HOST.md) | How a host boots registries | | [examples/](examples/) | Runnable parse / boot / helper demos | diff --git a/README.md b/README.md index 53e1ceb..3d5f092 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,12 @@ One source, three targets — Tish, JS, Rust — checked against one corpus. - **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface - **[Examples](docs/EXAMPLES.md)** — complete runnable songs (playable on the site) +- **[AST shape](docs/AST.md)** — what `parseProgram` / `parseTrackBody` return - **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm` - **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement - **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits -npm also exports `./grammar` and `./extension` to those markdown files. +npm also exports `./grammar`, `./ast`, `./examples`, `./extension` and `./host` to those markdown files. ## Release diff --git a/docs/AST.md b/docs/AST.md new file mode 100644 index 0000000..4db3c9f --- /dev/null +++ b/docs/AST.md @@ -0,0 +1,146 @@ +# AST shape + +What `parseProgram` and `parseTrackBody` return. This is the contract a host codes against. + +Two rules run through all of it: + +- **Never throws.** Malformed lines accumulate in `errors[]` and parsing continues, because a + streaming host has to be able to parse a partial program. +- **Parse-only.** An absent optional is `null`, never a default, and nothing is clamped or + range-checked. Defaults and ranges are host policy and hosts genuinely differ — one clamps an + out-of-range lock, another rejects it — and a check like "does this note fit inside `* N`?" needs + track context the line doesn't have. See [HOST.md](HOST.md). + +So `null` means *the source didn't say*, which a host can distinguish from *the source said the +default*. Don't collapse the two. + +## `parseProgram(source)` + +One flat object. Every field below is always present. + +| Field | Shape | +|-------|-------| +| `tplVersion` | number — `deck 1` / `tpl 1` | +| `bpm`, `swing`, `launchQuant`, `songSeed` | number or `null` | +| `mainDeck` | `"live"` \| `"local"` \| `null` | +| `scaleRoot`, `scaleMode` | pitch class `0..11` (`-1` = scale off) + mode name, or `null` | +| `xfade` | `{ x, y }` or `null` | +| `deckMix` | `{ A\|B\|C\|D: { hi?, mid?, lo?, flt?, vol? } }` or `null` | +| `tracks[]` | see [Track](#track) | +| `clipBlocks[]` | `{ clipId, channelId, bars, displayName, body[] }` | +| `removeTrackIds[]` | channel ids from `remove_track` | +| `macros` | `{ [name]: { params: { k: number\|string }, body: string[] } }` | +| `autos[]` | `{ lineNo, header: string[], points: [{ beat, value }] }` | +| `masterMixTokens` | `string[]` or `null` — raw, host-interpreted | +| `actorMixRows[]` | `{ lineNo, lane, tokens: string[] }` — raw, host-interpreted | +| `sessionSceneCount` | int or `null` | +| `sessionSlots[]` | `{ channelId, scene, clipId }` | +| `song` | `null` or `[{ scene, repeat }]` | +| `follow` | `null` or `[{ scene, a, wa, b, wb }]` | +| `directives[]` | `{ lineNo, verb, tokens: string[] }` — every `@ …` line | +| `hostStatements` | `{ [head]: [{ lineNo, value }] }` from `registerTopLevelStatement` | +| `errors[]` | `{ line, msg }` — 1-based line numbers | + +### Track + +```js +{ + name: "MOS 6581", // may be multi-word; anchored on the id/gen keyword pair + id: "c9", + generatorId: "fm", // normalizeGeneratorId(raw) — identity until a host registers aliases + rawGenId: "fm", // exactly what the source wrote + genParams: {}, // trailing `k v` pairs on the header (macro overrides), numbers coerced + loopBars: 2, // `* N`; null for `* inf` or unset + body: [{ lineNo, tokens, raw }], + genBlocks: [{ generatorId, lines: string[] }] +} +``` + +**`body[]` rows are raw token rows.** `parseProgram` does not interpret them — call +`parseTrackBody(track.body)` for typed rows. `lineNo` is 1-based throughout. + +A `gen_block` is only collected inside a `track` body. Inside a `clip` body the clip branch matches +first, so such a line stays an ordinary body row. + +## `parseTrackBody(bodyRows)` + +Returns `{ rows, errors }`. Every row carries `kind` and `lineNo`; `kind: "error"` rows are split out +into `errors[]` instead. + +| `kind` | Fields | +|--------|--------| +| `mix` | `gain`, `pan`, `mute`, `solo`, `eqLo`, `eqMid`, `eqHi` — absent = `null`, boolish → `true`/`false` | +| `steps` | `mode: "literal" \| "euclid"`, `on: boolean[]`, plus `hits`/`len` when euclid | +| `stepLane` | `lane: "vel" \| "prob" \| "ratchet" \| "nudge" \| "lyric"`, `values: (number \| null)[]` | +| `stepPitch` | `midi`, `bar` | +| `note` | `midi`, `startBeat`, `durBeats`, `vel`, `prob`, `ratchet`, `nudge`, `bar`, `lyric` | +| `notesClear` | — | +| `transpose` | `semitones` | +| `loops` | `cap` — `null` means `loops inf` | +| `gen`, `fx`, `voice` | `params: { camelKey: number \| string }` | +| `adsr` | `a`, `d`, `s`, `r` | +| `deckRoute` | `lane: "A".."D" \| "live" \| null`, `slot` | +| `unknown` | `head`, `tokens` — a head nothing claimed; **not an error**, a dialect may still take it | + +Real rows: + +```js +{ kind: "note", midi: 61, startBeat: 0, durBeats: 1, vel: 100, + prob: null, ratchet: null, nudge: null, bar: null, lyric: null, lineNo: 6 } + +{ kind: "steps", mode: "literal", + on: [true, false, false, false, true, false, false, false, …], lineNo: 3 } + +{ kind: "stepLane", lane: "vel", + values: [120, 100, 100, 100, 70, 100, …], lineNo: 4 } + +{ kind: "gen", params: { waveShape: "saw", vol: 15, pitchDrop: -12 }, lineNo: 7 } + +{ kind: "adsr", a: 0, d: 0.1, s: 0.5, r: 0.03, lineNo: 8 } + +{ kind: "deckRoute", lane: "A", slot: 2, lineNo: 6 } +``` + +Two things the parser does for you: + +- **Euclid is already expanded.** `steps euclid 5 16` arrives as the same `on: boolean[]` grid a + literal line produces, with `hits` and `len` alongside. +- **Param keys are camelCased and aliased.** `wave_shape` → `waveShape`, `reverb` → `reverbSend`, + `type` → `filterType` on `fx`. Extend with `registerParamKeyAliases`. + +## Bar selector + +The value of `bar` on a `note` or `stepPitch`; `null` means every bar. Evaluate with +`barSelectorMatches(sel, bar)` — bars are 0-indexed within `* N`. + +| Source | Shape | +|--------|-------| +| `all` | `{ kind: "all" }` | +| `2` | `{ kind: "eq", n: 2 }` | +| `-n+2` | `{ kind: "first", b: 2 }` | +| `0,2,3` | `{ kind: "list", list: [0, 2, 3] }` | +| `even` / `2n+1` | `{ kind: "mod", a: 2, b: 0 }` | + +## gen_block + +With no dialect registered, `parseGenBlock(id, lines)` returns the lines verbatim: + +```js +{ kind: "patch", tplHeaderId: "patch", version: 1, + raw: ["osc o1 sawtooth note", "filter f1 lowpass q 4 freq 1800", "conn f1 out 1"] } +``` + +Register a dialect to parse them into a graph — see [DECK_EXTENSION.md](DECK_EXTENSION.md). + +## Typed mirrors + +- **Rust** — `deckfile::parse(src)` returns typed structs. `rust/facade.rs` is the only hand-written + Rust in the crate and enumerates every variant above; it is the most precise statement of this + shape in the repo. +- **TypeScript** — the language package ships no declarations. `@spacedevin/deck-player` has + hand-written types for its own Song IR, which is a *host* shape (defaults applied, values clamped), + not this one. + +The [conformance corpus](https://github.com/spacedevin/deck/tree/main/conformance) stores the whole +observable parse of each case as JSON, so it doubles as a worked example of every shape here — and is +what stops the JS, Rust and Tish targets from drifting apart. diff --git a/docs/HOST.md b/docs/HOST.md index 58cdddf..96dc8f6 100644 --- a/docs/HOST.md +++ b/docs/HOST.md @@ -71,30 +71,16 @@ let ast = parseProgram(source) | Skills / ownership | co-DJ layer | | Highlight CSS | map `classifyLine` classes to themes | -## AST shape (summary) - -`parseProgram` returns one flat object. Every field is always present; the value is `null` (or an -empty array) when the source didn't set it. - -| Field | Shape | -|-------|-------| -| `tplVersion` | number (`deck 1` / `tpl 1`) | -| `bpm`, `swing`, `launchQuant`, `songSeed`, `mainDeck` | scalar or `null` | -| `scaleRoot`, `scaleMode` | pitch class `0..11` (`-1` = scale off) + mode name | -| `xfade` | `{ x, y }` or `null` | -| `deckMix` | `{ A\|B\|C\|D: { hi, mid, lo, flt, vol } }` or `null` | -| `tracks[]` | `{ name, id, generatorId, rawGenId, genParams, loopBars, body[], genBlocks[] }` | -| `clipBlocks[]` | `{ clipId, channelId, bars, displayName, body[] }` | -| `autos[]` | `{ lineNo, header[], points: [{ beat, value }] }` | -| `macros` | object map `name → { params, body[] }` | -| `removeTrackIds[]` | channel ids from `remove_track` | -| `masterMixTokens`, `actorMixRows[]` | raw token rows for the host to interpret | -| `sessionSceneCount`, `sessionSlots[]`, `song`, `follow` | session / arrangement | -| `directives[]` | `{ lineNo, verb, tokens[] }` — every `@ …` line | -| `errors[]` | `{ line, msg }` — the parser accumulates, it never throws | - -Track and clip `body[]` rows are `{ lineNo, tokens[], raw }`; `genBlocks[]` entries are -`{ generatorId, lines[] }`. +## AST shape + +`parseProgram` returns one flat object; `parseTrackBody` turns a track's raw body rows into typed +ones. Both are documented in full in **[AST.md](AST.md)** — the top-level fields, every body-row +`kind`, bar selectors and gen blocks. + +The two rules that shape everything a host does with it: the parser **never throws** (errors +accumulate in `errors[]` so a partial stream still parses), and it is **parse-only** — an absent +optional is `null` rather than a default, and nothing is clamped. Applying defaults and ranges is +your job, which is what the table above means by "clamps, defaults, range checks". **Control directives.** `@ launch`, `@ transport`, `@ cue`, `@ throw`, `@ fx`, `@ deck`, `@ perf_step` are transient stream lines, not document state, so the parser collects them into `directives[]` @@ -103,5 +89,6 @@ verbatim and leaves the meaning to the host. They never produce errors. ## Package docs - [DECK_GRAMMAR.md](DECK_GRAMMAR.md) — language +- [AST.md](AST.md) — what the parser returns - [DECK_EXTENSION.md](DECK_EXTENSION.md) — dialects - npm exports: `@spacedevin/deck/grammar`, `@spacedevin/deck/extension` diff --git a/package.json b/package.json index adf21b1..3884870 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "default": "./dist/deck.js" }, "./grammar": "./docs/DECK_GRAMMAR.md", + "./ast": "./docs/AST.md", + "./examples": "./docs/EXAMPLES.md", "./extension": "./docs/DECK_EXTENSION.md", "./host": "./docs/HOST.md", "./package.json": "./package.json" diff --git a/site/build.mjs b/site/build.mjs index b124d71..b21a594 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -44,7 +44,7 @@ const SECTIONS = [ label: 'Language', dir: 'docs', slug: 'docs', - order: ['DECK_GRAMMAR.md', 'EXAMPLES.md', 'DECK_EXTENSION.md', 'HOST.md'], + order: ['DECK_GRAMMAR.md', 'EXAMPLES.md', 'AST.md', 'DECK_EXTENSION.md', 'HOST.md'], // Every untagged fence in these files is `.deck` — the grammar reference shows the language it // documents, and the host-facing snippets are all tagged `tish`. Declared rather than guessed. defaultLang: 'deck',