diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7383661..e13dc56 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,32 @@ 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. + # + # 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 " + 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 +302,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..3df3d57 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,17 +41,53 @@ 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 | |-----|----------| | [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 d9a989b..3d5f092 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 @@ -44,6 +76,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,12 +114,16 @@ 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) +- **[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/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/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-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..3884870 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", @@ -25,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" @@ -52,7 +57,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 +90,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..b21a594 --- /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', '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', + }, + { + 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; } +}