fixes mapping changes
This commit is contained in:
Generated
+33
-33
@@ -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}
|
||||
|
||||
+5
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user