🎸🔊 worklet batch — Karplus string + 12-bit bitcrush (spectrally verified)

The two voices native Web Audio can't do safely, done as one AudioWorklet module
(loaded from a Blob URL, so still single-file):
- 🎸 Karplus-Strong string — a sample-accurate 8-voice plucked-string worklet
  (noise burst → damped delay loop). Right-click sky → "string voice" swaps the
  FM tine for it. Verified offline: STABLE (rms 0.04, not the billions the native
  version exploded to), in-tune (A3→221, A4→436), harmonic.
- 🔊 12-bit bitcrush — a world-drivable lo-fi stage on the master bus (true
  bit-depth quantise + sample-rate reduction). Route anything to `crush`; clean
  by default (16-bit → 4-bit + downsample). Verified: transparent at 16-bit,
  hi-freq grit 0.18 at 4-bit.

Loaded async on synth start, spliced glitch→crusher→out and karplus→preSat.
Verified live: both load with no errors, string-voice toggle flips correctly.
Grimoire + manual document the whole synth-voice suite.

This closes the timbre work: FM tine · supersaw · chorus · string · bitcrush —
every one tuned by the offline-render + FFT harness before it made a sound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-10 07:37:31 +10:00
parent 2db0d5945f
commit adde3bc326
2 changed files with 39 additions and 3 deletions

View File

