Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ jobs:
- name: Refuse to package without a licence
if: steps.gate.outputs.continue == 'true'
run: |
for f in LICENSE packages/player/LICENSE; do
for f in LICENSE packages/player/LICENSE packages/synths/LICENSE; do
if [ ! -f "$f" ]; then
echo "::error::Missing $f, but package.json declares a licence. Add the file, or correct the declaration, before anything is published."
exit 1
Expand All @@ -218,6 +218,22 @@ jobs:
npm pack
mv spacedevin-deck-*.tgz spacedevin-deck-npm-package.tgz

# The synths catalog releases in lockstep too. Same `file:../..` → real version rewrite as the
# player, and for the same reason: the workspace links locally so CI builds the source in this
# repo, but a published tarball needs a version a consumer can actually resolve.
- name: Stamp the version and pack @spacedevin/deck-synths
if: steps.gate.outputs.continue == 'true'
run: |
node -e '
const fs = require("fs"), p = "./packages/synths/package.json";
const j = JSON.parse(fs.readFileSync(p, "utf8"));
j.version = process.argv[1];
j.dependencies["@spacedevin/deck"] = "^" + process.argv[1];
fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");
' "${{ steps.gate.outputs.version }}"
npm pack -w @spacedevin/deck-synths
mv spacedevin-deck-synths-*.tgz spacedevin-deck-synths-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.
Expand Down Expand Up @@ -264,5 +280,8 @@ jobs:
- path: spacedevin-deck-player-npm-package.tgz
name: spacedevin-deck-player-npm-package.tgz
label: npm package (@spacedevin/deck-player)
- path: spacedevin-deck-synths-npm-package.tgz
name: spacedevin-deck-synths-npm-package.tgz
label: npm package (@spacedevin/deck-synths)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36 changes: 32 additions & 4 deletions .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +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 — needed SEPARATELY for `@spacedevin/deck` AND `@spacedevin/deck-player`, since trusted
# publishers are per package. Settings > Trusted Publisher > GitHub Actions:
# npmjs.com — needed SEPARATELY for `@spacedevin/deck`, `@spacedevin/deck-synths` 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.
# All three packages ship from one release at one version, published in dependency order —
# language, then synths, then player — because each one's dependency is pinned to that exact version.

name: NPM release

Expand Down Expand Up @@ -87,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 synths catalog ships in lockstep from the same release. Same retry loop, same reason,
# and the same tolerance: a release predating the package has no such asset, so its absence
# is not fatal.
for attempt in 1 2 3 4 5; do
SYNTHS_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-synths-npm-package.tgz" and .state == "uploaded") | .url')
[ -n "$SYNTHS_URL" ] && [ "$SYNTHS_URL" != "null" ] && break
echo "synths tarball not uploaded yet (attempt $attempt/5) — waiting 30s"
sleep 30
done
if [ -n "$SYNTHS_URL" ] && [ "$SYNTHS_URL" != "null" ]; then
curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$SYNTHS_URL" -o spacedevin-deck-synths-npm-package.tgz
else
echo "No spacedevin-deck-synths-npm-package.tgz on this release — skipping the synths publish."
fi

# 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.
Expand Down Expand Up @@ -120,6 +138,16 @@ jobs:
- name: Publish @spacedevin/deck to npm
run: npm publish spacedevin-deck-npm-package.tgz --access public

# After the language, for the same reason: the synths package's dependency was pinned to this
# exact version at pack time, so it is uninstallable until the language is on the registry.
- name: Publish @spacedevin/deck-synths to npm
run: |
if [ -f spacedevin-deck-synths-npm-package.tgz ]; then
npm publish spacedevin-deck-synths-npm-package.tgz --access public
else
echo "No synths tarball — nothing to publish."
fi

# 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.
Expand Down
29 changes: 26 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions packages/synths/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026-present spacedevin

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
49 changes: 49 additions & 0 deletions packages/synths/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# @spacedevin/deck-synths

