diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7687fad..d9124cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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. @@ -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 }} diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 850051d..b5869b6 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -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 @@ -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. @@ -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. diff --git a/package-lock.json b/package-lock.json index 0ca0be4..70fb51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,10 +160,18 @@ "node": ">=14" } }, + "node_modules/@spacedevin/deck": { + "resolved": "", + "link": true + }, "node_modules/@spacedevin/deck-player": { "resolved": "packages/player", "link": true }, + "node_modules/@spacedevin/deck-synths": { + "resolved": "packages/synths", + "link": true + }, "node_modules/@tishlang/tish": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@tishlang/tish/-/tish-3.7.1.tgz", @@ -1037,9 +1045,24 @@ } } }, - "packages/player/node_modules/@spacedevin/deck": { - "resolved": "", - "link": true + "packages/synths": { + "name": "@spacedevin/deck-synths", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@spacedevin/deck": "file:../.." + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@tishlang/tish": ">=3.2.2" + }, + "peerDependenciesMeta": { + "@tishlang/tish": { + "optional": true + } + } } } } diff --git a/packages/synths/LICENSE b/packages/synths/LICENSE new file mode 100644 index 0000000..56e3427 --- /dev/null +++ b/packages/synths/LICENSE @@ -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. diff --git a/packages/synths/README.md b/packages/synths/README.md new file mode 100644 index 0000000..c4842a4 --- /dev/null +++ b/packages/synths/README.md @@ -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(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. diff --git a/packages/synths/package.json b/packages/synths/package.json new file mode 100644 index 0000000..aa6a8f4 --- /dev/null +++ b/packages/synths/package.json @@ -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 + } + } +} diff --git a/packages/synths/src/Acid303.tish b/packages/synths/src/Acid303.tish new file mode 100644 index 0000000..8d5a94e --- /dev/null +++ b/packages/synths/src/Acid303.tish @@ -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) +} diff --git a/packages/synths/src/AdsrAmpSchedule.tish b/packages/synths/src/AdsrAmpSchedule.tish new file mode 100644 index 0000000..af17702 --- /dev/null +++ b/packages/synths/src/AdsrAmpSchedule.tish @@ -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 +} diff --git a/packages/synths/src/Aether.tish b/packages/synths/src/Aether.tish new file mode 100644 index 0000000..9789bd9 --- /dev/null +++ b/packages/synths/src/Aether.tish @@ -0,0 +1,211 @@ +// Aether — a THEREMIN. Not a sine with an LFO bolted on, but a model of the instrument's two living +// facts: a heterodyne tone the performer shapes with two free hands in the air. Everything eerie about a +// theremin comes from the fact that nothing is ever fastened down — the pitch hand and the volume hand +// drift, waver, and swoop, so the tone is ALIVE in both pitch and amplitude at all times. +// +// Its controls are the only two things a thereminist actually does, plus the timbre of the box: +// glide — the PORTAMENTO. Deckard triggers a discrete note, but a real player's pitch hand arrives FROM +// somewhere: each note swoops up into pitch from a little below (the hand "finding" the note) +// over a short time. This swoop is the theremin's calling card. 0 = snaps near-instantly, +// 1 = a long, sliding, sci-fi glissando. +// waver — the vibrato AND its instability. A real hand can't hold still: a ~5.5 Hz pitch waver that is +// itself slightly irregular (a slow second LFO detunes the rate and depth), plus a matching +// amplitude shimmer. The waver BUILDS IN after the onset, the way a player settles into a note. +// tone — pure heterodyne sine → a touch of odd harmonics for the throaty, voice-like quality. Stays +// sine-cored even at the top so it never turns into a buzzy synth. +// swell — the VOLUME HAND. A smooth, breathy, transient-free bloom. 0 = a slow swell that takes its +// time to speak, 1 = a quick, vocal attack. +// air — a whisper of breath/air noise, bandpassed near the tone, that swells with the note for an +// organic, human warmth. +// +// Engine: two sines a few cents apart (the heterodyne beat keeps it alive) + a faint odd-harmonic sine +// (3rd) folded in by `tone` → a gentle tilt lowpass → a no-transient swell-shaped amp. A pitch-glide +// envelope on the oscillators does the portamento. A sine vibrato LFO (itself wobbled by a slow +// irregularity LFO) drives detune AND a tremolo gain; both fade in after the onset. A bandpassed noise +// layer is the breath. + +import { midiToHz } from './Midi.tish' + +fn aetherClamp01(x) { + if (x < 0) { return 0 } + if (x > 1) { return 1 } + return x +} + +export fn playAether(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let glide = aetherClamp01(p.glide !== null ? p.glide : 0.4) + let waver = aetherClamp01(p.waver !== null ? p.waver : 0.5) + let tone = aetherClamp01(p.tone !== null ? p.tone : 0.3) + let swell = aetherClamp01(p.swell !== null ? p.swell : 0.45) + let air = aetherClamp01(p.air !== null ? p.air : 0.25) + + let hz = midiToHz(midi + bendSemis) + if (hz < 8) { hz = 8 } + let v = vel / 127 + if (v < 0) { v = 0 } + if (v > 1) { v = 1 } + if (v <= 0) { return } + + // Volume hand: a smooth, transient-free bloom. swell=0 takes its time to speak (~0.45s), swell=1 is a + // quick vocal attack (~0.05s). Release matches so the note also fades out like a hand pulling away. + let attack = 0.05 + (1 - swell) * 0.4 + let release = 0.12 + (1 - swell) * 0.22 + let hold = durSec > attack + 0.04 ? durSec : attack + 0.04 + let tEnd = hold + release + let stop = t + tEnd + 0.06 + + // Portamento: the pitch hand swoops UP into the note from a little below. glide scales BOTH how far + // below it starts (a couple semitones) and how long the swoop takes. The note settles before any + // vibrato fully blooms, so you hear the swoop, then the waver — like a real player. + let glideTime = 0.02 + glide * 0.5 + let startSemis = 0 - (1.5 + glide * 4) + let startHz = hz * Math.pow(2, startSemis / 12) + if (startHz < 8) { startHz = 8 } + + // The shared amp / swell stage. Heterodyne tone is a near-sine, so it can run hot without slamming the + // master — a single sine peaks at ~1; keep headroom for the detune beat + 3rd harmonic + air. + let amp = ctx.createGain() + // The tremolo LFO is summed onto amp.gain, so reserve its max upward swing inside the DC level + // (peak·(1+tremFactor) = v·0.52) rather than letting it overshoot on top. Combined with the 0.5 coreGain + // below (so the two heterodyne sines sum to 1.0, not 2.0, when they phase-align at onset), a single stem + // stays <= ~1 even at the loud/max-waver corner — no master-glue-comp slam. + let tremFactor = 0.05 + waver * 0.32 + let peak = (v * 0.52) / (1 + tremFactor) + amp.gain.setValueAtTime(0, t) + amp.gain.linearRampToValueAtTime(peak, t + attack) + amp.gain.setValueAtTime(peak, t + hold) + amp.gain.exponentialRampToValueAtTime(0.0008, t + tEnd) + amp.connect(bus.input) + + // Gentle brightness tilt — `tone` opens it so the odd harmonic can speak for the throaty quality, but + // it stays mellow (never a buzzy synth lead). + let lp = ctx.createBiquadFilter() + lp.type = "lowpass" + // `tone` opens the cutoff WIDE — from a dark, pure heterodyne sine (0) up to a bright, reedy, throaty voice + // (1) where the added odd harmonics speak. Wide range so the knob is an obvious dark↔bright sweep. + let cutoff = 700 + tone * 8000 + if (cutoff > 13000) { cutoff = 13000 } + lp.frequency.value = cutoff + lp.Q.value = 0.4 + lp.connect(amp) + + // Halve the oscillator bank so the two phase-aligned sines sum to 1.0 (not 2.0) at note onset. + let coreGain = ctx.createGain() + coreGain.gain.value = 0.5 + coreGain.connect(lp) + + // The waver: a ~5.5 Hz vibrato LFO whose rate and depth are themselves slowly wobbled by a slower + // "unsteady hand" LFO, so it's never a perfectly mechanical wobble. Both pitch detune and a tremolo + // gain ride this; both BUILD IN after the onset. + let vibHz = 5.0 + waver * 1.4 + Math.random() * 0.6 + let bloomAt = t + attack + 0.12 + + // Slow irregularity LFO — the hand drifting. It re-modulates the vibrato depth (via irregGain → vibGain) + // so the waver breathes in and out instead of sitting at one fixed depth. + let irreg = ctx.createOscillator() + irreg.type = "sine" + irreg.frequency.value = 0.28 + Math.random() * 0.4 + let irregGain = ctx.createGain() + irregGain.gain.value = waver * 9 + irreg.connect(irregGain) + irreg.start(t) + irreg.stop(stop) + + // Vibrato → oscillator detune (cents). Depth blooms in; irregGain adds/subtracts on top so peaks vary. + let vib = ctx.createOscillator() + vib.type = "sine" + vib.frequency.value = vibHz + let vibGain = ctx.createGain() + let vibDepth = 5 + waver * 65 + vibGain.gain.setValueAtTime(vibDepth * 0.12, t) + vibGain.gain.linearRampToValueAtTime(vibDepth, bloomAt) + irregGain.connect(vibGain.gain) + vib.connect(vibGain) + vib.start(t) + vib.stop(stop) + + // Tremolo: the same waver makes the amplitude shimmer (the volume hand drifts too). A gentle ±, also + // bloomed in, summed into the amp gain so the tone is never amplitude-static. + let trem = ctx.createGain() + let tremDepth = peak * tremFactor + trem.gain.setValueAtTime(0, t) + trem.gain.linearRampToValueAtTime(tremDepth, bloomAt) + vib.connect(trem) + trem.connect(amp.gain) + + // Heterodyne core: two sines a few cents apart. The slow beat between them is what keeps a "pure" tone + // from sounding dead — it's the analog heart of the instrument. Both ride the glide + vibrato. + let beat = 3 + waver * 10 + let k = 0 + while (k < 2) { + let osc = ctx.createOscillator() + osc.type = "sine" + osc.frequency.setValueAtTime(startHz, t) + osc.frequency.exponentialRampToValueAtTime(hz, t + glideTime) + osc.detune.value = k === 0 ? (0 - beat) : beat + vibGain.connect(osc.detune) + osc.connect(coreGain) + osc.start(t) + osc.stop(stop) + k = k + 1 + } + + // `tone` folds in ODD HARMONICS (3rd + 5th) for the throaty, reedy, voice-like color — a pure sine at 0, + // a rich, almost-vocal tone at 1. They glide + waver with the fundamental so they fuse into one voice + // (never a separate beep). Gains are bold so Tone is an obvious mellow↔reedy sweep, not a faint tint. + if (tone > 0.01) { + let partials = [[3, 0.6], [5, 0.45], [7, 0.28]] + let hpi = 0 + while (hpi < partials.length) { + let mul = partials[hpi][0] + let pamp = partials[hpi][1] + let ho = ctx.createOscillator() + ho.type = "sine" + ho.frequency.setValueAtTime(startHz * mul, t) + ho.frequency.exponentialRampToValueAtTime(hz * mul, t + glideTime) + vibGain.connect(ho.detune) + let hg = ctx.createGain() + hg.gain.value = tone * pamp + ho.connect(hg) + hg.connect(coreGain) + ho.start(t) + ho.stop(stop) + hpi = hpi + 1 + } + } + + // Breath: a whisper of air noise bandpassed near the tone, swelling in with the note for organic warmth. + if (air > 0.01) { + let nlen = Math.floor(ctx.sampleRate * 0.5) + if (nlen < 1) { nlen = 1 } + let nbuf = ctx.createBuffer(1, nlen, ctx.sampleRate) + let nd = nbuf.getChannelData(0) + let i = 0 + while (i < nlen) { + nd[i] = Math.random() * 2 - 1 + i = i + 1 + } + let nsrc = ctx.createBufferSource() + nsrc.buffer = nbuf + nsrc.loop = true + let nbp = ctx.createBiquadFilter() + nbp.type = "bandpass" + let bpf = hz * 2 + if (bpf < 200) { bpf = 200 } + if (bpf > 9000) { bpf = 9000 } + nbp.frequency.value = bpf + nbp.Q.value = 0.45 + let ng = ctx.createGain() + let nlevel = air * 0.8 * v + ng.gain.setValueAtTime(0, t) + ng.gain.linearRampToValueAtTime(nlevel, t + attack) + ng.gain.setValueAtTime(nlevel, t + hold) + ng.gain.exponentialRampToValueAtTime(0.0008, t + tEnd) + nsrc.connect(nbp) + nbp.connect(ng) + ng.connect(amp) + nsrc.start(t) + nsrc.stop(stop) + } +} diff --git a/packages/synths/src/Arco.tish b/packages/synths/src/Arco.tish new file mode 100644 index 0000000..84300e4 --- /dev/null +++ b/packages/synths/src/Arco.tish @@ -0,0 +1,157 @@ +// Arco — a bowed-string voice: violin · viola · cello · bass · fiddle. Not a generic synth dressed up, +// but the physics of a bowed string, so its only controls are the ones a string player actually has: +// +// voice — which member of the family. This scales the BODY RESONANCES (a violin's wooden box rings +// high & small; a cello/bass rings low & large) and the brightness. That formant signature +// is what makes it read as wood rather than a saw. +// pressure — bow pressure: light = airy flautando (dark, few harmonics); heavy = digging in (bright, +// more bite + more bow noise). +// bow — bow speed / articulation: low = a sharp martelé attack, high = a slow legato swell. +// vibrato — the signature left-hand wobble (a ~5.5 Hz pitch LFO that swells in after the attack). +// rosin — bow-on-string friction noise (the scratch that sells "bowed"); scales with pressure too. +// body — how resonant/woody the box is (how hard the formant peaks ring). +// +// Engine: two slightly-detuned SAW oscillators (a bowed string's Helmholtz stick-slip motion is a saw) +// → a brightness lowpass (opened by pressure) → a chain of PEAKING formant filters tuned to the body's +// air/wood/bridge-hill resonances (scaled by `voice`) → amp. A bandpassed noise layer adds rosin. A +// sine LFO drives both oscillators' detune for vibrato. + +import { midiToHz } from './Midi.tish' + +fn arcoClamp01(x) { + if (x < 0) { return 0 } + if (x > 1) { return 1 } + return x +} + +// Per-family body character. scale = formant-frequency multiplier (1 = violin); bright = brightness +// multiplier; det = ensemble detune in cents (fiddle a touch wider/rawer). +fn arcoVoiceProfile(voice) { + if (voice === "viola") { return { scale: 0.78, bright: 0.85, det: 5 } } + if (voice === "cello") { return { scale: 0.5, bright: 0.72, det: 6 } } + if (voice === "bass") { return { scale: 0.34, bright: 0.6, det: 7 } } + if (voice === "fiddle") { return { scale: 1.06, bright: 1.22, det: 9 } } + return { scale: 1.0, bright: 1.0, det: 5 } +} + +// One peaking body-resonance formant in the chain. Returns the filter so callers can wire it in series. +fn arcoFormant(ctx, freq, q, gainDb) { + let f = ctx.createBiquadFilter() + f.type = "peaking" + f.frequency.value = freq + f.Q.value = q + f.gain.value = gainDb + return f +} + +export fn playArco(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let voice = p.voice ? p.voice : "violin" + let pressure = arcoClamp01(p.pressure !== null ? p.pressure : 0.5) + let bow = arcoClamp01(p.bow !== null ? p.bow : 0.4) + let vibrato = arcoClamp01(p.vibrato !== null ? p.vibrato : 0.35) + let rosin = arcoClamp01(p.rosin !== null ? p.rosin : 0.3) + let body = arcoClamp01(p.body !== null ? p.body : 0.6) + let prof = arcoVoiceProfile(voice) + + let hz = midiToHz(midi + bendSemis) + if (hz < 8) { hz = 8 } + let v = vel / 127 + if (v < 0) { v = 0 } + if (v > 1) { v = 1 } + + // Articulation: slow bow swells in, a martelé bites fast. ~10..230 ms. + let attack = 0.01 + bow * 0.22 + let release = 0.1 + body * 0.18 + let hold = durSec > attack + 0.05 ? durSec : attack + 0.05 + + // Amp envelope — the shared output stage. + let amp = ctx.createGain() + amp.gain.setValueAtTime(0, t) + amp.gain.linearRampToValueAtTime(v, t + attack) + amp.gain.setValueAtTime(v, t + hold) + amp.gain.exponentialRampToValueAtTime(0.0008, t + hold + release) + amp.connect(bus.input) + + // Body formant chain: air resonance, main wood, bridge hill — scaled by family size. `body` rings them. + let g1 = 7 * body + let g2 = 6 * body + let g3 = 4.5 * body + let f1 = arcoFormant(ctx, 300 * prof.scale, 1.6, g1) + let f2 = arcoFormant(ctx, 460 * prof.scale, 2.6, g2) + let f3 = arcoFormant(ctx, 2600 * prof.scale, 1.1, g3) + f1.connect(f2) + f2.connect(f3) + f3.connect(amp) + + // Brightness lowpass — pressure opens it (digging in = brighter). + let lp = ctx.createBiquadFilter() + lp.type = "lowpass" + let cutoff = (700 + pressure * 5200) * prof.bright + if (cutoff > 16000) { cutoff = 16000 } + lp.frequency.value = cutoff + lp.Q.value = 0.7 + lp.connect(f1) + + // Vibrato LFO (sine ~5.5 Hz) → both oscillators' detune; swells in after the attack. + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = 5.3 + vibrato * 1.2 + let lfoGain = ctx.createGain() + let depth = vibrato * 32 + lfoGain.gain.setValueAtTime(depth * 0.15, t) + lfoGain.gain.linearRampToValueAtTime(depth, t + attack + 0.18) + lfo.connect(lfoGain) + lfo.start(t) + lfo.stop(t + hold + release + 0.05) + + // Two saw oscillators, lightly detuned for a living, bowed shimmer. + let k = 0 + while (k < 2) { + let osc = ctx.createOscillator() + osc.type = "sawtooth" + osc.frequency.value = hz + let baseDet = k === 0 ? (0 - prof.det) : prof.det + osc.detune.value = baseDet + lfoGain.connect(osc.detune) + osc.connect(lp) + osc.start(t) + osc.stop(t + hold + release + 0.05) + k = k + 1 + } + + // Rosin: bow-on-string friction noise, bandpassed near the bridge, louder under pressure & at the + // attack (the bow "catch"), then a quieter sustained scratch. + let noiseAmt = rosin * 0.6 + pressure * 0.18 + if (noiseAmt > 0.01) { + let nlen = Math.floor(ctx.sampleRate * 0.5) + if (nlen < 1) { nlen = 1 } + let nbuf = ctx.createBuffer(1, nlen, ctx.sampleRate) + let ndata = nbuf.getChannelData(0) + let i = 0 + while (i < nlen) { + ndata[i] = Math.random() * 2 - 1 + i = i + 1 + } + let nsrc = ctx.createBufferSource() + nsrc.buffer = nbuf + nsrc.loop = true + let nbp = ctx.createBiquadFilter() + nbp.type = "bandpass" + nbp.frequency.value = (1800 + pressure * 2600) * prof.bright + nbp.Q.value = 0.8 + let ng = ctx.createGain() + let peak = noiseAmt * 0.5 + let sustain = noiseAmt * 0.16 + ng.gain.setValueAtTime(0, t) + ng.gain.linearRampToValueAtTime(peak, t + 0.012) + ng.gain.exponentialRampToValueAtTime(sustain > 0.0008 ? sustain : 0.0008, t + attack + 0.08) + ng.gain.setValueAtTime(sustain > 0.0008 ? sustain : 0.0008, t + hold) + ng.gain.exponentialRampToValueAtTime(0.0008, t + hold + release) + nsrc.connect(nbp) + nbp.connect(ng) + ng.connect(amp) + nsrc.start(t) + nsrc.stop(t + hold + release + 0.05) + } +} diff --git a/packages/synths/src/BasicOsc.tish b/packages/synths/src/BasicOsc.tish new file mode 100644 index 0000000..995acb6 --- /dev/null +++ b/packages/synths/src/BasicOsc.tish @@ -0,0 +1,51 @@ +import { midiToHz } from './Midi.tish' +import { normalizeBasicOscWaveform } from './Registry.tish' +import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' + +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) +} diff --git a/packages/synths/src/Bell.tish b/packages/synths/src/Bell.tish new file mode 100644 index 0000000..46ad755 --- /dev/null +++ b/packages/synths/src/Bell.tish @@ -0,0 +1,50 @@ +// Bell / stellar — two inharmonic sine partials through a highpass with an exponential bell decay. +// The 2.01× partial + highpass + percussive exp decay give a metallic shimmer (great with reverb), +// far from a flat MIDI sine. (Ported from the AetherSynth "stellar_bells" voice.) + +import { midiToHz } from './Midi.tish' + +export fn playBell(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let n = Math.floor(midi + bendSemis) + let hz = 440 + if (n >= 0 && n <= 127) { + hz = midiToHz(n) + } + let partial = p.partial !== null && p.partial > 0 ? p.partial : 2.01 + let hp = p.highpass !== null && p.highpass > 0 ? p.highpass : 800 + let decay = p.decay !== null && p.decay > 0 ? p.decay : 1.0 + + let o1 = ctx.createOscillator() + o1.type = "sine" + o1.frequency.value = hz + let o2 = ctx.createOscillator() + o2.type = "sine" + o2.frequency.value = hz * partial + + let filt = ctx.createBiquadFilter() + filt.type = "highpass" + filt.frequency.value = hp + + let env = ctx.createGain() + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + let peak = v * 0.4 + env.gain.setValueAtTime(0, t) + env.gain.linearRampToValueAtTime(peak, t + 0.006) + env.gain.exponentialRampToValueAtTime(0.0008, t + decay) + + o1.connect(filt) + o2.connect(filt) + filt.connect(env) + env.connect(bus.input) + o1.start(t) + o2.start(t) + o1.stop(t + decay + 0.1) + o2.stop(t + decay + 0.1) +} diff --git a/packages/synths/src/BuiltinMacros.tish b/packages/synths/src/BuiltinMacros.tish new file mode 100644 index 0000000..a394db3 --- /dev/null +++ b/packages/synths/src/BuiltinMacros.tish @@ -0,0 +1,134 @@ +// Deckard built-in macro catalog (host-owned). Register into @spacedevin/deck at boot. +import { registerBuiltinMacros } from '@spacedevin/deck' + +fn deckardBuiltinMacros() { + return { + "kick_edm": { + params: { pitch: 150, decay: 0.5 }, + body: [ + "osc o sine", + "gain a", + "conn o a", + "conn a out", + "env o.freq set 0 $pitch exp $decay 0.01", + "env a.gain set 0 1 exp $decay 0.01", + "dur 0.6" + ] + }, + "kick_deep": { + params: { pitch: 80, end: 20, decay: 0.4 }, + body: [ + "osc o triangle", + "gain a", + "conn o a", + "conn a out", + "env o.freq set 0 $pitch exp $decay $end", + "env a.gain set 0 0.85 lin 0.6 0.01", + "dur 0.7" + ] + }, + "kick_distorted": { + params: { pitch: 120, decay: 0.5, drive: 400 }, + body: [ + "osc o sine", + "shaper sh amount $drive", + "gain a", + "conn o sh", + "conn sh a", + "conn a out", + "env o.freq set 0 $pitch exp $decay 0.01", + "env a.gain set 0 1 exp $decay 0.01", + "dur 0.6" + ] + }, + "bass_reese": { + params: { level: 0.32 }, + body: [ + "osc o1 sawtooth note detune -6", + "osc o2 sawtooth note detune 0", + "osc o3 sawtooth note detune 6", + "filter f lowpass", + "gain a", + "conn o1 f", + "conn o2 f", + "conn o3 f", + "conn f a", + "conn a out", + "env f.freq set 0 800 lin dur 200", + "env a.gain set 0 $level set dur-0.1 $level lin dur 0.01" + ] + }, + "bass_reese_punch": { + params: { level: 0.5 }, + body: [ + "osc o1 sawtooth note detune -6", + "osc o2 sawtooth note detune 0", + "osc o3 sawtooth note detune 6", + "filter f lowpass", + "gain a", + "conn o1 f", + "conn o2 f", + "conn o3 f", + "conn f a", + "conn a out", + "env f.freq set 0 800 lin 0.15 200", + "env a.gain set 0 $level set 0.06 $level lin 0.15 0.01", + "dur 0.16" + ] + }, + "bass_reese_sc": { + params: {}, + body: [ + "osc o1 sawtooth note detune -6", + "osc o2 sawtooth note detune 0", + "osc o3 sawtooth note detune 6", + "filter f lowpass", + "gain a", + "conn o1 f", + "conn o2 f", + "conn o3 f", + "conn f a", + "conn a out", + "env f.freq set 0 800 lin dur 200", + "env a.gain set 0 0.05 lin dur*0.4 0.3 set dur*0.75 0.3 lin dur 0.01" + ] + }, + "bass_acid": { + params: { q: 15, top: 3200, level: 0.26 }, + body: [ + "osc o sawtooth note", + "filter f lowpass q $q", + "gain a", + "conn o f", + "conn f a", + "conn a out", + "env f.freq set 0 $top exp 0.2 100", + "env a.gain set 0 $level set dur-0.05 $level lin dur 0.01" + ] + }, + "bass_wobble": { + params: { lfo: 4 }, + body: [ + "osc o sawtooth note", + "osc lfo sine $lfo", + "gain lg 2500", + "filter f lowpass q 12", + "gain a", + "conn o f", + "conn lfo lg", + "conn lg f.freq", + "conn f a", + "conn a out", + "env f.freq set 0 300", + "env a.gain set 0 0.55 set dur-0.1 0.55 lin dur 0.01" + ] + } + } +} + +let done = false +export fn ensureBuiltinMacros() { + if (done) { return } + done = true + registerBuiltinMacros(deckardBuiltinMacros()) +} diff --git a/packages/synths/src/C64Sid.tish b/packages/synths/src/C64Sid.tish new file mode 100644 index 0000000..606f4fa --- /dev/null +++ b/packages/synths/src/C64Sid.tish @@ -0,0 +1,253 @@ + +export fn defaultParamsForC64Sid() { + return { + waveform: "sawtooth", + pulseWidth: 0.5, + filterType: "lowpass", + cutoff: 2000, + resonance: 5, + attack: 0, + decay: 5, + sustain: 15, + release: 6, + hardSync: false, + ringMod: false, + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0 + } +} + +// SID Noise is a 23-bit LFSR +fn getSidNoiseBuffer(ctx) { + let cacheKey = "sidNoise" + if (ctx[cacheKey]) return ctx[cacheKey] + + let steps = 8388607 // 2^23 - 1 (too large for standard caching, we will use a shorter segment for practicality) + let bufSteps = ctx.sampleRate * 2 // 2 seconds is plenty + let buf = ctx.createBuffer(1, bufSteps, ctx.sampleRate) + let data = buf.getChannelData(0) + + let reg = 0x7FFFFF + let i = 0 + while (i < bufSteps) { + let bit22 = (reg >> 22) & 1 + let bit17 = (reg >> 17) & 1 + let feedback = bit22 ^ bit17 + reg = ((reg << 1) | feedback) & 0x7FFFFF + + // Output is taken from bits 22, 20, 16, 13, 11, 7, 4, 2 + let out = ((reg >> 22) & 1) << 7 | + ((reg >> 20) & 1) << 6 | + ((reg >> 16) & 1) << 5 | + ((reg >> 13) & 1) << 4 | + ((reg >> 11) & 1) << 3 | + ((reg >> 7) & 1) << 2 | + ((reg >> 4) & 1) << 1 | + ((reg >> 2) & 1) + + data[i] = (out / 128.0) - 1.0 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +// Synthesize Hard Sync / Ring Mod buffer +fn getSyncRingModBuffer(ctx, baseFreq, slaveFreqRatio, durSec, doSync, doRing) { + let len = Math.ceil(durSec * ctx.sampleRate) + let buf = ctx.createBuffer(1, len, ctx.sampleRate) + let data = buf.getChannelData(0) + + let phase1 = 0 + let phase2 = 0 + let inc1 = baseFreq / ctx.sampleRate + let inc2 = (baseFreq * slaveFreqRatio) / ctx.sampleRate + + let i = 0 + while (i < len) { + let prevPhase1 = phase1 + phase1 = phase1 + inc1 + if (phase1 >= 1.0) phase1 = phase1 - 1.0 + + if (doSync && phase1 < prevPhase1) { + phase2 = 0 // Hard reset + } + + phase2 = phase2 + inc2 + if (phase2 >= 1.0) phase2 = phase2 - 1.0 + + let tri1 = phase1 < 0.5 ? (phase1 * 4 - 1) : (3 - phase1 * 4) + let tri2 = phase2 < 0.5 ? (phase2 * 4 - 1) : (3 - phase2 * 4) + + let out = tri2 + if (doRing) { + // SID Ring Mod is an XOR on the triangle wave generation + // But approximated via standard ring mod for Web Audio unless building bit-level worklets + out = tri1 * tri2 + } + + data[i] = out + i = i + 1 + } + return buf +} + +export fn playC64Sid(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let waveform = gp.waveform ? gp.waveform : "sawtooth" + let pw = gp.pulseWidth !== null ? gp.pulseWidth : 0.5 + let filterType = gp.filterType ? gp.filterType : "lowpass" + let cutoff = gp.cutoff !== null ? gp.cutoff : 2000 + let resonance = gp.resonance !== null ? gp.resonance : 5 + + let attack = gp.attack !== null ? gp.attack : 0 + let decay = gp.decay !== null ? gp.decay : 5 + let sustain = gp.sustain !== null ? gp.sustain : 15 + let release = gp.release !== null ? gp.release : 6 + + let hardSync = gp.hardSync === true + let ringMod = gp.ringMod === true + + let pitchDrop = gp.pitchDrop !== null ? Math.round(gp.pitchDrop) : 0 + let pitchDec = gp.pitchDec !== null ? gp.pitchDec : 0.05 + let vibRate = gp.vibRate !== null ? gp.vibRate : 0 + let vibAmt = gp.vibAmt !== null ? gp.vibAmt : 0 + let arpRate = gp.arpRate !== null ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null ? 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 src = null + let disconnects = [] + + // SID ADSR maps to standard values but hardware is notoriously slow/logarithmic + // SID Attack: 0-15 (2ms to 8000ms) + let atkTable = [0.002, 0.008, 0.016, 0.024, 0.038, 0.056, 0.068, 0.080, 0.100, 0.250, 0.500, 0.800, 1.0, 3.0, 5.0, 8.0] + let decTable = [0.006, 0.024, 0.048, 0.072, 0.114, 0.168, 0.204, 0.240, 0.300, 0.750, 1.500, 2.400, 3.0, 9.0, 15.0, 24.0] + + let a = atkTable[Math.min(15, Math.max(0, Math.round(attack)))] + let d = decTable[Math.min(15, Math.max(0, Math.round(decay)))] + let s = (sustain / 15.0) + let r = decTable[Math.min(15, Math.max(0, Math.round(release)))] + + let safeDur = Math.max(durSec, 0.01) + let tA = t + a + let tD = tA + d + let tOff = Math.max(t + safeDur, tD) + let totalTime = tOff - t + r + 0.1 + let stopTime = t + totalTime + + // Complex synthesis (Sync / Ring Mod) + if (hardSync || ringMod) { + let slaveFreqRatio = 2.0 // Usually set by a second oscillator parameter, hardcoded to octave up for now + let buf = getSyncRingModBuffer(ctx, f0, slaveFreqRatio, totalTime, hardSync, ringMod) + src = ctx.createBufferSource() + src.buffer = buf + src.playbackRate.value = 1.0 + } else if (waveform === "noise") { + src = ctx.createBufferSource() + src.buffer = getSidNoiseBuffer(ctx) + src.loop = true + src.playbackRate.value = (f0 / 440.0) // SID noise pitch scales with frequency + } else if (waveform === "pulse") { + src = ctx.createOscillator() + src.type = "sawtooth" // We will emulate PWM via phase inversion if we needed 100%, but for simplicity: + src.frequency.value = f0 + // Real PWM requires dual sawtooth with phase offset. + let saw1 = ctx.createOscillator() + saw1.type = "sawtooth" + saw1.frequency.value = f0 + let saw2 = ctx.createOscillator() + saw2.type = "sawtooth" + saw2.frequency.value = f0 + // Web audio cannot offset phase. Let's use a simpler square for now until a custom PWM node is needed. + src.type = "square" + } else { + src = ctx.createOscillator() + src.type = waveform + src.frequency.value = f0 + } + + // Pitch Mod + let freqParam = src.frequency ? src.frequency : src.playbackRate + let baseFreq = src.frequency ? f0 : src.playbackRate.value + + if (pitchDrop !== 0) { + let rateDrop = baseFreq * Math.pow(2, pitchDrop / 12) + freqParam.setValueAtTime(rateDrop, t) + freqParam.setTargetAtTime(baseFreq, 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 + freqParam.setValueAtTime(baseFreq, t) + while (ai < steps) { + let semi = (ai % 2 === 1) ? arpSemis : 0 + freqParam.setValueAtTime(baseFreq * Math.pow(2, semi / 12), t + ai * arpStep) + ai = ai + 1 + } + } else { + freqParam.setValueAtTime(baseFreq, t) + } + + if (vibRate > 0 && vibAmt > 0 && !hardSync && !ringMod && waveform !== "noise") { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = vibRate + let lfoGain = ctx.createGain() + lfoGain.gain.value = baseFreq * (vibAmt / 1200) + lfo.connect(lfoGain) + lfoGain.connect(freqParam) + lfo.start(t) + lfo.stop(stopTime) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + // Filter (SID has 12dB/oct multi-mode analog filter) + let filter = ctx.createBiquadFilter() + filter.type = filterType + filter.frequency.value = cutoff + // Q roughly maps to 0 to 15 on SID. Standard Biquad Q is a multiplier. + filter.Q.value = Math.max(0.1, resonance / 2.0) + + // Amp Env + let maxAmp = v * 0.8 + let finalGain = ctx.createGain() + + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(maxAmp, tA) + finalGain.gain.setTargetAtTime(maxAmp * s, tA, Math.max(0.001, d / 3)) // setTarget curve + + finalGain.gain.setValueAtTime(maxAmp * s, tOff) + finalGain.gain.setTargetAtTime(0, tOff, Math.max(0.001, r / 3)) + + src.start(t) + src.stop(stopTime) + disconnects.push(src) + + src.connect(filter) + filter.connect(finalGain) + finalGain.connect(bus.input) + + disconnects.push(filter) + disconnects.push(finalGain) + + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/Chiptune.tish b/packages/synths/src/Chiptune.tish new file mode 100644 index 0000000..a566ff1 --- /dev/null +++ b/packages/synths/src/Chiptune.tish @@ -0,0 +1,208 @@ +export fn playChiptune(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let waveform = gp.waveform ? gp.waveform : "pulse" + let pw = gp.pulseWidth !== null ? gp.pulseWidth : 0.5 + let pwmSpeed = gp.pwmSpeed !== null ? gp.pwmSpeed : 0 + let crush = gp.bitcrush !== null ? gp.bitcrush : 0 + let lpAmt = gp.lowpass !== null ? gp.lowpass : 0 + let arpRate = gp.arpRate !== null ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null ? gp.arpSemis : 0 + let att = gp.attack !== null ? gp.attack : 0.005 + let dec = gp.decay !== null ? gp.decay : 0.3 + let sus = gp.sustain !== null ? gp.sustain : 0 + let rel = gp.release !== null ? gp.release : 0.05 + + 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 totalTime = durSec + rel + 0.5 + let stopTime = t + totalTime + let disconnects = [] + + // ─── Source oscillator (waveform switch) ─────────────────────── + let sourceNode = null + + if (waveform === "noise") { + // White noise buffer (looped) + let sr = ctx.sampleRate + let len = Math.floor(sr * 0.5) + if (len < 128) len = 128 + let buf = ctx.createBuffer(1, len, sr) + let data = buf.getChannelData(0) + let ni = 0 + while (ni < len) { + data[ni] = Math.random() * 2 - 1 + ni = ni + 1 + } + let src = ctx.createBufferSource() + src.buffer = buf + src.loop = true + src.start(t) + src.stop(stopTime) + sourceNode = src + disconnects.push(src) + + } else if (waveform === "triangle") { + let osc = ctx.createOscillator() + osc.type = "triangle" + osc.frequency.value = f0 + osc.start(t) + osc.stop(stopTime) + sourceNode = osc + disconnects.push(osc) + + // Arp on triangle + if (arpRate > 0 && arpSemis !== 0) { + let f1 = f0 * Math.pow(2, arpSemis / 12) + let interval = 1.0 / arpRate + let at = t + let toggle = false + while (at < stopTime) { + osc.frequency.setValueAtTime(toggle ? f1 : f0, at) + at = at + interval + toggle = !toggle + } + } + + } else if (waveform === "sawtooth") { + let osc = ctx.createOscillator() + osc.type = "sawtooth" + osc.frequency.value = f0 + osc.start(t) + osc.stop(stopTime) + sourceNode = osc + disconnects.push(osc) + + // Arp on sawtooth + if (arpRate > 0 && arpSemis !== 0) { + let f1 = f0 * Math.pow(2, arpSemis / 12) + let interval = 1.0 / arpRate + let at = t + let toggle = false + while (at < stopTime) { + osc.frequency.setValueAtTime(toggle ? f1 : f0, at) + at = at + interval + toggle = !toggle + } + } + + } else { + // Default: pulse wave (two saws with phase delay trick) + let osc1 = ctx.createOscillator() + osc1.type = "sawtooth" + osc1.frequency.value = f0 + let osc2 = ctx.createOscillator() + osc2.type = "sawtooth" + osc2.frequency.value = f0 + + let invGain = ctx.createGain() + invGain.gain.value = -1 + + let delayNode = ctx.createDelay() + let maxDelay = 1 / Math.max(f0, 10) + let clampedPw = Math.max(0.05, Math.min(pw, 0.95)) + delayNode.delayTime.value = maxDelay * clampedPw + + if (pwmSpeed > 0) { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = pwmSpeed + let lfoGain = ctx.createGain() + lfoGain.gain.value = maxDelay * 0.25 + lfo.connect(lfoGain) + lfoGain.connect(delayNode.delayTime) + lfo.start(t) + lfo.stop(stopTime) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + let mergeGain = ctx.createGain() + mergeGain.gain.value = 0.5 + + osc1.connect(mergeGain) + osc2.connect(invGain) + invGain.connect(delayNode) + delayNode.connect(mergeGain) + + osc1.start(t) + osc2.start(t) + osc1.stop(stopTime) + osc2.stop(stopTime) + + // Arp on pulse + if (arpRate > 0 && arpSemis !== 0) { + let f1 = f0 * Math.pow(2, arpSemis / 12) + let interval = 1.0 / arpRate + let at = t + let toggle = false + while (at < stopTime) { + let fNow = toggle ? f1 : f0 + osc1.frequency.setValueAtTime(fNow, at) + osc2.frequency.setValueAtTime(fNow, at) + at = at + interval + toggle = !toggle + } + } + + sourceNode = mergeGain + disconnects.push(osc1) + disconnects.push(osc2) + disconnects.push(invGain) + disconnects.push(delayNode) + disconnects.push(mergeGain) + } + + // ─── Bitcrush (waveshaper quantizer) ─────────────────────────── + let afterCrush = sourceNode + if (crush > 0) { + let shaper = ctx.createWaveShaper() + let steps = Math.max(2, Math.floor(16 - crush * 14)) + let n = 4096 + let curve = new Float32Array(n) + let ci = 0 + while (ci < n) { + let x = (ci * 2 / n) - 1 + curve[ci] = Math.round(x * steps) / steps + ci = ci + 1 + } + shaper.curve = curve + sourceNode.connect(shaper) + afterCrush = shaper + disconnects.push(shaper) + } + + // ─── Lowpass filter (chip character) ─────────────────────────── + let afterFilter = afterCrush + if (lpAmt > 0.01) { + let lp = ctx.createBiquadFilter() + lp.type = "lowpass" + lp.Q.value = 0.7 + lp.frequency.value = 300 + (1 - lpAmt) * 8000 + afterCrush.connect(lp) + afterFilter = lp + disconnects.push(lp) + } + + // ─── Amplitude envelope (full ADSR) ──────────────────────────── + let finalGain = ctx.createGain() + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(v * 0.8, t + att) + finalGain.gain.setTargetAtTime(v * 0.8 * sus, t + att, dec / 3) + finalGain.gain.setValueAtTime(v * 0.8 * sus, t + durSec) + finalGain.gain.setTargetAtTime(0, t + durSec, rel / 3) + + afterFilter.connect(finalGain) + finalGain.connect(bus.input) + disconnects.push(finalGain) + + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/Clap.tish b/packages/synths/src/Clap.tish new file mode 100644 index 0000000..99bfdaa --- /dev/null +++ b/packages/synths/src/Clap.tish @@ -0,0 +1,140 @@ +// The Clap — a handclap engine. A real clap isn't one sound: it's a CLUSTER of very short bandpassed +// noise bursts (each = one pair of hands meeting), slightly desynchronized because no two hands land at +// the same instant, plus a short reverberant room tail. Scaling those facts gives the whole range the +// user asked for: +// hands — bursts per clap: 1 = a single clean clap, 3-4 = the classic drum-machine clap (reads as a +// few people), 8-12 = a crowd / applause. +// spread — timing jitter between hands: 0 = tight/synced, 1 = sloppy (a real crowd is never tight). +// size — hand size → bandpass centre: big hands (0) are lower & duller, small hands (1) brighter. +// Each hand also varies randomly around this, so you hear DIFFERENT hand sizes in one clap. +// tone — brightness (filter Q + highpass): crisp snap vs soft pat. +// claps — number of clap CLUSTERS: 1 = single, 2 = double clap, 3-4 = rhythmic repeats. +// gap — time between those clusters. +// tail — length of the room tail after each cluster. +// body — a soft low-mid thump under the claps for weight. +// Claps are unpitched, so the triggered note is ignored — it sounds like a clap on every rack row. + +fn clapClamp01(x) { + if (x < 0) { return 0 } + if (x > 1) { return 1 } + return x +} + +fn clapClampInt(x, lo, hi) { + let v = Math.floor(x + 0.5) + if (v < lo) { return lo } + if (v > hi) { return hi } + return v +} + +// One hand-burst: a short slice of noise, bandpassed around `center` then highpassed, with a fast snap +// envelope. `off` reads from a random point in the shared buffer so every burst is a different grain. +fn clapBurst(ctx, bus, noiseBuf, when, center, q, hp, level, decaySec) { + let src = ctx.createBufferSource() + src.buffer = noiseBuf + let off = Math.random() * 0.2 + let bp = ctx.createBiquadFilter() + bp.type = "bandpass" + bp.frequency.value = center + bp.Q.value = q + let hpf = ctx.createBiquadFilter() + hpf.type = "highpass" + hpf.frequency.value = hp + let g = ctx.createGain() + g.gain.setValueAtTime(0, when) + g.gain.linearRampToValueAtTime(level, when + 0.0005) + g.gain.exponentialRampToValueAtTime(0.0008, when + decaySec) + src.connect(bp) + bp.connect(hpf) + hpf.connect(g) + g.connect(bus.input) + src.start(when, off) + src.stop(when + decaySec + 0.05) +} + +export fn playClap(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let hands = clapClampInt(p.hands !== null ? p.hands : 3, 1, 12) + let spread = clapClamp01(p.spread !== null ? p.spread : 0.4) + let size = clapClamp01(p.size !== null ? p.size : 0.5) + let tone = clapClamp01(p.tone !== null ? p.tone : 0.5) + let claps = clapClampInt(p.claps !== null ? p.claps : 1, 1, 4) + let gap = clapClamp01(p.gap !== null ? p.gap : 0.35) + let tail = clapClamp01(p.tail !== null ? p.tail : 0.4) + let body = clapClamp01(p.body !== null ? p.body : 0.2) + let v = vel / 127 + if (v < 0) { v = 0 } + if (v > 1) { v = 1 } + if (v <= 0) { return } + + let sr = ctx.sampleRate + // Shared 0.8s white-noise buffer; every burst reads a random offset into it. + let nlen = Math.floor(sr * 0.8) + if (nlen < 1) { nlen = 1 } + let noiseBuf = ctx.createBuffer(1, nlen, sr) + let nd = noiseBuf.getChannelData(0) + let i = 0 + while (i < nlen) { + nd[i] = Math.random() * 2 - 1 + i = i + 1 + } + + let baseCenter = 900 + size * 1400 // ~900..2300 Hz (big hands low, small hands high) + let q = 1.4 + tone * 4.5 // brighter tone = tighter band = crisper snap + let hp = 500 + tone * 900 + let handSpacing = 0.0018 + spread * 0.03 // ~1.8..32 ms nominal gap between hands + let clusterGap = 0.09 + gap * 0.33 // ~90..420 ms between clap clusters + let burstDecay = 0.006 + (1 - tone) * 0.012 + (1 - size) * 0.004 // bigger/duller hands ring a touch longer + // Normalize by WORST-CASE overlap, not raw hand count: when bursts pack tighter than their decay (tight + // spread + bright tone) several ring at once, so divide by the overlap density to keep one clap stem from + // peaking past ~1 and slamming the master glue compressor. + let overlap = handSpacing > 0 ? burstDecay / handSpacing : hands + let handNorm = Math.sqrt(hands) + if (overlap + 1 > handNorm) { + handNorm = overlap + 1 + } + + let c = 0 + while (c < claps) { + let clusterT = t + c * clusterGap + let clusterLevel = v * (1 - c * 0.13) // each repeat slightly softer + if (clusterLevel < 0.05) { clusterLevel = 0.05 } + let h = 0 + let lastOffset = 0 + while (h < hands) { + // Desync: cumulative spacing with per-hand jitter — a crowd is never perfectly tight. + if (h > 0) { + lastOffset = lastOffset + handSpacing * (0.5 + Math.random()) + } + let when = clusterT + lastOffset + let hc = baseCenter * (0.75 + Math.random() * 0.6) // each hand a different size + let hl = clusterLevel * (0.45 + Math.random() * 0.4) / handNorm + clapBurst(ctx, bus, noiseBuf, when, hc, q, hp, hl, burstDecay) + h = h + 1 + } + // Room tail after the cluster (the reverberant splat). + if (tail > 0.02) { + let tailDecay = 0.04 + tail * 0.34 + clapBurst(ctx, bus, noiseBuf, clusterT + lastOffset * 0.4, baseCenter * 0.85, 0.8 + tone * 1.5, hp * 0.6, clusterLevel * 0.28, tailDecay) + } + c = c + 1 + } + + // Optional low-mid body thump for weight (a soft lowpassed noise pop at the first cluster). + if (body > 0.02) { + let bsrc = ctx.createBufferSource() + bsrc.buffer = noiseBuf + let lp = ctx.createBiquadFilter() + lp.type = "lowpass" + lp.frequency.value = 260 + let bg = ctx.createGain() + bg.gain.setValueAtTime(0, t) + bg.gain.linearRampToValueAtTime(v * body * 0.45, t + 0.001) + bg.gain.exponentialRampToValueAtTime(0.0008, t + 0.05 + body * 0.06) + bsrc.connect(lp) + lp.connect(bg) + bg.connect(bus.input) + bsrc.start(t, Math.random() * 0.2) + bsrc.stop(t + 0.3) + } +} diff --git a/packages/synths/src/Cymbal.tish b/packages/synths/src/Cymbal.tish new file mode 100644 index 0000000..547ab9f --- /dev/null +++ b/packages/synths/src/Cymbal.tish @@ -0,0 +1,82 @@ +export fn playCymbal(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let tune = (gp.tune !== null) ? gp.tune : 300 + let metallic = (gp.metallic !== null) ? gp.metallic : 0.8 + let decay = (gp.decay !== null) ? gp.decay : 0.4 + let highpass = (gp.highpass !== null) ? gp.highpass : 7000 + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + // TR-808 cymbal ratios (approximate) + let ratios = [1.0, 1.447, 2.088, 2.761, 3.483, 4.254] + let oscs = [] + + let metallicGain = ctx.createGain() + metallicGain.gain.value = metallic + + let i = 0 + while (i < ratios.length) { + let osc = ctx.createOscillator() + osc.type = "square" + osc.frequency.value = tune * ratios[i] + osc.connect(metallicGain) + oscs.push(osc) + i = i + 1 + } + + // White noise for sizzle + let bufferSize = ctx.sampleRate * 2 + let noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate) + let output = noiseBuffer.getChannelData(0) + let ni = 0 + while (ni < bufferSize) { + output[ni] = Math.random() * 2 - 1 + ni = ni + 1 + } + let noise = ctx.createBufferSource() + noise.buffer = noiseBuffer + noise.loop = true + + let noiseGain = ctx.createGain() + noiseGain.gain.value = 1.0 - metallic + noise.connect(noiseGain) + + let filter = ctx.createBiquadFilter() + filter.type = "highpass" + filter.frequency.value = highpass + filter.Q.value = 1 + + let envGain = ctx.createGain() + envGain.gain.setValueAtTime(0, t) + envGain.gain.linearRampToValueAtTime(v * 0.5, t + 0.005) + envGain.gain.setTargetAtTime(0, t + 0.005, decay / 3) + + metallicGain.connect(filter) + noiseGain.connect(filter) + filter.connect(envGain) + envGain.connect(bus.input) + + let oi = 0 + while (oi < oscs.length) { + oscs[oi].start(t) + oscs[oi].stop(t + decay + 0.5) + oi = oi + 1 + } + noise.start(t) + noise.stop(t + decay + 0.5) + + setTimeout(() => { + let di = 0 + while (di < oscs.length) { + oscs[di].disconnect() + di = di + 1 + } + noise.disconnect() + metallicGain.disconnect() + noiseGain.disconnect() + filter.disconnect() + envGain.disconnect() + }, (decay + 1) * 1000) +} diff --git a/packages/synths/src/DeckIds.tish b/packages/synths/src/DeckIds.tish new file mode 100644 index 0000000..e66acbb --- /dev/null +++ b/packages/synths/src/DeckIds.tish @@ -0,0 +1,103 @@ +// Deckard host registrations for @spacedevin/deck (ids, params, gen_block dialects, highlight). +import { + registerGeneratorIdAliases, + registerParamKeyAliases, + registerGenBlockDialect, + paramKeyToCamel, + registerBodyLineDialect, + registerHighlightKeywords +} from '@spacedevin/deck' +import { parseMatrixFmGraph } from './MatrixFmGraph.tish' +import { parsePatchGraph } from './PatchGraph.tish' + +/// `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 } +} + +let registered = false + +fn parseMatrixFmDialect(generatorId, lines) { + return { + kind: "matrixFm", + tplHeaderId: generatorId, + version: 2, + raw: lines, + graph: parseMatrixFmGraph(lines) + } +} + +fn parsePatchDialect(generatorId, lines) { + return { + kind: "patch", + tplHeaderId: generatorId, + version: 1, + raw: lines, + graph: parsePatchGraph(lines) + } +} + +export fn ensureDeckGeneratorIds() { + if (registered) { + return + } + registered = true + registerGeneratorIdAliases( + { + noise_burst: "noiseBurst", + noiseburst: "noiseBurst", + fm: "fmTone", + fm_tone: "fmTone", + fmtone: "fmTone", + basic_osc: "basicOsc", + basicosc: "basicOsc", + osc: "basicOsc", + matrix_fm: "matrixFm", + matrixfm: "matrixFm", + drum: "drumSynth", + drum_synth: "drumSynth", + drumsynth: "drumSynth", + patch: "patch", + modular: "patch", + synth: "patch" + }, + { + noiseBurst: "noise_burst", + fmTone: "fm", + basicOsc: "basic_osc", + drumSynth: "drum", + matrixFm: "matrix_fm" + } + ) + registerParamKeyAliases({ + pitch_follow: "pitchFollow", + mod_index: "modIndex", + carrier_wave: "carrierWave", + mod_wave: "modWave", + filter_cutoff: "filterCutoff" + }) + // 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 — trailing key/value pairs with snake keys — so they + // register as a body dialect rather than being re-scanned in Apply. Keeps the shape in one place + // and lets a `.deck` written for this host still parse everywhere the package is used. + registerBodyLineDialect(["noise", "fm", "osc"], parseLegacyGenLine) + + registerGenBlockDialect(["matrixFm", "matrix_fm", "matrixfm"], parseMatrixFmDialect) + registerGenBlockDialect(["patch", "modular", "synth"], parsePatchDialect) + registerHighlightKeywords({ + body: [ + "noise", "fm", "osc", "conn", "env", "filter", "shaper", "gain", "dur", + "pan", "string", "syncosc", "op", "mod", "route" + ] + }) +} diff --git a/packages/synths/src/Dispatch.tish b/packages/synths/src/Dispatch.tish new file mode 100644 index 0000000..2c0b8f9 --- /dev/null +++ b/packages/synths/src/Dispatch.tish @@ -0,0 +1,175 @@ +import { playBasicOsc } from './BasicOsc.tish' +import { playNoiseBurst } from './NoiseBurst.tish' +import { playFmTone } from './FmTone.tish' +import { playMatrixFm } from './MatrixFm.tish' +import { playPad } from './Pad.tish' +import { playBell } from './Bell.tish' +import { playDrumSynth } from './DrumSynth.tish' +import { playGuitar } from './Guitar.tish' +import { playClap } from './Clap.tish' +import { playArco } from './Arco.tish' +import { playTine } from './Tine.tish' +import { playAether } from './Aether.tish' +import { playHalo } from './Halo.tish' +import { playFormantVocal } from './FormantVocal.tish' +import { playTtsVocal } from './TtsVocal.tish' +import { playMeSpeakVocal } from './MeSpeakVocal.tish' +import { playPatch } from './Patch.tish' +import { playAcid303 } from './Acid303.tish' +import { playSub808 } from './Sub808.tish' +import { playChiptune } from './Chiptune.tish' +import { playNes2a03 } from './Nes2a03.tish' +import { playC64Sid } from './C64Sid.tish' +import { playGameBoyDmg } from './GameBoyDmg.tish' +import { playYm2612 } from './Ym2612.tish' +import { playSn76489 } from './Sn76489.tish' +import { playSpc700 } from './Spc700.tish' +import { playGbaDirectSound } from './GbaDirectSound.tish' +import { playCymbal } from './Cymbal.tish' +import { playReeseBass } from './ReeseBass.tish' +import { playSyncLead } from './SyncLead.tish' +import { playSyncChoir } from './SyncChoir.tish' +import { playObSync } from './ObSync.tish' +import { playLaserSync } from './LaserSync.tish' + +export fn dispatchPlayNote(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) { + if (!bus || !bus.input) { + return + } + let id = ch.generatorId + if (!id) { + id = "basicOsc" + } + if (ch.octave !== null && ch.octave !== 0) { + midi = midi + ch.octave * 12 + } + if (id === "noiseBurst") { + playNoiseBurst(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "fmTone") { + playFmTone(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "matrixFm") { + playMatrixFm(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "pad") { + playPad(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "bell") { + playBell(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "drumSynth") { + playDrumSynth(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "guitar") { + playGuitar(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "clap") { + playClap(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "arco") { + playArco(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "tine") { + playTine(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "aether") { + playAether(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "halo") { + playHalo(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "formantVocal") { + playFormantVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) + return + } + if (id === "ttsVocal") { + playTtsVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) + return + } + if (id === "meSpeakVocal") { + playMeSpeakVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) + return + } + if (id === "acid303") { + playAcid303(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "sub808") { + playSub808(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "chiptune") { + playChiptune(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "nes2a03") { + playNes2a03(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "c64sid") { + playC64Sid(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "gameBoyDmg") { + playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "ym2612") { + playYm2612(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "sn76489") { + playSn76489(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "spc700") { + playSpc700(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "gbaDirectSound") { + playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "cymbal") { + playCymbal(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "reeseBass") { + playReeseBass(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "syncLead") { + playSyncLead(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "syncChoir") { + playSyncChoir(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "obSync") { + playObSync(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "laserSync") { + playLaserSync(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + if (id === "patch") { + playPatch(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) +} diff --git a/packages/synths/src/DrumSynth.tish b/packages/synths/src/DrumSynth.tish new file mode 100644 index 0000000..be6bc9a --- /dev/null +++ b/packages/synths/src/DrumSynth.tish @@ -0,0 +1,91 @@ +// Drum synth — a pitched body with a PITCH-ENVELOPE drop (the thing a flat MIDI osc can't do), +// an amp decay, an optional noise layer, and optional drive. This is what makes punchy kicks, snares, +// toms and 808s instead of beeps. Base pitch = the triggered note; pitchEnv = semitones it starts above. + +import { midiToHz } from './Midi.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 +} + +export fn playDrumSynth(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let n = Math.floor(midi + bendSemis) + let baseHz = 110 + if (n >= 0 && n <= 127) { + baseHz = midiToHz(n) + } + let tone = p.tone ? p.tone : "sine" + let pitchEnv = p.pitchEnv !== null ? p.pitchEnv : 36 + let pitchDecay = p.pitchDecay !== null && p.pitchDecay > 0 ? p.pitchDecay : 0.06 + let decay = p.decay !== null && p.decay > 0 ? p.decay : 0.35 + let noiseAmt = p.noise !== null ? p.noise : 0 + let noiseDecay = p.noiseDecay !== null && p.noiseDecay > 0 ? p.noiseDecay : 0.12 + let drive = p.drive !== null ? p.drive : 0 + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + + // Tonal body with a pitch-envelope drop. + let osc = ctx.createOscillator() + osc.type = tone + let startHz = baseHz * Math.pow(2, pitchEnv / 12) + osc.frequency.setValueAtTime(startHz, t) + osc.frequency.exponentialRampToValueAtTime(Math.max(0.001, baseHz), t + pitchDecay) + let env = ctx.createGain() + env.gain.setValueAtTime(v, t) + env.gain.exponentialRampToValueAtTime(0.0008, t + decay) + if (drive > 0) { + let ws = ctx.createWaveShaper() + ws.curve = makeDriveCurve(ctx, drive) + ws.oversample = "2x" + osc.connect(ws) + ws.connect(env) + } else { + osc.connect(env) + } + env.connect(bus.input) + osc.start(t) + osc.stop(t + decay + 0.05) + + // Optional noise layer (snare snap / clap / hat top). + if (noiseAmt > 0.001) { + let len = Math.floor(ctx.sampleRate * (noiseDecay + 0.02)) + if (len < 1) { + len = 1 + } + let buf = ctx.createBuffer(1, len, ctx.sampleRate) + let data = buf.getChannelData(0) + let k = 0 + while (k < len) { + data[k] = Math.random() * 2 - 1 + k = k + 1 + } + let src = ctx.createBufferSource() + src.buffer = buf + let nf = ctx.createBiquadFilter() + nf.type = "highpass" + nf.frequency.value = p.noiseHp !== null && p.noiseHp > 0 ? p.noiseHp : 1500 + let ng = ctx.createGain() + ng.gain.setValueAtTime(v * noiseAmt, t) + ng.gain.exponentialRampToValueAtTime(0.0008, t + noiseDecay) + src.connect(nf) + nf.connect(ng) + ng.connect(bus.input) + src.start(t) + src.stop(t + noiseDecay + 0.05) + } +} diff --git a/packages/synths/src/FmTone.tish b/packages/synths/src/FmTone.tish new file mode 100644 index 0000000..046ab49 --- /dev/null +++ b/packages/synths/src/FmTone.tish @@ -0,0 +1,81 @@ +import { midiToHz } from './Midi.tish' +import { normalizeBasicOscWaveform } from './Registry.tish' +import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' + +export fn playFmTone(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams + let ratio = 2 + let modIndex = 4 + let carW = "sine" + let modW = "sine" + let a = 0.005 + let d = 0.08 + let sus = 0.4 + let r = 0.12 + if (p) { + if (p.ratio > 0 && p.ratio < 64) { + ratio = p.ratio + } + if (p.modIndex >= 0 && p.modIndex <= 48) { + modIndex = p.modIndex + } + if (p.carrierWave) { + carW = normalizeBasicOscWaveform(p.carrierWave) + } + if (p.modWave) { + modW = normalizeBasicOscWaveform(p.modWave) + } + 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 n = Math.floor(midi + bendSemis) + let Fc = 440 + if (n >= 0 && n <= 127) { + Fc = midiToHz(n) + } + let mod = ctx.createOscillator() + mod.type = modW + mod.frequency.value = Fc * ratio + let modDepth = ctx.createGain() + let depthHz = Fc * modIndex * 0.12 + if (depthHz < 0.5) { + depthHz = 0.5 + } + if (depthHz > 6000) { + depthHz = 6000 + } + modDepth.gain.value = depthHz + mod.connect(modDepth) + let car = ctx.createOscillator() + car.type = carW + car.frequency.value = Fc + modDepth.connect(car.frequency) + let env = ctx.createGain() + env.gain.value = 0 + car.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) + mod.start(t) + car.start(t) + mod.stop(tEnd + 0.08) + car.stop(tEnd + 0.08) +} diff --git a/packages/synths/src/FormantVocal.tish b/packages/synths/src/FormantVocal.tish new file mode 100644 index 0000000..35178ee --- /dev/null +++ b/packages/synths/src/FormantVocal.tish @@ -0,0 +1,149 @@ +import { midiToHz } from './Midi.tish' + +// All 13 distinct English monophthong vowels. +// F1/F2/F3 values from Peterson & Barney (1952) male-speaker averages. +// Multi-char keys checked first so single-letter fallbacks still work for +// backward-compatible lyric strings. +export fn extractVowel(str) { + if (!str) return 'A' + let s = str.toUpperCase() + // 2-char keys first (order matters — longer match wins) + if (s.indexOf('EY') >= 0) return 'EY' + if (s.indexOf('AE') >= 0) return 'AE' + if (s.indexOf('OH') >= 0) return 'OH' + if (s.indexOf('OO') >= 0) return 'OO' + if (s.indexOf('UH') >= 0) return 'UH' + if (s.indexOf('ER') >= 0) return 'ER' + if (s.indexOf('IH') >= 0) return 'IH' + if (s.indexOf('UX') >= 0) return 'UX' + // single-letter fallbacks (backward compat) + if (s.indexOf('A') >= 0) return 'A' + if (s.indexOf('E') >= 0) return 'E' + if (s.indexOf('I') >= 0) return 'I' + if (s.indexOf('O') >= 0) return 'O' + if (s.indexOf('U') >= 0) return 'U' + return 'A' +} + +let VOWEL_FORMANTS = { + // ── front vowels (high → low) ── + 'I': [270, 2290, 3010], // /i/ heed "ee" (beat, see) + 'IH': [390, 1990, 2550], // /ɪ/ hid "ih" (bit, sit) + 'EY': [400, 2100, 2800], // /e/ hayed "ay" (bait, hey) + 'E': [530, 1840, 2480], // /ɛ/ head "eh" (bed, said) + 'AE': [660, 1720, 2410], // /æ/ had "ae" (cat, bat) + // ── open / back vowels ── + 'A': [730, 1090, 2440], // /ɑ/ hod "ah" (father, palm) + 'O': [570, 840, 2410], // /ɔ/ hawed "aw" (law, caught) + 'OH': [500, 910, 2450], // /o/ hoed "oh" (boat, go) + 'OO': [440, 1020, 2240], // /ʊ/ hood "uu" (book, put) + 'U': [300, 870, 2240], // /u/ who'd "oo" (boot, who) + // ── central vowels ── + 'UH': [640, 1190, 2390], // /ʌ/ hud "uh" (cup, bus) + 'ER': [490, 1350, 1690], // /ɝ/ heard "er" (bird, her) + 'UX': [500, 1500, 2500] // /ə/ about "ux" (schwa, sofa) +} + +let lastFreqByChannel = {} + +export fn playFormantVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) { + let p = ch.generatorParams + let release = 0.2 + let vibDepth = 0.02 + let vibRate = 5.0 + let glide = 0.1 + let humanize = 0.5 + if (p) { + if (p.release !== null) release = p.release + if (p.vibDepth !== null) vibDepth = p.vibDepth + if (p.vibRate !== null) vibRate = p.vibRate + if (p.glide !== null) glide = p.glide + if (p.humanize !== null) humanize = p.humanize + } + + let n = Math.floor(midi + bendSemis) + let hz = 440 + if (n >= 0 && n <= 127) { + hz = midiToHz(n) + } + + if (humanize > 0) { + hz = hz * (1 + (Math.random() - 0.5) * 0.02 * humanize) + } + + let vowel = extractVowel(lyric) + let formants = VOWEL_FORMANTS[vowel] + if (!formants) formants = VOWEL_FORMANTS['A'] + + let lastFreq = lastFreqByChannel[ch.id] + if (!lastFreq) lastFreq = hz + lastFreqByChannel[ch.id] = hz + + let glottis = ctx.createOscillator() + glottis.type = "sawtooth" + + if (glide > 0 && lastFreq > 0) { + glottis.frequency.setValueAtTime(lastFreq, t) + glottis.frequency.setTargetAtTime(hz, t, glide / 3) + } else { + glottis.frequency.setValueAtTime(hz, t) + } + + let vibratoOsc = null + if (vibDepth > 0) { + vibratoOsc = ctx.createOscillator() + vibratoOsc.frequency.value = vibRate + let vibratoGain = ctx.createGain() + vibratoGain.gain.value = hz * vibDepth + vibratoOsc.connect(vibratoGain) + vibratoGain.connect(glottis.frequency) + vibratoOsc.start(t) + vibratoOsc.stop(t + durSec + release) + } + + let masterGain = ctx.createGain() + let v = (vel / 127) * 6.0 + if (v < 0) v = 0 + + masterGain.gain.setValueAtTime(0, t) + masterGain.gain.linearRampToValueAtTime(v, t + 0.05) + masterGain.gain.setValueAtTime(v, t + durSec - 0.02) + masterGain.gain.linearRampToValueAtTime(0, t + durSec + release) + + let qValue = 12 + let gains = [1.0, 0.4, 0.1] + + let f1 = ctx.createBiquadFilter() + f1.type = "bandpass" + f1.frequency.value = formants[0] + f1.Q.value = qValue + let fg1 = ctx.createGain() + fg1.gain.value = gains[0] + glottis.connect(f1) + f1.connect(fg1) + fg1.connect(masterGain) + + let f2 = ctx.createBiquadFilter() + f2.type = "bandpass" + f2.frequency.value = formants[1] + f2.Q.value = qValue + let fg2 = ctx.createGain() + fg2.gain.value = gains[1] + glottis.connect(f2) + f2.connect(fg2) + fg2.connect(masterGain) + + let f3 = ctx.createBiquadFilter() + f3.type = "bandpass" + f3.frequency.value = formants[2] + f3.Q.value = qValue + let fg3 = ctx.createGain() + fg3.gain.value = gains[2] + glottis.connect(f3) + f3.connect(fg3) + fg3.connect(masterGain) + + masterGain.connect(bus.input) + glottis.start(t) + glottis.stop(t + durSec + release) +} diff --git a/packages/synths/src/GameBoyDmg.tish b/packages/synths/src/GameBoyDmg.tish new file mode 100644 index 0000000..d6265be --- /dev/null +++ b/packages/synths/src/GameBoyDmg.tish @@ -0,0 +1,313 @@ + +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 GBA tplpack) + len: 0, + envStep: 0, + envUp: false, + sweepShift: 0, + sweepPeriod: 0, + sweepDown: false, + noiseShift: null, + noiseRatio: 0 + } +} + +fn getDmgPulseBuffer(ctx, duty) { + let seq = [0, 0, 0, 0, 0, 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 === "50") seq = [0, 1, 1, 1, 1, 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 named `wave` table: 32 four-bit levels straight into a buffer. Levels are 0..15 about a 7.5 +/// rest line, which is exactly what wave RAM holds, so no quantization step is needed — they are +/// already on the 16-level grid the built-in shapes get rounded onto. +fn getNamedWaveBuffer(ctx, levels) { + let buf = ctx.createBuffer(1, 32, ctx.sampleRate) + let data = buf.getChannelData(0) + let i = 0 + while (i < 32) { + data[i] = levels[i] / 7.5 - 1 + 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) + 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 = gp.duty ? String(gp.duty) : "50" + let envMode = gp.envMode ? gp.envMode : "step" + let vol = gp.vol !== null ? Math.round(gp.vol) : 10 + let sweep = gp.sweep !== null ? 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 : 0 + let decay = gp.decay !== null ? gp.decay : 0 + let sustain = gp.sustain !== null ? gp.sustain : 15 + let release = gp.release !== null ? gp.release : 0 + let pitchDrop = gp.pitchDrop !== null ? Math.round(gp.pitchDrop) : 0 + let pitchDec = gp.pitchDec !== null ? gp.pitchDec : 0.05 + let vibRate = gp.vibRate !== null ? gp.vibRate : 0 + let vibAmt = gp.vibAmt !== null ? gp.vibAmt : 0 + let arpRate = gp.arpRate !== null ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null ? Math.round(gp.arpSemis) : 0 + let len = gp.len !== null ? Math.round(gp.len) : 0 + let envStep = gp.envStep !== null ? Math.round(gp.envStep) : 0 + let envUp = gp.envUp === true + let sweepShift = gp.sweepShift !== null ? Math.round(gp.sweepShift) : 0 + let sweepPeriod = gp.sweepPeriod !== null ? Math.round(gp.sweepPeriod) : 0 + let sweepDown = gp.sweepDown === true || sweep < 0 + let noiseShift = gp.noiseShift !== null ? Math.round(gp.noiseShift) : null + let noiseRatio = gp.noiseRatio !== null ? 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 HW-ish NR10 approximation via sweepShift/Period. + 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 + + // GB bounds clamping + 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 table wins over the built-in shapes — the same order the GBA bake uses. + 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 is typically treated continuously in modern trackers since it lacks + // a rigid 16-period table like the NES, instead using an expanding divider tree. + // We scale it naturally 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 approx: (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) + } + + // Game Boy step envelope decays 15 steps. Max time per step is 7/64s. + // Prefer authored env_step when set; else map vol (0-15) to 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) + + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/GbaDirectSound.tish b/packages/synths/src/GbaDirectSound.tish new file mode 100644 index 0000000..35908fc --- /dev/null +++ b/packages/synths/src/GbaDirectSound.tish @@ -0,0 +1,216 @@ +// Game Boy Advance (DirectSound) Emulator +// Emulates the software mixing 8-bit DAC of the GBA (often max 16kHz-32kHz). + +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 + // Create a staircase curve to force an audio signal into 8-bit (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 mixing "aliasing" + // at high frequencies, simulating a low sample rate output. + 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") { + 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 { + 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 = gp.duty ? String(gp.duty) : "50" + let vol = (gp.vol !== null) ? Math.round(gp.vol) : 15 + let attack = (gp.attack !== null) ? gp.attack : 0 + let decay = (gp.decay !== null) ? gp.decay : 2 + let sustain = (gp.sustain !== null) ? gp.sustain : 15 + let release = (gp.release !== null) ? gp.release : 0 + + let bitcrush = gp.bitcrush !== false && gp.bitcrush !== "16bit" + let pitchDrop = (gp.pitchDrop !== null) ? Math.round(gp.pitchDrop) : 0 + let pitchDec = (gp.pitchDec !== null) ? gp.pitchDec : 0.05 + let vibRate = (gp.vibRate !== null) ? gp.vibRate : 0 + let vibAmt = (gp.vibAmt !== null) ? gp.vibAmt : 0 + let arpRate = (gp.arpRate !== null) ? gp.arpRate : 0 + let arpSemis = (gp.arpSemis !== null) ? 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) + } + + // Envelope logic + let maxAmp = (vol / 15.0) * v * 0.8 + let sLevel = maxAmp * (sustain / 15.0) + + 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) + } + + src.start(t) + src.stop(tOff + release + 0.1) + + src.connect(envGain) + + disconnects.push(src) + disconnects.push(envGain) + disconnects.push(finalGain) + + + + if (bitcrush) { + let shaper = ctx.createWaveShaper() + shaper.curve = getGbaDacCurve(ctx) + // Avoid interpolation in the wave shaper by using oversample + shaper.oversample = "none" + + // GBA software mixing often had a sharp roll-off due to sample rate + let mixFilter = ctx.createBiquadFilter() + mixFilter.type = "lowpass" + mixFilter.frequency.value = 16000 // Typical max GBA mixing frequency Nyquist limit + + envGain.connect(shaper) + shaper.connect(mixFilter) + mixFilter.connect(finalGain) + disconnects.push(shaper) + disconnects.push(mixFilter) + } else { + envGain.connect(finalGain) + } + + finalGain.connect(bus.input) + + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, (tOff - t + release + 0.5) * 1000) +} diff --git a/packages/synths/src/Guitar.tish b/packages/synths/src/Guitar.tish new file mode 100644 index 0000000..d20be8d --- /dev/null +++ b/packages/synths/src/Guitar.tish @@ -0,0 +1,123 @@ +// Plucked electric-guitar voice via Karplus-Strong string synthesis. A short noise burst excites a +// digital "string": a delay line of one period fed back through a damping lowpass. The result is a +// realistic plucked-string tone — rendered into a buffer per note (like the noise/drum voices, no +// feedback-loop latency limits), then run through drive (pickup/amp grit) and a body/cab lowpass. +// Synthesis, not samples — but pair it with the `chord` voice param for strummed power chords. + +import { midiToHz } from './Midi.tish' + +fn guitarDriveCurve(amount) { + let k = amount * 100 + 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.6) / (Math.PI + k * Math.abs(x)) + i = i + 1 + } + return curve +} + +export fn playGuitar(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let nMidi = Math.floor(midi + bendSemis) + let hz = 220 + if (nMidi >= 0 && nMidi <= 127) { + hz = midiToHz(nMidi) + } + if (hz < 20) { + hz = 20 + } + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + + let tone = p.tone >= 0 && p.tone <= 1 ? p.tone : 0.5 + let decayP = p.decay >= 0 && p.decay <= 1 ? p.decay : 0.6 + let damping = p.damping >= 0 && p.damping <= 1 ? p.damping : 0.4 + let driveAmt = p.drive !== null ? p.drive : 0.25 + let body = p.body !== null && p.body > 0 ? p.body : 3500 + let mute = p.mute >= 0 && p.mute <= 1 ? p.mute : 0 + + // Palm mute: shorter ring, faster decay, darker body. + let decayGain = 0.985 + decayP * 0.0145 - mute * 0.012 + if (decayGain > 0.9997) { + decayGain = 0.9997 + } + let ringSec = (0.4 + decayP * 2.1) * (1 - mute * 0.8) + let bodyHz = body * (1 - mute * 0.5) + + let sr = ctx.sampleRate + let pN = Math.round(sr / hz) + if (pN < 2) { + pN = 2 + } + let len = Math.floor(sr * ringSec) + if (len < pN + 4) { + len = pN + 4 + } + let buf = ctx.createBuffer(1, len, sr) + let data = buf.getChannelData(0) + + // Excitation: one period of noise, brightness-shaped by `tone` (one-pole lowpass). + let lp = 1 - tone * 0.9 + let prev = 0 + let i = 0 + while (i < pN) { + let white = Math.random() * 2 - 1 + prev = prev + lp * (white - prev) + data[i] = prev + i = i + 1 + } + + // Karplus-Strong loop: y[n] = decayGain * (b0*y[n-N] + b1*y[n-N-1]); damping sets the lowpass split. + let b1 = damping * 0.5 + let b0 = 1 - b1 + let j = pN + while (j < len) { + let a = data[j - pN] + let bb = j - pN - 1 >= 0 ? data[j - pN - 1] : a + data[j] = decayGain * (b0 * a + b1 * bb) + j = j + 1 + } + + let src = ctx.createBufferSource() + src.buffer = buf + + let lastNode = src + if (driveAmt > 0) { + let ws = ctx.createWaveShaper() + ws.curve = guitarDriveCurve(driveAmt) + ws.oversample = "2x" + src.connect(ws) + lastNode = ws + } + + let filt = ctx.createBiquadFilter() + filt.type = "lowpass" + filt.frequency.value = bodyHz + filt.Q.value = 0.9 + lastNode.connect(filt) + + let amp = ctx.createGain() + let peak = v * 0.7 + let endT = t + ringSec + amp.gain.setValueAtTime(0, t) + amp.gain.linearRampToValueAtTime(peak, t + 0.004) + let relStart = endT - 0.06 + if (relStart < t + 0.005) { + relStart = t + 0.005 + } + amp.gain.setValueAtTime(peak, relStart) + amp.gain.linearRampToValueAtTime(0, endT) + filt.connect(amp) + amp.connect(bus.input) + + src.start(t) + src.stop(endT + 0.05) +} diff --git a/packages/synths/src/Halo.tish b/packages/synths/src/Halo.tish new file mode 100644 index 0000000..b7f8511 --- /dev/null +++ b/packages/synths/src/Halo.tish @@ -0,0 +1,197 @@ +// Halo — a HANG DRUM / HANDPAN: the dreamy meditative steel UFO drum, played with fingertips. This is not +// a generic bell or sine pad — it models the specific acoustics that make a handpan unmistakable: +// +// The SIGNATURE is the inharmonic TRIAD-RING. A struck handpan tone field rings with its fundamental +// PLUS a tuned OCTAVE (2×) PLUS a tuned COMPOUND FIFTH (octave-and-a-fifth, ~3×) — three coupled partials +// that are deliberately tuned to near-integer ratios. They are never exact: a real handpan's partials sit +// a few cents off, so they BEAT against each other and the note slowly shimmers/breathes. That beating +// triad is the whole soul of the instrument. +// +// Controls (handpan-sensible, no generic ADSR/waveform): +// temper — partial detune / inharmonicity. 0 = the partials lock to pure 1·2·3 (still, glassy); +// 1 = they spread a few cents and bloom apart, so the note shimmers and breathes. The signature. +// ring — sustain length. A handpan rings for many seconds; this scales the whole tail (~1.2..9 s). +// mallet — strike softness/brightness. Low = a very soft fingertip pad (dark, almost no tick); +// high = a brighter nail/finger strike (a short filtered-noise click + faster partial onset). +// bloom — the upper-partial SWELL. Right after the strike the octave & fifth softly swell UP before +// decaying — that gorgeous after-bloom of energy rising into the overtones, then settling. +// lows — the warm low "gu"-like body weight under the note (the resonant air of the shell). +// +// Engine: three sine partials (1× fundamental, 2× octave, 3× compound fifth), each split into a beating +// pair detuned ±cents by `temper`, so each partial is two slightly-offset sines that throb. The octave & +// fifth get a slow BLOOM envelope (swell in, then a faster-than-fundamental decay — energy moves down). A +// soft strike = a short lowpassed noise tick. A lowpassed sub-sine adds the "gu" body. A gentle highpass on +// the partial bus keeps it airy. Everything is normalized so one loud strike peaks <= ~1. + +import { midiToHz } from './Midi.tish' + +fn haloClamp01(x) { + if (x < 0) { return 0 } + if (x > 1) { return 1 } + return x +} + +// One partial of the triad as a BEATING PAIR: two sines at ratio·hz, detuned ±cents, summed. The detune is +// what makes a handpan throb instead of sit still. Returns the pair's shared input gain node already wired +// to `dest`, with both oscillators started/stopped. `level` is the partial's amplitude; the env on the +// returned gain shapes its bloom/decay. +fn haloPartialPair(ctx, dest, hz, ratio, cents, stopAt, t) { + let g = ctx.createGain() + g.gain.value = 0 + g.connect(dest) + let o1 = ctx.createOscillator() + o1.type = "sine" + o1.frequency.value = hz * ratio + o1.detune.value = 0 - cents + let o2 = ctx.createOscillator() + o2.type = "sine" + o2.frequency.value = hz * ratio + o2.detune.value = cents + o1.connect(g) + o2.connect(g) + o1.start(t) + o2.start(t) + o1.stop(stopAt) + o2.stop(stopAt) + return g +} + +export fn playHalo(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let temper = haloClamp01((p.temper !== null) ? p.temper : 0.4) + let ring = haloClamp01((p.ring !== null) ? p.ring : 0.55) + let mallet = haloClamp01((p.mallet !== null) ? p.mallet : 0.35) + let bloom = haloClamp01((p.bloom !== null) ? p.bloom : 0.5) + let lows = haloClamp01((p.lows !== null) ? p.lows : 0.45) + + let hz = midiToHz(midi + bendSemis) + if (hz < 20) { hz = 20 } + let v = vel / 127 + if (v < 0) { v = 0 } + if (v > 1) { v = 1 } + if (v <= 0) { return } + + // Sustain: a handpan rings for seconds. The fundamental sets the longest tail; upper partials decay + // faster (energy moves down), so the note darkens as it fades. + let fundDecay = 1.2 + ring * 7.8 // ~1.2..9.0 s + let octDecay = fundDecay * 0.62 // octave fades sooner + let fifthDecay = fundDecay * 0.42 // compound fifth fades soonest + let tEnd = t + fundDecay + 0.2 + + // Inharmonicity: at temper 0 the partials lock to pure 1·2·3; turning it up spreads them a few cents so + // the triad beats and breathes. Each partial is itself a detuned pair (beatCents) for an inner throb, and + // the octave/fifth ratios drift slightly sharp/flat (driftCents) the way real tuned tone fields do. + let beatCents = 1.5 + temper * 8 // inner beat of each partial pair + let octDrift = temper * 6 // octave nudged sharp + let fifthDrift = 0 - temper * 7 // fifth nudged flat (opposite sign = wider shimmer) + let octRatio = 2.0 + let fifthRatio = 3.0 + + // Shared partial bus → gentle highpass (keeps it airy, removes mud) → master amp. Velocity scales here. + // The headroom factor (0.42) accounts for each partial being a detuned PAIR (two sines can momentarily + // align to ~2× the gain-node value) plus the three partials and the body/tick stacking — it keeps one + // loud strike at the brightest corner peaking <= ~1 so the master glue compressor isn't slammed. + let amp = ctx.createGain() + // 0.39 (not 0.42): at the worst corner (ring/bloom/mallet=1, vel127, high note) all 6 partial-sines plus + // the body align at the octave's bloom-peak; 0.39 keeps that summed strike at/under 1.0 off the master comp. + amp.gain.value = v * 0.39 + amp.connect(bus.input) + + let air = ctx.createBiquadFilter() + air.type = "highpass" + air.frequency.value = 110 + air.Q.value = 0.5 + air.connect(amp) + + // Partial level balance, normalized so the summed peak of one strike stays <= ~1. Fundamental dominates; + // octave & fifth are progressively quieter (they're shimmer, not body). + let fundLvl = 0.5 + let octLvl = 0.3 + let fifthLvl = 0.2 + + // FUNDAMENTAL — the longest, deepest ring. Fast soft onset, then a long exponential tail. + let gF = haloPartialPair(ctx, air, hz, 1.0, beatCents, tEnd, t) + let fundAtk = 0.004 + (1 - mallet) * 0.02 // softer mallet = slightly slower partial onset + gF.gain.setValueAtTime(0, t) + gF.gain.linearRampToValueAtTime(fundLvl, t + fundAtk) + gF.gain.exponentialRampToValueAtTime(0.0008, t + fundDecay) + + // OCTAVE (2×) — gets the BLOOM: it swells UP after the strike before decaying. The swell time/depth grows + // with `bloom`. This rising-then-settling overtone is the gorgeous handpan after-bloom. + let gO = haloPartialPair(ctx, air, hz, octRatio, beatCents + octDrift, tEnd, t) + let octBloomT = 0.04 + bloom * 0.28 // how long the octave keeps rising + let octStart = octLvl * (0.35 + (1 - bloom) * 0.4) // less bloom = starts nearer its peak (less swell) + let octPeak = octLvl * (1.0 + bloom * 0.5) // more bloom = swells higher + gO.gain.setValueAtTime(0, t) + gO.gain.linearRampToValueAtTime(octStart, t + fundAtk * 0.8) + gO.gain.linearRampToValueAtTime(octPeak, t + octBloomT) + gO.gain.exponentialRampToValueAtTime(0.0008, t + octDecay) + + // COMPOUND FIFTH (3×) — the brightest shimmer, blooms a touch later and decays fastest. Quietest layer. + let gFi = haloPartialPair(ctx, air, hz, fifthRatio, beatCents + fifthDrift, tEnd, t) + let fiBloomT = 0.06 + bloom * 0.34 + let fiStart = fifthLvl * (0.25 + (1 - bloom) * 0.45) + let fiPeak = fifthLvl * (1.0 + bloom * 0.6) + gFi.gain.setValueAtTime(0, t) + gFi.gain.linearRampToValueAtTime(fiStart, t + fundAtk) + gFi.gain.linearRampToValueAtTime(fiPeak, t + fiBloomT) + gFi.gain.exponentialRampToValueAtTime(0.0008, t + fifthDecay) + + // WARM "GU" BODY — a soft lowpassed sub-sine an octave below, the resonant air of the steel shell. Short, + // rounded, sits under the note. Scaled by `lows`. + if (lows > 0.02) { + let body = ctx.createOscillator() + body.type = "sine" + let bodyHz = hz * 0.5 + if (bodyHz < 20) { bodyHz = hz } // don't drop below audible for high notes; fall back to fundamental + body.frequency.value = bodyHz + let blp = ctx.createBiquadFilter() + blp.type = "lowpass" + blp.frequency.value = 220 + lows * 180 + blp.Q.value = 0.6 + let bg = ctx.createGain() + let bodyDecay = 0.4 + ring * 1.6 + let bodyPeak = v * lows * 0.34 + bg.gain.setValueAtTime(0, t) + bg.gain.linearRampToValueAtTime(bodyPeak, t + 0.006) + bg.gain.exponentialRampToValueAtTime(0.0008, t + bodyDecay) + body.connect(blp) + blp.connect(bg) + bg.connect(bus.input) + body.start(t) + body.stop(t + bodyDecay + 0.05) + } + + // SOFT FINGERTIP STRIKE — a very short lowpassed noise tick (the pad of a fingertip on steel, NOT a hard + // mallet). `mallet` brightens it and adds a touch more click; even at max it stays gentle and rounded. + let tickLen = Math.floor(ctx.sampleRate * 0.04) + if (tickLen < 1) { tickLen = 1 } + let nbuf = ctx.createBuffer(1, tickLen, ctx.sampleRate) + let nd = nbuf.getChannelData(0) + let i = 0 + while (i < tickLen) { + nd[i] = Math.random() * 2 - 1 + i = i + 1 + } + let nsrc = ctx.createBufferSource() + nsrc.buffer = nbuf + let nlp = ctx.createBiquadFilter() + nlp.type = "lowpass" + nlp.frequency.value = 1400 + mallet * 4200 // dark fingertip → brighter nail strike + nlp.Q.value = 0.7 + let nhp = ctx.createBiquadFilter() + nhp.type = "highpass" + nhp.frequency.value = 300 // keep the tick off the body + let ng = ctx.createGain() + let tickPeak = v * (0.05 + mallet * 0.16) // always soft; never a hard mallet + let tickDecay = 0.012 + mallet * 0.02 + ng.gain.setValueAtTime(0, t) + ng.gain.linearRampToValueAtTime(tickPeak, t + 0.0008) + ng.gain.exponentialRampToValueAtTime(0.0008, t + tickDecay) + nsrc.connect(nlp) + nlp.connect(nhp) + nhp.connect(ng) + ng.connect(bus.input) + nsrc.start(t) + nsrc.stop(t + tickDecay + 0.05) +} diff --git a/packages/synths/src/LaserSync.tish b/packages/synths/src/LaserSync.tish new file mode 100644 index 0000000..f34bd7d --- /dev/null +++ b/packages/synths/src/LaserSync.tish @@ -0,0 +1,62 @@ +export fn playLaserSync(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let dropRate = gp.dropRate !== null ? gp.dropRate : 0.8 + let dropAmt = gp.dropAmt !== null ? gp.dropAmt : 36 + let slaveBase = gp.slaveBase !== null ? gp.slaveBase : 18 + let att = gp.attack !== null ? gp.attack : 0.01 + let dec = gp.decay !== null ? gp.decay : 0.3 + + let masterPitch = midi + bendSemis + let masterFreq = 440 * Math.pow(2, (masterPitch - 69) / 12) + + let dropDecay = 0.01 + (1 - dropRate) * 0.5 + let dropRatio = Math.pow(2, dropAmt / 12) + let slaveRatio = Math.pow(2, slaveBase / 12) + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let outGain = ctx.createGain() + outGain.gain.value = 0 + outGain.gain.setValueAtTime(0, t) + outGain.gain.linearRampToValueAtTime(v * 0.8, t + att) + outGain.gain.setTargetAtTime(0.0001, t + att, dec / 3) + outGain.gain.setValueAtTime(0.0001, t + durSec) + outGain.gain.linearRampToValueAtTime(0, t + durSec + 0.1) + + outGain.connect(bus.input) + + let osc = null + let useFallback = false + + try { + osc = new AudioWorkletNode(ctx, "sync-oscillator") + } catch (e) { + useFallback = true + osc = ctx.createOscillator() + osc.type = "sawtooth" + } + + osc.connect(outGain) + + if (!useFallback && osc.parameters) { + let mParam = osc.parameters.get("masterFreq") + let sParam = osc.parameters.get("slaveFreq") + + mParam.setValueAtTime(masterFreq * dropRatio, t) + mParam.setTargetAtTime(masterFreq, t, dropDecay / 3) + + sParam.setValueAtTime(masterFreq * slaveRatio, t) + } else { + // Fallback behavior + osc.frequency.value = masterFreq + osc.start(t) + } + + let totalTime = durSec + 0.5 + setTimeout(() => { + osc.disconnect() + outGain.disconnect() + }, totalTime * 1000) +} diff --git a/packages/synths/src/MatrixFm.tish b/packages/synths/src/MatrixFm.tish new file mode 100644 index 0000000..857ab54 --- /dev/null +++ b/packages/synths/src/MatrixFm.tish @@ -0,0 +1,332 @@ +import { midiToHz } from './Midi.tish' +import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' +import { playBasicOsc } from './BasicOsc.tish' + +fn mergeOpEnv(e) { + let a = 0.005 + let d = 0.1 + let s = 0.35 + let r = 0.12 + if (e) { + if (e.a !== null) { + a = e.a + } + if (e.d !== null) { + d = e.d + } + if (e.s !== null) { + s = e.s + } + if (e.r !== null) { + r = e.r + } + } + return { a: a, d: d, s: s, r: r } +} + +fn mergeFilterEnv(e) { + let o = mergeOpEnv(e) + let amt = 0 + if (e && e.amount !== null) { + amt = e.amount + } + return { a: o.a, d: o.d, s: o.s, r: o.r, amount: amt } +} + +fn clampHz(h) { + if (h < 20) { + return 20 + } + if (h > 20000) { + return 20000 + } + return h +} + +fn scheduleAdsrCutoff(param, t, base, amount, a, d, s, 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) + } + let peakF = clampHz(base + amount) + let susF = clampHz(base + amount * s) + let b0 = clampHz(base) + param.setValueAtTime(b0, t) + param.linearRampToValueAtTime(peakF, t + a) + param.linearRampToValueAtTime(susF, tDecayEnd) + param.setValueAtTime(susF, susPlateau) + param.linearRampToValueAtTime(b0, releaseEnd) + return releaseEnd +} + +fn makeNoiseBuffer(ctx) { + let sr = ctx.sampleRate + let n = Math.floor(sr * 2) + if (n < 256) { + n = 256 + } + let buf = ctx.createBuffer(1, n, sr) + let data = buf.getChannelData(0) + let i = 0 + while (i < n) { + data[i] = Math.random() * 2 - 1 + i = i + 1 + } + return buf +} + +fn filterNeedsTwoStages(tp) { + let s = String(tp).toLowerCase() + return s.indexOf("24") >= 0 || s === "lp24" || s === "hp24" +} + +fn setupBiquadType(bq, tp) { + let s = String(tp).toLowerCase() + if (s.indexOf("hp") >= 0) { + bq.type = "highpass" + } else { + bq.type = "lowpass" + } +} + +export fn playMatrixFm(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let spec = ch.generatorSpec + let graph = spec ? spec.graph : null + let ops = graph ? graph.ops : null + let opKeys = ops ? Object.keys(ops) : [] + if (!graph || opKeys.length === 0) { + playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + + let nMidi = Math.floor(midi + bendSemis) + let Fc = 440 + if (nMidi >= 0 && nMidi <= 127) { + Fc = midiToHz(nMidi) + } + + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + + let opEnvs = graph.opEnvs ? graph.opEnvs : {} + let filterEnvs = graph.filterEnvs ? graph.filterEnvs : {} + let filtersDef = graph.filters ? graph.filters : {} + let mods = graph.mods ? graph.mods : [] + let routes = graph.routes ? graph.routes : [] + + let opOsc = {} + let opIsNoise = {} + let opTail = {} + let opEnvGain = {} + let toStopOsc = [] + let toStopNoise = [] + + let maxEnd = 0 + + let ki = 0 + while (ki < opKeys.length) { + let oid = opKeys[ki] + let od = ops[oid] + let wave = od.wave ? od.wave : "sine" + let ratio = od.ratio !== null && od.ratio > 0 ? od.ratio : 1 + let hz = Fc * ratio + + let srcNode = null + if (wave === "noise") { + let nb = ctx.createBufferSource() + nb.buffer = makeNoiseBuffer(ctx) + nb.loop = true + srcNode = nb + opIsNoise[oid] = true + toStopNoise.push(nb) + } else { + let osc = ctx.createOscillator() + osc.type = wave + osc.frequency.value = hz + opOsc[oid] = osc + opIsNoise[oid] = false + srcNode = osc + toStopOsc.push(osc) + } + opTail[oid] = srcNode + ki = ki + 1 + } + + let miRm = 0 + while (miRm < mods.length) { + let md = mods[miRm] + if (md.type === "rm" && md.src !== md.dst) { + let tailD = opTail[md.dst] + let tailS = opTail[md.src] + if (tailD && tailS) { + let rmG = ctx.createGain() + rmG.gain.value = 0 + tailD.connect(rmG) + let scale = ctx.createGain() + let amt = md.amount !== null && md.amount === md.amount ? md.amount : 1 + scale.gain.value = amt * 0.4 + tailS.connect(scale) + scale.connect(rmG.gain) + opTail[md.dst] = rmG + } + } + miRm = miRm + 1 + } + + let ki2 = 0 + while (ki2 < opKeys.length) { + let oid = opKeys[ki2] + let tail = opTail[oid] + let eg = ctx.createGain() + eg.gain.value = 0 + tail.connect(eg) + opEnvGain[oid] = eg + + let envDef = mergeOpEnv(opEnvs[oid]) + let peak = v + let slev = envDef.s * v + let tEnd = scheduleAdsrAmpEnvelope(eg, t, peak, slev, envDef.a, envDef.d, envDef.r, durSec) + if (tEnd > maxEnd) { + maxEnd = tEnd + } + ki2 = ki2 + 1 + } + + let mi2 = 0 + while (mi2 < mods.length) { + let md = mods[mi2] + if (md.type === "fm" && md.dst) { + let car = opOsc[md.dst] + if (car && !opIsNoise[md.dst]) { + let modTap = opTail[md.src] + if (modTap) { + let dg = ctx.createGain() + let ratioD = ops[md.dst].ratio !== null && ops[md.dst].ratio > 0 ? ops[md.dst].ratio : 1 + let hzDst = Fc * ratioD + let amt = md.amount !== null && md.amount === md.amount ? md.amount : 1 + let depthHz = hzDst * amt * 0.12 + if (depthHz < 0.5) { + depthHz = 0.5 + } + if (depthHz > 8000) { + depthHz = 8000 + } + dg.gain.value = depthHz + modTap.connect(dg) + dg.connect(car.frequency) + } + } + } + mi2 = mi2 + 1 + } + + let filterKeys = Object.keys(filtersDef) + let filterFirst = {} + let filterLast = {} + let filterSecond = {} + let fi = 0 + while (fi < filterKeys.length) { + let fid = filterKeys[fi] + let fd = filtersDef[fid] + let bq1 = ctx.createBiquadFilter() + setupBiquadType(bq1, fd.type) + let c0 = clampHz(fd.cutoff !== null ? fd.cutoff : 8000) + bq1.frequency.value = c0 + let qv = fd.res !== null ? fd.res : 0.7 + bq1.Q.value = Math.min(40, Math.max(0.0001, qv * 8)) + filterFirst[fid] = bq1 + let last = bq1 + filterSecond[fid] = null + if (filterNeedsTwoStages(fd.type)) { + let bq2 = ctx.createBiquadFilter() + setupBiquadType(bq2, fd.type) + bq2.frequency.value = c0 + bq2.Q.value = bq1.Q.value + bq1.connect(bq2) + last = bq2 + filterSecond[fid] = bq2 + } + filterLast[fid] = last + + let fe = mergeFilterEnv(filterEnvs[fid]) + if (fe.amount !== null && fe.amount !== 0) { + let tfe = scheduleAdsrCutoff(bq1.frequency, t, c0, fe.amount, fe.a, fe.d, fe.s, fe.r, durSec) + let bq2b = filterSecond[fid] + if (bq2b) { + scheduleAdsrCutoff(bq2b.frequency, t, c0, fe.amount, fe.a, fe.d, fe.s, fe.r, durSec) + } + if (tfe > maxEnd) { + maxEnd = tfe + } + } + fi = fi + 1 + } + + let outSum = ctx.createGain() + outSum.gain.value = 1 + outSum.connect(bus.input) + + let ri = 0 + while (ri < routes.length) { + let rt = routes[ri] + let vol = rt.volume !== null && rt.volume === rt.volume ? rt.volume : 1 + let rg = ctx.createGain() + rg.gain.value = vol * v + let srcN = null + if (rt.srcKind === "op") { + srcN = opEnvGain[rt.srcId] + } else { + if (rt.srcKind === "filter") { + srcN = filterLast[rt.srcId] + } + } + if (srcN) { + srcN.connect(rg) + if (rt.dstKind === "filter") { + let fin = filterFirst[rt.dstId] + if (fin) { + rg.connect(fin) + } + } else { + if (rt.dstKind === "out") { + rg.connect(outSum) + } + } + } + ri = ri + 1 + } + + if (routes.length === 0) { + let firstId = opKeys[0] + if (firstId && opEnvGain[firstId]) { + let rg2 = ctx.createGain() + rg2.gain.value = v + opEnvGain[firstId].connect(rg2) + rg2.connect(outSum) + } + } + + let tStop = maxEnd + 0.12 + let si = 0 + while (si < toStopOsc.length) { + let o = toStopOsc[si] + o.start(t) + o.stop(tStop) + si = si + 1 + } + let sn = 0 + while (sn < toStopNoise.length) { + let ns = toStopNoise[sn] + ns.start(t) + ns.stop(tStop) + sn = sn + 1 + } +} diff --git a/packages/synths/src/MatrixFmGraph.tish b/packages/synths/src/MatrixFmGraph.tish new file mode 100644 index 0000000..620bef4 --- /dev/null +++ b/packages/synths/src/MatrixFmGraph.tish @@ -0,0 +1,332 @@ +// Matrix FM gen_block dialect (Deckard host). +import { tokenize, isNumberToken, formatTplFloat } from '@spacedevin/deck' + +fn parseKvPairs(toks, start) { + let o = {} + let i = start + while (i + 1 < toks.length) { + let k = toks[i] + let v = toks[i + 1] + if (isNumberToken(v)) { + o[k] = Number(v) + } else { + o[k] = v + } + i = i + 2 + } + return o +} + +fn parseEnvSegment(toks, start) { + let o = { a: null, d: null, s: null, r: null, amount: null } + let i = start + while (i + 1 < toks.length) { + let k = toks[i] + let v = toks[i + 1] + if (!isNumberToken(v)) { + break + } + let n = Number(v) + if (k === "a") { + o.a = n + } else { + if (k === "d") { + o.d = n + } else { + if (k === "s") { + o.s = n + } else { + if (k === "r") { + o.r = n + } else { + if (k === "amount") { + o.amount = n + } + } + } + } + } + i = i + 2 + } + return o +} + +fn normalizeOpDef(raw) { + let w = raw.wave ? String(raw.wave).toLowerCase() : "sine" + if (w === "noise") { + return { wave: "noise", ratio: raw.ratio !== null ? raw.ratio : 1 } + } + return { + wave: (w ? String(w).toLowerCase() : "sine"), + ratio: raw.ratio !== null && raw.ratio > 0 ? raw.ratio : 1 + } +} + +fn normalizeFilterDef(raw) { + let t = raw.type ? String(raw.type).toLowerCase() : "lp12" + let co = raw.cutoff !== null ? raw.cutoff : 8000 + let rq = raw.res !== null ? raw.res : raw.q !== null ? raw.q : 0.7 + return { type: t, cutoff: co, res: rq } +} + +export fn parseMatrixFmGraph(lines) { + let ops = {} + let opEnvs = {} + let filterEnvs = {} + let filters = {} + let mods = [] + let routes = [] + let li = 0 + while (li < lines.length) { + let toks = tokenize(lines[li]) + if (toks.length === 0) { + li = li + 1 + continue + } + let h = toks[0] + if (h === "op" && toks.length >= 3) { + let oid = String(toks[1]) + let raw = parseKvPairs(toks, 2) + ops[oid] = normalizeOpDef(raw) + } else { + if (h === "env" && toks.length >= 4 && toks[1] === "op") { + let oid = String(toks[2]) + opEnvs[oid] = parseEnvSegment(toks, 3) + } else { + if (h === "env" && toks.length >= 4 && toks[1] === "filter") { + let fid = String(toks[2]) + filterEnvs[fid] = parseEnvSegment(toks, 3) + } else { + if (h === "mod" && toks.length >= 5) { + mods.push({ + type: String(toks[1]).toLowerCase(), + src: String(toks[2]), + dst: String(toks[3]), + amount: Number(toks[4]) + }) + } else { + if (h === "filter" && toks.length >= 3) { + let fid = String(toks[1]) + let raw = parseKvPairs(toks, 2) + filters[fid] = normalizeFilterDef(raw) + } else { + if (h === "route") { + if (toks.length >= 6 && toks[3] === "filter") { + routes.push({ + srcKind: String(toks[1]), + srcId: String(toks[2]), + dstKind: "filter", + dstId: String(toks[4]), + volume: Number(toks[5]) + }) + } else { + if (toks.length >= 5 && toks[3] === "out") { + routes.push({ + srcKind: String(toks[1]), + srcId: String(toks[2]), + dstKind: "out", + dstId: "", + volume: Number(toks[4]) + }) + } + } + } + } + } + } + } + } + li = li + 1 + } + return { + ops: ops, + opEnvs: opEnvs, + filterEnvs: filterEnvs, + filters: filters, + mods: mods, + routes: routes + } +} + +export fn sortedStringKeys(obj) { + if (!obj) { + return [] + } + let k = Object.keys(obj) + let n = k.length + let a = 0 + while (a < n) { + let b = a + 1 + while (b < n) { + let na = Number(k[a]) + let nb = Number(k[b]) + let va = na === na ? na : 1000000000 + let vb = nb === nb ? nb : 1000000000 + if (va > vb || (va === vb && k[a] > k[b])) { + let t = k[a] + k[a] = k[b] + k[b] = t + } + b = b + 1 + } + a = a + 1 + } + return k +} + +fn waveToTpl(w) { + let s = String(w).toLowerCase() + if (s === "noise") { + return "noise" + } + if (s === "sawtooth") { + return "saw" + } + if (s === "square") { + return "square" + } + if (s === "triangle") { + return "tri" + } + return "sine" +} + +fn envOpLine(oid, e) { + if (!e) { + return "" + } + if ((e.a === null) && (e.d === null) && (e.s === null) && (e.r === null)) { + return "" + } + let s = "env op " + oid + if (e.a !== null) { + s = s + " a " + formatTplFloat(e.a) + } + if (e.d !== null) { + s = s + " d " + formatTplFloat(e.d) + } + if (e.s !== null) { + s = s + " s " + formatTplFloat(e.s) + } + if (e.r !== null) { + s = s + " r " + formatTplFloat(e.r) + } + return s +} + +fn envFilterLine(fid, e) { + if (!e) { + return "" + } + if ((e.a === null) && (e.d === null) && (e.s === null) && (e.r === null) && (e.amount === null)) { + return "" + } + let s = "env filter " + fid + if (e.a !== null) { + s = s + " a " + formatTplFloat(e.a) + } + if (e.d !== null) { + s = s + " d " + formatTplFloat(e.d) + } + if (e.s !== null) { + s = s + " s " + formatTplFloat(e.s) + } + if (e.r !== null) { + s = s + " r " + formatTplFloat(e.r) + } + if (e.amount !== null) { + s = s + " amount " + formatTplFloat(e.amount) + } + return s +} + +export fn matrixFmGraphToLines(graph) { + let lines = [] + let opIds = sortedStringKeys(graph.ops) + let oi = 0 + while (oi < opIds.length) { + let oid = opIds[oi] + let op = graph.ops[oid] + let wv = waveToTpl(op.wave) + if (wv === "noise") { + lines.push("op " + oid + " wave noise") + } else { + lines.push("op " + oid + " wave " + wv + " ratio " + formatTplFloat(op.ratio)) + } + oi = oi + 1 + } + let ei = 0 + while (ei < opIds.length) { + let oid2 = opIds[ei] + let ev = graph.opEnvs[oid2] + if (ev) { + let ln = envOpLine(oid2, ev) + if (ln.length > 0) { + lines.push(ln) + } + } + ei = ei + 1 + } + let mi = 0 + while (mi < graph.mods.length) { + let m = graph.mods[mi] + lines.push("mod " + m.type + " " + m.src + " " + m.dst + " " + formatTplFloat(m.amount)) + mi = mi + 1 + } + let fids = sortedStringKeys(graph.filters) + let fi = 0 + while (fi < fids.length) { + let fid = fids[fi] + let fd = graph.filters[fid] + lines.push( + "filter " + + fid + + " type " + + fd.type + + " cutoff " + + formatTplFloat(fd.cutoff) + + " res " + + formatTplFloat(fd.res) + ) + fi = fi + 1 + } + let fei = 0 + while (fei < fids.length) { + let fid2 = fids[fei] + let fev = graph.filterEnvs[fid2] + if (fev) { + let fln = envFilterLine(fid2, fev) + if (fln.length > 0) { + lines.push(fln) + } + } + fei = fei + 1 + } + let ri = 0 + while (ri < graph.routes.length) { + let rt = graph.routes[ri] + if (rt.dstKind === "filter") { + lines.push( + "route " + + rt.srcKind + + " " + + rt.srcId + + " filter " + + rt.dstId + + " " + + formatTplFloat(rt.volume) + ) + } else { + lines.push("route " + rt.srcKind + " " + rt.srcId + " out " + formatTplFloat(rt.volume)) + } + ri = ri + 1 + } + return lines +} + +export fn syncMatrixFmSpecFromGraph(spec) { + if (!spec || !spec.graph) { + return + } + spec.raw = matrixFmGraphToLines(spec.graph) +} diff --git a/packages/synths/src/MeSpeakVocal.tish b/packages/synths/src/MeSpeakVocal.tish new file mode 100644 index 0000000..f2d1a38 --- /dev/null +++ b/packages/synths/src/MeSpeakVocal.tish @@ -0,0 +1,182 @@ +// The meSpeak voice needs a worker and a voice-data directory, and those are assets a host serves +// rather than anything this package can ship. The defaults are the paths Deckard has always used, +// so a host that serves them there needs no configuration; anything else calls `configureMeSpeak` +// before playing. This is the one voice in the catalog with an external asset dependency — +// `ensureSyncWorklet` removed the other one by inlining its processor. + +let meSpeakWorkerUrl = "/mespeak-worker.js" +let meSpeakAssetsBase = "/mespeak" + +/// Point the meSpeak voice at its worker script and voice-data directory. +export fn configureMeSpeak(opts) { + if (!opts) { + return + } + if (opts.workerUrl) { + meSpeakWorkerUrl = opts.workerUrl + } + if (opts.assetsBaseUrl) { + meSpeakAssetsBase = opts.assetsBaseUrl + } +} + + + +let worker = null +let workerInitialized = false +let ttsCache = {} +let ttsCacheKeys = [] +let loadedVoices = {} +let lastSpeakCall = 0 +let lastCtx = null + +fn initWorker() { + if (worker) return + if (typeof window === "undefined" || typeof Worker === "undefined") return + + worker = new Worker(meSpeakWorkerUrl) + worker.onmessage = (e) => { + let d = e.data + if (d.type === 'init_done') { + workerInitialized = true + } else if (d.type === 'voice_done') { + loadedVoices[d.voiceId] = 'ready' + } else if (d.type === 'speak_done') { + let cacheKey = d.key + if (d.buffer && lastCtx) { + lastCtx.decodeAudioData(d.buffer, (audioBuf) => { + if (ttsCache[cacheKey]) { + ttsCache[cacheKey] = { isReady: true, buffer: audioBuf } + } + }, (err) => { + console.log("decodeAudioData error", err) + delete ttsCache[cacheKey] + }) + } else { + delete ttsCache[cacheKey] + } + } else if (d.type === 'worker_tired') { + console.log("Recycling meSpeak worker to prevent WASM heap exhaustion") + worker.terminate() + worker = null + workerInitialized = false + loadedVoices = {} + initWorker() + } + } + worker.onerror = (e) => { + console.warn("MeSpeak worker crashed or OOM. Restarting...", e) + worker.terminate() + worker = null + workerInitialized = false + loadedVoices = {} + initWorker() + } + + fetch(meSpeakAssetsBase + '/mespeak_config.json') + .then((r) => r.json()) + .then((cfg) => { + worker.postMessage({ type: 'init', config: cfg }) + }) +} + +fn playCachedBuffer(ctx, bus, t, v, audioBuf) { + let src = ctx.createBufferSource() + src.buffer = audioBuf + let gain = ctx.createGain() + gain.gain.value = v + src.connect(gain) + gain.connect(bus.input) + src.start(t) +} + +export fn playMeSpeakVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) { + lastCtx = ctx + initWorker() + + if (!workerInitialized) { + return + } + + let p = ch.generatorParams + let voicePath = p && (p.voice !== null) ? p.voice : "en/en-us" + + if (!loadedVoices[voicePath]) { + loadedVoices[voicePath] = "loading" + fetch(meSpeakAssetsBase + '/voices/' + voicePath + '.json') + .then((r) => r.json()) + .then((vc) => { + worker.postMessage({ type: 'loadVoice', voiceId: voicePath, voice: vc }) + }) + } + + if (loadedVoices[voicePath] !== "ready") { + return + } + + let l = lyric + if (!l || l.trim() === "") { + l = "beep" + } + + let basePitch = p && (p.pitch !== null) ? p.pitch : 50 + let baseSpeed = p && (p.speed !== null) ? p.speed : 175 + let wordgap = p && (p.wordgap !== null) ? p.wordgap : 0 + let variant = p && (p.variant !== null) ? p.variant : "m1" + let amplitude = p && (p.amplitude !== null) ? p.amplitude : 100 + + // Track the MIDI pitch. Base pitch is centered around C4 (MIDI 60). + let n = Math.floor(midi + bendSemis) + let calculatedPitch = Math.round(basePitch + (n - 60)) + if (calculatedPitch < 0) calculatedPitch = 0 + if (calculatedPitch > 99) calculatedPitch = 99 + + // Track the note duration. Calculate required WPM to fit the lyrics inside durSec. + let estimatedDurAt175 = Math.max(0.2, l.length * 0.07) + let speedMultiplier = estimatedDurAt175 / durSec + let calculatedSpeed = Math.round(baseSpeed * speedMultiplier) + if (calculatedSpeed < 10) calculatedSpeed = 10 + if (calculatedSpeed > 400) calculatedSpeed = 400 + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let cacheKey = l + "|" + calculatedPitch + "|" + calculatedSpeed + "|" + wordgap + "|" + variant + "|" + amplitude + "|" + voicePath + + if (ttsCache[cacheKey]) { + if (ttsCache[cacheKey].isReady) { + playCachedBuffer(ctx, bus, t, v, ttsCache[cacheKey].buffer) + } + return + } + + let now = Date.now() + if (now - lastSpeakCall < 150) { + return + } + lastSpeakCall = now + + if (ttsCacheKeys.length > 100) { + let oldest = ttsCacheKeys.shift() + delete ttsCache[oldest] + } + + ttsCache[cacheKey] = { isReady: false } + ttsCacheKeys.push(cacheKey) + + worker.postMessage({ + type: 'speak', + key: cacheKey, + text: l, + opts: { + pitch: calculatedPitch, + speed: calculatedSpeed, + wordgap: wordgap, + variant: variant, + amplitude: amplitude, + voice: voicePath, + rawdata: true + } + }) +} diff --git a/packages/synths/src/Midi.tish b/packages/synths/src/Midi.tish new file mode 100644 index 0000000..6c03fac --- /dev/null +++ b/packages/synths/src/Midi.tish @@ -0,0 +1,9 @@ +// Pitch conversion, shared by every generator in this package. +// +// This lived in the app's `src/schedule/Engine.tish` while the generators did; it moved with them so +// the package has no import that points back at Deckard. The app re-exports it from here rather than +// keeping a second copy. + +export fn midiToHz(midi) { + return 440 * Math.pow(2, (midi - 69) / 12) +} diff --git a/packages/synths/src/Nes2a03.tish b/packages/synths/src/Nes2a03.tish new file mode 100644 index 0000000..ce64584 --- /dev/null +++ b/packages/synths/src/Nes2a03.tish @@ -0,0 +1,426 @@ +// NES 2A03 APU Emulator (100% Hardware Parity) +// Uses exact hardware waveforms via AudioBuffers for exact aliasing and quantization. + +export fn defaultParamsForNes2a03() { + return { + type: "pulse", + duty: "50", + envMode: "decay", + vol: 10, + sweep: 0, + noiseMode: "long", + attack: 0, + decay: 0, + sustain: 15, + release: 0, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0, + pitchDrop: 0, + pitchDec: 0.05, + dutySweep: 0, + dpcmSample: "kick" + } +} + +fn getNesPulseBuffer(ctx, duty) { + let cacheKey = "nesPulse_" + duty + if (ctx[cacheKey]) { + return ctx[cacheKey] + } + + let buf = ctx.createBuffer(1, 8, ctx.sampleRate) + let data = buf.getChannelData(0) + + // NES duty cycles are 8 steps + let seq = [0, 0, 0, 0, 0, 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 === "50") { + seq = [0, 1, 1, 1, 1, 0, 0, 0] + } + if (duty === "25_i") { + seq = [1, 0, 0, 1, 1, 1, 1, 1] + } + + let i = 0 + while (i < 8) { + data[i] = seq[i] === 1 ? 1 : -1 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +fn getNesTriangleBuffer(ctx) { + let cacheKey = "nesTriangle" + if (ctx[cacheKey]) { + return ctx[cacheKey] + } + + let buf = ctx.createBuffer(1, 32, ctx.sampleRate) + let data = buf.getChannelData(0) + + // 32-step triangle sequence + let seq = [ + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7, + 6, + 5, + 4, + 3, + 2, + 1, + 0, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ] + + let i = 0 + while (i < 32) { + data[i] = seq[i] / 7.5 - 1 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +fn getNesLfsrBuffer(ctx, mode) { + let cacheKey = "nesLfsr_" + mode + if (ctx[cacheKey]) { + return ctx[cacheKey] + } + + let steps = mode === 15 ? 32767 : 93 + 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 = mode === 15 ? reg >> 1 & 1 : reg >> 6 & 1 + let feedback = bit0 ^ bitOther + reg = reg >> 1 | feedback << 14 + data[i] = (reg & 1) === 0 ? 1 : -1 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +fn getNesDpcmBuffer(ctx, sampleType) { + let cacheKey = "nesDpcm_" + sampleType + if (ctx[cacheKey]) { + return ctx[cacheKey] + } + + let len = Math.floor(ctx.sampleRate * 0.2) + let buf = ctx.createBuffer(1, len, ctx.sampleRate) + let data = buf.getChannelData(0) + + let i = 0 + while (i < len) { + let t = i / ctx.sampleRate + let env = Math.exp(-t * 20) + + if (sampleType === "kick") { + let freq = 150 * Math.exp(-t * 40) + data[i] = Math.sin(2 * Math.PI * freq * t) * env + } else { + if (sampleType === "snare") { + data[i] = (Math.random() * 2 - 1) * Math.exp(-t * 30) + } else { + if (sampleType === "hihat") { + data[i] = (Math.random() * 2 - 1) * Math.exp(-t * 50) + } + } + } + + // 4-bit quantization for crunch + data[i] = Math.round(data[i] * 7) / 7 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +export fn playNes2a03(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let type = gp.type ? gp.type : "pulse" + let duty = gp.duty ? String(gp.duty) : "50" + let envMode = gp.envMode ? gp.envMode : "decay" + let vol = (gp.vol !== null) ? gp.vol : 10 + let sweep = (gp.sweep !== null) ? gp.sweep : 0 + if (type !== "pulse") { + sweep = 0 + } + let noiseMode = gp.noiseMode ? gp.noiseMode : "long" + + let attack = (gp.attack !== null) ? gp.attack : 0 + let decay = (gp.decay !== null) ? gp.decay : 0 + let sustain = (gp.sustain !== null) ? gp.sustain : 15 + let release = (gp.release !== null) ? gp.release : 0 + + let vibRate = (gp.vibRate !== null) ? gp.vibRate : 0 + let vibAmt = (gp.vibAmt !== null) ? gp.vibAmt : 0 + let arpRate = (gp.arpRate !== null) ? gp.arpRate : 0 + let arpSemis = (gp.arpSemis !== null) ? gp.arpSemis : 0 + + let pitchDrop = (gp.pitchDrop !== null) ? gp.pitchDrop : 0 + let pitchDec = (gp.pitchDec !== null) ? gp.pitchDec : 0.05 + let dpcmSample = gp.dpcmSample ? gp.dpcmSample : "kick" + + let f0 = 440 * Math.pow(2, (midi + bendSemis - 69) / 12) + let f1 = f0 * Math.pow(2, sweep / 12) + + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + + let isNoise = type === "noise" + let isTriangle = type === "triangle" + let isDpcm = type === "dpcm" + + if (isTriangle) { + f0 = f0 / 2 + f1 = f1 / 2 + } + + // Enforce APU 11-bit Period limits (CPU clock = 1789773 Hz) + let minFreqPulse = 1789773 / (16 * 2048) // ~54.6 Hz + let minFreqTri = 1789773 / (32 * 2048) // ~27.3 Hz + + if (isTriangle) { + if (f0 < minFreqTri) { + f0 = minFreqTri + } + if (f1 < minFreqTri) { + f1 = minFreqTri + } + } else { + if (!isNoise && !isDpcm) { + if (f0 < minFreqPulse) { + f0 = minFreqPulse + } + if (f1 < minFreqPulse) { + f1 = minFreqPulse + } + } + } + + let src = ctx.createBufferSource() + src.loop = true + + if (isDpcm) { + src.buffer = getNesDpcmBuffer(ctx, dpcmSample) + src.loop = false + } else { + if (isNoise) { + src.buffer = getNesLfsrBuffer(ctx, noiseMode === "short" ? 9 : 15) + } else { + if (isTriangle) { + src.buffer = getNesTriangleBuffer(ctx) + } else { + let d = duty === "75" || duty === "25_i" ? "25_i" : duty + src.buffer = getNesPulseBuffer(ctx, d) + } + } + } + + let rate0 = 1 + let rate1 = 1 + if (isNoise) { + // NES Noise channel has exactly 16 discrete hardware periods + let noisePeriods = [4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068] + // Map the 128 MIDI keys evenly across the 16 hardware periods (8 keys per period) + let noiseIdx = 15 - Math.floor(Math.min(127, Math.max(0, midi)) / 8) + if (noiseIdx < 0) { + noiseIdx = 0 + } + if (noiseIdx > 15) { + noiseIdx = 15 + } + let pTimer = noisePeriods[noiseIdx] + + // Cycle-accurate playback rate calculation + rate0 = 1789773 / pTimer / ctx.sampleRate + rate1 = rate0 + } else { + if (isDpcm) { + rate0 = Math.pow(2, (midi + bendSemis - 60) / 12) + rate1 = rate0 + } else { + rate0 = f0 * src.buffer.length / ctx.sampleRate + rate1 = f1 * src.buffer.length / ctx.sampleRate + } + } + + let safeDur = Math.max(durSec, 0.01) + let decTime = (vol + 1) * 0.0625 + + if (isDpcm && envMode !== "adsr") { + let dpcmDur = 0.2 / rate0 + safeDur = dpcmDur + envMode = "constant" + } + + 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 === "decay") { + 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 && !isDpcm) { + let arpStep = 1 / arpRate + let steps = Math.ceil(totalTime / arpStep) + let rateArp = isNoise ? f0 * Math.pow(2, arpSemis / 12) / 440 : f0 * Math.pow( + 2, + arpSemis / 12 + ) * src.buffer.length / ctx.sampleRate + let i = 0 + while (i < steps) { + src.playbackRate.setValueAtTime(i % 2 === 0 ? rate0 : rateArp, t + i * arpStep) + i = i + 1 + } + } else { + if (sweep !== 0 && !isDpcm) { + src.playbackRate.exponentialRampToValueAtTime(rate1, t + decTime) + } + } + } + + // Vibrato operates independently via an additive AudioNode + if (vibRate > 0 && vibAmt > 0 && !isDpcm) { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = vibRate + let lfoGain = ctx.createGain() + let rateDev = rate0 * (Math.pow(2, vibAmt / 1200) - 1) + lfoGain.gain.value = rateDev + lfo.connect(lfoGain) + lfoGain.connect(src.playbackRate) + lfo.start(t) + lfo.stop(stopTime) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + src.start(t) + src.stop(stopTime) + disconnects.push(src) + + let finalGain = ctx.createGain() + + let maxAmp = vol / 15 * v * 0.8 + if (maxAmp < 0) { + maxAmp = 0 + } + if (maxAmp > 0.8) { + maxAmp = 0.8 + } + + if (type === "triangle" && envMode !== "adsr") { + let tOff = t + safeDur + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(v * 0.8, t + 0.005) + finalGain.gain.setValueAtTime(v * 0.8, tOff) + finalGain.gain.linearRampToValueAtTime(0, tOff + 0.005) + } else { + if (envMode === "adsr") { + let a = attack > 0 ? attack : 0.005 + let d = decay > 0 ? decay : 0.005 + let s = sustain / 15 * 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 + finalGain.gain.setValueAtTime(0, t) + finalGain.gain.linearRampToValueAtTime(maxAmp, t + 0.005) + if (envMode === "decay") { + 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.connect(finalGain) + finalGain.connect(bus.input) + disconnects.push(finalGain) + + setTimeout( + () => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, + totalTime * 1000 + ) +} diff --git a/packages/synths/src/NoiseBurst.tish b/packages/synths/src/NoiseBurst.tish new file mode 100644 index 0000000..ade33e4 --- /dev/null +++ b/packages/synths/src/NoiseBurst.tish @@ -0,0 +1,62 @@ +import { midiToHz } from './Midi.tish' + +export fn playNoiseBurst(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams + let decay = 0.08 + let tone = 0.5 + let pitchFollow = 0.2 + let rise = 0.002 + if (p) { + if (p.decay > 0) { + decay = p.decay + } + if (p.tone >= 0 && p.tone <= 1) { + tone = p.tone + } + if (p.pitchFollow >= 0 && p.pitchFollow <= 1) { + pitchFollow = p.pitchFollow + } + if (p.attack > 0) { + rise = p.attack + } + } + if (rise > 0.15) { + rise = 0.15 + } + let sr = ctx.sampleRate + let n = Math.floor(sr * 0.35) + let buf = ctx.createBuffer(1, n, sr) + let data = buf.getChannelData(0) + let i = 0 + while (i < n) { + data[i] = Math.random() * 2 - 1 + i = i + 1 + } + let src = ctx.createBufferSource() + src.buffer = buf + let bp = ctx.createBiquadFilter() + bp.type = "bandpass" + let rootHz = midiToHz(midi + bendSemis) + let center = 400 + tone * 7000 + center = center * (1 - pitchFollow) + rootHz * pitchFollow + if (center < 80) { + center = 80 + } + if (center > 12000) { + center = 12000 + } + bp.frequency.value = center + bp.Q.value = 0.7 + tone * 3 + let env = ctx.createGain() + env.gain.value = 0 + src.connect(bp) + bp.connect(env) + env.connect(bus.input) + let v = vel / 127 + let hitDur = Math.min(durSec, decay * 3 + 0.02) + env.gain.setValueAtTime(0, t) + env.gain.linearRampToValueAtTime(v, t + rise) + env.gain.exponentialRampToValueAtTime(0.001, t + hitDur) + src.start(t) + src.stop(t + hitDur + 0.05) +} diff --git a/packages/synths/src/ObSync.tish b/packages/synths/src/ObSync.tish new file mode 100644 index 0000000..f4c0392 --- /dev/null +++ b/packages/synths/src/ObSync.tish @@ -0,0 +1,114 @@ +export fn playObSync(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let detune = gp.detune !== null ? gp.detune : 15 // cents + let sweepRate = gp.sweepRate !== null ? gp.sweepRate : 0.5 + let sweepAmt = gp.sweepAmt !== null ? gp.sweepAmt : 24 + let cutoff = gp.cutoff !== null ? gp.cutoff : 1200 + let res = gp.resonance !== null ? gp.resonance : 2 + let filterEnv = gp.filterEnv !== null ? gp.filterEnv : 2400 + let filterDecay = gp.filterDecay !== null ? gp.filterDecay : 0.8 + let att = gp.attack !== null ? gp.attack : 0.1 + let dec = gp.decay !== null ? gp.decay : 0.4 + let sus = gp.sustain !== null ? gp.sustain : 0.6 + let rel = gp.release !== null ? gp.release : 0.5 + + let masterPitch = midi + bendSemis + let masterFreq = 440 * Math.pow(2, (masterPitch - 69) / 12) + + let sweepDecay = 0.05 + (1 - sweepRate) * 1.5 + let cutoffPeak = cutoff + filterEnv + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let outGain = ctx.createGain() + outGain.gain.value = 0 + outGain.gain.setValueAtTime(0, t) + outGain.gain.linearRampToValueAtTime(v * 0.6, t + att) + outGain.gain.setTargetAtTime(v * 0.6 * sus, t + att, dec / 3) + outGain.gain.setValueAtTime(v * 0.6 * sus, t + durSec) + outGain.gain.setTargetAtTime(0, t + durSec, rel / 3) + + let fL = ctx.createBiquadFilter() + fL.type = "lowpass" + fL.Q.value = res + fL.frequency.value = cutoffPeak + fL.frequency.setValueAtTime(cutoffPeak, t) + fL.frequency.setTargetAtTime(cutoff, t, filterDecay / 3) + + let fR = ctx.createBiquadFilter() + fR.type = "lowpass" + fR.Q.value = res + fR.frequency.value = cutoffPeak + fR.frequency.setValueAtTime(cutoffPeak, t) + fR.frequency.setTargetAtTime(cutoff, t, filterDecay / 3) + + let panL = ctx.createStereoPanner() + panL.pan.value = -0.6 + let panR = ctx.createStereoPanner() + panR.pan.value = 0.6 + + fL.connect(panL) + fR.connect(panR) + panL.connect(outGain) + panR.connect(outGain) + outGain.connect(bus.input) + + let oscL = null + let oscR = null + let useFallback = false + + try { + oscL = new AudioWorkletNode(ctx, "sync-oscillator") + oscR = new AudioWorkletNode(ctx, "sync-oscillator") + } catch (e) { + useFallback = true + oscL = ctx.createOscillator() + oscL.type = "sawtooth" + oscR = ctx.createOscillator() + oscR.type = "sawtooth" + } + + oscL.connect(fL) + oscR.connect(fR) + + if (!useFallback && oscL.parameters && oscR.parameters) { + let ml = oscL.parameters.get("masterFreq") + let sl = oscL.parameters.get("slaveFreq") + let mr = oscR.parameters.get("masterFreq") + let sr = oscR.parameters.get("slaveFreq") + + ml.setValueAtTime(masterFreq, t) + mr.setValueAtTime(masterFreq, t) + + let sweepPeakRatio = Math.pow(2, sweepAmt / 12) + let freqL = masterFreq * Math.pow(2, -detune / 1200) + let freqR = masterFreq * Math.pow(2, detune / 1200) + + sl.setValueAtTime(freqL * sweepPeakRatio, t) + sl.setTargetAtTime(freqL, t, sweepDecay / 3) + + sr.setValueAtTime(freqR * sweepPeakRatio, t) + sr.setTargetAtTime(freqR, t, sweepDecay / 3) + } else { + // Fallback behavior + let freqL = masterFreq * Math.pow(2, -detune / 1200) + let freqR = masterFreq * Math.pow(2, detune / 1200) + oscL.frequency.value = freqL + oscR.frequency.value = freqR + oscL.start(t) + oscR.start(t) + } + + let totalTime = durSec + rel + 0.5 + setTimeout(() => { + oscL.disconnect() + oscR.disconnect() + fL.disconnect() + fR.disconnect() + panL.disconnect() + panR.disconnect() + outGain.disconnect() + }, totalTime * 1000) +} diff --git a/packages/synths/src/Pad.tish b/packages/synths/src/Pad.tish new file mode 100644 index 0000000..1badba0 --- /dev/null +++ b/packages/synths/src/Pad.tish @@ -0,0 +1,63 @@ +// Pad / ethereal — detuned triple-oscillator through a lowpass with a slow amp envelope. The +// detune + sub-octave + filter is what keeps it from sounding like a naked MIDI tone. Pairs well +// with the channel reverb send. (Ported from the AetherSynth "ethereal" voice.) + +import { midiToHz } from './Midi.tish' +import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' + +export fn playPad(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let n = Math.floor(midi + bendSemis) + let hz = 440 + if (n >= 0 && n <= 127) { + hz = midiToHz(n) + } + let detune = (p.detune !== null) ? p.detune : 9 + let cutoff = (p.cutoff !== null) && p.cutoff > 0 ? p.cutoff : 2200 + let a = (p.attack !== null) && p.attack > 0 ? p.attack : 0.08 + let d = (p.decay !== null) && p.decay > 0 ? p.decay : 0.25 + let sus = (p.sustain !== null) && p.sustain >= 0 && p.sustain <= 1 ? p.sustain : 0.6 + let r = (p.release !== null) && p.release > 0 ? p.release : 0.7 + + let o1 = ctx.createOscillator() + o1.type = p.wave1 ? p.wave1 : "sine" + o1.frequency.value = hz + let o2 = ctx.createOscillator() + o2.type = p.wave2 ? p.wave2 : "triangle" + o2.frequency.value = hz + o2.detune.value = detune + let o3 = ctx.createOscillator() + o3.type = "sine" + o3.frequency.value = hz * 0.5 + o3.detune.value = -detune + + let filt = ctx.createBiquadFilter() + filt.type = "lowpass" + filt.frequency.value = cutoff + filt.Q.value = 0.7 + + let env = ctx.createGain() + env.gain.value = 0 + o1.connect(filt) + o2.connect(filt) + o3.connect(filt) + filt.connect(env) + env.connect(bus.input) + + let v = vel / 127 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + let peak = v * 0.5 + let slev = sus * peak + let tEnd = scheduleAdsrAmpEnvelope(env, t, peak, slev, a, d, r, durSec) + o1.start(t) + o2.start(t) + o3.start(t) + o1.stop(tEnd + 0.05) + o2.stop(tEnd + 0.05) + o3.stop(tEnd + 0.05) +} diff --git a/packages/synths/src/Patch.tish b/packages/synths/src/Patch.tish new file mode 100644 index 0000000..5216df6 --- /dev/null +++ b/packages/synths/src/Patch.tish @@ -0,0 +1,618 @@ +import { + parsePatchGraph +} from './PatchGraph.tish' +// Modular patch voice — interprets a `gen_block patch` graph into a Web Audio voice per trigger. +// This is the universal synth: oscillators, noise, filters, waveshapers and gains wired by `conn` +// lines, with breakpoint envelopes (`env node.param set/lin/exp t v ...`) on any AudioParam. +// Expression tokens resolve per-trigger: `note` (Hz of the played note), `note*2`, `dur`, `dur-0.1`, +// `vel`. Anything deck.tsx hand-coded (EDM kick, acid/reese bass, FM pluck, pad, stab) is now data. + +import { midiToHz } from './Midi.tish' +import { normalizeBasicOscWaveform } from './Registry.tish' +import { playBasicOsc } from './BasicOsc.tish' + +// `curveType` selects the waveshaper transfer curve. Default = the bright/hard broadband clip. "soft"/"tube" = +// a gentle, mostly-linear saturation with rounded shoulders (matches the live Tine/Guitar tube curves) for warmth +// without the harsh clip — the shaper node's `curve ` token picks it. +fn makeDistortionCurve(amount, curveType) { + let k = amount === amount && amount > 0 ? amount : 50 + let n = 1024 + let curve = new Float32Array(n) + let soft = curveType === "soft" || curveType === "tube" + let guitar = curveType === "guitar" + let deg = Math.PI / 180 + let i = 0 + while (i < n) { + let x = (i * 2) / n - 1 + if (soft) { + curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x)) + } else { + if (guitar) { + // The live electric-guitar pickup/amp grit (Guitar.tish guitarDriveCurve), k = drive*100. Warmer and + // less bright than the default hard clip — matches the plucked-string voice's body. + curve[i] = ((3 + k) * x * 0.6) / (Math.PI + k * Math.abs(x)) + } else { + curve[i] = ((3 + k) * x * 20 * deg) / (Math.PI + k * Math.abs(x)) + } + } + i = i + 1 + } + return curve +} + +// Karplus-Strong plucked-string render — IDENTICAL math to playGuitar (Guitar.tish): a one-period noise burst +// (brightness-shaped by `tone`) excites a digital string = a delay line of `pN` samples fed back through a +// damping lowpass. The full decay rings out INSIDE the buffer (length = ringSec), so the caller plays it as a +// one-shot (loop=false) and must size voiceDur ≥ ringSec. Per-trigger Math.random is a LOCAL synthesis detail +// (same precedent as the `noise` node / `rand` token), not streamed — peers differing in micro-timbre is fine, +// the deck transmits the `string` node + params. A real feedback DelayNode can't do this: a node inside a Web +// Audio cycle is forced to ≥1 render quantum (128 samp) of latency → max fundamental ≈ sr/128 ≈ 344 Hz, far +// below guitar pitch; the buffer render has no such ceiling (pN = round(sr/hz), only Nyquist-limited). +fn makeStringBuffer(ctx, hz, tone, damping, decayP, mute) { + let sr = ctx.sampleRate + let f = hz > 20 ? hz : 20 + let to = tone >= 0 && tone <= 1 ? tone : 0.5 + let dp = decayP >= 0 && decayP <= 1 ? decayP : 0.6 + let dm = damping >= 0 && damping <= 1 ? damping : 0.4 + let mu = mute >= 0 && mute <= 1 ? mute : 0 + let decayGain = 0.985 + dp * 0.0145 - mu * 0.012 + if (decayGain > 0.9997) { + decayGain = 0.9997 + } + let ringSec = (0.4 + dp * 2.1) * (1 - mu * 0.8) + let pN = Math.round(sr / f) + if (pN < 2) { + pN = 2 + } + let len = Math.floor(sr * ringSec) + if (len < pN + 4) { + len = pN + 4 + } + let buf = ctx.createBuffer(1, len, sr) + let data = buf.getChannelData(0) + // Excitation: one period of noise, brightness-shaped by `tone` via a one-pole lowpass. + let lp = 1 - to * 0.9 + let prev = 0 + let i = 0 + while (i < pN) { + let white = Math.random() * 2 - 1 + prev = prev + lp * (white - prev) + data[i] = prev + i = i + 1 + } + // KS loop: y[n] = decayGain * (b0*y[n-N] + b1*y[n-N-1]); damping sets the lowpass split. + let b1 = dm * 0.5 + let b0 = 1 - b1 + let j = pN + while (j < len) { + let a = data[j - pN] + let bb = j - pN - 1 >= 0 ? data[j - pN - 1] : a + data[j] = decayGain * (b0 * a + b1 * bb) + j = j + 1 + } + return { buffer: buf, ringSec: ringSec } +} + +fn makeNoiseBuffer(ctx) { + let sr = ctx.sampleRate + let n = Math.floor(sr * 2) + if (n < 256) { + n = 256 + } + let buf = ctx.createBuffer(1, n, sr) + let data = buf.getChannelData(0) + let i = 0 + while (i < n) { + data[i] = Math.random() * 2 - 1 + i = i + 1 + } + return buf +} + +/// Parse ONE leading factor (max(a,b)|min(a,b)|note|dur|vel|number) at the start of `s`; returns {val, len} +/// (val=null on miss). +fn resolveFactorTok(s, note, dur, v) { + // max(a,b) / min(a,b) — a 2-arg clamp so a generator can hold a sub-attack note to `max(dur, attack)` (the + // live voices' staccato hold). Args are full sub-expressions resolved recursively via resolveExpr. + if (s.indexOf("max(") === 0 || s.indexOf("min(") === 0) { + let isMax = s.charAt(1) === "a" + let depth = 0 + let j = 3 + let close = 0 - 1 + while (j < s.length && close < 0) { + let c = s.charAt(j) + if (c === "(") { + depth = depth + 1 + } else { + if (c === ")") { + depth = depth - 1 + if (depth === 0) { + close = j + } + } + } + j = j + 1 + } + if (close < 0) { + return { val: null, len: 0 } + } + let inner = s.substring(4, close) + // Split on the TOP-LEVEL comma (depth 0) so nested max/min args survive. + let comma = 0 - 1 + let d2 = 0 + let ci = 0 + while (ci < inner.length && comma < 0) { + let cc = inner.charAt(ci) + if (cc === "(") { + d2 = d2 + 1 + } else { + if (cc === ")") { + d2 = d2 - 1 + } else { + if (cc === "," && d2 === 0) { + comma = ci + } + } + } + ci = ci + 1 + } + if (comma < 0) { + return { val: null, len: 0 } + } + let av = resolveExpr(inner.substring(0, comma), note, dur, v) + let bv = resolveExpr(inner.substring(comma + 1), note, dur, v) + let val = isMax ? (av > bv ? av : bv) : (av < bv ? av : bv) + return { val: val, len: close + 1 } + } + if (s.indexOf("note") === 0) { + return { val: note, len: 4 } + } + if (s.indexOf("dur") === 0) { + return { val: dur, len: 3 } + } + if (s.indexOf("vel") === 0) { + return { val: v, len: 3 } + } + // A number: [-]?[0-9.]+ (a leading '-' only as the first char, so mid-chain '-' reads as the subtract op). + let j = 0 + let go = true + while (go && j < s.length) { + let c = s.charAt(j) + if ((c >= "0" && c <= "9") || c === "." || (c === "-" && j === 0)) { + j = j + 1 + } else { + go = false + } + } + if (j === 0) { + return { val: null, len: 0 } + } + let num = Number(s.substring(0, j)) + if (num !== num) { + return { val: null, len: 0 } + } + return { val: num, len: j } +} + +/// Resolve a patch expression against the trigger {note, dur, v}: a chain of factors joined by */+/- /, evaluated +/// LEFT-TO-RIGHT (no precedence — emitted forms are product chains like `note*vel*4.2` or a single `base±num`, +/// never a precedence-sensitive mix). base∈{note,dur,vel}. `note*vel` lets velocity scale a note-relative depth. +export fn resolveExpr(tok, note, dur, v) { + let s = String(tok) + let whole = Number(s) + if (whole === whole) { + return whole + } + let f0 = resolveFactorTok(s, note, dur, v) + if (f0.val === null) { + return 0 + } + let result = f0.val + let i = f0.len + while (i < s.length) { + let op = s.charAt(i) + let f = resolveFactorTok(s.substring(i + 1), note, dur, v) + if (f.val === null) { + i = s.length + } else { + i = i + 1 + f.len + if (op === "*") { + result = result * f.val + } else { + if (op === "/") { + result = f.val !== 0 ? result / f.val : result + } else { + if (op === "+") { + result = result + f.val + } else { + if (op === "-") { + result = result - f.val + } + } + } + } + } + } + return result +} + +fn audioParamFor(node, param) { + if (!node) { + return null + } + let p = String(param) + if (p === "gain") { + return node.gain ? node.gain : null + } + if (p === "freq" || p === "frequency") { + return node.frequency ? node.frequency : null + } + if (p === "detune") { + return node.detune ? node.detune : null + } + if (p === "q") { + return node.Q ? node.Q : null + } + if (p === "pan") { + return node.pan ? node.pan : null + } + if (p === "masterFreq") { + return node.parameters && node.parameters.get ? node.parameters.get("masterFreq") : null + } + if (p === "slaveFreq") { + return node.parameters && node.parameters.get ? node.parameters.get("slaveFreq") : null + } + return null +} + +export fn playPatch(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let spec = ch.generatorSpec + let graph = spec ? spec.graph : null + if (!graph && spec && spec.raw) { + graph = parsePatchGraph(spec.raw) + } + if (!graph || !graph.nodes || graph.nodes.length === 0) { + playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) + return + } + + let nMidi = midi + bendSemis + let note = 440 + if (nMidi >= 0 && nMidi <= 127) { + note = midiToHz(nMidi) + } + let v = vel / 100 + if (v < 0) { + v = 0 + } + if (v > 1) { + v = 1 + } + let dur = durSec > 0 ? durSec : 0.25 + + // 1) Build nodes by id. + let byId = {} + let oscList = [] + let noiseList = [] + let disconnectList = [] + let i = 0 + while (i < graph.nodes.length) { + let nd = graph.nodes[i] + if (nd.kind === "osc") { + let osc = ctx.createOscillator() + osc.type = normalizeBasicOscWaveform(nd.wave) + let hz = nd.freqMode === "fixed" ? nd.fixedHz : note * (nd.ratio > 0 ? nd.ratio : 1) + if (hz < 0.0001) { + hz = 0.0001 + } + osc.frequency.value = hz + if (nd.detune) { + osc.detune.value = nd.detune + } + // `rand N` — per-note humanize jitter: ±N cents on a pitched osc (de-correlates a unison), or ±N Hz on a + // fixed LFO (varies its rate per note). Math.random is per-trigger (playPatch runs once per note), exactly + // like the live generators' jitter — a LOCAL synthesis detail, not streamed, so peers differing is fine. + if (nd.rand && nd.rand > 0) { + let jit = (Math.random() * 2 - 1) * nd.rand + if (nd.freqMode === "fixed") { + let fh = osc.frequency.value + jit + if (fh < 0.0001) { + fh = 0.0001 + } + osc.frequency.value = fh + } else { + osc.detune.value = (nd.detune ? nd.detune : 0) + jit + } + } + byId[nd.id] = osc + oscList.push(osc) + } else { + if (nd.kind === "syncosc") { + let osc = null + try { + osc = new AudioWorkletNode(ctx, "sync-oscillator") + } catch (e) { + console.error("PATCH syncosc FALLBACK to sawtooth:", e) + osc = ctx.createOscillator() + osc.type = "sawtooth" + } + let hz = nd.freqMode === "fixed" ? nd.fixedHz : note * (nd.ratio > 0 ? nd.ratio : 1) + if (hz < 0.0001) { + hz = 0.0001 + } + let det = nd.detune ? nd.detune : 0 + if (nd.rand && nd.rand > 0) { + let jit = (Math.random() * 2 - 1) * nd.rand + if (nd.freqMode === "fixed") { + hz = hz + jit + if (hz < 0.0001) { + hz = 0.0001 + } + } else { + det = det + jit + } + } + if (osc.parameters) { + let mParam = osc.parameters.get("masterFreq") + let sParam = osc.parameters.get("slaveFreq") + let masterHz = hz * Math.pow(2, det / 1200) + mParam.setValueAtTime(masterHz, t) + sParam.setValueAtTime(masterHz, t) + } else { + osc.frequency.value = hz + osc.detune.value = det + } + byId[nd.id] = osc + oscList.push(osc) + } else { + if (nd.kind === "noise") { + let src = ctx.createBufferSource() + src.buffer = makeNoiseBuffer(ctx) + src.loop = true + byId[nd.id] = src + noiseList.push(src) + } else { + if (nd.kind === "string") { + // KS plucked string: render the full ring into a one-shot buffer (loop=false — the decay is IN the + // buffer, re-triggering would re-pluck). Render at UNIT amplitude — velocity/conn weight are applied + // ONCE downstream by the conn machinery (cn.vol * v), never baked in (would square velocity). Stash + // _ringSec so voiceDur honors the ring even for a bare `string` node with no `dur` line. + let ks = makeStringBuffer(ctx, note, nd.tone, nd.damping, nd.decay, nd.mute) + let src = ctx.createBufferSource() + src.buffer = ks.buffer + src.loop = false + nd._ringSec = ks.ringSec + byId[nd.id] = src + noiseList.push(src) + } else { + if (nd.kind === "filter") { + let f = ctx.createBiquadFilter() + f.type = nd.ftype + if (nd.freq && nd.freq > 0) { + f.frequency.value = nd.freq + } + f.Q.value = nd.q + byId[nd.id] = f + disconnectList.push(f) + } else { + if (nd.kind === "shaper") { + let ws = ctx.createWaveShaper() + ws.curve = makeDistortionCurve(nd.amount, nd.curve) + ws.oversample = "4x" + byId[nd.id] = ws + disconnectList.push(ws) + } else { + if (nd.kind === "gain") { + let g = ctx.createGain() + g.gain.value = nd.value + byId[nd.id] = g + disconnectList.push(g) + } else { + if (nd.kind === "pan") { + // StereoPanner — a stereo position node (modulatable .pan for auto-pan tremolo). Pure processing + // node, no start/stop. Falls back to a gain pass-through if the context lacks createStereoPanner. + if (ctx.createStereoPanner) { + let sp = ctx.createStereoPanner() + sp.pan.value = nd.pos !== null ? nd.pos : 0 + byId[nd.id] = sp + disconnectList.push(sp) + } else { + byId[nd.id] = ctx.createGain() + } + } + } + } + } + } + } + } + } + i = i + 1 + } + + // 2) Envelopes (before connections so AudioParam schedules are in place). + let maxEnd = dur + let ei = 0 + while (ei < graph.envs.length) { + let en = graph.envs[ei] + let node = byId[en.node] + let ap = audioParamFor(node, en.param) + if (ap) { + let sj = 0 + // Web Audio requires MONOTONICALLY INCREASING automation times; a `set dur 1` on a note SHORTER than the + // attack (dur < attack) yields out-of-order events that mis-schedule into a transient SPIKE — heard as a + // pop/percussive blip on a sustained voice. Clamp each segment's time to ≥ the previous so a short note + // simply completes its swell then releases (matching the live voices' hold = max(dur, attack)). + let lastTt = 0 + while (sj < en.segs.length) { + let sg = en.segs[sj] + let tt = resolveExpr(sg.tExpr, note, dur, v) + if (tt < 0) { + tt = 0 + } + if (tt < lastTt) { + tt = lastTt + } + lastTt = tt + let vv = resolveExpr(sg.vExpr, note, dur, v) + let at = t + tt + if (sg.type === "set") { + ap.setValueAtTime(vv, at) + } else { + if (sg.type === "lin") { + ap.linearRampToValueAtTime(vv, at) + } else { + if (sg.type === "exp") { + let ev = vv + if (ev < 0.0001) { + ev = 0.0001 + } + ap.exponentialRampToValueAtTime(ev, at) + } + } + } + if (tt > maxEnd) { + maxEnd = tt + } + sj = sj + 1 + } + } + ei = ei + 1 + } + + // A `string` (KS) node's decay rings out INSIDE its buffer (length ringSec), not via an env — so lift maxEnd + // to the longest string ring so a bare `string` with no `dur` line isn't truncated by tStop/the declick fade. + // (The compiled guitar voice ALSO sets graph.dur = ringSec, so both layers agree.) + let si = 0 + while (si < graph.nodes.length) { + let snd = graph.nodes[si] + if (snd._ringSec !== null && snd._ringSec > maxEnd) { + maxEnd = snd._ringSec + } + si = si + 1 + } + + // Stop time + a short output fade window (declick): a hard osc start/stop or an instantaneous gain + // jump emits a broadband transient that, through a sub-heavy voice, reads as a phantom kick/thump. + // Ramping the OUTPUT gain 0→level→0 around the voice removes that without touching the inner envelope. + let voiceDur = graph.dur !== null ? graph.dur : Math.max(dur, maxEnd) + let tStop = t + voiceDur + 0.06 + let fadeIn = 0.004 + let fadeOut = 0.008 + if (fadeIn + fadeOut > voiceDur + 0.05) { + fadeIn = (voiceDur + 0.05) * 0.3 + fadeOut = (voiceDur + 0.05) * 0.3 + } + + fn wireFadedOut(srcNode, target, level) { + let og = ctx.createGain() + og.gain.setValueAtTime(0, t) + og.gain.linearRampToValueAtTime(level, t + fadeIn) + og.gain.setValueAtTime(level, tStop - fadeOut) + og.gain.linearRampToValueAtTime(0, tStop) + srcNode.connect(og) + og.connect(target) + disconnectList.push(og) + } + + // 3) Connections. dst "out" → channel input (× velocity); "reverb" → channel reverb send. + let ci = 0 + while (ci < graph.conns.length) { + let cn = graph.conns[ci] + let srcNode = byId[cn.src] + if (srcNode) { + if (cn.dst === "out") { + wireFadedOut(srcNode, bus.input, cn.vol * v) + } else { + if (cn.dst === "reverb") { + if (bus.reverbSend) { + wireFadedOut(srcNode, bus.reverbSend, cn.vol * v) + } + } else { + let dstNode = byId[cn.dst] + if (dstNode) { + if (cn.dstParam) { + let ap = audioParamFor(dstNode, cn.dstParam) + if (ap) { + if (cn.vol !== 1) { + let sg = ctx.createGain() + sg.gain.value = cn.vol + srcNode.connect(sg) + sg.connect(ap) + disconnectList.push(sg) + } else { + srcNode.connect(ap) + } + } + } else { + // node→node AUDIO connection: guard against source nodes (osc/noise/string) which have + // 0 audio inputs — connecting to them throws IndexSizeError. + if (dstNode.numberOfInputs !== null && dstNode.numberOfInputs === 0) { + // skip — can't feed audio into a source node + } else { + // Apply the conn WEIGHT via a scaling gain (it was silently dropped — + // a long-standing bug that made every `conn x y 0.5` connect at full gain, so e.g. aether's two + // `conn o1/o2 f1 0.5` summed to 2× and aahs's `conn bpN a1 0.5` mixed 2× hot). + if (cn.vol !== 1) { + let cg = ctx.createGain() + cg.gain.value = cn.vol + srcNode.connect(cg) + cg.connect(dstNode) + disconnectList.push(cg) + } else { + srcNode.connect(dstNode) + } + } + } + } + } + } + } + ci = ci + 1 + } + + // 4) Start / stop all sources (tStop computed above for the declick window). + // AudioWorkletNodes (syncosc) don't have .start/.stop — they run immediately and + // are cleaned up via disconnect in the setTimeout below. + let oi = 0 + while (oi < oscList.length) { + if (oscList[oi].start) { + oscList[oi].start(t) + if (oscList[oi].stop) { + oscList[oi].stop(tStop) + } + } + oi = oi + 1 + } + let ni = 0 + while (ni < noiseList.length) { + noiseList[ni].start(t) + noiseList[ni].stop(tStop) + ni = ni + 1 + } + + // 5) Cleanup: disconnect all nodes after the voice finishes to prevent Web Audio graph bloat. + setTimeout(() => { + let di = 0 + while (di < oscList.length) { + if (oscList[di].disconnect) { + oscList[di].disconnect() + } + di = di + 1 + } + let ni2 = 0 + while (ni2 < noiseList.length) { + if (noiseList[ni2].disconnect) { + noiseList[ni2].disconnect() + } + ni2 = ni2 + 1 + } + let cli = 0 + while (cli < disconnectList.length) { + if (disconnectList[cli].disconnect) { + disconnectList[cli].disconnect() + } + cli = cli + 1 + } + }, (tStop - ctx.currentTime) * 1000 + 200) +} diff --git a/packages/synths/src/PatchGraph.tish b/packages/synths/src/PatchGraph.tish new file mode 100644 index 0000000..4565e37 --- /dev/null +++ b/packages/synths/src/PatchGraph.tish @@ -0,0 +1,497 @@ +// Modular patch gen_block dialect (Deckard host). +import { tokenize, isNumberToken, formatTplFloat } from '@spacedevin/deck' + +export fn normalizePatchFilterType(raw) { + let s = raw ? String(raw).toLowerCase() : "lowpass" + if (s === "lp" || s === "lowpass") { + return "lowpass" + } + if (s === "hp" || s === "highpass") { + return "highpass" + } + if (s === "bp" || s === "bandpass") { + return "bandpass" + } + if (s === "notch") { + return "notch" + } + if (s === "lowshelf") { + return "lowshelf" + } + if (s === "highshelf") { + return "highshelf" + } + if (s === "peaking" || s === "peak") { + return "peaking" + } + if (s === "allpass") { + return "allpass" + } + return "lowpass" +} + +export fn parsePatchGraph(lines) { + let nodes = [] + let conns = [] + let envs = [] + let dur = null + let li = 0 + while (li < lines.length) { + let toks = tokenize(lines[li]) + if (toks.length === 0) { + li = li + 1 + continue + } + let h = toks[0] + if (h === "string" && toks.length >= 2) { + let snd = { + kind: "string", + id: String(toks[1]), + tone: 0.5, + damping: 0.4, + decay: 0.6, + mute: 0 + } + let sidx = 2 + while (sidx < toks.length) { + let stk = toks[sidx] + if (stk === "tone" && sidx + 1 < toks.length) { + snd.tone = Number(toks[sidx + 1]) + sidx = sidx + 2 + } else { + if (stk === "damping" && sidx + 1 < toks.length) { + snd.damping = Number(toks[sidx + 1]) + sidx = sidx + 2 + } else { + if (stk === "decay" && sidx + 1 < toks.length) { + snd.decay = Number(toks[sidx + 1]) + sidx = sidx + 2 + } else { + if (stk === "mute" && sidx + 1 < toks.length) { + snd.mute = Number(toks[sidx + 1]) + sidx = sidx + 2 + } else { + if (stk === "gain" && sidx + 1 < toks.length) { + snd.gain = Number(toks[sidx + 1]) + sidx = sidx + 2 + } else { + sidx = sidx + 1 + } + } + } + } + } + } + nodes.push(snd) + li = li + 1 + continue + } + if (h === "syncosc" && toks.length >= 2) { + let nd = { + kind: "syncosc", + id: String(toks[1]), + freqMode: "note", + fixedHz: 0, + ratio: 1, + detune: 0, + rand: 0 + } + let idx = 2 + while (idx < toks.length) { + let tk = toks[idx] + if (tk === "note") { + nd.freqMode = "note" + idx = idx + 1 + } else { + if (tk === "ratio" && idx + 1 < toks.length) { + nd.ratio = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "detune" && idx + 1 < toks.length) { + nd.detune = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "rand" && idx + 1 < toks.length) { + nd.rand = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "gain" && idx + 1 < toks.length) { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (isNumberToken(tk)) { + nd.freqMode = "fixed" + nd.fixedHz = Number(tk) + idx = idx + 1 + } else { + idx = idx + 1 + } + } + } + } + } + } + } + nodes.push(nd) + } else { + if (h === "osc" && toks.length >= 2) { + let nd = { + kind: "osc", + id: String(toks[1]), + wave: "sine", + freqMode: "note", + fixedHz: 0, + ratio: 1, + detune: 0, + rand: 0 + } + let idx = 2 + if (idx < toks.length) { + let w = toks[idx] + if (w !== "note" && w !== "ratio" && w !== "detune" && !isNumberToken(w)) { + nd.wave = String(w) + idx = idx + 1 + } + } + while (idx < toks.length) { + let tk = toks[idx] + if (tk === "note") { + nd.freqMode = "note" + idx = idx + 1 + } else { + if (tk === "ratio" && idx + 1 < toks.length) { + nd.ratio = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "detune" && idx + 1 < toks.length) { + nd.detune = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "rand" && idx + 1 < toks.length) { + nd.rand = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "gain" && idx + 1 < toks.length) { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (isNumberToken(tk)) { + nd.freqMode = "fixed" + nd.fixedHz = Number(tk) + idx = idx + 1 + } else { + idx = idx + 1 + } + } + } + } + } + } + } + nodes.push(nd) + } else { + if (h === "noise" && toks.length >= 2) { + let nd = { kind: "noise", id: String(toks[1]) } + let idx = 2 + while (idx + 1 < toks.length) { + if (toks[idx] === "gain") { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + idx = idx + 1 + } + } + nodes.push(nd) + } else { + if (h === "filter" && toks.length >= 2) { + let nd = { kind: "filter", id: String(toks[1]), ftype: "lowpass", q: 0.7, freq: 0 } + let idx = 2 + if (idx < toks.length) { + let ty = toks[idx] + if (ty !== "q" && ty !== "freq" && !isNumberToken(ty)) { + nd.ftype = normalizePatchFilterType(ty) + idx = idx + 1 + } + } + while (idx + 1 < toks.length) { + let tk = toks[idx] + if (tk === "q") { + nd.q = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "freq") { + nd.freq = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (tk === "gain" && idx + 1 < toks.length) { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + idx = idx + 1 + } + } + } + } + nodes.push(nd) + } else { + if (h === "shaper" && toks.length >= 2) { + let nd = { kind: "shaper", id: String(toks[1]), amount: 50, curve: "hard" } + let idx = 2 + while (idx + 1 < toks.length) { + if (toks[idx] === "amount" || toks[idx] === "drive") { + nd.amount = Number(toks[idx + 1]) + idx = idx + 2 + } else { + if (toks[idx] === "curve") { + nd.curve = String(toks[idx + 1]) + idx = idx + 2 + } else { + if (toks[idx] === "gain" && idx + 1 < toks.length) { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + idx = idx + 1 + } + } + } + } + nodes.push(nd) + } else { + if (h === "pan" && toks.length >= 2) { + let pos = 0 + if (toks.length >= 3 && isNumberToken(toks[2])) { + pos = Number(toks[2]) + } + let nd = { kind: "pan", id: String(toks[1]), pos: pos } + let idx = 3 + while (idx + 1 < toks.length) { + if (toks[idx] === "gain") { + nd.gain = Number(toks[idx + 1]) + idx = idx + 2 + } else { + idx = idx + 1 + } + } + nodes.push(nd) + } else { + if (h === "gain" && toks.length >= 2) { + let val = 1 + if (toks.length >= 3 && isNumberToken(toks[2])) { + val = Number(toks[2]) + } + nodes.push({ kind: "gain", id: String(toks[1]), value: val }) + } else { + if (h === "conn" && toks.length >= 3) { + let src = String(toks[1]) + let dstRaw = String(toks[2]) + let dstNode = dstRaw + let dstParam = null + let dot = dstRaw.indexOf(".") + if (dot >= 0) { + dstNode = dstRaw.substring(0, dot) + dstParam = dstRaw.substring(dot + 1) + } + let vol = 1 + if (toks.length >= 4 && isNumberToken(toks[3])) { + vol = Number(toks[3]) + } + conns.push({ src: src, dst: dstNode, dstParam: dstParam, vol: vol }) + } else { + if (h === "env" && toks.length >= 5) { + let target = String(toks[1]) + let node = target + let param = "gain" + let dot = target.indexOf(".") + if (dot >= 0) { + node = target.substring(0, dot) + param = target.substring(dot + 1) + } + let segs = [] + let idx = 2 + while (idx + 3 <= toks.length) { + segs.push({ + type: String(toks[idx]), + tExpr: String(toks[idx + 1]), + vExpr: String(toks[idx + 2]) + }) + idx = idx + 3 + } + envs.push({ node: node, param: param, segs: segs }) + } else { + if (h === "dur" && toks.length >= 2) { + dur = Number(toks[1]) + } + } + } + } + } + } + } + } + } + } + li = li + 1 + } + return { nodes: nodes, conns: conns, envs: envs, dur: dur } +} + +fn waveTok(w) { + if (!w) { return "sine" } + return String(w).toLowerCase() +} + +fn patchWaveTok(w) { + return waveTok(w) +} + +fn oscLine(nd) { + let s = "osc " + nd.id + " " + patchWaveTok(nd.wave) + if (nd.freqMode === "fixed") { + let hz = nd.fixedHz !== null ? nd.fixedHz : 220 + s = s + " " + formatTplFloat(hz) + } else { + s = s + " note" + let r = nd.ratio !== null ? nd.ratio : 1 + if (Math.abs(r - 1) > 0.00001) { + s = s + " ratio " + formatTplFloat(r) + } + } + if (nd.detune !== null && Math.abs(nd.detune) > 0.00001) { + s = s + " detune " + formatTplFloat(nd.detune) + } + if (nd.rand !== null && nd.rand > 0.00001) { + s = s + " rand " + formatTplFloat(nd.rand) + } + return s +} + +fn filterLine(nd) { + let s = "filter " + nd.id + " " + (nd.ftype ? nd.ftype : "lowpass") + s = s + " q " + formatTplFloat(nd.q !== null ? nd.q : 0.7) + if (nd.freq !== null && nd.freq > 0) { + s = s + " freq " + formatTplFloat(nd.freq) + } + return s +} + +fn syncoscLine(nd) { + let s = "syncosc " + nd.id + if (nd.freqMode === "fixed") { + let hz = nd.fixedHz !== null ? nd.fixedHz : 220 + s = s + " " + formatTplFloat(hz) + } else { + s = s + " note" + let r = nd.ratio !== null ? nd.ratio : 1 + if (Math.abs(r - 1) > 0.00001) { + s = s + " ratio " + formatTplFloat(r) + } + } + if (nd.detune !== null && Math.abs(nd.detune) > 0.00001) { + s = s + " detune " + formatTplFloat(nd.detune) + } + if (nd.rand !== null && nd.rand > 0.00001) { + s = s + " rand " + formatTplFloat(nd.rand) + } + return s +} + +fn gainSuffix(nd) { + if (nd.gain !== null && Math.abs(nd.gain - 1) > 0.00001) { + return " gain " + formatTplFloat(nd.gain) + } + return "" +} + +fn nodeLine(nd) { + if (nd.kind === "osc") { + return oscLine(nd) + gainSuffix(nd) + } + if (nd.kind === "syncosc") { + return syncoscLine(nd) + gainSuffix(nd) + } + if (nd.kind === "noise") { + return "noise " + nd.id + gainSuffix(nd) + } + if (nd.kind === "string") { + let st = "string " + nd.id + " note" + st = st + " tone " + formatTplFloat(nd.tone !== null ? nd.tone : 0.5) + st = st + " damping " + formatTplFloat(nd.damping !== null ? nd.damping : 0.4) + st = st + " decay " + formatTplFloat(nd.decay !== null ? nd.decay : 0.6) + if (nd.mute !== null && nd.mute > 0.00001) { + st = st + " mute " + formatTplFloat(nd.mute) + } + return st + gainSuffix(nd) + } + if (nd.kind === "filter") { + return filterLine(nd) + gainSuffix(nd) + } + if (nd.kind === "shaper") { + let ss = "shaper " + nd.id + " amount " + formatTplFloat(nd.amount !== null ? nd.amount : 50) + if (nd.curve !== null && nd.curve !== "hard" && String(nd.curve).length > 0) { + ss = ss + " curve " + String(nd.curve) + } + return ss + gainSuffix(nd) + } + if (nd.kind === "pan") { + return "pan " + nd.id + " " + formatTplFloat(nd.pos !== null ? nd.pos : 0) + gainSuffix(nd) + } + if (nd.kind === "gain") { + return "gain " + nd.id + " " + formatTplFloat(nd.value !== null ? nd.value : 1) + } + return "" +} + +fn connLine(cn) { + let dstTok = cn.dst + if (cn.dstParam) { + dstTok = cn.dst + "." + cn.dstParam + } + return "conn " + cn.src + " " + dstTok + " " + formatTplFloat(cn.vol !== null ? cn.vol : 1) +} + +fn envLine(en) { + let s = "env " + en.node + "." + (en.param ? en.param : "gain") + let i = 0 + while (i < en.segs.length) { + let sg = en.segs[i] + s = s + " " + sg.type + " " + String(sg.tExpr) + " " + String(sg.vExpr) + i = i + 1 + } + return s +} + +export fn patchGraphToLines(graph) { + let lines = [] + let i = 0 + while (i < graph.nodes.length) { + let ln = nodeLine(graph.nodes[i]) + if (ln.length > 0) { + lines.push(ln) + } + i = i + 1 + } + let ci = 0 + while (ci < graph.conns.length) { + lines.push(connLine(graph.conns[ci])) + ci = ci + 1 + } + let ei = 0 + while (ei < graph.envs.length) { + let en = graph.envs[ei] + if (en.segs && en.segs.length > 0) { + lines.push(envLine(en)) + } + ei = ei + 1 + } + if (graph.dur !== null) { + lines.push("dur " + formatTplFloat(graph.dur)) + } + return lines +} + +export fn syncPatchSpecFromGraph(spec) { + if (!spec || !spec.graph) { + return + } + spec.raw = patchGraphToLines(spec.graph) +} diff --git a/packages/synths/src/ReeseBass.tish b/packages/synths/src/ReeseBass.tish new file mode 100644 index 0000000..5ac0511 --- /dev/null +++ b/packages/synths/src/ReeseBass.tish @@ -0,0 +1,84 @@ +export fn playReeseBass(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let voices = gp.voices !== null ? Math.floor(gp.voices) : 3 + let detune = gp.detune !== null ? gp.detune : 30 + let cutoff = gp.cutoff !== null ? gp.cutoff : 1500 + let wobble = gp.wobble !== null ? gp.wobble : 0 + let decay = gp.decay !== null ? gp.decay : 1.0 + + if (voices < 1) voices = 1 + if (voices > 7) voices = 7 + + 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 oscs = [] + let filter = ctx.createBiquadFilter() + filter.type = "lowpass" + filter.frequency.value = cutoff + filter.Q.value = 2 + + let envGain = ctx.createGain() + envGain.gain.setValueAtTime(0, t) + envGain.gain.linearRampToValueAtTime(v * 0.8 / voices, t + 0.05) + envGain.gain.setTargetAtTime(0, t + 0.05, decay / 3) + + let i = 0 + while (i < voices) { + let osc = ctx.createOscillator() + osc.type = "sawtooth" + osc.frequency.value = f0 + + // Spread detune symmetrically around 0 + let d = 0 + if (voices > 1) { + // voices=3 -> -1, 0, 1 + d = (i - (voices - 1) / 2) * (detune / Math.max(1, voices - 1)) + } + osc.detune.value = d + + osc.connect(filter) + oscs.push(osc) + i = i + 1 + } + + let lfo = null + let lfoGain = null + if (wobble > 0) { + lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = wobble + lfoGain = ctx.createGain() + lfoGain.gain.value = cutoff * 0.8 // Modulate by 80% of cutoff + lfo.connect(lfoGain) + lfoGain.connect(filter.frequency) + lfo.start(t) + lfo.stop(t + decay + 0.5) + } + + filter.connect(envGain) + envGain.connect(bus.input) + + let oi = 0 + while (oi < oscs.length) { + oscs[oi].start(t) + oscs[oi].stop(t + decay + 0.5) + oi = oi + 1 + } + + setTimeout(() => { + let di = 0 + while (di < oscs.length) { + oscs[di].disconnect() + di = di + 1 + } + if (lfo) { + lfo.disconnect() + lfoGain.disconnect() + } + filter.disconnect() + envGain.disconnect() + }, (decay + 1) * 1000) +} diff --git a/packages/synths/src/Registry.tish b/packages/synths/src/Registry.tish new file mode 100644 index 0000000..5e81311 --- /dev/null +++ b/packages/synths/src/Registry.tish @@ -0,0 +1,506 @@ +// Catalog + default generatorParams (includes ADSR where the generator uses an amp envelope). + +export fn generatorCatalog() { + return [ + { + id: "basicOsc", + label: "Basic OSC", + description: "Single oscillator + ADSR in generator params." + }, + { + id: "noiseBurst", + label: "Noise burst", + description: "Filtered noise; attack + decay shape the hit." + }, + { + id: "fmTone", + label: "FM tone", + description: "Two-operator FM + ADSR in generator params." + }, + { + id: "matrixFm", + label: "Matrix FM", + description: "Multi-operator FM/RM graph via deck gen_block (Sytrus-style)." + }, + { + id: "pad", + label: "Pad", + description: "Detuned triple-osc + lowpass + slow env — ethereal, reverb-friendly." + }, + { + id: "bell", + label: "Bell", + description: "Inharmonic sine partials + highpass + bell decay — metallic shimmer." + }, + { + id: "drumSynth", + label: "Drum", + description: "Pitch-envelope drum synth — punchy kicks, snares, toms, 808s (+ noise + drive)." + }, + { + id: "guitar", + label: "Guitar", + description: "Karplus-Strong plucked electric guitar — string model + palm mute + drive + body. Pairs with the chord voice for strums." + }, + { + id: "clap", + label: "Clap", + description: "The Clap — handclap engine: hand count, timing spread, hand size, brightness, room tail, and clusters (single / double / many-hand crowd)." + }, + { + id: "arco", + label: "Arco", + description: "Bowed strings — violin · viola · cello · bass · fiddle. Stick-slip saw through real body-resonance formants, with bow pressure, articulation, vibrato and rosin noise. Only string-player controls." + }, + { + id: "tine", + label: "Tine", + description: "Rhodes-style electric piano — velocity-barked FM tine (hard = metallic bark, soft = mellow bell), a metal tine ping, EP decay, and lush suitcase tremolo." + }, + { + id: "aether", + label: "Aether", + description: "Theremin — eerie, voice-like heterodyne tone with a portamento swoop into each note, a living two-hand pitch/amplitude waver, and a breathy volume-hand swell." + }, + { + id: "halo", + label: "Halo", + description: "Hang drum / handpan — lush inharmonic octave + compound-fifth shimmer triad over a long ethereal ring, with a soft fingertip strike and a warm 'gu' body." + }, + { + id: "formantVocal", + label: "Formant Vocal", + description: "Expressive formant synthesizer with gliding notes and vibrato." + }, + { + id: "ttsVocal", + label: "TTS Vocal", + description: "Web Speech API Text-to-Speech engine for robotic vocal sequences." + }, + { + id: "acid303", + label: "Acid 303", + description: "Classic 303-style bassline with resonant filter and envMod sweep." + }, + { + id: "sub808", + label: "Sub 808", + description: "Heavy sine wave sub-bass with punch and drive saturation." + }, + { + id: "chiptune", + label: "Chiptune", + description: "Retro 8-bit pulse-width modulation and decimation crush." + }, + { + id: "nes2a03", + label: "2A03", + description: "NES APU emulator (100% hardware parity)." + }, + { + id: "c64sid", + label: "MOS 6581", + description: "Commodore 64 SID chip emulator (100% hardware parity)." + }, + { + id: "gameBoyDmg", + label: "LR35902", + description: "Game Boy DMG APU emulator (100% hardware parity)." + }, + { + id: "ym2612", + label: "YM2612", + description: "Sega Genesis FM Synth emulator (References: Nuked-OPN2, Genesis Plus GX)." + }, + { + id: "sn76489", + label: "SN76489", + description: "Sega Master System / Genesis PSG emulator (References: MAME)." + }, + { + id: "spc700", + label: "SPC700", + description: "Super Nintendo S-DSP emulator (References: bsnes, snes9x)." + }, + { + id: "gbaDirectSound", + label: "GBA DirectSound", + description: "Game Boy Advance 8-bit DAC software mixing simulator." + }, + { + id: "cymbal", + label: "Cymbal", + description: "TR-808 style cymbal cluster (6 tuned squares + noise + highpass)." + }, + { + id: "reeseBass", + label: "Reese Bass", + description: "Thick, multi-oscillator detuned Supersaw bass with filter wobble." + }, + { + id: "syncLead", + label: "Sync Lead", + description: "Aggressive true hard sync analog lead with an automated sweep envelope." + }, + { + id: "syncChoir", + label: "Sync Choir", + description: "Lush, robotic 80s analog choir built from detuned hard sync formants." + }, + { + id: "obSync", + label: "OB Sync", + description: "Massive, creamy dual-oscillator hard sync synth with Oberheim-style width and a sweeping filter." + }, + { + id: "laserSync", + label: "Laser Sync", + description: "Punchy, retro-arcade zap. A rapid pitch-dropping master oscillator ripping through a static sync slave." + }, + { + id: "meSpeakVocal", + label: "meSpeak Vocal", + description: "Retro robotic TTS using meSpeak.js with sample-accurate timing." + }, + { + id: "patch", + label: "Patch", + description: "Modular synth patch (gen_block patch): osc/noise/filter/shaper/gain + breakpoint envelopes — any voice, written in deck." + } + ] +} + +export fn generatorLabelById(id) { + let cat = generatorCatalog() + let i = 0 + while (i < cat.length) { + if (cat[i].id === id) { + return cat[i].label + } + i = i + 1 + } + return id +} + +export fn generatorDescriptionById(id) { + let cat = generatorCatalog() + let i = 0 + while (i < cat.length) { + if (cat[i].id === id) { + return cat[i].description + } + i = i + 1 + } + return "" +} + +/// Maps deck / shorthand names to valid OscillatorType (sine|square|sawtooth|triangle). +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") { + return "square" + } + if (s === "triangle" || s === "tri") { + return "triangle" + } + if (s === "sine" || s === "sin") { + return "sine" + } + return "sine" +} + +import { playNes2a03, defaultParamsForNes2a03 } from "./Nes2a03.tish" +import { playGameBoyDmg, defaultParamsForGameBoyDmg } from "./GameBoyDmg.tish" +import { defaultParamsForC64Sid } from "./C64Sid.tish" +import { defaultParamsForYm2612 } from "./Ym2612.tish" +import { defaultParamsForSn76489 } from "./Sn76489.tish" +import { defaultParamsForSpc700 } from "./Spc700.tish" +import { defaultParamsForGbaDirectSound } from "./GbaDirectSound.tish" + + +export fn defaultParamsForGeneratorId(id) { + if (id === "basicOsc") { + return { + waveform: "sine", + attack: 0.005, + decay: 0.08, + sustain: 0.4, + release: 0.12 + } + } + if (id === "noiseBurst") { + return { + attack: 0.002, + decay: 0.07, + tone: 0.45, + pitchFollow: 0.25 + } + } + if (id === "fmTone") { + return { + ratio: 2, + modIndex: 4, + carrierWave: "sine", + modWave: "sine", + attack: 0.005, + decay: 0.08, + sustain: 0.4, + release: 0.12 + } + } + if (id === "matrixFm") { + return {} + } + if (id === "pad") { + return { + wave1: "sine", + wave2: "triangle", + detune: 9, + cutoff: 2200, + attack: 0.08, + decay: 0.25, + sustain: 0.6, + release: 0.7 + } + } + if (id === "bell") { + return { + partial: 2.01, + highpass: 800, + decay: 1.0 + } + } + if (id === "drumSynth") { + return { + tone: "sine", + pitchEnv: 36, + pitchDecay: 0.06, + decay: 0.35, + noise: 0, + noiseDecay: 0.12, + noiseHp: 1500, + drive: 0 + } + } + if (id === "guitar") { + return { + tone: 0.5, + decay: 0.6, + damping: 0.4, + drive: 0.25, + body: 3500, + mute: 0 + } + } + if (id === "clap") { + return { + hands: 3, + spread: 0.4, + size: 0.5, + tone: 0.5, + claps: 1, + gap: 0.35, + tail: 0.4, + body: 0.2 + } + } + if (id === "arco") { + return { + voice: "violin", + pressure: 0.5, + bow: 0.4, + vibrato: 0.35, + rosin: 0.3, + body: 0.6 + } + } + if (id === "tine") { + return { + bark: 0.55, + tine: 0.6, + tremolo: 0.35, + decay: 0.5, + drive: 0.2 + } + } + if (id === "aether") { + return { + glide: 0.4, + waver: 0.5, + tone: 0.3, + swell: 0.45, + air: 0.25 + } + } + if (id === "halo") { + return { + temper: 0.4, + ring: 0.55, + mallet: 0.35, + bloom: 0.5, + lows: 0.45 + } + } + if (id === "formantVocal") { + return { + glide: 0.1, + vibDepth: 0.02, + vibRate: 5.0, + humanize: 0.5, + release: 0.2 + } + } + if (id === "ttsVocal") { + return { + voice: 0, + rate: 1.5 + } + } + if (id === "meSpeakVocal") { + return { + pitch: 50, + speed: 175, + wordgap: 0, + variant: "m1", + amplitude: 100 + } + } + if (id === "acid303") { + return { + waveform: "sawtooth", + cutoff: 800, + resonance: 15, + envMod: 4000, + decay: 0.4 + } + } + if (id === "sub808") { + return { + punch: 60, + decay: 1.2, + drive: 0, + glide: 0.05 + } + } + if (id === "chiptune") { + return { + waveform: "pulse", + pulseWidth: 0.5, + pwmSpeed: 0, + bitcrush: 0, + lowpass: 0, + arpRate: 0, + arpSemis: 0, + attack: 0.005, + decay: 0.3, + sustain: 0, + release: 0.05 + } + } + if (id === "nes2a03") { + return defaultParamsForNes2a03() + } + if (id === "gameBoyDmg") { + return defaultParamsForGameBoyDmg() + } + if (id === "c64sid") { + return defaultParamsForC64Sid() + } + if (id === "ym2612") { + return defaultParamsForYm2612() + } + if (id === "sn76489") { + return defaultParamsForSn76489() + } + if (id === "spc700") { + return defaultParamsForSpc700() + } + if (id === "gbaDirectSound") { + return defaultParamsForGbaDirectSound() + } + + if (id === "cymbal") { + return { + tune: 300, + metallic: 0.8, + decay: 0.4, + highpass: 7000 + } + } + if (id === "reeseBass") { + return { + voices: 3, + detune: 30, + cutoff: 1500, + wobble: 0, + decay: 1.0 + } + } + if (id === "syncLead") { + return { + masterTune: 0, + slaveBase: 12, + sweepAmt: 24, + sweepDecay: 0.4, + lfoRate: 0, + lfoAmt: 0, + cutoff: 3000, + resonance: 5, + filterEnvAmt: 0, + filterDecay: 0.4, + attack: 0.05, + decay: 0.3, + sustain: 0.8, + release: 0.5 + } + } + if (id === "syncChoir") { + return { + vowelShift: 24, + morphRate: 0.5, + morphAmt: 12, + ensembleDetune: 15, + vibRate: 5.0, + vibAmt: 10, + highpass: 300, + attack: 1.0, + decay: 1.0, + sustain: 0.8, + release: 1.5 + } + } + if (id === "obSync") { + return { + detune: 15, + sweepRate: 0.5, + sweepAmt: 24, + cutoff: 1200, + resonance: 2, + filterEnv: 2400, + filterDecay: 0.8, + attack: 0.1, + decay: 0.4, + sustain: 0.6, + release: 0.5 + } + } + if (id === "laserSync") { + return { + dropRate: 0.8, + dropAmt: 36, + slaveBase: 18, + attack: 0.01, + decay: 0.3 + } + } + if (id === "patch") { + return {} + } + return {} +} diff --git a/packages/synths/src/Sn76489.tish b/packages/synths/src/Sn76489.tish new file mode 100644 index 0000000..0281646 --- /dev/null +++ b/packages/synths/src/Sn76489.tish @@ -0,0 +1,191 @@ +// SN76489 (Sega Genesis / Master System PSG) Emulator +// References: +// 1. MAME (sn76496.cpp) - For 15-bit shift register logic and tapped bits. + +export fn defaultParamsForSn76489() { + return { + type: "square", + noiseMode: "white", // or "periodic" + noiseFreq: 0, // 0 = high, 1 = med, 2 = low, 3 = sync with CH3 + vol: 15, + attack: 0, + decay: 0.1, + sustain: 15, + release: 0, + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0 + } +} + +fn getSnNoiseBuffer(ctx, periodic) { + let cacheKey = periodic ? "snNoiseP" : "snNoiseW" + if (ctx[cacheKey]) return ctx[cacheKey] + + // SN76489 noise uses a 15-bit shift register + // Periodic noise taps bit 0 only (simple divide by 15-bit length = pulse) + // White noise taps bit 0 XOR bit 3 (for SN76489) + + let steps = 32767 // 2^15 - 1 + let buf = ctx.createBuffer(1, steps, ctx.sampleRate) + let data = buf.getChannelData(0) + + let reg = 0x4000 + let i = 0 + while (i < steps) { + let bit0 = reg & 1 + let feedback = bit0 + if (!periodic) { + // White noise tap for SN76489 is bit 0 XOR bit 3 + let bit3 = (reg >> 3) & 1 + feedback = bit0 ^ bit3 + } + + reg = (reg >> 1) | (feedback << 14) + data[i] = (reg & 1) ? 1.0 : -1.0 + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +export fn playSn76489(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let type = gp.type ? gp.type : "square" + let noiseMode = gp.noiseMode ? gp.noiseMode : "white" + let vol = gp.vol !== null ? Math.round(gp.vol) : 15 + let attack = gp.attack !== null ? gp.attack : 0 + let decay = gp.decay !== null ? gp.decay : 0.1 + let sustain = gp.sustain !== null ? gp.sustain : 15 + let release = gp.release !== null ? gp.release : 0 + + let pitchDrop = gp.pitchDrop !== null ? Math.round(gp.pitchDrop) : 0 + let pitchDec = gp.pitchDec !== null ? gp.pitchDec : 0.05 + let vibRate = gp.vibRate !== null ? gp.vibRate : 0 + let vibAmt = gp.vibAmt !== null ? gp.vibAmt : 0 + let arpRate = gp.arpRate !== null ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null ? 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 + + // SN76489 clock is typically 3.579545 MHz + // Period = Clock / (32 * Frequency). Max period is 10-bit (1023). + // Min frequency = 3579545 / (32 * 1023) = ~109.3 Hz + if (f0 < 109.3 && type === "square") { + f0 = 109.3 // Hardware clamp + } + + let src = null + let disconnects = [] + + if (type === "noise") { + src = ctx.createBufferSource() + src.buffer = getSnNoiseBuffer(ctx, noiseMode === "periodic") + src.loop = true + // Pitch mapped to MIDI scaling for ease of use in tracker + src.playbackRate.value = (f0 * 10) / ctx.sampleRate + } else { + src = ctx.createOscillator() + src.type = "square" + src.frequency.value = f0 + } + + let safeDur = Math.max(durSec, 0.01) + let tA = t + attack + let tD = tA + decay + let tOff = Math.max(t + safeDur, tD) + + let freqParam = src.frequency ? src.frequency : src.playbackRate + let baseFreq = src.frequency ? f0 : src.playbackRate.value + + if (pitchDrop !== 0) { + let rateDrop = baseFreq * Math.pow(2, pitchDrop / 12) + freqParam.setValueAtTime(rateDrop, t) + freqParam.setTargetAtTime(baseFreq, t, Math.max(pitchDec, 0.001)) + } else if (arpRate > 0 && arpSemis !== 0) { + let arpStep = 1.0 / arpRate + let steps = Math.ceil((tOff - t + release) / arpStep) + let ai = 0 + freqParam.setValueAtTime(baseFreq, t) + while (ai < steps) { + let semi = (ai % 2 === 1) ? arpSemis : 0 + freqParam.setValueAtTime(baseFreq * Math.pow(2, semi / 12), t + ai * arpStep) + ai = ai + 1 + } + } else { + freqParam.setValueAtTime(baseFreq, t) + } + + if (vibRate > 0 && vibAmt > 0 && type !== "noise") { + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = vibRate + let lfoGain = ctx.createGain() + lfoGain.gain.value = baseFreq * (vibAmt / 1200) + lfo.connect(lfoGain) + lfoGain.connect(freqParam) + lfo.start(t) + lfo.stop(tOff + release) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + // SN76489 Attenuation is 4-bit (0-15). 2dB per step. + // 15 = 0dB (Max). 0 = Silence (or -28dB depending on chip rev, usually silence in emus). + let volStep = Math.max(0, Math.min(15, Math.round(vol))) + let maxAmp = 0 + if (volStep > 0) { + // 0 is silent, 1 is -28dB, 15 is 0dB + maxAmp = Math.pow(10, -((15 - volStep) * 2.0) / 20.0) * v * 0.8 + } + + let finalGain = ctx.createGain() + + let sLevel = maxAmp * (sustain / 15.0) + + finalGain.gain.setValueAtTime(0, t) + if (attack > 0) { + finalGain.gain.linearRampToValueAtTime(maxAmp, tA) + } else { + finalGain.gain.setValueAtTime(maxAmp, tA) + } + + if (decay > 0) { + finalGain.gain.setTargetAtTime(sLevel, tA, decay / 3.0) + } else { + finalGain.gain.setValueAtTime(sLevel, tA) + } + + finalGain.gain.setValueAtTime(sLevel, tOff) + if (release > 0) { + finalGain.gain.setTargetAtTime(0, tOff, release / 3.0) + } else { + finalGain.gain.setValueAtTime(0, tOff) + } + + src.start(t) + src.stop(tOff + release + 0.1) + disconnects.push(src) + + src.connect(finalGain) + finalGain.connect(bus.input) + disconnects.push(finalGain) + + let totalTime = tOff - t + release + 0.2 + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/Spc700.tish b/packages/synths/src/Spc700.tish new file mode 100644 index 0000000..73e388a --- /dev/null +++ b/packages/synths/src/Spc700.tish @@ -0,0 +1,262 @@ +// SPC700 / S-DSP (Super Nintendo) Emulator +// References and Algorithmic Parity: +// 1. bsnes (smp/dsp) - For understanding the S-DSP non-linear ADSR envelope logic +// (Linear Attack, Exponential Decay/Sustain). +// 2. snes9x (apu/biquad) - For the 4-point Gaussian interpolation low-pass equivalent. +// 3. SNES Dev Manual - 8-tap FIR global echo implementation details. + +export fn defaultParamsForSpc700() { + return { + waveform: "strings", + attack: 0, + decay: 3, + sustainLevel: 7, + sustainRate: 0, + echoEnable: false, + echoDelay: 4, // x 16ms + echoFeedback: 0, // % + echoFir: 0, // FIR filter preset + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0 + } +} + +// Generates typical 16-bit SNES waveforms, processed through a simulated BRR quantization pass +fn getSpcSampleBuffer(ctx, shape) { + let cacheKey = "snes_" + shape + if (ctx[cacheKey]) return ctx[cacheKey] + + // SNES samples are typically short loops (e.g. 64, 128, 256 samples) + // Played back at up to 32kHz + let len = 256 + let buf = ctx.createBuffer(1, len, ctx.sampleRate) + let data = buf.getChannelData(0) + + let i = 0 + while (i < len) { + let phase = i / len + let v = 0 + if (shape === "strings") { + v = Math.sin(phase * Math.PI * 2) + 0.5 * Math.sin(phase * Math.PI * 4) + 0.25 * Math.sin(phase * Math.PI * 6) + } else if (shape === "brass") { + v = (phase * 2 - 1) * (1.0 - phase) // Sawtooth with a slight roll-off + } else if (shape === "bass") { + v = Math.sin(phase * Math.PI * 2) + 0.3 * (phase < 0.5 ? 1 : -1) + } else { + v = Math.sin(phase * Math.PI * 2) + } + + // Normalize + if (v > 1.0) v = 1.0 + if (v < -1.0) v = -1.0 + + // BRR roughly equates to adaptive 4-bit ADPCM, which introduces a specific noise floor. + // For pure Web Audio synthesis without actual BRR block compression, we quantize to ~9 bits + // to simulate the internal compounding precision loss of the S-DSP before the Gaussian filter. + v = Math.round(v * 256.0) / 256.0 + + data[i] = v + i = i + 1 + } + + ctx[cacheKey] = buf + return buf +} + +// S-DSP AR maps 0-15 to linear times (from 4100ms down to 1.5ms) +fn getSpcAttackTime(ar) { + if (ar >= 15) return 0.001 + let times = [4.100, 2.600, 1.500, 1.000, 0.640, 0.380, 0.260, 0.160, 0.096, 0.064, 0.040, 0.024, 0.016, 0.010, 0.006, 0.001] + return times[Math.min(15, Math.max(0, ar))] +} + +// S-DSP DR maps 0-7 to exponential times (from 1200ms down to 14ms) +fn getSpcDecayTime(dr) { + let times = [1.200, 0.740, 0.440, 0.290, 0.180, 0.110, 0.074, 0.014] + return times[Math.min(7, Math.max(0, dr))] +} + +// S-DSP SR maps 0-31 to exponential times +fn getSpcSustainRateTime(sr) { + if (sr >= 31) return 0.001 + if (sr === 0) return 100.0 // infinite + // Approximation of the 31 exponential steps + return 38.0 * Math.pow(0.75, sr) +} + +// Global Echo State (since SNES has 1 global DSP echo per APU) +let globalSpcEchoNode = null +let globalSpcEchoDelay = null +let globalSpcEchoFeedback = null +let globalSpcEchoFir = null + +fn initSpcEcho(ctx, bus) { + if (globalSpcEchoNode) return + + globalSpcEchoNode = ctx.createGain() + globalSpcEchoNode.gain.value = 1.0 + + globalSpcEchoDelay = ctx.createDelay(1.0) // Max SNES delay is ~240ms (15 * 16ms) + globalSpcEchoFeedback = ctx.createGain() + + // S-DSP 8-Tap FIR filter approximation via IIR + // The actual S-DSP lets users set 8 coefficients. We provide a generic low-pass echo reflection + // typical of SNES games (e.g. Secret of Mana, Chrono Trigger). + globalSpcEchoFir = ctx.createBiquadFilter() + globalSpcEchoFir.type = "lowpass" + globalSpcEchoFir.frequency.value = 3000 + globalSpcEchoFir.Q.value = 0.5 + + globalSpcEchoNode.connect(globalSpcEchoDelay) + globalSpcEchoDelay.connect(globalSpcEchoFir) + globalSpcEchoFir.connect(globalSpcEchoFeedback) + globalSpcEchoFeedback.connect(globalSpcEchoDelay) + + // Output the echo to the main bus + globalSpcEchoFir.connect(bus.input) +} + +export fn playSpc700(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let waveform = gp.waveform ? gp.waveform : "strings" + let attack = gp.attack !== null ? Math.round(gp.attack) : 0 + let decay = gp.decay !== null ? Math.round(gp.decay) : 3 + let sustainLevel = gp.sustainLevel !== null ? Math.round(gp.sustainLevel) : 7 + let sustainRate = gp.sustainRate !== null ? Math.round(gp.sustainRate) : 0 + + let echoEnable = gp.echoEnable === true + let echoDelay = gp.echoDelay !== null ? Math.round(gp.echoDelay) : 4 + let echoFeedback = gp.echoFeedback !== null ? gp.echoFeedback : 0 + + let pitchDrop = gp.pitchDrop !== null ? Math.round(gp.pitchDrop) : 0 + let pitchDec = gp.pitchDec !== null ? gp.pitchDec : 0.05 + let vibRate = gp.vibRate !== null ? gp.vibRate : 0 + let vibAmt = gp.vibAmt !== null ? gp.vibAmt : 0 + let arpRate = gp.arpRate !== null ? gp.arpRate : 0 + let arpSemis = gp.arpSemis !== null ? 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 = getSpcSampleBuffer(ctx, waveform) + let src = ctx.createBufferSource() + src.buffer = buf + src.loop = true + + // The base frequency of our 256-sample buffer at 44.1kHz is 172.26Hz. + // Playback rate scaling + 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 + 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 + 2.0) + disconnects.push(lfo) + disconnects.push(lfoGain) + } + + // Gaussian Interpolation Simulator + // The SNES S-DSP 4-point Gaussian interpolator effectively acts as a low-pass filter + // that rolls off heavily above ~11kHz, giving the SNES its famous "muffled" but warm sound. + let gaussianFilter = ctx.createBiquadFilter() + gaussianFilter.type = "lowpass" + gaussianFilter.frequency.value = 11000 // Fixed cutoff simulating the Gaussian roll-off + gaussianFilter.Q.value = 0.5 // Gentle curve + + // S-DSP Envelopes + let aTime = getSpcAttackTime(attack) + let dTime = getSpcDecayTime(decay) + let sLevel = (sustainLevel + 1) / 8.0 // 0=1/8, 7=8/8 (1.0) + let srTime = getSpcSustainRateTime(sustainRate) + + let safeDur = Math.max(durSec, 0.01) + let maxAmp = v * 0.8 + + let finalGain = ctx.createGain() + finalGain.gain.setValueAtTime(0, t) + + let tA = t + aTime + // S-DSP Attack is Linear + finalGain.gain.linearRampToValueAtTime(maxAmp, tA) + + // S-DSP Decay is Exponential + finalGain.gain.setTargetAtTime(maxAmp * sLevel, tA, Math.max(0.001, dTime / 3.0)) + + let tOff = Math.max(t + safeDur, tA + dTime) + + // S-DSP Sustain is Exponential down to 0 + if (sustainRate < 31) { + finalGain.gain.setTargetAtTime(0, tA + dTime, Math.max(0.001, srTime / 3.0)) + } + + // S-DSP Release is a fixed exponential fade (~8ms time constant) + finalGain.gain.cancelScheduledValues(tOff) + finalGain.gain.setValueAtTime(finalGain.gain.value, tOff) + finalGain.gain.setTargetAtTime(0, tOff, 0.008) + + src.start(t) + src.stop(tOff + 0.1) + disconnects.push(src) + disconnects.push(gaussianFilter) + disconnects.push(finalGain) + + src.connect(gaussianFilter) + gaussianFilter.connect(finalGain) + finalGain.connect(bus.input) + + // Global Echo Bus Routing + if (echoEnable) { + initSpcEcho(ctx, bus) + globalSpcEchoDelay.delayTime.setValueAtTime(echoDelay * 0.016, t) // EDP sets delay in 16ms increments + globalSpcEchoFeedback.gain.setValueAtTime(echoFeedback / 127.0, t) // Feedback is -128 to 127 + + // SNES routes specific channels to echo via EON (Echo On) register + let echoSend = ctx.createGain() + echoSend.gain.value = 0.5 // EVOL (Echo Volume) approximation + finalGain.connect(echoSend) + echoSend.connect(globalSpcEchoNode) + disconnects.push(echoSend) + } + + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, (tOff - t + 0.5) * 1000) +} diff --git a/packages/synths/src/Sub808.tish b/packages/synths/src/Sub808.tish new file mode 100644 index 0000000..78b022b --- /dev/null +++ b/packages/synths/src/Sub808.tish @@ -0,0 +1,62 @@ +export fn playSub808(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + let punch = gp.punch !== null ? gp.punch : 60 + let decay = gp.decay !== null ? gp.decay : 1.2 + let drive = gp.drive !== null ? gp.drive : 0 + let glide = gp.glide !== null ? gp.glide : 0.05 // short glide for 808 slides + + 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 = "sine" + + // Pitch envelope + osc.frequency.setValueAtTime(f0 + punch * v, t) + osc.frequency.setTargetAtTime(f0, t + 0.01, 0.05) + + let shaper = null + let gain = ctx.createGain() + + if (drive > 0) { + shaper = ctx.createWaveShaper() + let k = drive * 10 + let n = 4096 + let curve = new Float32Array(n) + let deg = Math.PI / 180 + let i = 0 + while (i < n) { + let x = i * 2 / n - 1 + curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x)) + i = i + 1 + } + shaper.curve = curve + shaper.oversample = '4x' + + // Gain compensation for drive + gain.gain.setValueAtTime(0, t) + gain.gain.linearRampToValueAtTime(v * 0.8 * (1 - Math.min(drive / 100, 0.5)), t + 0.01) + gain.gain.setTargetAtTime(0, t + 0.01, decay / 3) + + osc.connect(shaper) + shaper.connect(gain) + } else { + gain.gain.setValueAtTime(0, t) + gain.gain.linearRampToValueAtTime(v * 0.8, t + 0.01) + gain.gain.setTargetAtTime(0, t + 0.01, decay / 3) + osc.connect(gain) + } + + gain.connect(bus.input) + + osc.start(t) + osc.stop(t + decay + 0.5) + + setTimeout(() => { + osc.disconnect() + if (shaper) shaper.disconnect() + gain.disconnect() + }, (decay + 1) * 1000) +} diff --git a/packages/synths/src/SyncChoir.tish b/packages/synths/src/SyncChoir.tish new file mode 100644 index 0000000..275958e --- /dev/null +++ b/packages/synths/src/SyncChoir.tish @@ -0,0 +1,150 @@ +export fn playSyncChoir(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + + // Vowel + let vowelShift = gp.vowelShift !== null ? gp.vowelShift : 24 + let morphRate = gp.morphRate !== null ? gp.morphRate : 0.5 + let morphAmt = gp.morphAmt !== null ? gp.morphAmt : 12 + + // Ensemble + let detune = gp.ensembleDetune !== null ? gp.ensembleDetune : 15 // cents + let vibRate = gp.vibRate !== null ? gp.vibRate : 5.0 + let vibAmt = gp.vibAmt !== null ? gp.vibAmt : 10 // cents + + // Tone & Amp + let highpassFreq = gp.highpass !== null ? gp.highpass : 300 + let att = gp.attack !== null ? gp.attack : 1.0 + let dec = gp.decay !== null ? gp.decay : 1.0 + let sus = gp.sustain !== null ? gp.sustain : 0.8 + let rel = gp.release !== null ? gp.release : 1.5 + + let masterPitch = midi + bendSemis + let masterFreq = 440 * Math.pow(2, (masterPitch - 69) / 12) + let slaveBaseFreq = 440 * Math.pow(2, (masterPitch + vowelShift - 69) / 12) + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let outGain = ctx.createGain() + outGain.gain.value = 0 + outGain.gain.setValueAtTime(0, t) + outGain.gain.linearRampToValueAtTime(v * 0.8, t + att) + outGain.gain.setTargetAtTime(v * 0.8 * sus, t + att, dec / 3) + outGain.gain.setValueAtTime(v * 0.8 * sus, t + durSec) + outGain.gain.setTargetAtTime(0, t + durSec, rel / 3) + + let hp = ctx.createBiquadFilter() + hp.type = "highpass" + hp.frequency.value = highpassFreq + + let lp = ctx.createBiquadFilter() + lp.type = "lowpass" + lp.frequency.value = 8000 // Tame the very high digital harshness + + outGain.connect(hp) + hp.connect(lp) + lp.connect(bus.input) + + let oscL = null + let oscR = null + let useFallback = false + + try { + oscL = new AudioWorkletNode(ctx, "sync-oscillator") + oscR = new AudioWorkletNode(ctx, "sync-oscillator") + } catch (e) { + useFallback = true + oscL = ctx.createOscillator() + oscL.type = "sawtooth" + oscR = ctx.createOscillator() + oscR.type = "sawtooth" + } + + // Create Stereo Panner for width + let panL = ctx.createStereoPanner() + panL.pan.value = -0.7 + let panR = ctx.createStereoPanner() + panR.pan.value = 0.7 + + oscL.connect(panL) + oscR.connect(panR) + panL.connect(outGain) + panR.connect(outGain) + + let lfosToStop = [] + + if (!useFallback && oscL.parameters && oscR.parameters) { + let ml = oscL.parameters.get("masterFreq") + let sl = oscL.parameters.get("slaveFreq") + let mr = oscR.parameters.get("masterFreq") + let sr = oscR.parameters.get("slaveFreq") + + let freqL = masterFreq * Math.pow(2, -detune / 1200) + let freqR = masterFreq * Math.pow(2, detune / 1200) + + ml.setValueAtTime(freqL, t) + mr.setValueAtTime(freqR, t) + sl.setValueAtTime(slaveBaseFreq, t) + sr.setValueAtTime(slaveBaseFreq, t) + + // Vowel Morph LFO + if (morphRate > 0 && morphAmt > 0) { + let morphLFO = ctx.createOscillator() + morphLFO.type = "sine" + morphLFO.frequency.value = morphRate + + let morphGain = ctx.createGain() + morphGain.gain.value = slaveBaseFreq * (Math.pow(2, morphAmt / 12) - 1) + + morphLFO.connect(morphGain) + morphGain.connect(sl) + morphGain.connect(sr) + morphLFO.start(t) + lfosToStop.push(morphLFO) + } + + // Vibrato + if (vibRate > 0 && vibAmt > 0) { + let vibLFO = ctx.createOscillator() + vibLFO.type = "sine" + vibLFO.frequency.value = vibRate + + let vibGain = ctx.createGain() + vibGain.gain.value = masterFreq * (Math.pow(2, vibAmt / 1200) - 1) + + vibLFO.connect(vibGain) + vibGain.connect(ml) + vibGain.connect(mr) + vibLFO.start(t) + lfosToStop.push(vibLFO) + } + } else { + // Fallback behavior + let freqL = masterFreq * Math.pow(2, -detune / 1200) + let freqR = masterFreq * Math.pow(2, detune / 1200) + oscL.frequency.value = freqL + oscR.frequency.value = freqR + oscL.start(t) + oscR.start(t) + lfosToStop.push(oscL) + lfosToStop.push(oscR) + } + + let totalTime = durSec + rel + 0.5 + setTimeout(() => { + oscL.disconnect() + oscR.disconnect() + panL.disconnect() + panR.disconnect() + hp.disconnect() + lp.disconnect() + outGain.disconnect() + let i = 0 + while (i < lfosToStop.length) { + lfosToStop[i].stop() + lfosToStop[i].disconnect() + i = i + 1 + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/SyncLead.tish b/packages/synths/src/SyncLead.tish new file mode 100644 index 0000000..90586c3 --- /dev/null +++ b/packages/synths/src/SyncLead.tish @@ -0,0 +1,114 @@ +export fn playSyncLead(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams ? ch.generatorParams : {} + + // Oscillator & Sync + let masterTune = gp.masterTune !== null ? gp.masterTune : 0 + let slaveBase = gp.slaveBase !== null ? gp.slaveBase : 12 + let sweepAmt = gp.sweepAmt !== null ? gp.sweepAmt : 24 + let sweepDecay = gp.sweepDecay !== null ? gp.sweepDecay : 0.4 + let lfoRate = gp.lfoRate !== null ? gp.lfoRate : 0 + let lfoAmt = gp.lfoAmt !== null ? gp.lfoAmt : 0 + + // Filter + let cutoff = gp.cutoff !== null ? gp.cutoff : 3000 + let res = gp.resonance !== null ? gp.resonance : 5 + let filterEnvAmt = gp.filterEnvAmt !== null ? gp.filterEnvAmt : 0 + let filterDecay = gp.filterDecay !== null ? gp.filterDecay : 0.4 + + // ADSR + let att = gp.attack !== null ? gp.attack : 0.05 + let dec = gp.decay !== null ? gp.decay : 0.3 + let sus = gp.sustain !== null ? gp.sustain : 0.8 + let rel = gp.release !== null ? gp.release : 0.5 + + let masterPitch = midi + bendSemis + masterTune + let masterFreq = 440 * Math.pow(2, (masterPitch - 69) / 12) + let slaveBaseFreq = 440 * Math.pow(2, (masterPitch + slaveBase - 69) / 12) + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + + let osc = null + try { + osc = new AudioWorkletNode(ctx, "sync-oscillator") + } catch (e) { + let fallback = ctx.createOscillator() + fallback.type = "sawtooth" + fallback.frequency.value = masterFreq + osc = fallback + } + + let filter = ctx.createBiquadFilter() + filter.type = "lowpass" + filter.Q.value = res + + let gain = ctx.createGain() + gain.gain.value = 0 + gain.gain.setValueAtTime(0, t) + gain.gain.linearRampToValueAtTime(v, t + att) + gain.gain.setTargetAtTime(v * sus, t + att, dec / 3) + gain.gain.setValueAtTime(v * sus, t + durSec) + gain.gain.setTargetAtTime(0, t + durSec, rel / 3) + + osc.connect(filter) + filter.connect(gain) + gain.connect(bus.input) + + let lfo = null + let lfoGain = null + + if (osc.parameters) { + let masterParam = osc.parameters.get("masterFreq") + let slaveParam = osc.parameters.get("slaveFreq") + + masterParam.setValueAtTime(masterFreq, t) + + // Envelope sweep on the slave pitch + let startFreq = slaveBaseFreq * Math.pow(2, sweepAmt / 12) + if (startFreq > 20000) startFreq = 20000 + slaveParam.setValueAtTime(startFreq, t) + slaveParam.exponentialRampToValueAtTime(slaveBaseFreq, t + sweepDecay + 0.01) + + if (lfoRate > 0 && lfoAmt > 0) { + lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = lfoRate + lfoGain = ctx.createGain() + // Approx linear FM depth based on semitones + lfoGain.gain.value = slaveBaseFreq * (Math.pow(2, lfoAmt / 12) - 1) + lfo.connect(lfoGain) + lfoGain.connect(slaveParam) + lfo.start(t) + lfo.stop(t + durSec + rel + 0.5) + } + + } else { + osc.start(t) + osc.stop(t + durSec + rel + 0.5) + } + + // Filter Envelope + let cBase = cutoff + if (cBase < 20) cBase = 20 + let cPeak = cBase * Math.pow(2, filterEnvAmt * 6) // up to 6 octaves + if (cPeak > 20000) cPeak = 20000 + if (cPeak < 20) cPeak = 20 + + filter.frequency.setValueAtTime(cBase, t) + filter.frequency.linearRampToValueAtTime(cPeak, t + att) + filter.frequency.setTargetAtTime(cBase + (cPeak - cBase) * sus, t + att, filterDecay / 3) + filter.frequency.setValueAtTime(cBase + (cPeak - cBase) * sus, t + durSec) + filter.frequency.setTargetAtTime(cBase, t + durSec, rel / 3) + + let totalTime = durSec + rel + 0.5 + setTimeout(() => { + osc.disconnect() + filter.disconnect() + gain.disconnect() + if (lfo) { + lfo.disconnect() + lfoGain.disconnect() + } + }, totalTime * 1000) +} diff --git a/packages/synths/src/SyncWorklet.tish b/packages/synths/src/SyncWorklet.tish new file mode 100644 index 0000000..ba70554 --- /dev/null +++ b/packages/synths/src/SyncWorklet.tish @@ -0,0 +1,114 @@ +// The hard-sync oscillator the sync voices run on, as source rather than as a file. +// +// `syncLead`, `syncChoir`, `obSync` and `laserSync` all instantiate an `AudioWorkletNode` named +// "sync-oscillator". A worklet has to be `class X extends AudioWorkletProcessor`, and Tish has no +// class syntax, so the processor stays hand-written JavaScript — but it lives here as a string and +// is loaded from a Blob URL, so no consumer of this package has to copy an asset into its own +// public directory to get sound. Deckard used to serve it from `/sync-worklet.js`; that copy is +// gone, and this is the only one. + +export fn syncWorkletSource() { + return ` +class SyncOscillator extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'masterFreq', defaultValue: 110, minValue: 20, maxValue: 20000, automationRate: 'a-rate' }, + { name: 'slaveFreq', defaultValue: 220, minValue: 20, maxValue: 20000, automationRate: 'a-rate' } + ]; + } + + constructor() { + super(); + this.masterPhase = 0; + this.slavePhase = 0; + } + + polyblep(t, dt) { + if (t < dt) { + t /= dt; + return t + t - t * t - 1.0; + } else if (t > 1.0 - dt) { + t = (t - 1.0) / dt; + return t * t + t + t + 1.0; + } + return 0.0; + } + + process(inputs, outputs, parameters) { + const output = outputs[0]; + if (!output || !output[0]) return true; + + const channel = output[0]; + const masterFreqArray = parameters.masterFreq; + const slaveFreqArray = parameters.slaveFreq; + + for (let i = 0; i < channel.length; i++) { + const mf = masterFreqArray.length > 1 ? masterFreqArray[i] : masterFreqArray[0]; + const sf = slaveFreqArray.length > 1 ? slaveFreqArray[i] : slaveFreqArray[0]; + + const mdt = mf / sampleRate; + const sdt = sf / sampleRate; + + this.masterPhase += mdt; + + let syncJump = 0; + + if (this.masterPhase >= 1.0) { + this.masterPhase -= 1.0; + // Sub-sample precise reset of the slave phase + let frac = this.masterPhase / mdt; + let prevSlavePhase = this.slavePhase + (1 - frac) * sdt; + if (prevSlavePhase > 1.0) prevSlavePhase -= 1.0; + + // The jump is from current slave position down to 0 + syncJump = prevSlavePhase; + + this.slavePhase = this.masterPhase * (sf / mf); + } else { + this.slavePhase += sdt; + } + + if (this.slavePhase >= 1.0) { + this.slavePhase -= 1.0; + } + + // Basic sawtooth output (-1 to 1) + let out = 2.0 * this.slavePhase - 1.0; + + // PolyBLEP anti-aliasing for the slave's natural wrap + out -= this.polyblep(this.slavePhase, sdt); + + // Simple PolyBLEP for the forced sync wrap (scaled by jump height) + if (syncJump > 0) { + out -= this.polyblep(this.masterPhase * (sf / mf), sdt) * syncJump; + } + + channel[i] = out; + } + return true; + } +} + +registerProcessor('sync-oscillator', SyncOscillator); +` +} + +/// Register the sync processor on `ctx`, once. Returns a promise that resolves when the module is +/// ready, or null when the context has no worklet support (the voices fall back to a plain +/// oscillator in that case, the same as before). +export fn ensureSyncWorklet(ctx) { + if (!ctx || typeof ctx.audioWorklet === "undefined") { + return null + } + if (!ctx.audioWorklet || !ctx.audioWorklet.addModule) { + return null + } + if (ctx.__deckSyncWorklet) { + return ctx.__deckSyncWorklet + } + let blob = new Blob([syncWorkletSource()], { type: "text/javascript" }) + let url = URL.createObjectURL(blob) + let done = ctx.audioWorklet.addModule(url) + ctx.__deckSyncWorklet = done + return done +} diff --git a/packages/synths/src/Tine.tish b/packages/synths/src/Tine.tish new file mode 100644 index 0000000..aad519d --- /dev/null +++ b/packages/synths/src/Tine.tish @@ -0,0 +1,199 @@ +// Tine — a Rhodes-style electric piano (the "suitcase" sound: Herbie Hancock, "Riders on the Storm"). +// Not a generic FM patch, but a model of the real thing: a felt hammer strikes a metal TINE next to a +// pickup, and the pickup's nonlinear response gives the classic FM "bark" that blooms bright on hard +// hits and chimes mellow on soft ones. That velocity-dependent timbre is the whole magic of an EP. +// +// Controls (EP-sensible — the things you'd actually reach for, not generic ADSR): +// bark — the FM-index FLOOR + how much harder hits open it. This is the metallic carrier↔modulator +// depth: high bark = aggressive, vocal, mid-'70s "dyno" growl; low bark = soft & woody. +// tine — the bell-like ATTACK PING: a fast, slightly-inharmonic high partial that pings as the +// hammer first contacts the metal, then dies away quickly over the sustained body. +// tremolo — the SUITCASE amp tremolo: a gentle ~5 Hz amplitude LFO, baked musical, with the two +// "speakers" in anti-phase for that lush stereo-ish wobble. This control is its depth. +// decay — body length: how long the note blooms and falls (still respects held-note duration). +// drive — gentle tube/preamp saturation for suitcase warmth and a little harmonic grit. +// +// Engine: a sine CARRIER at the note pitch, phase-modulated (true FM, mod → carrier.frequency) by a sine +// MODULATOR at 1× the pitch. The FM-depth envelope is the bark: it spikes on the attack (scaled by bark × +// velocity) and decays into a low sustain, so the spectrum is bright at the strike and mellows as it rings +// — exactly how a pickup behaves. A separate fast, inharmonic TINE PING sine (~the metal's flexural mode) +// adds the hammer "tink". The summed signal goes through an optional saturator, then a stereo tremolo +// (two anti-phase LFO-gated paths panned L/R) into the bus. Velocity scales loudness, FM-index AND ping. + +import { midiToHz } from './Midi.tish' + +fn tineClamp01(x) { + if (x < 0) { return 0 } + if (x > 1) { return 1 } + return x +} + +// Soft tube/preamp saturation curve (tanh-ish), used only when drive > 0 for suitcase warmth. +fn tineDriveCurve(amount) { + let k = amount * 60 + let n = 1024 + let curve = new Float32Array(n) + let i = 0 + while (i < n) { + let x = (i * 2) / n - 1 + curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x)) + i = i + 1 + } + return curve +} + +export fn playTine(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let p = ch.generatorParams ? ch.generatorParams : {} + let bark = tineClamp01(p.bark !== null ? p.bark : 0.55) + let tine = tineClamp01(p.tine !== null ? p.tine : 0.6) + let tremolo = tineClamp01(p.tremolo !== null ? p.tremolo : 0.35) + let decay = tineClamp01(p.decay !== null ? p.decay : 0.5) + let drive = tineClamp01(p.drive !== null ? p.drive : 0.2) + + let hz = midiToHz(midi + bendSemis) + if (hz < 8) { hz = 8 } + let v = vel / 127 + if (v < 0) { v = 0 } + if (v > 1) { v = 1 } + if (v <= 0) { return } + + // Body decay: low notes ring longer than high notes on a real EP. ~1.1..6.5 s, shortened up the keyboard. + let keyScale = 1 - tineClamp01((hz - 80) / 1400) * 0.55 + if (keyScale < 0.35) { keyScale = 0.35 } + let body = (1.1 + decay * 5.4) * keyScale + // The note rings for its natural body, or longer if held past that; on key-lift it damps with a short fall. + let hold = durSec > 0.05 ? durSec : 0.05 + let sustainEnd = body + if (hold > sustainEnd) { sustainEnd = hold } + let endAt = sustainEnd + 0.4 + + // ---- Master amp envelope: a fast attack, a gentle EP "bloom" then exponential fall to the damp. + let amp = ctx.createGain() + let peak = v * 0.5 + let bloomLevel = peak * 0.72 + amp.gain.setValueAtTime(0, t) + amp.gain.linearRampToValueAtTime(peak, t + 0.004) + // Bloom: a brief settle below the initial spike is what makes EPs sing rather than beep. + amp.gain.exponentialRampToValueAtTime(bloomLevel, t + 0.09) + // Long exponential body fall; the tail keeps falling through the held duration and damps at the end. + amp.gain.exponentialRampToValueAtTime(0.0008, t + endAt) + + // ---- Optional saturator for warmth, then into the tremolo stage. + let preTrem = amp + if (drive > 0.001) { + let ws = ctx.createWaveShaper() + ws.curve = tineDriveCurve(drive) + ws.oversample = "2x" + amp.connect(ws) + preTrem = ws + // tame the make-up gain so drive doesn't push the stem hot + let pad = ctx.createGain() + pad.gain.value = 1 / (1 + drive * 0.6) + ws.connect(pad) + preTrem = pad + } + + // ---- Suitcase TREMOLO: two anti-phase LFO-gated paths panned hard L/R. Baked musical ~5.2 Hz; speeds up + // a hair with depth. depth=0 → static; depth high → deep, lush amp wobble across the stereo field. + let tremRate = 4.6 + tremolo * 1.6 + let depth = tremolo * 0.42 + let lfo = ctx.createOscillator() + lfo.type = "sine" + lfo.frequency.value = tremRate + let lfoGain = ctx.createGain() + lfoGain.gain.value = depth + lfo.connect(lfoGain) + lfo.start(t) + lfo.stop(t + endAt + 0.05) + + let side = 0 + while (side < 2) { + let pan = ctx.createStereoPanner() + pan.pan.value = side === 0 ? -0.6 : 0.6 + let tremGain = ctx.createGain() + // baseline 1 minus depth so the LFO modulates around unity without clipping; sides anti-phase. + tremGain.gain.value = 1 - depth + if (side === 0) { + lfoGain.connect(tremGain.gain) + } else { + // invert for the opposite speaker: a second gain stage negates the LFO. + let inv = ctx.createGain() + inv.gain.value = -1 + lfoGain.connect(inv) + inv.connect(tremGain.gain) + } + preTrem.connect(tremGain) + tremGain.connect(pan) + pan.connect(bus.input) + side = side + 1 + } + + // ---- FM CARRIER + MODULATOR (the bark). Modulator at 1× pitch → carrier.frequency (phase mod). + // The FM-index envelope spikes on the strike (scaled by bark × velocity) then decays to a soft sustain: + // hard hits = bright metallic bark, soft hits = a mellow woody bell. THIS is the velocity magic. + let car = ctx.createOscillator() + car.type = "sine" + car.frequency.value = hz + + let mod = ctx.createOscillator() + mod.type = "sine" + mod.frequency.value = hz + let modDepth = ctx.createGain() + // Peak index: floor from bark, plus a velocity-scaled bright spike. In Hz of carrier deviation. + let idxFloor = 0.6 + bark * 1.4 + let idxPeak = idxFloor + (1.5 + bark * 7.5) * v + let depthPeakHz = hz * idxPeak + let depthFloorHz = hz * (idxFloor * (0.25 + 0.35 * v)) + if (depthPeakHz > 8000) { depthPeakHz = 8000 } + if (depthFloorHz < 0.5) { depthFloorHz = 0.5 } + if (depthFloorHz > depthPeakHz) { depthFloorHz = depthPeakHz } + let barkDecay = 0.12 + decay * 0.5 + if (barkDecay > endAt * 0.5) { barkDecay = endAt * 0.5 } + // Spike on the strike, fast-decay to the floor (the bark dying into a mellow bell), hold, then fade with + // the body so the ring goes purer (less FM) as it falls away — just like a pickup's response decaying. + modDepth.gain.setValueAtTime(depthPeakHz, t) + modDepth.gain.exponentialRampToValueAtTime(depthFloorHz, t + barkDecay) + modDepth.gain.setValueAtTime(depthFloorHz, t + barkDecay) + modDepth.gain.exponentialRampToValueAtTime(0.5, t + endAt) + mod.connect(modDepth) + modDepth.connect(car.frequency) + + // Carrier through its own gain (so we can balance against the ping), into the amp. + let carGain = ctx.createGain() + carGain.gain.value = 0.85 + car.connect(carGain) + carGain.connect(amp) + car.start(t) + mod.start(t) + car.stop(t + endAt + 0.05) + mod.stop(t + endAt + 0.05) + + // ---- TINE PING: a fast, slightly-inharmonic high partial (the hammer hitting the metal). Pings hard on + // the attack — louder with velocity — then dies away quickly over the sustained body. Scaled by `tine`. + if (tine > 0.01) { + let ping = ctx.createOscillator() + ping.type = "sine" + // ~the tine's flexural overtone: a stretched, slightly-sharp high partial. + let pingMul = 6.0 + tine * 8.0 + let pingHz = hz * pingMul + if (pingHz > 17000) { pingHz = 17000 } + ping.frequency.value = pingHz + let pingGain = ctx.createGain() + // brighter/louder ping with harder hits; the strike "tink". + let pingPeak = v * (0.05 + tine * 0.16) * (0.5 + 0.5 * v) + if (pingPeak < 0.0009) { pingPeak = 0.0009 } + let pingDecay = 0.04 + tine * 0.18 + pingGain.gain.setValueAtTime(0, t) + pingGain.gain.linearRampToValueAtTime(pingPeak, t + 0.002) + pingGain.gain.exponentialRampToValueAtTime(0.0008, t + pingDecay) + // a tiny highpass keeps it as a "tink" not a low thud + let pingHp = ctx.createBiquadFilter() + pingHp.type = "highpass" + pingHp.frequency.value = hz * 3 < 2000 ? hz * 3 : 2000 + ping.connect(pingHp) + pingHp.connect(pingGain) + pingGain.connect(amp) + ping.start(t) + ping.stop(t + pingDecay + 0.1) + } +} diff --git a/packages/synths/src/TtsVocal.tish b/packages/synths/src/TtsVocal.tish new file mode 100644 index 0000000..3de4d96 --- /dev/null +++ b/packages/synths/src/TtsVocal.tish @@ -0,0 +1,113 @@ +import { midiToHz } from './Midi.tish' + +export fn playTtsVocal(ctx, bus, t, midi, vel, durSec, ch, bendSemis, lyric) { + let p = ch.generatorParams + let voiceIdx = 0 + if (p && (p.voice !== null)) { + voiceIdx = p.voice + } + + let n = Math.floor(midi + bendSemis) + let hz = 440 + if (n >= 0 && n <= 127) { + hz = midiToHz(n) + } + + let pitchRatio = hz / 261.63 // C4 = 261.63Hz + if (pitchRatio < 0) pitchRatio = 0 + if (pitchRatio > 2) pitchRatio = 2 + + let l = lyric + if (!l || l.trim() === "") { + l = "beep" + } + + if (typeof window !== "undefined" && window.speechSynthesis) { + let synth = window.speechSynthesis + let now = ctx.currentTime + let delaySec = t - now + + let runTts = () => { + let u = new SpeechSynthesisUtterance(l) + let voices = synth.getVoices() + let preferred = [] + let vi = 0 + while (vi < voices.length) { + if (voices[vi].lang.indexOf("en-") === 0) { + preferred.push(voices[vi]) + } + vi = vi + 1 + } + if (preferred.length === 0) { + preferred = voices + } + + let vTarget = null + if (preferred.length > 0) { + let vidx = Math.floor(voiceIdx) % preferred.length + vTarget = preferred[vidx] + } + + if (vTarget) { + u.voice = vTarget + } + u.pitch = pitchRatio + + let estimatedDurAt1x = Math.max(0.2, l.length * 0.07) + let calculatedRate = estimatedDurAt1x / durSec + let globalRate = (p && (p.rate !== null)) ? p.rate : 1.5 + let finalRate = calculatedRate * globalRate + + if (finalRate < 0.1) finalRate = 0.1 + if (finalRate > 10.0) finalRate = 10.0 + u.rate = finalRate + + let v = vel / 127 + if (v < 0) v = 0 + if (v > 1) v = 1 + u.volume = v + + // CRITICAL: Prevent Safari garbage collection bug + if (!window.__ttsUtterances) { + window.__ttsUtterances = [] + } + window.__ttsUtterances.push(u) + + if (synth.speaking) { + synth.cancel() + setTimeout(() => synth.speak(u), 10) + } else { + synth.speak(u) + } + } + + if (delaySec > 0.01) { + if (!window.__ttsTimeouts) { + window.__ttsTimeouts = [] + } + let tid = setTimeout(runTts, delaySec * 1000) + window.__ttsTimeouts.push(tid) + } else { + runTts() + } + } +} + +export fn cancelTtsVocal() { + if (typeof window !== "undefined") { + if (window.speechSynthesis) { + window.speechSynthesis.cancel() + } + if (window.__ttsTimeouts) { + let i = 0 + while (i < window.__ttsTimeouts.length) { + clearTimeout(window.__ttsTimeouts[i]) + i = i + 1 + } + window.__ttsTimeouts = [] + } + if (window.__ttsUtterances) { + window.__ttsUtterances = [] + } + } +} diff --git a/packages/synths/src/Ym2612.tish b/packages/synths/src/Ym2612.tish new file mode 100644 index 0000000..629f6bd --- /dev/null +++ b/packages/synths/src/Ym2612.tish @@ -0,0 +1,210 @@ +// YM2612 (Sega Genesis / Mega Drive FM Chip) Emulator +// References and Algorithmic Parity: +// 1. Genesis Plus GX (ym2612.c) - For Key Scaling (RKS) and operator phase generation logic. +// 2. MAME (fm.cpp) - For standard 8-algorithm routing topologies. +// 3. Nuked-OPN2 - For reference on the 10-bit logarithmic attenuation bounds (0x340). + +export fn defaultParamsForYm2612() { + return { + algorithm: 0, + feedback: 0, // Op 1 feedback + + // Operator 1 + op1_mul: 1, op1_tl: 0, op1_ar: 31, op1_dr: 5, op1_sr: 5, op1_rr: 5, op1_sl: 0, + // Operator 2 + op2_mul: 1, op2_tl: 0, op2_ar: 31, op2_dr: 5, op2_sr: 5, op2_rr: 5, op2_sl: 0, + // Operator 3 + op3_mul: 1, op3_tl: 0, op3_ar: 31, op3_dr: 5, op3_sr: 5, op3_rr: 5, op3_sl: 0, + // Operator 4 + op4_mul: 1, op4_tl: 0, op4_ar: 31, op4_dr: 5, op4_sr: 5, op4_rr: 5, op4_sl: 0, + + pitchDrop: 0, + pitchDec: 0.05, + vibRate: 0, + vibAmt: 0, + arpRate: 0, + arpSemis: 0 + } +} + +// Emulates YM2612 Attack, Decay, Sustain, Release curves. +// Hardware uses 0-31 rates. 31 is fastest (~instant). 0 is slowest (infinite). +fn getRateTime(rate) { + if (rate >= 31) return 0.002 + if (rate === 0) return 1000.0 // Basically never decays + // YM2612 rates map exponentially to time. + // Rate 15 is roughly 1 second. + return Math.pow(2, (15 - rate) / 2.0) +} + +fn applyOpEnv(t, tOff, opGain, p, opId, maxVol) { + let ar = p[opId + "_ar"] !== null ? p[opId + "_ar"] : 31 + let dr = p[opId + "_dr"] !== null ? p[opId + "_dr"] : 5 + let sr = p[opId + "_sr"] !== null ? p[opId + "_sr"] : 5 + let rr = p[opId + "_rr"] !== null ? p[opId + "_rr"] : 5 + let sl = p[opId + "_sl"] !== null ? p[opId + "_sl"] : 0 // 0-15 + let tl = p[opId + "_tl"] !== null ? p[opId + "_tl"] : 0 // 0-127 (Total Level attenuation) + + // Total Level maps logarithmically. 0 is max volume, 127 is silent. + let opMax = maxVol * Math.pow(10, -(tl / 127.0) * 4.0) // 40dB of attenuation + + let atkTime = getRateTime(ar) + let decTime = getRateTime(dr) + let susTime = getRateTime(sr) + let relTime = getRateTime(rr) + + let susLevel = opMax * Math.pow(10, -(sl / 15.0) * 4.0) + + opGain.gain.setValueAtTime(0, t) + opGain.gain.linearRampToValueAtTime(opMax, t + atkTime) + opGain.gain.setTargetAtTime(susLevel, t + atkTime, Math.max(0.001, decTime / 3)) + + opGain.gain.setValueAtTime(susLevel, tOff) + opGain.gain.setTargetAtTime(0, tOff, Math.max(0.001, relTime / 3)) + + return tOff - t + relTime +} + +export fn playYm2612(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { + let gp = ch.generatorParams + if (!gp) gp = {} + + let algo = gp.algorithm !== null ? Math.round(gp.algorithm) : 0 + let fdbk = gp.feedback !== null ? gp.feedback : 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 safeDur = Math.max(durSec, 0.01) + let tOff = t + safeDur + + let ops = [] + let gains = [] + let disconnects = [] + + let i = 1 + let maxTime = safeDur + while (i <= 4) { + let mul = gp["op" + i + "_mul"] !== null ? gp["op" + i + "_mul"] : 1 + if (mul === 0) mul = 0.5 + + let osc = ctx.createOscillator() + osc.type = "sine" // YM2612 only uses sines (OPN2 doesn't have custom wave shapes like OPL3) + osc.frequency.value = f0 * mul + + let gain = ctx.createGain() + // Modulation index scaling: In Web Audio FM, the gain must be scaled by the carrier frequency + // to achieve consistent phase modulation depth. + let isCarrier = false + + // Determine if this op is a carrier based on the algorithm (MAME fm.cpp reference routing) + if (algo === 0 && i === 4) isCarrier = true + if (algo === 1 && i === 4) isCarrier = true + if (algo === 2 && i === 4) isCarrier = true + if (algo === 3 && i === 4) isCarrier = true + if (algo === 4 && (i === 2 || i === 4)) isCarrier = true + if (algo === 5 && (i === 2 || i === 3 || i === 4)) isCarrier = true + if (algo === 6 && (i === 2 || i === 3 || i === 4)) isCarrier = true + if (algo === 7) isCarrier = true // All carriers + + let maxVol = isCarrier ? v * 0.8 : (f0 * 2.0) // Modulators scale by freq + + let envDur = applyOpEnv(t, tOff, gain, gp, "op" + i, maxVol) + if (envDur > maxTime) maxTime = envDur + + ops.push(osc) + gains.push(gain) + + osc.start(t) + osc.stop(t + envDur + 0.1) + + disconnects.push(osc) + disconnects.push(gain) + + i = i + 1 + } + + // Op1 Feedback (0-7 mapped to 0-Math.PI phase shift approximation) + if (fdbk > 0) { + let fdbkDelay = ctx.createDelay() + fdbkDelay.delayTime.value = 1.0 / (f0 * ops[0].frequency.value) // 1 sample delay roughly + let fdbkGain = ctx.createGain() + fdbkGain.gain.value = (fdbk / 7.0) * (f0 * 2.0) + + ops[0].connect(gains[0]) + gains[0].connect(fdbkDelay) + fdbkDelay.connect(fdbkGain) + fdbkGain.connect(ops[0].frequency) + + disconnects.push(fdbkDelay) + disconnects.push(fdbkGain) + } else { + ops[0].connect(gains[0]) + } + + ops[1].connect(gains[1]) + ops[2].connect(gains[2]) + ops[3].connect(gains[3]) + + // Topologies based on Genesis Plus GX / MAME ym2612.c + let outGain = ctx.createGain() + outGain.gain.value = 1.0 + disconnects.push(outGain) + + if (algo === 0) { + gains[0].connect(ops[1].frequency) + gains[1].connect(ops[2].frequency) + gains[2].connect(ops[3].frequency) + gains[3].connect(outGain) + } else if (algo === 1) { + gains[0].connect(ops[2].frequency) + gains[1].connect(ops[2].frequency) + gains[2].connect(ops[3].frequency) + gains[3].connect(outGain) + } else if (algo === 2) { + gains[0].connect(ops[1].frequency) + gains[1].connect(ops[3].frequency) + gains[2].connect(ops[3].frequency) + gains[3].connect(outGain) + } else if (algo === 3) { + gains[0].connect(ops[1].frequency) + gains[1].connect(ops[2].frequency) + gains[2].connect(outGain) + gains[3].connect(outGain) + } else if (algo === 4) { + gains[0].connect(ops[1].frequency) + gains[1].connect(outGain) + gains[2].connect(ops[3].frequency) + gains[3].connect(outGain) + } else if (algo === 5) { + gains[0].connect(ops[1].frequency) + gains[0].connect(ops[2].frequency) + gains[0].connect(ops[3].frequency) + gains[1].connect(outGain) + gains[2].connect(outGain) + gains[3].connect(outGain) + } else if (algo === 6) { + gains[0].connect(ops[1].frequency) + gains[1].connect(outGain) + gains[2].connect(outGain) + gains[3].connect(outGain) + } else if (algo === 7) { + gains[0].connect(outGain) + gains[1].connect(outGain) + gains[2].connect(outGain) + gains[3].connect(outGain) + } + + outGain.connect(bus.input) + + let stopTime = t + maxTime + 0.1 + setTimeout(() => { + let di = 0 + while (di < disconnects.length) { + disconnects[di].disconnect() + di = di + 1 + } + }, (maxTime + 0.5) * 1000) +} diff --git a/packages/synths/src/index.tish b/packages/synths/src/index.tish new file mode 100644 index 0000000..a78ad06 --- /dev/null +++ b/packages/synths/src/index.tish @@ -0,0 +1,67 @@ +// @spacedevin/deck-synths — Deckard's instrument catalog, as a package. +// +// These are the same modules Deckard has always played through; they were lifted out of +// `src/generators/` so that a second host — the docs-site player, a demo, a renderer — can make the +// same sounds without a second copy of the code. Deckard imports this package rather than keeping a +// private set, so there is exactly one implementation of every voice. +// +// A generator is a pure function that builds a short-lived Web Audio subgraph and connects its last +// node to `bus.input`: +// +// play(ctx, bus, t, midi, vel, durSec, ch, bendSemis) +// +// `dispatchPlayNote` picks one by `ch.generatorId`. Unknown ids fall back to `basicOsc`. +// See docs/GENERATORS.md in the Deckard checkout for how to add one. + +import { dispatchPlayNote } from './Dispatch.tish' +import { ensureDeckGeneratorIds } from './DeckIds.tish' +import { ensureBuiltinMacros } from './BuiltinMacros.tish' +import { midiToHz } from './Midi.tish' +import { cancelTtsVocal } from './TtsVocal.tish' +import { configureMeSpeak } from './MeSpeakVocal.tish' +import { + generatorCatalog, + generatorLabelById, + generatorDescriptionById, + normalizeBasicOscWaveform, + defaultParamsForGeneratorId +} from './Registry.tish' +import { + normalizePatchFilterType, + parsePatchGraph, + patchGraphToLines, + syncPatchSpecFromGraph +} from './PatchGraph.tish' +import { + parseMatrixFmGraph, + sortedStringKeys, + matrixFmGraphToLines, + syncMatrixFmSpecFromGraph +} from './MatrixFmGraph.tish' +import { ensureSyncWorklet, syncWorkletSource } from './SyncWorklet.tish' + +export { dispatchPlayNote } +export { ensureDeckGeneratorIds, ensureBuiltinMacros } +export { midiToHz } +export { cancelTtsVocal } +export { configureMeSpeak } +export { + generatorCatalog, + generatorLabelById, + generatorDescriptionById, + normalizeBasicOscWaveform, + defaultParamsForGeneratorId +} +export { + normalizePatchFilterType, + parsePatchGraph, + patchGraphToLines, + syncPatchSpecFromGraph +} +export { + parseMatrixFmGraph, + sortedStringKeys, + matrixFmGraphToLines, + syncMatrixFmSpecFromGraph +} +export { ensureSyncWorklet, syncWorkletSource }