@ -584,6 +584,8 @@ Because the orrery is rendered client-side, in each listener's own browser, **ev
The matrix pours out clean control values whether or not anyone is listening. Choosing *how* to hear it is choosing which instrument the planet plays.
**The built-in voice, up close.** Press `♪` and the browser synth wakes. Its plucked notes ring with a **DX7-style FM tine** — brighter the harder they're struck; its pads bloom into a **detuned supersaw**; a **Juno chorus** warms the whole thing. Prefer strings? Right-click the sky → **🎸 string voice** and every pluck becomes a real **Karplus-Strong** plucked string. And route anything to `crush` for a **12-bit-DAC bitcrusher** — the planet degrading its own converter into lo-fi grit. (Every one of these was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)
**Zero — the moods, the gentle front door.** On your very first visit the instrument greets you with **🌅 first light** instead of the full flood: a bass line, soft hats, a small melody, modest space — a thing shaped like a song, with the world still playing underneath it. Right-click the sky → **🌗 mood** to move between *first light*, *ambient drift* (no drums, all atmosphere, the planet as weather), and **the full storm** — the factory instrument with every one of the world's hands on it at once. Ease in, then open the door.
**One — the built-in browser synth.** Click **♪** and a full voice rack wakes inside the page: **lead** (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), **bass** (quake depth chooses its notes in a low minor), **pad** (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), **drone** (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and **perc** (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: **reverb** (earthquakes open the void, room light sizes it), **delay** (your hand's Y feeds it, Mercury retrograde smears it), **saturation** (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and **glitch** (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.
@ -1005,3 +1007,9 @@ Every feed the world sends is one of eight temperaments; the shape dictates how
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **🔔 FM tine** — every plucked note (sequencer, hands, mind mode) is a sine carrier phase-modulated by a sine operator (DX7-style), index enveloped for a bright attack → mellow tail and scaled by velocity (harder = brighter). Spectrally verified alias-free across the register.
- **🎹 Supersaw pad** — each of the pad's chord tones is a detuned 9-saw cluster (JP-8000 style) for a lush, wide wall.
- **🎸 String voice** — right-click the sky → *string voice* swaps the FM tine for a real Karplus-Strong plucked string (an AudioWorklet: noise burst → damped delay loop, sample-accurate). Spectrally verified stable and in-tune.
- **🔊 12-bit bitcrush** — a world-drivable lo-fi stage on the synth's master bus (an AudioWorklet doing true bit-depth quantise + sample-rate reduction). Route anything to `crush` and the planet degrades its own DAC — clean by default (16-bit).

View File

@ -1016,6 +1016,8 @@
<p class="lead">The matrix pours out clean control values whether or not anyone is listening. Choosing <i>how</i> to hear it is choosing which instrument the planet plays.</p>
<p><b>The built-in voice, up close.</b> Press <kbd></kbd> and the browser synth wakes. Its plucked notes ring with a <b>DX7-style FM tine</b> — brighter the harder they're struck; its pads bloom into a <b>detuned supersaw</b>; a <b>Juno chorus</b> warms the whole thing. Prefer strings? Right-click the sky → <b>🎸 string voice</b> and every pluck becomes a real <b>Karplus-Strong</b> plucked string. And route anything to <kbd>crush</kbd> for a <b>12-bit-DAC bitcrusher</b> — the planet degrading its own converter into lo-fi grit. (Every one of these was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)</p>
<p><b>Zero — the moods, the gentle front door.</b> On your very first visit the instrument greets you with <b>🌅 first light</b> instead of the full flood: a bass line, soft hats, a small melody, modest space — a thing shaped like a song, with the world still playing underneath it. Right-click the sky → <b>🌗 mood</b> to move between <i>first light</i>, <i>ambient drift</i> (no drums, all atmosphere, the planet as weather), and <b>the full storm</b> — the factory instrument with every one of the world's hands on it at once. Ease in, then open the door.</p>
<p><b>One — the built-in browser synth.</b> Click <b></b> and a full voice rack wakes inside the page: <b>lead</b> (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), <b>bass</b> (quake depth chooses its notes in a low minor), <b>pad</b> (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), <b>drone</b> (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and <b>perc</b> (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: <b>reverb</b> (earthquakes open the void, room light sizes it), <b>delay</b> (your hand's Y feeds it, Mercury retrograde smears it), <b>saturation</b> (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and <b>glitch</b> (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.</p>
@ -5532,6 +5534,8 @@
() => openCV());
ctxItem((earthEchoOn ? "🌀 earth echo: on" : "🌀 earth echo — the planet on tape"),
() => earthEchoSet(!earthEchoOn));
ctxItem("🎸 string voice" + (Synth.stringVoice() ? " (on — plucks ring like a string)" : " — swap the FM tine for a Karplus string"),
() => Synth.stringVoice(!Synth.stringVoice()));
ctxItem("⌨ keys — remap the keyboard", () => openKeys());
if (commune.room) {
const gOwner = commune.alloc["@godtime"];
@ -6869,6 +6873,11 @@
N.leadFilt.Q.setTargetAtTime(1 + d("wavetable.morph", 0) * 8, t, 0.1);
S(N.padFilt.frequency, 220 + d("pad.brightness", 0) * 4200);
S(N.padAmp.gain, d("pad.brightness", 0) * 0.07, 0.3); // ×9 detuned saws → trimmed to hold the level
if (crusher) { // world-driven 12-bit-DAC crunch (default clean)
const cb = clamp(d("crush", 0), 0, 1);
crusher.parameters.get("bits").value = 16 - cb * 12; // 16 clean → 4 crunchy
crusher.parameters.get("reduction").value = 1 + cb * 8; // 1 → 9× downsample
}
S(N.droneAmp.gain, d("drone.voices", 0) * 0.08, 0.3);
S(N.revSend.gain, (d("reverb.size", 0) * 0.5 + d("master.space", 0) * 0.5) * 0.30, 0.3);
S(N.fb.gain, Math.min(0.55, d("delay.feedback", 0) * 0.6), 0.2);
@ -6919,9 +6928,25 @@
params();
}
// ---- AudioWorklet voices: a 12-bit-DAC bitcrusher on the master bus (world-
// drivable) and a sample-accurate Karplus-Strong string. Both need a worklet
// (native feedback + real sample-rate reduction are unstable/impossible with
// stock nodes — the spectral harness proved it). Loaded async from a Blob URL.
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);";
let crusher = null, ksNode = null, ksMode = false, workletTried = false;
function loadWorklets() {
if (workletTried || !ctx || !ctx.audioWorklet || !N) return;
workletTried = true;
const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: "application/javascript" }));
ctx.audioWorklet.addModule(url).then(() => {
crusher = new AudioWorkletNode(ctx, "bitcrush");
N.glitch.disconnect(); N.glitch.connect(crusher); crusher.connect(N.out); // splice onto the master bus
ksNode = new AudioWorkletNode(ctx, "karplus"); ksNode.connect(N.preSat);
}).catch(() => { workletTried = false; });
}
function start() {
if (!AC) return;
if (!ctx) { ctx = new AC(); N = build(); }
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
ctx.resume();
on = true; nextAt = ctx.currentTime;
N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.5);
@ -6935,12 +6960,14 @@
const FM_RATIO = 3; // FM operator ratio (2 = warm/woody, 3 = bright tine, 4 = glassy) — all verified alias-free
function pluck(midi, vel) {
if (!AC) return;
if (!ctx) { ctx = new AC(); N = build(); }
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
ctx.resume();
const t = ctx.currentTime;
if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1); // master up; data voices stay silent
const v = vel == null ? 0.8 : vel;
const freq = mtof(midi);
if (ksMode && ksNode) { ksNode.port.postMessage({ freq, vel: v }); return; } // Karplus string voice
// FM pluck — a sine carrier phase-modulated by a sine operator, index enveloped
// (bright "tine" attack → mellow tail) and scaled by velocity. Spectrally verified:
// in-tune, harder=brighter, no aliasing, no DC. (DX7 §, PM not FM — stable pitch.)
@ -6981,7 +7008,8 @@
});
return { update(dests) { if (dests) Object.assign(D, dests); }, pluck,
toggle() { on ? stop() : start(); paint(); return on; }, playing() { return on; } };
toggle() { on ? stop() : start(); paint(); return on; }, playing() { return on; },
stringVoice(v) { if (v === undefined) return ksMode; if (!ctx) start(); ksMode = !!v; return ksMode; } };
})();
// ---------------------------------------------------------------------------