The instrument catalog for the [`.deck`](https://github.com/spacedevin/deck) language — 33 voices
covering chip emulations, FM, drums, hard sync, bowed and plucked models, and vocals.

These are the voices [Deckard](https://deckard.lol) plays through. They live here so a second host —
a docs-site player, a renderer, a demo — can make the same sounds without a second copy of the code.

```bash
npm install @spacedevin/deck-synths
```

```js
import { dispatchPlayNote, ensureDeckGeneratorIds, ensureSyncWorklet } from '@spacedevin/deck-synths'

// Teach the language this catalog's generator ids, param aliases and gen_block dialects.
// Do this before parsing, or `gen sweep_amt` never reaches the voice as `sweepAmt`.
ensureDeckGeneratorIds()

await ensureSyncWorklet(ctx) // only needed if a sync voice is used
dispatchPlayNote(ctx, bus, t, midi, vel, durSec, channel, bendSemis)
```

## The contract

A voice is a pure function that builds a short-lived Web Audio subgraph and connects its last node
to `bus.input`:

```
play<Name>(ctx, bus, t, midi, vel, durSec, ch, bendSemis)
```

`dispatchPlayNote` picks one by `ch.generatorId`; an unknown id falls back to `basicOsc`. Patch and
envelope come from `ch.generatorParams` — the ADSR lives there, not on the channel root.

## Assets

`ensureSyncWorklet` registers the hard-sync oscillator from an inlined Blob URL, so `syncLead`,
`syncChoir`, `obSync` and `laserSync` need nothing copied into your public directory.

`meSpeakVocal` is the exception: it needs a worker and voice data the host serves. The defaults are
`/mespeak-worker.js` and `/mespeak`; call `configureMeSpeak({ workerUrl, assetsBaseUrl })` if yours
differ. `ttsVocal` needs the Web Speech API.

## Known limits

- Two hosts that each bundle their own copy of `@spacedevin/deck` end up with two dialect
registries, and a dialect registered against one is invisible to the other. Deduplicate the
language package so there is a single instance.
53 changes: 53 additions & 0 deletions packages/synths/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@spacedevin/deck-synths",
"version": "0.1.0",
"type": "module",
"description": "The .deck instrument catalog \u2014 chip, FM, drum, sync and vocal voices as a shared package",
"license": "MIT",
"exports": {
".": {
"tish": "./src/index.tish",
"default": "./dist/deck-synths.js"
},
"./package.json": "./package.json"
},
"tish": {
"module": "./src/index.tish"
},
"files": [
"src/",
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tish build --target js src/index.tish -o dist/deck-synths.js",
"prepack": "npm run build",
"prepublishOnly": "npm run build"
},
"author": "spacedevin",
"repository": {
"type": "git",
"url": "https://github.com/spacedevin/deck.git",
"directory": "packages/synths"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=22"
},
"main": "./dist/deck-synths.js",
"module": "./dist/deck-synths.js",
"dependencies": {
"@spacedevin/deck": "file:../.."
},
"peerDependencies": {
"@tishlang/tish": ">=3.2.2"
},
"peerDependenciesMeta": {
"@tishlang/tish": {
"optional": true
}
}
}
48 changes: 48 additions & 0 deletions packages/synths/src/Acid303.tish
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { normalizeBasicOscWaveform } from './Registry.tish'

export fn playAcid303(ctx, bus, t, midi, vel, durSec, ch, bendSemis) {
let gp = ch.generatorParams ? ch.generatorParams : {}
let wave = normalizeBasicOscWaveform(gp.waveform !== null ? gp.waveform : "sawtooth")
let cutoff = gp.cutoff !== null ? gp.cutoff : 800
let res = gp.resonance !== null ? gp.resonance : 15
let envMod = gp.envMod !== null ? gp.envMod : 4000
let decay = gp.decay !== null ? gp.decay : 0.4

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 osc = ctx.createOscillator()
osc.type = wave
osc.frequency.value = f0

let filter = ctx.createBiquadFilter()
filter.type = "lowpass"
filter.Q.value = res

let gain = ctx.createGain()

osc.connect(filter)
filter.connect(gain)
gain.connect(bus.input)

// Filter envelope
filter.frequency.setValueAtTime(cutoff + envMod * v, t)
filter.frequency.setTargetAtTime(cutoff, t + 0.01, decay / 3)

// Amp envelope
gain.gain.setValueAtTime(0, t)
gain.gain.linearRampToValueAtTime(v * 0.8, t + 0.01)
gain.gain.setTargetAtTime(0, t + 0.01, decay / 3)

osc.start(t)
osc.stop(t + decay + 0.5)

// Cleanup
setTimeout(() => {
osc.disconnect()
filter.disconnect()
gain.disconnect()
}, (decay + 1) * 1000)
}
17 changes: 17 additions & 0 deletions packages/synths/src/AdsrAmpSchedule.tish
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Schedules gain ADSR so AudioParam times never go backwards (short gates + long A/D would throw).

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
}
Loading
Loading