fixes mapping changes

This commit is contained in:
André Michelle
2026-03-19 21:12:19 +01:00
parent 08fa21c727
commit 7a21b78283
12 changed files with 708 additions and 65 deletions
+33 -33
View File
@@ -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": {
@@ -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<CodeEditorExample> = [
{name: "Simple Sine Synth", code: SimpleSine},
{name: "Grain Synthesizer", code: GrainSynth}
{name: "Grain Synthesizer", code: GrainSynth},
{name: "TB-303 Bass Line", code: TB303}
]
@@ -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
}
}
}
@@ -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<void> => {
@@ -158,7 +159,9 @@ export const createScriptCompiler = (config: ScriptCompilerConfig) => {
append: boolean = false): Promise<void> => {
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)
@@ -34,8 +34,6 @@ const ExternalControlTypes = [
export class AutomatableParameterFieldAdapter<T extends PrimitiveValues = any> implements Parameter<T>, Terminable {
readonly #context: BoxAdaptersContext
readonly #field: PrimitiveField<T, Pointers.Automation>
readonly #valueMapping: ValueMapping<T>
readonly #stringMapping: StringMapping<T>
readonly #name: string
readonly #anchor: unitValue
@@ -43,6 +41,9 @@ export class AutomatableParameterFieldAdapter<T extends PrimitiveValues = any> i
readonly #valueChangeNotifier: Notifier<this>
readonly #controlSource: Listeners<ControlSourceListener>
#valueMapping: ValueMapping<T>
#stringMapping: StringMapping<T>
#trackBoxAdapter: Option<TrackBoxAdapter> = Option.None
#automationHandle: Option<Terminable> = Option.None
#controlledValue: Nullable<unitValue> = null
@@ -131,6 +132,12 @@ export class AutomatableParameterFieldAdapter<T extends PrimitiveValues = any> i
get address(): Address {return this.#field.address}
get track(): Option<TrackBoxAdapter> {return this.#trackBoxAdapter}
updateMappings(valueMapping: ValueMapping<T>, stringMapping: StringMapping<T>): void {
this.#valueMapping = valueMapping
this.#stringMapping = stringMapping
this.#valueChangeNotifier.notify(this)
}
valueAt(position: ppqn): T {
const optTrack = this.#trackBoxAdapter
if (optTrack.nonEmpty()) {
@@ -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}
@@ -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<LabeledAudioOutput> {
* labeledAudioOutputs(): Iterable<LabeledAudioOutput> {
yield {address: this.address, label: this.labelField.getValue(), children: () => Option.None}
}
@@ -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}
@@ -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}
@@ -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)
}
}
@@ -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)
}
}
+400
View File
@@ -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 | 01 | int | 0=sawtooth, 1=square |
| tuning | 0 | -55 | linear (st) | Pitch offset in semitones |
| cutoff | 500 | 805000 | exp (Hz) | Base filter cutoff frequency |
| resonance | 0.5 | 01 | linear | Filter resonance |
| envmod | 0.5 | 01 | linear | Filter envelope modulation depth |
| decay | 400 | 2002000 | exp (ms) | Filter envelope decay time |
| accent | 0.5 | 01 | linear | Accent amount |
| volume | 0.7 | 01 | 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 (C2C5), 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 810 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: 60360 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 | 01 | ~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 (~810 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)