diff --git a/package-lock.json b/package-lock.json index ae96747d..a23e1d92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -197,9 +197,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "dependencies": { @@ -208,9 +208,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "dependencies": { @@ -2991,9 +2991,9 @@ } }, "node_modules/@turbo/darwin-64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.19.tgz", - "integrity": "sha512-WNzpqJV7rO/oKmsxjtSFXuV87fZb/qxzeGWXJng4oHFz7iPOFSWrPb2gTazJgoTvXUfe1kOH2o0F+zFrerwzRw==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.20.tgz", + "integrity": "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==", "cpu": [ "x64" ], @@ -3005,9 +3005,9 @@ ] }, "node_modules/@turbo/darwin-arm64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.8.19.tgz", - "integrity": "sha512-KTq8/PH0/ml9bNFomZ2VjmOqW6RYPRqBqV5CfNeD5ekJMuNwJUdeQz0qXguxCzW4OkHCnTdPeKIehR0cdQObPg==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.8.20.tgz", + "integrity": "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==", "cpu": [ "arm64" ], @@ -3019,9 +3019,9 @@ ] }, "node_modules/@turbo/linux-64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.8.19.tgz", - "integrity": "sha512-d0xcXiGUt1Q/HzMfgkqai5KyXrassLRuUZhxmUs4ZX99tQFbqRL2/CkSKCnMqyDv7c0K5eugLerVUY+DYr1DQg==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.8.20.tgz", + "integrity": "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==", "cpu": [ "x64" ], @@ -3033,9 +3033,9 @@ ] }, "node_modules/@turbo/linux-arm64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.8.19.tgz", - "integrity": "sha512-UX3r1iwqsOK8dMgKFtZpDqZk7feqHXZJ/EQAknh0SVQrnc4vkm6mZqjX4j2j3DVoSo4Ho8xGITYlor9Xxsyomw==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.8.20.tgz", + "integrity": "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==", "cpu": [ "arm64" ], @@ -3047,9 +3047,9 @@ ] }, "node_modules/@turbo/windows-64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.8.19.tgz", - "integrity": "sha512-3MrvD/gMYWfQKZVZOeilKm1629zPG0+KlErZbI0n9Nr3L32+5qrZOF/cgWErukLnOSR38whgf8t2WaPJQALqxg==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.8.20.tgz", + "integrity": "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==", "cpu": [ "x64" ], @@ -3061,9 +3061,9 @@ ] }, "node_modules/@turbo/windows-arm64": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.8.19.tgz", - "integrity": "sha512-j+8mi7kyEgT7cUTPOtPJS8KbTWxu4+OQBiDIo+zklz/RKV4hoN+k4+J8iCM9Nj+o+SVnzc01xSI+s3eBG0MDSA==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.8.20.tgz", + "integrity": "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==", "cpu": [ "arm64" ], @@ -11875,21 +11875,21 @@ } }, "node_modules/turbo": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.19.tgz", - "integrity": "sha512-9NTKvQZ/02XnJCSU4mF5gvAPK+nuLbvCtft/NE8dJeOwaup0bJ14rYx7TxmSXp8JJuAN8nAjqfXWFsWwoQSFHg==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.20.tgz", + "integrity": "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==", "dev": true, "license": "MIT", "bin": { "turbo": "bin/turbo" }, "optionalDependencies": { - "@turbo/darwin-64": "2.8.19", - "@turbo/darwin-arm64": "2.8.19", - "@turbo/linux-64": "2.8.19", - "@turbo/linux-arm64": "2.8.19", - "@turbo/windows-64": "2.8.19", - "@turbo/windows-arm64": "2.8.19" + "@turbo/darwin-64": "2.8.20", + "@turbo/darwin-arm64": "2.8.20", + "@turbo/linux-64": "2.8.20", + "@turbo/linux-arm64": "2.8.20", + "@turbo/windows-64": "2.8.20", + "@turbo/windows-arm64": "2.8.20" } }, "node_modules/tweetnacl": { diff --git a/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts b/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts index 1f4bc70c..9f49843e 100644 --- a/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts +++ b/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts @@ -1,8 +1,10 @@ import SimpleSine from "./examples/simple-sine.js?raw" import GrainSynth from "./examples/grain-synth.js?raw" +import TB303 from "./examples/tb-303.js?raw" import {CodeEditorExample} from "@/ui/werkstatt-editor/CodeEditorState" export const ApparatExamples: ReadonlyArray = [ {name: "Simple Sine Synth", code: SimpleSine}, - {name: "Grain Synthesizer", code: GrainSynth} + {name: "Grain Synthesizer", code: GrainSynth}, + {name: "TB-303 Bass Line", code: TB303} ] diff --git a/packages/app/studio/src/ui/devices/instruments/examples/tb-303.js b/packages/app/studio/src/ui/devices/instruments/examples/tb-303.js new file mode 100644 index 00000000..6f754cc0 --- /dev/null +++ b/packages/app/studio/src/ui/devices/instruments/examples/tb-303.js @@ -0,0 +1,208 @@ +// TB-303 Bass Line +// @param waveform 0 0 1 int +// @param tuning 0 -5 5 linear st +// @param cutoff 500 80 5000 exp Hz +// @param resonance 0.5 0 1 linear +// @param envmod 0.5 0 1 linear +// @param decay 400 200 2000 exp ms +// @param accent 0.5 0 1 linear +// @param volume 0.7 0 1 linear + +class Processor { + waveform = 0 + tuning = 0 + cutoffHz = 500 + resonance = 0.5 + envmod = 0.5 + decayMs = 400 + accentAmount = 0.5 + volume = 0.7 + phase = 0 + freqTarget = 220 + freqSmoothed = 220 + y1 = 0 + y2 = 0 + y3 = 0 + y4 = 0 + fbHpState = 0 + oscHpState = 0 + postHpState = 0 + vcaGate = 0 + vcaDecay = 0 + megEnv = 0 + megDecayCoeff = 0.9999 + vcaDecayCoeff = 0.9999 + vcaGateCoeff = 0.999 + accentCap = 0 + isAccented = false + gate = false + sliding = false + heldNotes = [] + cutoffSmoothed = 200 + ampSmoothed = 0 + paramChanged(name, value) { + if (name === "waveform") this.waveform = value + if (name === "tuning") this.tuning = value + if (name === "cutoff") this.cutoffHz = value + if (name === "resonance") this.resonance = value + if (name === "envmod") this.envmod = value + if (name === "decay") this.decayMs = value + if (name === "accent") this.accentAmount = value + if (name === "volume") this.volume = value + } + noteOn(pitch, velocity, cent, id) { + const freq = 440 * Math.pow(2, (pitch - 69 + cent / 100 + this.tuning) / 12) + const isSlide = this.heldNotes.length > 0 + this.heldNotes.push({id, pitch, cent}) + this.freqTarget = freq + if (isSlide) { + this.sliding = true + } else { + this.sliding = false + this.freqSmoothed = freq + this.gate = true + this.vcaDecay = 1.0 + this.megEnv = 1.0 + this.isAccented = velocity >= 1.0 + if (this.isAccented) { + this.megDecayCoeff = Math.exp(-1 / (0.2 * sampleRate)) + this.vcaDecayCoeff = Math.exp(-1 / (0.2 * sampleRate)) + this.vcaGateCoeff = Math.exp(-1 / (0.05 * sampleRate)) + } else { + this.megDecayCoeff = Math.exp(-1 / (this.decayMs * 0.001 * sampleRate)) + this.vcaDecayCoeff = Math.exp(-1 / (1.23 * sampleRate)) + this.vcaGateCoeff = Math.exp(-1 / (0.001 * sampleRate)) + } + } + } + noteOff(id) { + this.heldNotes = this.heldNotes.filter(note => note.id !== id) + if (this.heldNotes.length === 0) { + this.gate = false + this.sliding = false + } else { + const prev = this.heldNotes[this.heldNotes.length - 1] + this.freqTarget = 440 * Math.pow(2, (prev.pitch - 69 + prev.cent / 100 + this.tuning) / 12) + this.sliding = true + } + } + reset() { + this.gate = false + this.sliding = false + this.vcaGate = 0 + this.ampSmoothed = 0 + this.vcaGateCoeff = Math.exp(-1 / (0.005 * sampleRate)) + this.heldNotes = [] + } + process(output, block) { + const [outL, outR] = output + const sr = sampleRate + const invSr = 1 / sr + const sr2 = sr * 2 + const PI = Math.PI + const gateAttack = 1 - Math.exp(-1 / (0.003 * sr)) + const slideAlpha = 1 - Math.exp(-1 / (0.012 * sr)) + const accChargeAlpha = 1 - Math.exp(-1 / (0.147 * sr)) + const accDischargeMul = Math.exp(-1 / (0.1 * sr)) + const resoSkewed = (1 - Math.exp(-3 * this.resonance)) / 0.950212931632136 + const kFeedback = resoSkewed * 17 + const gNorm = kFeedback / 17 + let gComp = (gNorm - 1) * resoSkewed + 1 + gComp *= (1 + resoSkewed) + const fbHpAlpha = 1 - Math.exp(-2 * PI * 150 / sr2) + const oscHpAlpha = 1 - Math.exp(-2 * PI * 44 / sr) + const postHpAlpha = 1 - Math.exp(-2 * PI * 24 / sr) + const cutoffAlpha = 1 - Math.exp(-1 / (0.0005 * sr)) + const ampAlpha = 1 - Math.exp(-1 / (0.0005 * sr)) + const envDepth = this.envmod * 4.5 + const envOffset = this.envmod * 0.33 + for (let s = block.s0; s < block.s1; s++) { + if (this.sliding) { + this.freqSmoothed += (this.freqTarget - this.freqSmoothed) * slideAlpha + } else { + this.freqSmoothed = this.freqTarget + } + const freq = this.freqSmoothed + const phaseInc = freq * invSr + let osc = 0 + if (this.waveform === 0) { + osc = 2 * this.phase - 1 + if (this.phase < phaseInc) { + const t = this.phase / phaseInc + osc -= t + t - t * t - 1 + } else if (this.phase > 1 - phaseInc) { + const t = (this.phase - 1) / phaseInc + osc -= t * t + t + t + 1 + } + } else { + const duty = Math.max(0.1, Math.min(0.9, 0.71 - 0.26 * (freq - 65) / 595)) + osc = this.phase < duty ? 1 : -1 + if (this.phase < phaseInc) { + const t = this.phase / phaseInc + osc += t + t - t * t - 1 + } else if (this.phase > 1 - phaseInc) { + const t = (this.phase - 1) / phaseInc + osc += t * t + t + t + 1 + } + let rd = this.phase - duty + if (rd < 0) rd += 1 + if (rd < phaseInc && rd >= 0) { + const t = rd / phaseInc + osc -= t + t - t * t - 1 + } else if (rd > 1 - phaseInc) { + const t = (rd - 1) / phaseInc + osc -= t * t + t + t + 1 + } + } + this.oscHpState += oscHpAlpha * (osc - this.oscHpState) + osc -= this.oscHpState + this.megEnv *= this.megDecayCoeff + if (this.isAccented && this.gate) { + this.accentCap += accChargeAlpha * (this.megEnv * this.accentAmount - this.accentCap) + } else { + this.accentCap *= accDischargeMul + } + let targetCutoff = this.cutoffHz * Math.pow(2, envDepth * (this.megEnv - envOffset)) + targetCutoff *= Math.pow(2, this.accentCap * 3) + if (targetCutoff < 20) targetCutoff = 20 + if (targetCutoff > sr * 0.45) targetCutoff = sr * 0.45 + this.cutoffSmoothed += (targetCutoff - this.cutoffSmoothed) * cutoffAlpha + const gf = Math.tan(PI * this.cutoffSmoothed / sr2) + const b0 = gf / (1 + gf) + for (let os = 0; os < 2; os++) { + const fbRaw = kFeedback * this.y4 + this.fbHpState += fbHpAlpha * (fbRaw - this.fbHpState) + const fbHp = fbRaw - this.fbHpState + const y0 = Math.tanh(osc - fbHp) + this.y1 += 2 * b0 * (y0 - this.y1 + this.y2) + this.y1 = Math.tanh(this.y1) + this.y2 += b0 * (this.y1 - 2 * this.y2 + this.y3) + this.y2 = Math.tanh(this.y2) + this.y3 += b0 * (this.y2 - 2 * this.y3 + this.y4) + this.y3 = Math.tanh(this.y3) + this.y4 += b0 * (this.y3 - 2 * this.y4) + this.y4 = Math.tanh(this.y4) + } + let filtered = 2.5 * gComp * this.y4 + this.postHpState += postHpAlpha * (filtered - this.postHpState) + filtered -= this.postHpState + if (this.gate) { + this.vcaGate += (1 - this.vcaGate) * gateAttack + } else { + this.vcaGate *= this.vcaGateCoeff + } + this.vcaDecay *= this.vcaDecayCoeff + let amp = this.vcaGate * this.vcaDecay + amp += 0.3 * this.megEnv * this.vcaGate + if (this.isAccented) { + amp += this.accentAmount * 1.5 * this.megEnv * this.vcaGate + } + this.ampSmoothed += (amp - this.ampSmoothed) * ampAlpha + const sample = Math.tanh(filtered * this.ampSmoothed * 0.7) * this.volume + outL[s] += sample + outR[s] += sample + this.phase += phaseInc + if (this.phase >= 1) this.phase -= 1 + } + } +} \ No newline at end of file diff --git a/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts index 7554a6c2..9d6b31ba 100644 --- a/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts +++ b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts @@ -141,6 +141,7 @@ export const createScriptCompiler = (config: ScriptCompilerConfig) => { const headerPattern = createHeaderPattern(config.headerTag) const createHeader = (update: number): string => `// @${config.headerTag} js ${COMPILER_VERSION} ${update}\n` + let maxUpdate = 0 return { stripHeader: (source: string): string => parseHeader(source, headerPattern).userCode, load: async (audioContext: BaseAudioContext, deviceBox: ScriptDeviceBox): Promise => { @@ -158,7 +159,9 @@ export const createScriptCompiler = (config: ScriptCompilerConfig) => { append: boolean = false): Promise => { const userCode = parseHeader(source, headerPattern).userCode const currentUpdate = parseHeader(deviceBox.code.getValue(), headerPattern).update - const newUpdate = currentUpdate + 1 + maxUpdate = Math.max(maxUpdate, currentUpdate) + const newUpdate = maxUpdate + 1 + maxUpdate = newUpdate const uuid = UUID.toString(deviceBox.address.uuid) const params = parseParams(userCode) const samples = parseSamples(userCode) diff --git a/packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts b/packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts index c4876850..9f5cdfd9 100644 --- a/packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts +++ b/packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts @@ -34,8 +34,6 @@ const ExternalControlTypes = [ export class AutomatableParameterFieldAdapter implements Parameter, Terminable { readonly #context: BoxAdaptersContext readonly #field: PrimitiveField - readonly #valueMapping: ValueMapping - readonly #stringMapping: StringMapping readonly #name: string readonly #anchor: unitValue @@ -43,6 +41,9 @@ export class AutomatableParameterFieldAdapter i readonly #valueChangeNotifier: Notifier readonly #controlSource: Listeners + #valueMapping: ValueMapping + #stringMapping: StringMapping + #trackBoxAdapter: Option = Option.None #automationHandle: Option = Option.None #controlledValue: Nullable = null @@ -131,6 +132,12 @@ export class AutomatableParameterFieldAdapter i get address(): Address {return this.#field.address} get track(): Option {return this.#trackBoxAdapter} + updateMappings(valueMapping: ValueMapping, stringMapping: StringMapping): void { + this.#valueMapping = valueMapping + this.#stringMapping = stringMapping + this.#valueChangeNotifier.notify(this) + } + valueAt(position: ppqn): T { const optTrack = this.#trackBoxAdapter if (optTrack.nonEmpty()) { diff --git a/packages/studio/adapters/src/devices/audio-effects/WerkstattDeviceBoxAdapter.ts b/packages/studio/adapters/src/devices/audio-effects/WerkstattDeviceBoxAdapter.ts index 47adaa88..dc6b9570 100644 --- a/packages/studio/adapters/src/devices/audio-effects/WerkstattDeviceBoxAdapter.ts +++ b/packages/studio/adapters/src/devices/audio-effects/WerkstattDeviceBoxAdapter.ts @@ -41,6 +41,16 @@ export class WerkstattDeviceBoxAdapter implements AudioEffectDeviceAdapter { .removeParameter(asInstanceOf(box, WerkstattParameterBox).value.address)) }) ) + this.#terminator.own(box.code.subscribe(() => { + const declarations = parseParams(box.code.getValue()) + for (const adapter of this.#parametric.parameters()) { + const declaration = declarations.find(decl => decl.label === adapter.name) + const {valueMapping, stringMapping} = isDefined(declaration) + ? resolveParamMappings(declaration) + : {valueMapping: ValueMapping.unipolar(), stringMapping: StringMapping.percent({fractionDigits: 1})} + adapter.updateMappings(valueMapping, stringMapping) + } + })) } get box(): WerkstattDeviceBox {return this.#box} diff --git a/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts b/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts index 8fc34174..158b776c 100644 --- a/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts +++ b/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts @@ -1,7 +1,6 @@ import {asInstanceOf, isDefined, Option, StringMapping, Terminator, UUID, ValueMapping} from "@opendaw/lib-std" -import {Address, BooleanField, PointerField, StringField} from "@opendaw/lib-box" +import {Address, BooleanField, StringField} from "@opendaw/lib-box" import {ApparatDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes" -import {Pointers} from "@opendaw/studio-enums" import {DeviceHost, Devices, InstrumentDeviceBoxAdapter} from "../../DeviceAdapter" import {LabeledAudioOutput} from "../../LabeledAudioOutputsOwner" import {BoxAdaptersContext} from "../../BoxAdaptersContext" @@ -35,13 +34,26 @@ export class ApparatDeviceBoxAdapter implements InstrumentDeviceBoxAdapter { const declaration = declarations.find(decl => decl.label === label) const {valueMapping, stringMapping} = isDefined(declaration) ? resolveParamMappings(declaration) - : {valueMapping: ValueMapping.unipolar(), stringMapping: StringMapping.percent({fractionDigits: 1})} + : { + valueMapping: ValueMapping.unipolar(), + stringMapping: StringMapping.percent({fractionDigits: 1}) + } this.#parametric.createParameter(paramBox.value, valueMapping, stringMapping, label) }), onRemoved: (({box}) => this.#parametric .removeParameter(asInstanceOf(box, WerkstattParameterBox).value.address)) }) ) + this.#terminator.own(box.code.subscribe(() => { + const declarations = parseParams(box.code.getValue()) + for (const adapter of this.#parametric.parameters()) { + const declaration = declarations.find(decl => decl.label === adapter.name) + const {valueMapping, stringMapping} = isDefined(declaration) + ? resolveParamMappings(declaration) + : {valueMapping: ValueMapping.unipolar(), stringMapping: StringMapping.percent({fractionDigits: 1})} + adapter.updateMappings(valueMapping, stringMapping) + } + })) } get box(): ApparatDeviceBox {return this.#box} @@ -62,7 +74,7 @@ export class ApparatDeviceBoxAdapter implements InstrumentDeviceBoxAdapter { audioUnitBoxAdapter(): AudioUnitBoxAdapter {return this.deviceHost().audioUnitBoxAdapter()} - *labeledAudioOutputs(): Iterable { + * labeledAudioOutputs(): Iterable { yield {address: this.address, label: this.labelField.getValue(), children: () => Option.None} } diff --git a/packages/studio/adapters/src/devices/midi-effects/SpielwerkDeviceBoxAdapter.ts b/packages/studio/adapters/src/devices/midi-effects/SpielwerkDeviceBoxAdapter.ts index d67f0a64..1ed672db 100644 --- a/packages/studio/adapters/src/devices/midi-effects/SpielwerkDeviceBoxAdapter.ts +++ b/packages/studio/adapters/src/devices/midi-effects/SpielwerkDeviceBoxAdapter.ts @@ -40,6 +40,16 @@ export class SpielwerkDeviceBoxAdapter implements MidiEffectDeviceAdapter { .removeParameter(asInstanceOf(box, WerkstattParameterBox).value.address)) }) ) + this.#terminator.own(box.code.subscribe(() => { + const declarations = parseParams(box.code.getValue()) + for (const adapter of this.#parametric.parameters()) { + const declaration = declarations.find(decl => decl.label === adapter.name) + const {valueMapping, stringMapping} = isDefined(declaration) + ? resolveParamMappings(declaration) + : {valueMapping: ValueMapping.unipolar(), stringMapping: StringMapping.percent({fractionDigits: 1})} + adapter.updateMappings(valueMapping, stringMapping) + } + })) } get box(): SpielwerkDeviceBox {return this.#box} diff --git a/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts b/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts index 58d717d3..fbf15042 100644 --- a/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts +++ b/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts @@ -74,7 +74,7 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff this.#silenced = true this.#userProcessor = Option.None this.#output.clear() - this.#tryLoad(newUpdate) + this.#tryLoad() } }), box.parameters.pointerHub.catchupAndSubscribe({ @@ -96,10 +96,10 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff this.readAllParameters() } - #tryLoad(update: int): void { + #tryLoad(): void { const registry = (globalThis as any).openDAW?.werkstattProcessors?.[this.#uuid] - if (isDefined(registry) && registry.update === update) { - this.#swapProcessor(registry.create, update) + if (isDefined(registry)) { + this.#swapProcessor(registry.create, registry.update) } } @@ -167,10 +167,7 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff processAudio(block: Block): void { if (this.#silenced) { - const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue()) - if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) { - this.#tryLoad(expectedUpdate) - } + this.#tryLoad() if (this.#silenced) {return} } if (this.#source.isEmpty() || this.#userProcessor.isEmpty()) {return} diff --git a/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts b/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts index e72d6f1b..1ba87bfa 100644 --- a/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts +++ b/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts @@ -82,7 +82,7 @@ export class ApparatDeviceProcessor extends AudioProcessor this.#silenced = true this.#userProcessor = Option.None this.#audioOutput.clear() - this.#tryLoad(newUpdate) + this.#tryLoad() } }), box.parameters.pointerHub.catchupAndSubscribe({ @@ -168,10 +168,7 @@ export class ApparatDeviceProcessor extends AudioProcessor processAudio(block: Block): void { if (!this.#enabled) {return} if (this.#silenced) { - const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue()) - if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) { - this.#tryLoad(expectedUpdate) - } + this.#tryLoad() if (this.#silenced) {return} } if (this.#userProcessor.isEmpty()) {return} @@ -213,10 +210,10 @@ export class ApparatDeviceProcessor extends AudioProcessor processEvents(_block: Block): void {} - #tryLoad(update: int): void { + #tryLoad(): void { const registry = (globalThis as any).openDAW?.apparatProcessors?.[this.#uuid] - if (isDefined(registry) && registry.update === update) { - this.#swapProcessor(registry.create, update) + if (isDefined(registry)) { + this.#swapProcessor(registry.create, registry.update) } } diff --git a/packages/studio/core-processors/src/devices/midi-effects/SpielwerkDeviceProcessor.ts b/packages/studio/core-processors/src/devices/midi-effects/SpielwerkDeviceProcessor.ts index 7143adb7..63deae3d 100644 --- a/packages/studio/core-processors/src/devices/midi-effects/SpielwerkDeviceProcessor.ts +++ b/packages/studio/core-processors/src/devices/midi-effects/SpielwerkDeviceProcessor.ts @@ -113,7 +113,7 @@ export class SpielwerkDeviceProcessor extends EventProcessor implements MidiEffe if (newUpdate > 0 && newUpdate !== this.#currentUpdate) { this.#silenced = true this.#userProcessor = Option.None - this.#tryLoad(newUpdate) + this.#tryLoad() } }), box.parameters.pointerHub.catchupAndSubscribe({ @@ -165,10 +165,7 @@ export class SpielwerkDeviceProcessor extends EventProcessor implements MidiEffe } this.#wasPlaying = playing if (this.#silenced) { - const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue()) - if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) { - this.#tryLoad(expectedUpdate) - } + this.#tryLoad() } if (this.#source.nonEmpty() && this.#userProcessor.nonEmpty() && !this.#silenced) { const source = this.#source.unwrap() @@ -290,10 +287,10 @@ export class SpielwerkDeviceProcessor extends EventProcessor implements MidiEffe index(): number {return this.#adapter.indexField.getValue()} adapter(): SpielwerkDeviceBoxAdapter {return this.#adapter} - #tryLoad(update: int): void { + #tryLoad(): void { const registry = (globalThis as any).openDAW?.spielwerkProcessors?.[this.#uuid] - if (isDefined(registry) && registry.update === update) { - this.#swapProcessor(registry.create, update) + if (isDefined(registry)) { + this.#swapProcessor(registry.create, registry.update) } } diff --git a/plans/apparat-tb-303.md b/plans/apparat-tb-303.md new file mode 100644 index 00000000..102283f4 --- /dev/null +++ b/plans/apparat-tb-303.md @@ -0,0 +1,400 @@ +# TB-303 Bass Synthesizer — Apparat Emulation + +Compiled 2026-03-19 for openDAW Apparat implementation. + +## Sources + +- **Open303** (Robin Schmidt) — full C++ DSP implementation +- **Robin Whittle** (Devil Fish) — circuit analysis +- **Tim Stinchcombe** — diode ladder filter mathematical model +- **a1k0n/303** — JS/Python resynthesis experiments +- **KVR Audio DSP forums** — community measurements +- **Csound diode_ladder** documentation + +## Implementation + +Example file: `packages/app/studio/src/ui/devices/instruments/examples/tb-303.js` + +### Parameters (Knobs) + +| Knob | Default | Range | Type | Description | +|------|---------|-------|------|-------------| +| waveform | 0 | 0–1 | int | 0=sawtooth, 1=square | +| tuning | 0 | -5–5 | linear (st) | Pitch offset in semitones | +| cutoff | 500 | 80–5000 | exp (Hz) | Base filter cutoff frequency | +| resonance | 0.5 | 0–1 | linear | Filter resonance | +| envmod | 0.5 | 0–1 | linear | Filter envelope modulation depth | +| decay | 400 | 200–2000 | exp (ms) | Filter envelope decay time | +| accent | 0.5 | 0–1 | linear | Accent amount | +| volume | 0.7 | 0–1 | linear | Output level | + +### Architecture + +Monophonic. Overlapping notes trigger slide (portamento). High velocity (>0.78) triggers accent. + +--- + +## 1. Oscillator (VCO) + +**Circuit:** Discrete thyristor-based oscillator (Q24, Q25, Q27 as SCR) with integrator capacitor C33. Faster reset time than conventional switched-integrator designs. + +### Sawtooth + +- NOT mathematically ideal +- Raw saw passes through ~10 high-pass filters (coupling capacitors), the ladder filter, VCA, and mixer +- Even harmonics present due to S-curve shaping in the ramp +- **DSP:** bandlimited sawtooth via polyBLEP with DC blocking + +### Square + +- Derived from sawtooth via single-transistor waveshaper (Q8, PNP) +- **Frequency-dependent duty cycle:** + - ~71% at lowest pitches (65 Hz) + - ~45% at highest pitches (660 Hz) + - Linear mapping: `duty = 0.71 - 0.26 * clamp((freq - 65) / 595, 0, 1)` +- Two capacitors in waveshaper circuit cause this behavior +- **DSP:** bandlimited pulse via polyBLEP with pitch-dependent duty cycle + +### Tuning + +- 3 octaves in pattern mode (C2–C5), 4 in track mode +- Untransposed low C ≈ 65 Hz (2.0V CV) +- Tuning control: ±500 cents (±5 semitones) +- 6-bit R2R DAC: theoretical range ~16.35 Hz to ~659 Hz + +--- + +## 2. Filter (VCF) — The Key to the 303 Sound + +**Architecture:** 4-pole DIODE ladder filter (NOT Moog transistor ladder). Transistors with base-collector shorted to function as diodes. + +### The C18 Mismatch (Critical Characteristic) + +- **C18 = 0.18 µF**, other three caps = **0.33 µF** each +- First pole sits **~1.83x higher** than the other three (≈ one octave) +- Result: "1+3 pole" filter — three matched poles at nominal cutoff, one pole an octave above +- Effective slope: **~18 dB/oct** near cutoff (not 24 dB/oct) +- Deeper in stopband: transitions toward 24 dB/oct +- **Prevents true self-oscillation** (phase shift never reaches 360°) + +### Open303 Filter Structure + +``` +y1 += 2*b0*(y0 - y1 + y2) // First stage: 2x coeff (halved cap) +y2 += b0*(y1 - 2*y2 + y3) // Stages 2-4: normal coeff +y3 += b0*(y2 - 2*y3 + y4) +y4 += b0*(y3 - 2*y4) +``` + +Cross-coupling terms model the bidirectional coupling between stages through the diodes. Sequential evaluation (each stage uses updated value of previous stage). + +### Filter Coefficient + +For native sample rate (no oversampling): +``` +g = tan(π * cutoff / sampleRate) +b0 = g / (1 + g) +``` + +For 2x oversampled inner loop (used in our implementation): +``` +g = tan(π * cutoff / (sampleRate * 2)) +b0 = g / (1 + g) +``` + +### Resonance Feedback + +- Global feedback from y4 back to input +- **High-pass filter in feedback path** (~150 Hz cutoff) from coupling capacitors +- Tim Stinchcombe: ~6 additional poles/zeros from coupling caps +- Resonant peak around 8–10 Hz (measured on hardware) + +Resonance mapping: +``` +resoSkewed = (1 - exp(-3 * resonance)) / (1 - exp(-3)) +kFeedback = resoSkewed * 17 +``` + +### Gain Compensation + +``` +gNorm = kFeedback / 17 +gComp = (gNorm - 1) * resoSkewed + 1 +gComp *= (1 + resoSkewed) +output = 2 * gComp * y4 +``` + +### Saturation + +- tanh() nonlinearity at filter input: `y0 = tanh(input - feedbackHP)` +- Transistor-diode ladder natural saturation +- Csound alternative: `(1/tanh(k_sat)) * tanh(k_sat * input)` + +### Cutoff Range + +| Condition | Range | +|-----------|-------| +| Env Mod at minimum | ~77 Hz – ~620 Hz | +| Env Mod at maximum | up to ~36 kHz | +| Practical maximum | ~20 kHz | + +### Post-Filter Chain (Open303 reference values) + +- Allpass: 14.008 Hz +- Highpass: 24.167 Hz +- Notch: 7.5164 Hz, BW 4.7 +- Pre-filter highpass: 44.486 Hz + +--- + +## 3. Envelopes + +Two separate envelope generators. + +### VEG (Volume Envelope) → VCA + +| Parameter | Normal | Accented | +|-----------|--------|----------| +| Attack | ~3 ms | ~3 ms | +| Decay | 1230 ms | 200 ms | +| Sustain | 0 | 0 | +| Release | 0.5 ms | 50 ms | + +RC-filter style: +``` +attack: out += coeff * (1.3 - out); transition to decay when out >= 1.0 +decay: out *= exp(-1 / (decayTime * sr)) +release: out *= exp(-1 / (releaseTime * sr)) +``` + +### MEG (Main Envelope) → Filter Cutoff + +- Attack: near-instantaneous (~3 ms smoothing) +- Decay: **variable**, controlled by Decay knob (200 ms – 2000 ms) +- On **accented notes**: decay **fixed at ~200 ms** regardless of knob + +``` +megEnv *= exp(-1 / (decayTime * sr)) +``` + +### VCA + MEG Compound + +The MEG contributes to the VCA while gate is on: +``` +ampOut = vcaEnv + 0.45 * megEnv + accentGain * 4.0 * megEnv +``` + +### Retriggering + +- Retriggers on each new note gate +- During **slide**: gate stays HIGH → envelope does NOT restart + +--- + +## 4. Accent + +Accent simultaneously affects multiple parameters. Triggered when velocity > ~0.78 (100/127). + +### Effect on VCA + +- MEG voltage → Accent pot → VCA control current +- Through RC: **47k resistor + 0.033 µF capacitor** +- Primary reason accented notes are louder + +### Accent Sweep Circuit + +Adds to filter control current. Components: + +| Component | Value | +|-----------|-------| +| Diode + resistor in series | 47k | +| Pot (second section of Resonance pot) | 100k | +| Capacitor to ground | **1 µF** | +| Mixing resistor to filter | 100k | + +**Time constants:** +- Charge: (47k + 100k) × 1 µF = **~147 ms** +- Discharge: 100k × 1 µF = **~100 ms** + +### Accent Stacking (Iconic 303 Behavior) + +The 1 µF capacitor **does NOT fully discharge** between steps. Each successive accented note creates a **higher filter peak** due to charge accumulation. This "building" effect is one of the most iconic 303 behaviors. + +``` +if (accented && gate): + accentCap += (1 - exp(-1/(0.147*sr))) * (megEnv * accent - accentCap) +else: + accentCap *= exp(-1/(0.1*sr)) +cutoff *= pow(2, accentCap * sweepOctaves) +``` + +### Accent on Envelopes + +- MEG decay: **fixed 200 ms** (regardless of Decay knob) +- VCA decay: **200 ms** (vs 1230 ms normal) +- VCA release: **50 ms** (vs 0.5 ms normal) + +### "Gimmick" Circuit + +Three transistors pull cutoff **down** at end of envelope decay, creating characteristic dip/pluck. Partially modeled by accent sweep discharge behavior. + +--- + +## 5. Slide (Portamento) + +### Characteristics + +- **Exponential** glide (NOT linear) +- Equivalent to 6 dB/octave lowpass on pitch CV +- **Constant time** regardless of interval size +- Time constant: **~60 ms** (stock TB-303) +- Devil Fish: 60–360 ms via Slide Time pot + +### Circuit + +RC lowpass on pitch CV. When slide activated, pitch CV charges capacitor through resistor. + +### Implementation + +``` +freqSmoothed += (freqTarget - freqSmoothed) * (1 - exp(-1/(0.012*sr))) +``` + +Time constant 12 ms for the RC gives ~60 ms effective glide time. + +### Behavior + +- Overlapping notes trigger slide (gate stays HIGH) +- NO envelope retrigger during slide +- Oscillator phase NOT reset +- Filter state NOT reset + +### Sequencer Gate Timing + +- 12 internal clock ticks per 16th note +- Gate high at tick 0, low halfway through tick 3 (~50% duty) +- Slide extends gate through current step into next +- Slide activates on the NEXT note after the slid note +- Sequencer does NOT anticipate the next note + +--- + +## 6. Parameter Ranges Summary + +| Parameter | Range | Notes | +|-----------|-------|-------| +| Cutoff frequency | ~77 Hz – ~620 Hz | Up to ~36 kHz with full envmod | +| Resonance | 0 – just below self-osc | 303 can't truly self-oscillate | +| Env Mod | 0–1 | ~4 octaves sweep at maximum | +| Decay time | ~200 ms – ~2000 ms | Fixed ~200 ms on accented notes | +| Tuning | ±500 cents | ±5 semitones | +| Oscillator range | ~32 Hz – ~660 Hz | 3-4 octaves | +| Slide time | ~60 ms | Exponential RC lowpass | +| VCA attack | ~3 ms | | +| VCA decay | ~1230 ms / ~200 ms | Normal / accent | +| VCA release | ~0.5 ms / ~50 ms | Normal / accent | + +--- + +## 7. Circuit Quirks Critical for Authentic Emulation + +1. **Mismatched first filter pole** — C18=0.18µF vs 0.33µF → first stage 2×b0 coefficient +2. **High-pass in resonance feedback** — ~150 Hz HPF from coupling capacitors +3. **Accent stacking** — 1µF cap doesn't fully discharge between steps +4. **No true self-oscillation** — mismatched poles prevent 360° phase shift +5. **Frequency-dependent square duty cycle** — 45% high pitches, 71% low pitches +6. **VCA = ADSR(sustain=0) + MEG** — compound envelope, not simple gate +7. **tanh saturation at filter input** — transistor-diode ladder natural clipping +8. **Slide prevents envelope retrigger** — gate stays high, smooth transitions +9. **Low-frequency resonance peak (~8–10 Hz)** — from coupling caps in filter core +10. **BA662 OTA for VCA** — introduces subtle saturation and offset + +--- + +## 8. Open303 Magic Numbers + +``` +Oversampling: 4x +Wavetable length: 2048 samples +Mip-map tables: 12 + +Square shaping: + tanhShaperFactor: dB2amp(36.9) ≈ 69.98 + tanhShaperOffset: 4.37 + +Filter: + Feedback HP: 150 Hz (one-pole) + Cutoff clamp: 200 Hz – 20000 Hz + Post-filter allpass: 14.008 Hz + Post-filter HP: 24.167 Hz + Post-filter notch: 7.5164 Hz, BW 4.7 + Pre-filter HP: 44.486 Hz + +Envelopes: + VCA attack: 3 ms + VCA decay: 1230 ms (normal), 200 ms (accent) + VCA release: 0.5 ms (normal), 50 ms (accent) + MEG RC1 smoothing: 0 ms + MEG RC2 smoothing: 15 ms + +Envelope modulation scaling (from measurement): + c0 = 3.138152786059267e+002 + c1 = 2.394411986817546e+003 + oF = 0.048292930943553 + oC = 0.294391201442418 + sLoF = 3.773996325111173 + sLoC = 0.736965594166206 + sHiF = 4.194548788411135 + sHiC = 0.864344900642434 + +VCA MEG contribution: + ampEnvOut += 0.45 * mainEnvOut + accentGain * 4.0 * mainEnvOut + +Default level: -12 dB +Level by velocity: 12 dB +Default cutoff: 1000 Hz +Default envMod: 25% +envUpFraction: 2/3 +AmpDeClicker: Biquad LP 12dB/oct, 200 Hz, Q=sqrt(0.5) +Slide time: 60 ms (tau = 12 ms) +Anti-alias: Elliptic quarter-band +``` + +--- + +## 9. a1k0n/303 JS Reference + +``` +VCA: + attack: 1.0 - 0.94406088 = 0.05593912 + decay: 0.99897516 + amplitude: 0.5 + Update rate: every 64 samples + +VCF envelope (measured from x0xb0x): + vcf_e1 = exp(5.55921003 + 2.17788267*cutoff + 1.99224351*envmod) + 103 + vcf_e0 = exp(5.22617147 + 1.70418937*cutoff - 0.68382928*envmod) + 103 + +Filter: 5 poles + 1 zero + Section 1: one-zero + one-pole HP (~100 Hz) + Section 2: complex conjugate pole pair (biquad) + Section 3: complex conjugate pole pair (biquad) + Lookup: 64 resonance × 10 coefficients + Gain: 2/(1+reso_k) + 0.5*reso, where reso_k = reso * 4.0 +``` + +--- + +## 10. References + +- [Tim Stinchcombe — TB-303 Diode Ladder Filter Model](https://www.timstinchcombe.co.uk/index.php?pge=diode2) +- [Robin Whittle — TB-303 Unique Characteristics](https://www.firstpr.com.au/rwi/dfish/303-unique.html) +- [Robin Whittle — TB-303 Slide Analysis](https://www.firstpr.com.au/rwi/dfish/303-slide.html) +- [Open303 (Robin Schmidt)](https://github.com/RobinSchmidt/Open303) +- [a1k0n/303 — Resynthesis Experiments](https://github.com/a1k0n/303) +- [Csound diode_ladder](https://csound.com/manual/opcodes/diode_ladder/) +- [Vadim Zavalishin — The Art of VA Filter Design](https://www.native-instruments.com/fileadmin/ni_media/downloads/pdf/VAFilterDesign_2.1.0.pdf) +- [TB-303 Wikipedia](https://en.wikipedia.org/wiki/Roland_TB-303) +- KVR Audio DSP Forums: [filter](https://www.kvraudio.com/forum/viewtopic.php?t=346155), [envelopes](https://www.kvraudio.com/forum/viewtopic.php?t=258816), [square duty](https://www.kvraudio.com/forum/viewtopic.php?t=261379), [facts](https://www.kvraudio.com/forum/viewtopic.php?t=455469) +- [Eddy Bergman — TB-303 VCF Build](https://www.eddybergman.com/2025/03/TB303-VCF.html) +- [Sequence 15 — How Many Poles?](http://sequence15.blogspot.com/2009/02/how-many-poles-does-tb-303-filter-have.html)