Merge branch 'refs/heads/dev'
This commit is contained in:
Generated
+39
-39
@@ -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"
|
||||
],
|
||||
@@ -8414,9 +8414,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.39.2",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz",
|
||||
"integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==",
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.40.0.tgz",
|
||||
"integrity": "sha512-UztWnjkA15yYxqq7AC8MSsU4U6FIFIBHvHV94pouvjxQa0y2pGvi1HPmrFcaYrRZAuSKTrdw45VXUIe3gEEpsA==",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -8931,9 +8931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
||||
"integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Apparat
|
||||
|
||||
A programmable instrument that lets you write custom synthesizers and samplers in JavaScript. Generate audio from note events, load samples for granular synthesis, declare parameters with knobs, and hot-reload changes in real time.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 0. Overview
|
||||
|
||||
_Apparat_ is a scriptable instrument device. You write a `Processor` class in JavaScript that receives note events and generates stereo audio. Parameters and samples declared in the code appear as automatable knobs and file pickers on the device panel.
|
||||
|
||||
Example uses:
|
||||
|
||||
- Custom synthesizers (additive, subtractive, FM, wavetable)
|
||||
- Sample playback with pitch tracking
|
||||
- Granular synthesis
|
||||
- Algorithmic sound generators
|
||||
- Prototyping new instrument ideas
|
||||
|
||||
---
|
||||
|
||||
## 1. Editor
|
||||
|
||||
Click the **Editor** button on the device panel to open the full-screen code editor. The editor uses Monaco (the engine behind VS Code) with JavaScript syntax highlighting.
|
||||
|
||||
The status bar at the bottom shows the current state:
|
||||
|
||||
- **Idle** — No compilation attempted yet
|
||||
- **Successfully compiled** — Code compiled and loaded into the audio engine
|
||||
- **Error message** — Syntax error, runtime error, or validation failure
|
||||
|
||||
---
|
||||
|
||||
## 2. Parameters
|
||||
|
||||
Declare parameters using `// @param` comments at the top of your code:
|
||||
|
||||
```javascript
|
||||
// @param attack 0.01 0.001 1.0 exp s
|
||||
// @param release 0.3 0.01 2.0 exp s
|
||||
// @param mode 0 0 3 int
|
||||
// @param bypass false
|
||||
```
|
||||
|
||||
Each `@param` directive creates an automatable knob on the device panel. The full syntax is:
|
||||
|
||||
```
|
||||
// @param <name> [default] [min max type [unit]]
|
||||
```
|
||||
|
||||
### Simple (unipolar)
|
||||
|
||||
```
|
||||
// @param gain → 0–1, default 0
|
||||
// @param gain 0.5 → 0–1, default 0.5
|
||||
```
|
||||
|
||||
### Mapped
|
||||
|
||||
```
|
||||
// @param attack 0.01 0.001 1.0 exp s → exponential 0.001–1.0, default 0.01
|
||||
// @param mode 0 0 3 int → integer 0–3, default 0
|
||||
```
|
||||
|
||||
The knob displays the mapped value with the unit. `paramChanged` receives the mapped value directly.
|
||||
|
||||
### Boolean
|
||||
|
||||
```
|
||||
// @param bypass false → Off/On, default Off
|
||||
// @param bypass true → Off/On, default On
|
||||
```
|
||||
|
||||
`paramChanged` receives `0` or `1`.
|
||||
|
||||
### Supported mapping types
|
||||
|
||||
| Type | Description | paramChanged receives |
|
||||
|---|---|---|
|
||||
| *(none)* | Unipolar 0–1 | `number` (0–1) |
|
||||
| `linear` | Linear scaling between min and max | `number` (min–max) |
|
||||
| `exp` | Exponential scaling (for frequency, time) | `number` (min–max) |
|
||||
| `int` | Integer snapping between min and max | `number` (integer) |
|
||||
| `bool` | On/Off toggle | `number` (0 or 1) |
|
||||
|
||||
Parameters are reconciled on each compile: new parameters are added, removed parameters are deleted, and existing parameters keep their current value. Multiple spaces between tokens are allowed for alignment.
|
||||
|
||||
---
|
||||
|
||||
## 3. Samples
|
||||
|
||||
Declare samples using `// @sample` comments:
|
||||
|
||||
```javascript
|
||||
// @sample wavetable
|
||||
// @sample grain
|
||||
```
|
||||
|
||||
Each `@sample` creates a file picker on the device panel. Drag an audio file onto it or click to browse. The sample data is available in the processor as `this.samples.<name>`.
|
||||
|
||||
When loaded, `this.samples.wavetable` returns an `AudioData` object:
|
||||
|
||||
```javascript
|
||||
{
|
||||
sampleRate: number, // original sample rate
|
||||
numberOfFrames: number, // number of frames
|
||||
numberOfChannels: number, // 1 (mono) or 2 (stereo)
|
||||
frames: [Float32Array, Float32Array] // per-channel sample data
|
||||
}
|
||||
```
|
||||
|
||||
Returns `null` before the sample is loaded. Always check:
|
||||
|
||||
```javascript
|
||||
const data = this.samples.grain
|
||||
if (data === null) return
|
||||
```
|
||||
|
||||
When playing back samples, account for sample rate differences between the sample and the project:
|
||||
|
||||
```javascript
|
||||
const playbackRate = data.sampleRate / sampleRate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|---------------------|-------------------------------------|
|
||||
| `Alt+Enter` | Compile and run |
|
||||
| `Ctrl+S` / `Cmd+S` | Compile, run, and save to project |
|
||||
|
||||
---
|
||||
|
||||
## 5. Safety
|
||||
|
||||
The engine validates your output on every audio block:
|
||||
|
||||
- **NaN detection** — If any output sample is NaN, the processor is silenced and the error is reported.
|
||||
- **Overflow protection** — If any sample exceeds ~60 dB (amplitude > 1000), the processor is silenced.
|
||||
- **Runtime errors** — If `process()` throws an exception, the processor is silenced and the error is shown.
|
||||
|
||||
When silenced, the device outputs silence until the next successful compile.
|
||||
|
||||
---
|
||||
|
||||
## 6. API Reference
|
||||
|
||||
Your code must define a `class Processor` with a `process` method. Optionally implement `noteOn`, `noteOff`, `reset`, and `paramChanged`.
|
||||
|
||||
### Globals
|
||||
|
||||
| Variable | Type | Description |
|
||||
|--------------|----------|------------------------------------------|
|
||||
| `sampleRate` | `number` | Audio sample rate in Hz (e.g. 48000) |
|
||||
|
||||
### Processor class
|
||||
|
||||
```javascript
|
||||
class Processor {
|
||||
noteOn(pitch, velocity, cent, id) { } // note starts (sample-accurate)
|
||||
noteOff(id) { } // note ends (sample-accurate)
|
||||
reset() { } // transport stop — fast-release all voices
|
||||
paramChanged(label, value) { } // parameter knob changed
|
||||
process(output, block) { } // generate audio
|
||||
}
|
||||
```
|
||||
|
||||
### Note methods
|
||||
|
||||
`noteOn` and `noteOff` are called at the exact sample position within the block. The host splits the block at event boundaries and calls `process()` between them:
|
||||
|
||||
```
|
||||
[host clears output buffer]
|
||||
process(output, {s0: 0, s1: 47, ...}) // existing voices render
|
||||
noteOn(60, 0.8, 0, 42) // note starts at sample 47
|
||||
process(output, {s0: 47, s1: 128, ...}) // new voice renders
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------------|----------|--------------------------------------------|
|
||||
| `pitch` | `number` | MIDI note number (0–127) |
|
||||
| `velocity` | `number` | Note velocity (0.0–1.0) |
|
||||
| `cent` | `number` | Fine pitch offset in cents |
|
||||
| `id` | `number` | Unique note identifier (use for noteOff) |
|
||||
|
||||
Always use `id` to identify notes — not pitch. Multiple notes on the same pitch can be active simultaneously.
|
||||
|
||||
### reset()
|
||||
|
||||
Called on transport stop and position jumps. Put all voices into a fast release (e.g., 5ms fade) to avoid clicks. Do NOT hard-kill voices with `this.voices = []`.
|
||||
|
||||
### process(output, block)
|
||||
|
||||
The host clears the output buffer before each block. You write to it with `=` or `+=`.
|
||||
|
||||
- `output[0]` — left channel (`Float32Array`)
|
||||
- `output[1]` — right channel (`Float32Array`)
|
||||
|
||||
### Block properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|----------|-------------------------------------------------------|
|
||||
| `s0` | `number` | First sample index to process (inclusive) |
|
||||
| `s1` | `number` | Last sample index to process (exclusive) |
|
||||
| `index` | `number` | Block counter |
|
||||
| `bpm` | `number` | Current project tempo |
|
||||
| `p0` | `number` | Start position in ppqn |
|
||||
| `p1` | `number` | End position in ppqn |
|
||||
| `flags` | `number` | Bitmask: 1=transporting, 2=discontinuous, 4=playing, 8=bpmChanged |
|
||||
|
||||
---
|
||||
|
||||
## 7. Examples
|
||||
|
||||
Select **Examples** in the code editor toolbar to load ready-made instruments (Simple Sine Synth, Grain Synthesizer).
|
||||
|
||||
---
|
||||
|
||||
## 8. AI Prompt
|
||||
|
||||
Copy the following prompt into an AI assistant to get help writing Apparat instruments:
|
||||
|
||||
```
|
||||
You are helping the user write an instrument processor for the openDAW Apparat device.
|
||||
The user writes plain JavaScript (no imports, no modules). The code runs inside an AudioWorklet.
|
||||
|
||||
The code MUST define a class called `Processor` with the following interface:
|
||||
|
||||
class Processor {
|
||||
noteOn(pitch, velocity, cent, id) { } // called at exact sample position
|
||||
noteOff(id) { } // called at exact sample position
|
||||
reset() { } // called on transport stop — fast-release all voices
|
||||
paramChanged(label, value) { } // optional
|
||||
process(output, block) { } // generate audio
|
||||
}
|
||||
|
||||
## noteOn(pitch, velocity, cent, id)
|
||||
Called when a note starts, at the exact sample position within the audio block.
|
||||
|
||||
- pitch: number (0–127) — MIDI note number
|
||||
- velocity: number (0.0–1.0) — note velocity
|
||||
- cent: number — fine pitch offset in cents
|
||||
- id: number — unique identifier for this note instance
|
||||
|
||||
Convert pitch to frequency: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12)
|
||||
|
||||
IMPORTANT: Always use `id` to identify notes, not `pitch`. Multiple notes on the same
|
||||
pitch can be active simultaneously (e.g. overlapping regions, multiple MIDI sources).
|
||||
|
||||
## noteOff(id)
|
||||
Called when a note ends. Find the voice by `id` and release it.
|
||||
|
||||
## reset()
|
||||
Called on transport stop and position jumps. Put all voices into a fast release
|
||||
state (e.g., set a short fade rate like 0.05) to avoid clicks. Do NOT hard-kill
|
||||
voices with `this.voices = []` — that causes clicks.
|
||||
|
||||
Example:
|
||||
reset() {
|
||||
for (const voice of this.voices) {
|
||||
voice.gate = false
|
||||
voice.fadeRate = 0.05 // fast ~5ms fade
|
||||
}
|
||||
}
|
||||
|
||||
## process(output, block)
|
||||
Called between note events. The host clears the output buffer before each block.
|
||||
You write audio samples to the output buffers.
|
||||
|
||||
- output[0]: Float32Array — left channel (write to this)
|
||||
- output[1]: Float32Array — right channel (write to this)
|
||||
|
||||
block (timing and transport):
|
||||
- block.s0 — first sample index to process (inclusive)
|
||||
- block.s1 — last sample index to process (exclusive)
|
||||
- block.index — block counter
|
||||
- block.bpm — current project tempo in BPM
|
||||
- block.p0 — start position in ppqn (480 ppqn per quarter note)
|
||||
- block.p1 — end position in ppqn
|
||||
- block.flags — bitmask: 1=transporting, 2=discontinuous, 4=playing, 8=bpmChanged
|
||||
|
||||
The host interleaves noteOn/noteOff calls with process() calls at exact sample
|
||||
positions. Your process() may be called multiple times per block:
|
||||
|
||||
process(output, {s0: 0, s1: 47}) // render existing voices
|
||||
noteOn(60, 0.8, 0, 42) // note starts at sample 47
|
||||
process(output, {s0: 47, s1: 128}) // render with new voice
|
||||
|
||||
## paramChanged(label, value)
|
||||
Called when a parameter knob changes value.
|
||||
- label — string, matches the name from the @param comment
|
||||
- value — the mapped value (number). For unipolar: 0.0–1.0. For linear/exp: min–max.
|
||||
For int: integer in min–max. For bool: 0 or 1.
|
||||
|
||||
## Declaring parameters
|
||||
Parameters are declared as comments at the top of the file:
|
||||
|
||||
// @param <name> [default] [min max type [unit]]
|
||||
|
||||
Supported types: linear, exp, int, bool.
|
||||
If no type is given, the parameter is unipolar (0–1).
|
||||
If the default is "true" or "false", the type is bool.
|
||||
Multiple spaces between tokens are allowed for alignment.
|
||||
|
||||
Examples:
|
||||
// @param attack 0.01 0.001 1.0 exp s
|
||||
// @param mode 0 0 3 int
|
||||
// @param bypass false
|
||||
|
||||
## Declaring samples
|
||||
Samples are declared as comments:
|
||||
|
||||
// @sample <name>
|
||||
|
||||
Each @sample creates a file picker on the device panel. Access the loaded audio via
|
||||
this.samples.<name>. Returns null before loaded.
|
||||
|
||||
When loaded, this.samples.<name> is an AudioData object:
|
||||
- sampleRate: number — the sample's own sample rate
|
||||
- numberOfFrames: number — total frames
|
||||
- numberOfChannels: number — 1 (mono) or 2 (stereo)
|
||||
- frames: [Float32Array, ...] — per-channel audio data
|
||||
|
||||
IMPORTANT: The sample's sampleRate may differ from the project sampleRate.
|
||||
When playing back samples, always account for this:
|
||||
const playbackRate = data.sampleRate / sampleRate
|
||||
Advance your read position by playbackRate per output sample.
|
||||
|
||||
## Globals
|
||||
- sampleRate — number, the project audio sample rate in Hz (e.g. 48000).
|
||||
Always use this instead of hardcoding a sample rate.
|
||||
|
||||
## Constraints
|
||||
- NEVER allocate memory inside process(). No `new`, no array literals, no object
|
||||
literals, no string concatenation, no closures. Any allocation in the audio hot path
|
||||
causes GC pauses and audio glitches. Pre-allocate all buffers and state as class fields.
|
||||
- Output is validated every block. NaN or amplitudes > 1000 will silence the processor.
|
||||
- Do not use import/export/require. No access to DOM or fetch.
|
||||
- The code runs in an AudioWorklet thread. Only AudioWorklet-safe APIs are available
|
||||
(Math, typed arrays, basic JS). No console, no setTimeout, no DOM.
|
||||
- You can define and use helper classes alongside the Processor class.
|
||||
|
||||
## Template
|
||||
|
||||
// @param attack 0.01 0.001 1.0 exp s
|
||||
// @param release 0.3 0.01 2.0 exp s
|
||||
|
||||
class Processor {
|
||||
voices = []
|
||||
attack = 0.01
|
||||
release = 0.3
|
||||
paramChanged(name, value) {
|
||||
if (name === "attack") this.attack = value
|
||||
if (name === "release") this.release = value
|
||||
}
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
this.voices.push({
|
||||
id, velocity,
|
||||
freq: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12),
|
||||
phase: 0, gain: 0, gate: true, releaseTime: this.release
|
||||
})
|
||||
}
|
||||
noteOff(id) {
|
||||
const voice = this.voices.find(v => v.id === id)
|
||||
if (voice) voice.gate = false
|
||||
}
|
||||
reset() {
|
||||
for (const voice of this.voices) {
|
||||
voice.gate = false
|
||||
voice.releaseTime = 0.005
|
||||
}
|
||||
}
|
||||
process(output, block) {
|
||||
const [outL, outR] = output
|
||||
const attackRate = 1 / (this.attack * sampleRate)
|
||||
for (let i = this.voices.length - 1; i >= 0; i--) {
|
||||
const voice = this.voices[i]
|
||||
const releaseRate = 1 / (voice.releaseTime * sampleRate)
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
if (voice.gate) {
|
||||
voice.gain += (voice.velocity - voice.gain) * attackRate
|
||||
} else {
|
||||
voice.gain -= voice.gain * releaseRate
|
||||
if (voice.gain < 0.001) {
|
||||
this.voices.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
const sample = Math.sin(voice.phase * Math.PI * 2) * voice.gain * 0.3
|
||||
outL[s] += sample
|
||||
outR[s] += sample
|
||||
voice.phase += voice.freq / sampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -10,9 +10,10 @@ import {BuildInfo} from "@/BuildInfo"
|
||||
const ExtensionPatterns = ["script-src blocked eval", "extension", "chrome-extension://", "blocked by CSP", "Zotero Connector"]
|
||||
const IgnoredErrors = [
|
||||
"ResizeObserver loop completed with undelivered notifications.",
|
||||
"Request timeout appSettingsDistributor.getValue"
|
||||
"Request timeout appSettingsDistributor.getValue",
|
||||
"Script error."
|
||||
]
|
||||
const BrowserInternalPatterns = ["feature named"]
|
||||
const BrowserInternalPatterns = ["feature named", "window.__firefox__"]
|
||||
const MonacoPatterns = ["monaco-editor", "vs/base/common/errors"]
|
||||
const ThirdPartyAppPatterns = ["_callback_receiveMIDIMessage", "_callback_addSource"]
|
||||
const UrlPattern = /https?:\/\/[^\s)]+/g
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createElement, JsxValue} from "@opendaw/lib-jsx"
|
||||
import {
|
||||
ApparatDeviceBox,
|
||||
ArpeggioDeviceBox,
|
||||
AudioBusBox,
|
||||
BoxVisitor,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
} from "@opendaw/studio-boxes"
|
||||
import {ArpeggioDeviceEditor} from "@/ui/devices/midi-effects/ArpeggioDeviceEditor.tsx"
|
||||
import {
|
||||
ApparatDeviceBoxAdapter,
|
||||
ArpeggioDeviceBoxAdapter,
|
||||
AudioBusBoxAdapter,
|
||||
CompressorDeviceBoxAdapter,
|
||||
@@ -76,6 +78,7 @@ import {PitchDeviceEditor} from "./midi-effects/PitchDeviceEditor"
|
||||
import {TapeDeviceEditor} from "@/ui/devices/instruments/TapeDeviceEditor.tsx"
|
||||
import {VaporisateurDeviceEditor} from "@/ui/devices/instruments/VaporisateurDeviceEditor.tsx"
|
||||
import {AudioBusEditor} from "@/ui/devices/AudioBusEditor.tsx"
|
||||
import {ApparatDeviceEditor} from "./instruments/ApparatDeviceEditor"
|
||||
import {NanoDeviceEditor} from "./instruments/NanoDeviceEditor"
|
||||
import {PlayfieldDeviceEditor} from "./instruments/PlayfieldDeviceEditor"
|
||||
import {StereoToolDeviceEditor} from "./audio-effects/StereoToolDeviceEditor"
|
||||
@@ -145,6 +148,12 @@ export namespace DeviceEditorFactory {
|
||||
box: Box,
|
||||
deviceHost: DeviceHost) =>
|
||||
asDefined(box.accept<BoxVisitor<JsxValue>>({
|
||||
visitApparatDeviceBox: (box: ApparatDeviceBox): JsxValue => (
|
||||
<ApparatDeviceEditor lifecycle={lifecycle}
|
||||
service={service}
|
||||
adapter={service.project.boxAdapters.adapterFor(box, ApparatDeviceBoxAdapter)}
|
||||
deviceHost={deviceHost}/>
|
||||
),
|
||||
visitTapeDeviceBox: (box: TapeDeviceBox): JsxValue => (
|
||||
<TapeDeviceEditor lifecycle={lifecycle}
|
||||
service={service}
|
||||
|
||||
@@ -26,4 +26,39 @@ component
|
||||
> .error
|
||||
font-size: 16px
|
||||
color: orange
|
||||
cursor: default
|
||||
cursor: default
|
||||
|
||||
.sample-drop
|
||||
margin-top: 1px
|
||||
scale: 0.8
|
||||
border-radius: 50%
|
||||
color: var(--color-shadow)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
outline: 1px dashed rgba(white, 0.1)
|
||||
aspect-ratio: 1
|
||||
cursor: pointer
|
||||
pointer-events: all
|
||||
|
||||
> svg
|
||||
width: 1.5em
|
||||
height: 1.5em
|
||||
pointer-events: none
|
||||
|
||||
&[sample]
|
||||
color: var(--color-blue)
|
||||
outline-color: var(--color-blue)
|
||||
|
||||
&.accept
|
||||
color: var(--color-black)
|
||||
background-color: var(--color-blue)
|
||||
|
||||
.sample-name
|
||||
width: 100%
|
||||
font-size: 0.5em
|
||||
text-align: center
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
overflow: hidden
|
||||
color: var(--color-gray)
|
||||
@@ -1,5 +1,5 @@
|
||||
import css from "./ScriptDeviceEditor.sass?inline"
|
||||
import {DeviceHost, EffectDeviceBoxAdapter, parseParams, ParameterAdapterSet} from "@opendaw/studio-adapters"
|
||||
import {DeviceBoxAdapter, DeviceHost, parseParams, ParameterAdapterSet} from "@opendaw/studio-adapters"
|
||||
import {asInstanceOf, Editing, EmptyExec, isDefined, Lifecycle, MutableObservableValue, Nullable, ObservableValue, Observer, Subscription, Terminable, Terminator, UUID} from "@opendaw/lib-std"
|
||||
import {AutomatableParameterFieldAdapter} from "@opendaw/studio-adapters"
|
||||
import {Promises} from "@opendaw/lib-runtime"
|
||||
@@ -7,10 +7,9 @@ import {createElement} from "@opendaw/lib-jsx"
|
||||
import {Field, StringField} from "@opendaw/lib-box"
|
||||
import {Colors, IconSymbol, Pointers} from "@opendaw/studio-enums"
|
||||
import {DeviceEditor} from "@/ui/devices/DeviceEditor.tsx"
|
||||
import {MenuItems} from "@/ui/devices/menu-items.ts"
|
||||
import {Html} from "@opendaw/lib-dom"
|
||||
import {StudioService} from "@/service/StudioService"
|
||||
import {WerkstattParameterBox} from "@opendaw/studio-boxes"
|
||||
import {AudioFileBox, WerkstattParameterBox, WerkstattSampleBox} from "@opendaw/studio-boxes"
|
||||
import {ControlBuilder} from "@/ui/devices/ControlBuilder"
|
||||
import {Button} from "@/ui/components/Button"
|
||||
import {Checkbox} from "@/ui/components/Checkbox"
|
||||
@@ -20,6 +19,8 @@ import {Column} from "@/ui/devices/Column"
|
||||
import {LKR} from "@/ui/devices/constants"
|
||||
import {CodeEditorExample} from "@/ui/werkstatt-editor/CodeEditorState"
|
||||
import {createScriptCompiler, ScriptCompilerConfig} from "@/ui/werkstatt-editor/ScriptCompiler"
|
||||
import {SampleSelector, SampleSelectStrategy} from "@/ui/devices/SampleSelector"
|
||||
import {MenuItem} from "@opendaw/studio-core"
|
||||
|
||||
const className = Html.adoptStyleSheet(css, "ScriptDeviceEditor")
|
||||
|
||||
@@ -38,9 +39,10 @@ const boolModel = (editing: Editing, parameter: AutomatableParameterFieldAdapter
|
||||
type ScriptDeviceBox = {
|
||||
readonly code: StringField
|
||||
readonly parameters: Field<Pointers.Parameter>
|
||||
readonly samples: Field<Pointers.Sample>
|
||||
}
|
||||
|
||||
type ScriptAdapter = EffectDeviceBoxAdapter & {
|
||||
type ScriptAdapter = DeviceBoxAdapter & {
|
||||
readonly box: ScriptDeviceBox
|
||||
readonly parameters: ParameterAdapterSet
|
||||
}
|
||||
@@ -50,6 +52,7 @@ export type ScriptDeviceEditorConfig = {
|
||||
readonly defaultCode: string
|
||||
readonly examples: ReadonlyArray<CodeEditorExample>
|
||||
readonly icon: IconSymbol
|
||||
readonly populateMenu: (parent: MenuItem, service: StudioService, deviceHost: DeviceHost, adapter: ScriptAdapter) => void
|
||||
readonly populateMeter: (construct: {
|
||||
lifecycle: Lifecycle,
|
||||
service: StudioService,
|
||||
@@ -183,13 +186,68 @@ export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost, con
|
||||
},
|
||||
onRemoved: ({box: {address: {uuid}}}) =>
|
||||
set.removeByKey(uuid).lifecycle.terminate()
|
||||
}),
|
||||
box.samples.pointerHub.catchupAndSubscribe({
|
||||
onAdded: ({box: sampleBox}) => {
|
||||
const sample = asInstanceOf(sampleBox, WerkstattSampleBox)
|
||||
const label = sample.label.getValue()
|
||||
const terminator = new Terminator()
|
||||
const fileNameLabel: HTMLSpanElement = (<span className="sample-name"/>)
|
||||
const dropZone: HTMLElement = (
|
||||
<div className="sample-drop">
|
||||
<Icon symbol={IconSymbol.Waveform}/>
|
||||
</div>
|
||||
)
|
||||
const sampleSelector = new SampleSelector(service, {
|
||||
hasSample: () => sample.file.nonEmpty(),
|
||||
replace: (replacement) => replacement.match({
|
||||
none: () => sample.file.targetVertex.ifSome(({box: fileBox}) => {
|
||||
const mustDelete = fileBox.pointerHub.size() === 1
|
||||
sample.file.defer()
|
||||
if (mustDelete) {fileBox.delete()}
|
||||
}),
|
||||
some: () => SampleSelectStrategy.changePointer(sample.file, replacement)
|
||||
})
|
||||
})
|
||||
terminator.ownAll(
|
||||
sample.file.catchupAndSubscribe(pointer => pointer.targetVertex.match({
|
||||
none: () => {
|
||||
dropZone.removeAttribute("sample")
|
||||
fileNameLabel.textContent = ""
|
||||
},
|
||||
some: ({box: fileBox}) => {
|
||||
const name = asInstanceOf(fileBox, AudioFileBox).fileName.getValue()
|
||||
dropZone.setAttribute("sample", name)
|
||||
fileNameLabel.textContent = name
|
||||
}
|
||||
})),
|
||||
sampleSelector.configureBrowseClick(dropZone),
|
||||
sampleSelector.configureContextMenu(dropZone),
|
||||
sampleSelector.configureDrop(dropZone)
|
||||
)
|
||||
const element: HTMLElement = (
|
||||
<Column ems={LKR} color={Colors.cream}>
|
||||
<h5>{label}</h5>
|
||||
{dropZone}
|
||||
{fileNameLabel}
|
||||
</Column>
|
||||
)
|
||||
element.style.order = String(sample.index.getValue())
|
||||
terminator.own(sample.index.catchupAndSubscribe(owner =>
|
||||
element.style.order = String(owner.getValue())))
|
||||
controls.appendChild(element)
|
||||
set.add({uuid: sampleBox.address.uuid, lifecycle: terminator})
|
||||
terminator.own({terminate: () => element.remove()})
|
||||
},
|
||||
onRemoved: ({box: {address: {uuid}}}) =>
|
||||
set.removeByKey(uuid).lifecycle.terminate()
|
||||
})
|
||||
)
|
||||
return (
|
||||
<DeviceEditor lifecycle={lifecycle}
|
||||
project={project}
|
||||
adapter={adapter}
|
||||
populateMenu={parent => MenuItems.forEffectDevice(parent, service, deviceHost, adapter)}
|
||||
populateMenu={parent => config.populateMenu(parent, service, deviceHost, adapter)}
|
||||
populateControls={() => (
|
||||
<div className={className}>
|
||||
{controls}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import defaultCode from "./werkstatt-default.txt?raw"
|
||||
import defaultCode from "./werkstatt-default.js?raw"
|
||||
import {DeviceHost, WerkstattDeviceBoxAdapter} from "@opendaw/studio-adapters"
|
||||
import {Lifecycle} from "@opendaw/lib-std"
|
||||
import {createElement} from "@opendaw/lib-jsx"
|
||||
import {DevicePeakMeter} from "@/ui/devices/panel/DevicePeakMeter.tsx"
|
||||
import {MenuItems} from "@/ui/devices/menu-items.ts"
|
||||
import {StudioService} from "@/service/StudioService"
|
||||
import {EffectFactories} from "@opendaw/studio-core"
|
||||
import {WerkstattExamples} from "./werkstatt-examples"
|
||||
@@ -13,6 +14,8 @@ const config: ScriptDeviceEditorConfig = {
|
||||
defaultCode,
|
||||
examples: WerkstattExamples,
|
||||
icon: EffectFactories.AudioNamed.Werkstatt.defaultIcon,
|
||||
populateMenu: (parent, service, deviceHost, adapter) =>
|
||||
MenuItems.forEffectDevice(parent, service, deviceHost, adapter as WerkstattDeviceBoxAdapter),
|
||||
populateMeter: ({lifecycle, service, adapter}) => (
|
||||
<DevicePeakMeter lifecycle={lifecycle}
|
||||
receiver={service.project.liveStreamReceiver}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import defaultCode from "./apparat-default.js?raw"
|
||||
import {DeviceHost, ApparatDeviceBoxAdapter} from "@opendaw/studio-adapters"
|
||||
import {Lifecycle} from "@opendaw/lib-std"
|
||||
import {createElement} from "@opendaw/lib-jsx"
|
||||
import {DevicePeakMeter} from "@/ui/devices/panel/DevicePeakMeter.tsx"
|
||||
import {MenuItems} from "@/ui/devices/menu-items.ts"
|
||||
import {IconSymbol} from "@opendaw/studio-enums"
|
||||
import {StudioService} from "@/service/StudioService"
|
||||
import {ScriptDeviceEditor, ScriptDeviceEditorConfig} from "@/ui/devices/ScriptDeviceEditor"
|
||||
import {ApparatExamples} from "./apparat-examples"
|
||||
|
||||
const config: ScriptDeviceEditorConfig = {
|
||||
compiler: {headerTag: "apparat", registryName: "apparatProcessors", functionName: "apparat"},
|
||||
defaultCode,
|
||||
examples: ApparatExamples,
|
||||
icon: IconSymbol.Code,
|
||||
populateMenu: (parent, service, deviceHost) =>
|
||||
MenuItems.forAudioUnitInput(parent, service, deviceHost),
|
||||
populateMeter: ({lifecycle, service, adapter}) => (
|
||||
<DevicePeakMeter lifecycle={lifecycle}
|
||||
receiver={service.project.liveStreamReceiver}
|
||||
address={adapter.address}/>
|
||||
)
|
||||
}
|
||||
|
||||
type Construct = {
|
||||
lifecycle: Lifecycle
|
||||
service: StudioService
|
||||
adapter: ApparatDeviceBoxAdapter
|
||||
deviceHost: DeviceHost
|
||||
}
|
||||
|
||||
export const ApparatDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => (
|
||||
<ScriptDeviceEditor lifecycle={lifecycle}
|
||||
service={service}
|
||||
adapter={adapter}
|
||||
deviceHost={deviceHost}
|
||||
config={config}/>
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
class Processor {
|
||||
voices = []
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
this.voices.push({
|
||||
id, velocity,
|
||||
freq: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12),
|
||||
phase: 0, gain: 0, gate: true, fadeRate: 0.002
|
||||
})
|
||||
}
|
||||
noteOff(id) {
|
||||
const voice = this.voices.find(v => v.id === id)
|
||||
if (voice) voice.gate = false
|
||||
}
|
||||
reset() {
|
||||
for (const voice of this.voices) {
|
||||
voice.gate = false
|
||||
voice.fadeRate = 0.05
|
||||
}
|
||||
}
|
||||
process(output, block) {
|
||||
const [outL, outR] = output
|
||||
for (let i = this.voices.length - 1; i >= 0; i--) {
|
||||
const voice = this.voices[i]
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
if (voice.gate) {
|
||||
voice.gain += (voice.velocity - voice.gain) * voice.fadeRate
|
||||
} else {
|
||||
voice.gain -= voice.gain * voice.fadeRate
|
||||
if (voice.gain < 0.001) {
|
||||
this.voices.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
const sample = Math.sin(voice.phase * Math.PI * 2) * voice.gain * 0.3
|
||||
outL[s] += sample
|
||||
outR[s] += sample
|
||||
voice.phase += voice.freq / sampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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: "TB-303 Bass Line", code: TB303}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
// Grain Synthesizer
|
||||
// @sample grain
|
||||
// @param density 8 1 32 int
|
||||
// @param size 0.2 0.01 0.5 exp s
|
||||
// @param spread 0.5 0 1 linear
|
||||
// @param pitch 0 -12 12 int
|
||||
|
||||
class Processor {
|
||||
density = 8
|
||||
size = 0.05
|
||||
spread = 0.5
|
||||
pitchShift = 0
|
||||
grains = []
|
||||
held = []
|
||||
paramChanged(name, value) {
|
||||
if (name === "density") this.density = value
|
||||
if (name === "size") this.size = value
|
||||
if (name === "spread") this.spread = value
|
||||
if (name === "pitch") this.pitchShift = value
|
||||
}
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
this.held.push({id, pitch, velocity, cent, elapsed: 0})
|
||||
}
|
||||
noteOff(id) {
|
||||
this.held = this.held.filter(note => note.id !== id)
|
||||
}
|
||||
reset() {
|
||||
this.held = []
|
||||
this.grains = []
|
||||
}
|
||||
process(output, block) {
|
||||
const data = this.samples.grain
|
||||
if (data === null || data.numberOfFrames === 0) return
|
||||
const [outL, outR] = output
|
||||
const rate = data.sampleRate / sampleRate
|
||||
const srcL = data.frames[0]
|
||||
const srcR = data.frames[data.numberOfChannels > 1 ? 1 : 0]
|
||||
const interval = sampleRate / this.density
|
||||
const grainSamples = Math.round(this.size * sampleRate)
|
||||
for (const note of this.held) {
|
||||
const playbackRate = rate * Math.pow(2, (note.pitch - 60 + note.cent / 100 + this.pitchShift) / 12)
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
if (note.elapsed % Math.round(interval) === 0) {
|
||||
const offset = Math.random() * this.spread * data.numberOfFrames
|
||||
this.grains.push({
|
||||
position: offset,
|
||||
age: 0,
|
||||
length: grainSamples,
|
||||
rate: playbackRate,
|
||||
velocity: note.velocity,
|
||||
pan: Math.random() * 2 - 1
|
||||
})
|
||||
}
|
||||
note.elapsed++
|
||||
}
|
||||
}
|
||||
for (let i = this.grains.length - 1; i >= 0; i--) {
|
||||
const grain = this.grains[i]
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
if (grain.age >= grain.length) {
|
||||
this.grains.splice(i, 1)
|
||||
break
|
||||
}
|
||||
const env = Math.sin(grain.age / grain.length * Math.PI)
|
||||
const pos = Math.floor(grain.position) % data.numberOfFrames
|
||||
const sample = srcL[pos] * env * grain.velocity * 0.2
|
||||
const sampleR = srcR[pos] * env * grain.velocity * 0.2
|
||||
const panL = 1 - Math.max(0, grain.pan)
|
||||
const panR = 1 + Math.min(0, grain.pan)
|
||||
outL[s] += sample * panL
|
||||
outR[s] += sampleR * panR
|
||||
grain.position += grain.rate
|
||||
grain.age++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Simple Sine Synth
|
||||
// @param attack 0.01 0.001 1.0 exp s
|
||||
// @param release 0.3 0.01 2.0 exp s
|
||||
|
||||
class Processor {
|
||||
voices = []
|
||||
attack = 0.01
|
||||
release = 0.3
|
||||
paramChanged(name, value) {
|
||||
if (name === "attack") this.attack = value
|
||||
if (name === "release") this.release = value
|
||||
}
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
this.voices.push({
|
||||
id, velocity,
|
||||
freq: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12),
|
||||
phase: 0, gain: 0, target: velocity, gate: true, releaseTime: this.release
|
||||
})
|
||||
}
|
||||
noteOff(id) {
|
||||
const voice = this.voices.find(v => v.id === id)
|
||||
if (voice) voice.gate = false
|
||||
}
|
||||
reset() {
|
||||
for (const voice of this.voices) {
|
||||
voice.gate = false
|
||||
voice.releaseTime = 0.005
|
||||
}
|
||||
}
|
||||
process(output, block) {
|
||||
const [outL, outR] = output
|
||||
const attackRate = 1 / (this.attack * sampleRate)
|
||||
for (let i = this.voices.length - 1; i >= 0; i--) {
|
||||
const voice = this.voices[i]
|
||||
const releaseRate = 1 / (voice.releaseTime * sampleRate)
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
if (voice.gate) {
|
||||
voice.gain += (voice.target - voice.gain) * attackRate
|
||||
} else {
|
||||
voice.gain -= voice.gain * releaseRate
|
||||
if (voice.gain < 0.001) {
|
||||
this.voices.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
const sample = Math.sin(voice.phase * Math.PI * 2) * voice.gain * 0.3
|
||||
outL[s] += sample
|
||||
outR[s] += sample
|
||||
voice.phase += voice.freq / sampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export namespace MenuItems {
|
||||
await Files.save(presetBytes as ArrayBuffer, {
|
||||
types: [FilePickerAcceptTypes.PresetFileType],
|
||||
suggestedName: `${audioUnit.label}.odp`
|
||||
})
|
||||
}).catch(console.warn)
|
||||
}),
|
||||
MenuItem.default({label: "Load Preset..."})
|
||||
.setTriggerProcedure(async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import defaultCode from "./spielwerk-default.txt?raw"
|
||||
import defaultCode from "./spielwerk-default.js?raw"
|
||||
import {DeviceHost, SpielwerkDeviceBoxAdapter} from "@opendaw/studio-adapters"
|
||||
import {Lifecycle} from "@opendaw/lib-std"
|
||||
import {createElement} from "@opendaw/lib-jsx"
|
||||
import {IconSymbol} from "@opendaw/studio-enums"
|
||||
import {MenuItems} from "@/ui/devices/menu-items.ts"
|
||||
import {StudioService} from "@/service/StudioService"
|
||||
import {SpielwerkExamples} from "./spielwerk-examples"
|
||||
import {ScriptDeviceEditor, ScriptDeviceEditorConfig} from "@/ui/devices/ScriptDeviceEditor"
|
||||
@@ -12,6 +13,8 @@ const config: ScriptDeviceEditorConfig = {
|
||||
defaultCode,
|
||||
examples: SpielwerkExamples,
|
||||
icon: IconSymbol.Code,
|
||||
populateMenu: (parent, service, deviceHost, adapter) =>
|
||||
MenuItems.forEffectDevice(parent, service, deviceHost, adapter as SpielwerkDeviceBoxAdapter),
|
||||
populateMeter: () => null
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,12 @@ export const Manuals: ReadonlyArray<Manual> = [
|
||||
type: "folder",
|
||||
label: "Instruments",
|
||||
files: [
|
||||
{
|
||||
type: "page",
|
||||
label: "Apparat",
|
||||
path: "/manuals/devices/instruments/apparat",
|
||||
icon: InstrumentFactories.Apparat.defaultIcon
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
label: "MIDIOutput",
|
||||
|
||||
@@ -28,7 +28,10 @@ export const UsersPage: PageFactory<StudioService> = ({lifecycle}: PageContext<S
|
||||
const labels = entries.map(([k]) => k)
|
||||
const maxValue = Math.max(...values)
|
||||
const minValue = Math.min(...values)
|
||||
const padding = {top: 20, right: 20, bottom: 60, left: 50}
|
||||
const years = [...new Set(labels.map(label => label.slice(0, 4)))]
|
||||
const yearColors = [Colors.blue, Colors.green, Colors.purple, Colors.orange]
|
||||
const yearColorMap = new Map(years.map((year, index) => [year, yearColors[index % yearColors.length]]))
|
||||
const padding = {top: 20, right: 20, bottom: 80, left: 50}
|
||||
const gridLines = 5
|
||||
|
||||
return (
|
||||
@@ -42,6 +45,10 @@ export const UsersPage: PageFactory<StudioService> = ({lifecycle}: PageContext<S
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
const barWidth = chartWidth / values.length
|
||||
const barPadding = barWidth * 0.2
|
||||
const dateLabelMinSpacing = 48
|
||||
const dateLabelStep = Math.max(1, Math.ceil(dateLabelMinSpacing / barWidth))
|
||||
const valueLabelMinWidth = 20
|
||||
const showValueLabels = barWidth >= valueLabelMinWidth
|
||||
replaceChildren(element, (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} width={width} height={height}>
|
||||
{Array.from({length: gridLines + 1}, (_, i) => {
|
||||
@@ -61,23 +68,59 @@ export const UsersPage: PageFactory<StudioService> = ({lifecycle}: PageContext<S
|
||||
const barHeight = ((value - minValue) / (maxValue - minValue)) * chartHeight * 0.9 + chartHeight * 0.1
|
||||
const x = padding.left + i * barWidth + barPadding / 2
|
||||
const y = padding.top + chartHeight - barHeight
|
||||
const year = labels[i].slice(0, 4)
|
||||
const dateLabel = labels[i].slice(5)
|
||||
const centerX = x + (barWidth - barPadding) / 2
|
||||
const barColor = yearColorMap.get(year) ?? Colors.blue
|
||||
return (
|
||||
<Frag>
|
||||
<rect x={x} y={y} width={barWidth - barPadding} height={barHeight}
|
||||
fill={Colors.blue} rx="4" ry="4"/>
|
||||
<text x={`${centerX}`} y={`${y - 6}`} fill={Colors.cream}
|
||||
fill={barColor} rx="4" ry="4"/>
|
||||
{showValueLabels && <text x={`${centerX}`} y={`${y - 6}`}
|
||||
fill={Colors.cream}
|
||||
font-size="11" font-family="sans-serif"
|
||||
text-anchor="middle">{value}</text>
|
||||
<text x={`${centerX}`} y={`${padding.top + chartHeight + 8}`}
|
||||
text-anchor="middle">{value}</text>}
|
||||
{i % dateLabelStep === 0 && <text x={`${centerX}`}
|
||||
y={`${padding.top + chartHeight + 8}`}
|
||||
fill={Colors.shadow} font-size="10" font-family="sans-serif"
|
||||
text-anchor="end"
|
||||
transform={`rotate(-45, ${centerX}, ${padding.top + chartHeight + 8})`}
|
||||
>{dateLabel}</text>
|
||||
>{dateLabel}</text>}
|
||||
</Frag>
|
||||
)
|
||||
})}
|
||||
{years.map(year => {
|
||||
const firstIndex = labels.findIndex(label => label.startsWith(year))
|
||||
const lastIndex = labels.length - 1 - [...labels].reverse()
|
||||
.findIndex(label => label.startsWith(year))
|
||||
const x1 = padding.left + firstIndex * barWidth
|
||||
const x2 = padding.left + (lastIndex + 1) * barWidth
|
||||
const centerX = (x1 + x2) / 2
|
||||
const labelY = padding.top + chartHeight + 50
|
||||
const color = yearColorMap.get(year) ?? Colors.blue
|
||||
return (
|
||||
<text x={`${centerX}`} y={`${labelY}`}
|
||||
fill={color} font-size="13" font-weight="bold"
|
||||
font-family="sans-serif" text-anchor="middle"
|
||||
>{year}</text>
|
||||
)
|
||||
})}
|
||||
{(() => {
|
||||
const windowSize = Math.max(1, Math.round(values.length / 15))
|
||||
const points = values.map((_, i) => {
|
||||
const start = Math.max(0, i - Math.floor(windowSize / 2))
|
||||
const end = Math.min(values.length, start + windowSize)
|
||||
const slice = values.slice(start, end)
|
||||
const avg = slice.reduce((sum, val) => sum + val, 0) / slice.length
|
||||
const barHeight = ((avg - minValue) / (maxValue - minValue)) * chartHeight * 0.9 + chartHeight * 0.1
|
||||
const x = padding.left + i * barWidth + barWidth / 2
|
||||
const y = padding.top + chartHeight - barHeight
|
||||
return `${x},${y}`
|
||||
})
|
||||
return <polyline points={points.join(" ")} fill="none"
|
||||
stroke={Colors.orange} stroke-width="2"
|
||||
stroke-linejoin="round"/>
|
||||
})()}
|
||||
</svg>
|
||||
))
|
||||
}))
|
||||
|
||||
+7
-4
@@ -1,5 +1,5 @@
|
||||
import {RegionModifier} from "@/ui/timeline/tracks/audio-unit/regions/RegionModifier.ts"
|
||||
import {Arrays, int, Option} from "@opendaw/lib-std"
|
||||
import {Arrays, int, isNotNull, Option} from "@opendaw/lib-std"
|
||||
import {ppqn, PPQN, RegionCollection} from "@opendaw/lib-dsp"
|
||||
import {
|
||||
AnyLoopableRegionBoxAdapter,
|
||||
@@ -113,8 +113,11 @@ export class RegionLoopDurationModifier implements RegionModifier {
|
||||
|
||||
approve(): void {
|
||||
const modifiedTracks: ReadonlyArray<TrackBoxAdapter> =
|
||||
Arrays.removeDuplicates(this.#adapters.map(adapter => adapter.trackBoxAdapter.unwrap()))
|
||||
const result = this.#adapters.map<BeforeState>(region =>
|
||||
Arrays.removeDuplicates(this.#adapters
|
||||
.map(adapter => adapter.trackBoxAdapter.unwrapOrNull())
|
||||
.filter(isNotNull))
|
||||
const adapters = this.#adapters.filter(({box}) => box.isAttached())
|
||||
const result = adapters.map<BeforeState>(region =>
|
||||
({
|
||||
region,
|
||||
duration: this.#selectedModifyStrategy.readDuration(region),
|
||||
@@ -134,7 +137,7 @@ export class RegionLoopDurationModifier implements RegionModifier {
|
||||
})),
|
||||
trackSnapshots
|
||||
})
|
||||
this.#project.overlapResolver.apply(modifiedTracks, this.#adapters, this, 0, (_trackResolver) => {
|
||||
this.#project.overlapResolver.apply(modifiedTracks, adapters, this, 0, (_trackResolver) => {
|
||||
result.forEach(({region, duration, loopDuration}) => {
|
||||
region.duration = duration
|
||||
region.loopDuration = loopDuration
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import css from "./CodeEditorPanel.sass?inline"
|
||||
import defaultCode from "../devices/audio-effects/werkstatt-default.txt?raw"
|
||||
import defaultCode from "../devices/audio-effects/werkstatt-default.js?raw"
|
||||
import {isDefined, Lifecycle, Nullable} from "@opendaw/lib-std"
|
||||
import {Await, createElement} from "@opendaw/lib-jsx"
|
||||
import {Events, Html, Keyboard, Shortcut} from "@opendaw/lib-dom"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import {asInstanceOf, Editing, isDefined, UUID} from "@opendaw/lib-std"
|
||||
import {BoxGraph, Field, StringField} from "@opendaw/lib-box"
|
||||
import {Pointers} from "@opendaw/studio-enums"
|
||||
import {WerkstattParameterBox} from "@opendaw/studio-boxes"
|
||||
import {parseParams} from "@opendaw/studio-adapters"
|
||||
import type {ParamDeclaration} from "@opendaw/studio-adapters"
|
||||
import {WerkstattParameterBox, WerkstattSampleBox} from "@opendaw/studio-boxes"
|
||||
import type {ParamDeclaration, SampleDeclaration} from "@opendaw/studio-adapters"
|
||||
import {parseDeclarationOrder, parseParams, parseSamples} from "@opendaw/studio-adapters"
|
||||
|
||||
export interface ScriptDeviceBox {
|
||||
readonly graph: BoxGraph
|
||||
readonly address: {readonly uuid: UUID.Bytes}
|
||||
readonly address: { readonly uuid: UUID.Bytes }
|
||||
readonly code: StringField
|
||||
readonly parameters: Field<Pointers.Parameter>
|
||||
readonly samples: Field<Pointers.Sample>
|
||||
}
|
||||
|
||||
export type ScriptCompilerConfig = {
|
||||
@@ -19,11 +20,10 @@ export type ScriptCompilerConfig = {
|
||||
}
|
||||
|
||||
const COMPILER_VERSION = 1
|
||||
const FLOAT_TOLERANCE = 1e-6
|
||||
|
||||
const createHeaderPattern = (tag: string): RegExp => new RegExp(`^// @${tag} (\\w+) (\\d+) (\\d+)\n`)
|
||||
|
||||
const parseHeader = (source: string, pattern: RegExp): {userCode: string, update: number} => {
|
||||
const parseHeader = (source: string, pattern: RegExp): { userCode: string, update: number } => {
|
||||
const match = source.match(pattern)
|
||||
return match !== null ? {
|
||||
userCode: source.slice(match[0].length),
|
||||
@@ -34,7 +34,7 @@ const parseHeader = (source: string, pattern: RegExp): {userCode: string, update
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray<ParamDeclaration>): void => {
|
||||
const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray<ParamDeclaration>, order: Map<string, number>): void => {
|
||||
const boxGraph = deviceBox.graph
|
||||
const existingPointers = deviceBox.parameters.pointerHub.filter()
|
||||
const existingByLabel = new Map<string, WerkstattParameterBox>()
|
||||
@@ -50,23 +50,21 @@ const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray
|
||||
}
|
||||
}
|
||||
seen.clear()
|
||||
for (let index = 0; index < declared.length; index++) {
|
||||
const declaration = declared[index]
|
||||
for (const declaration of declared) {
|
||||
if (seen.has(declaration.label)) {continue}
|
||||
seen.add(declaration.label)
|
||||
const unifiedIndex = order.get(declaration.label) ?? 0
|
||||
const existing = existingByLabel.get(declaration.label)
|
||||
const mappedDefault = declaration.defaultValue
|
||||
if (isDefined(existing)) {
|
||||
existing.index.setValue(index)
|
||||
if (Math.abs(existing.defaultValue.getValue() - mappedDefault) > FLOAT_TOLERANCE) {
|
||||
existing.defaultValue.setValue(mappedDefault)
|
||||
existing.value.setValue(mappedDefault)
|
||||
}
|
||||
existing.index.setValue(unifiedIndex)
|
||||
existing.defaultValue.setValue(mappedDefault)
|
||||
existing.value.setValue(mappedDefault)
|
||||
} else {
|
||||
WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => {
|
||||
paramBox.owner.refer(deviceBox.parameters)
|
||||
paramBox.label.setValue(declaration.label)
|
||||
paramBox.index.setValue(index)
|
||||
paramBox.index.setValue(unifiedIndex)
|
||||
paramBox.value.setValue(mappedDefault)
|
||||
paramBox.defaultValue.setValue(mappedDefault)
|
||||
})
|
||||
@@ -74,6 +72,44 @@ const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileSamples = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray<SampleDeclaration>, order: Map<string, number>): void => {
|
||||
const boxGraph = deviceBox.graph
|
||||
const existingPointers = deviceBox.samples.pointerHub.filter()
|
||||
const existingByLabel = new Map<string, WerkstattSampleBox>()
|
||||
for (const pointer of existingPointers) {
|
||||
const sampleBox = asInstanceOf(pointer.box, WerkstattSampleBox)
|
||||
existingByLabel.set(sampleBox.label.getValue(), sampleBox)
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const {label} of declared) {seen.add(label)}
|
||||
for (const [label, sampleBox] of existingByLabel) {
|
||||
if (!seen.has(label)) {
|
||||
sampleBox.file.targetVertex.ifSome(({box: fileBox}) => {
|
||||
const mustDelete = fileBox.pointerHub.size() === 1
|
||||
sampleBox.file.defer()
|
||||
if (mustDelete) {fileBox.delete()}
|
||||
})
|
||||
sampleBox.delete()
|
||||
}
|
||||
}
|
||||
seen.clear()
|
||||
for (const declaration of declared) {
|
||||
if (seen.has(declaration.label)) {continue}
|
||||
seen.add(declaration.label)
|
||||
const unifiedIndex = order.get(declaration.label) ?? 0
|
||||
const existing = existingByLabel.get(declaration.label)
|
||||
if (isDefined(existing)) {
|
||||
existing.index.setValue(unifiedIndex)
|
||||
} else {
|
||||
WerkstattSampleBox.create(boxGraph, UUID.generate(), sampleBox => {
|
||||
sampleBox.owner.refer(deviceBox.samples)
|
||||
sampleBox.label.setValue(declaration.label)
|
||||
sampleBox.index.setValue(unifiedIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wrapCode = (config: ScriptCompilerConfig, uuid: string, update: number, userCode: string): string => `
|
||||
if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} }
|
||||
if (typeof globalThis.openDAW.${config.registryName} === "undefined") { globalThis.openDAW.${config.registryName} = {} }
|
||||
@@ -102,6 +138,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> => {
|
||||
@@ -119,14 +156,19 @@ 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)
|
||||
const order = parseDeclarationOrder(userCode)
|
||||
const wrappedCode = wrapCode(config, uuid, newUpdate, userCode)
|
||||
validateCode(wrappedCode)
|
||||
const modifier = () => {
|
||||
deviceBox.code.setValue(createHeader(newUpdate) + userCode)
|
||||
reconcileParameters(deviceBox, params)
|
||||
reconcileParameters(deviceBox, params, order)
|
||||
reconcileSamples(deviceBox, samples, order)
|
||||
}
|
||||
if (append) {
|
||||
editing.append(modifier)
|
||||
|
||||
@@ -14,10 +14,20 @@ export namespace Files {
|
||||
message: String(error)
|
||||
}).then(() => Promise.reject(error))
|
||||
}
|
||||
const writable = await handle.createWritable()
|
||||
await writable.truncate(0)
|
||||
await writable.write(arrayBuffer)
|
||||
await writable.close()
|
||||
const writeFile = async (): Promise<void> => {
|
||||
const writable = await handle.createWritable()
|
||||
await writable.truncate(0)
|
||||
await writable.write(arrayBuffer)
|
||||
await writable.close()
|
||||
}
|
||||
const {status: writeStatus, error: writeError} = await Promises.tryCatch(writeFile())
|
||||
if (writeStatus === "rejected") {
|
||||
await RuntimeNotifier.info({
|
||||
headline: "Could not save file",
|
||||
message: String(writeError)
|
||||
})
|
||||
return Promise.reject(writeError)
|
||||
}
|
||||
return handle.name ?? "unknown"
|
||||
} else {
|
||||
return saveBlobFallback(arrayBuffer, options)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {Box, Update} from "@opendaw/lib-box"
|
||||
import {
|
||||
ArpeggioDeviceBox,
|
||||
AudioBusBox,
|
||||
ApparatDeviceBox,
|
||||
AudioClipBox,
|
||||
AudioFileBox,
|
||||
AudioPitchStretchBox,
|
||||
@@ -113,6 +114,7 @@ import {VaporisateurDeviceBoxAdapter} from "./devices/instruments/VaporisateurDe
|
||||
import {ArpeggioDeviceBoxAdapter} from "./devices/midi-effects/ArpeggioDeviceBoxAdapter"
|
||||
import {PitchDeviceBoxAdapter} from "./devices/midi-effects/PitchDeviceBoxAdapter"
|
||||
import {SpielwerkDeviceBoxAdapter} from "./devices/midi-effects/SpielwerkDeviceBoxAdapter"
|
||||
import {ApparatDeviceBoxAdapter} from "./devices/instruments/ApparatDeviceBoxAdapter"
|
||||
import {NanoDeviceBoxAdapter} from "./devices/instruments/NanoDeviceBoxAdapter"
|
||||
import {PlayfieldDeviceBoxAdapter} from "./devices/instruments/PlayfieldDeviceBoxAdapter"
|
||||
import {StereoToolDeviceBoxAdapter} from "./devices/audio-effects/StereoToolDeviceBoxAdapter"
|
||||
@@ -205,6 +207,7 @@ export class BoxAdapters implements Terminable {
|
||||
visitAudioPitchStretchBox: (box: AudioPitchStretchBox) => new AudioPitchStretchBoxAdapter(this.#context, box),
|
||||
visitTransientMarkerBox: (box: TransientMarkerBox) => new TransientMarkerBoxAdapter(box),
|
||||
visitWarpMarkerBox: (box: WarpMarkerBox) => new WarpMarkerBoxAdapter(this.#context, box),
|
||||
visitApparatDeviceBox: (box: ApparatDeviceBox) => new ApparatDeviceBoxAdapter(this.#context, box),
|
||||
visitAudioRegionBox: (box: AudioRegionBox) => new AudioRegionBoxAdapter(this.#context, box),
|
||||
visitAudioUnitBox: (box: AudioUnitBox) => new AudioUnitBoxAdapter(this.#context, box),
|
||||
visitAuxSendBox: (box: AuxSendBox): BoxAdapter => new AuxSendBoxAdapter(this.#context, box),
|
||||
|
||||
@@ -27,6 +27,7 @@ export namespace DeviceManualUrls {
|
||||
export const Werkstatt = "manuals/devices/audio/werkstatt"
|
||||
|
||||
// Instruments
|
||||
export const Apparat = "manuals/devices/instruments/apparat"
|
||||
export const Tape = "manuals/devices/instruments/tape"
|
||||
export const Nano = "manuals/devices/instruments/nano"
|
||||
export const Playfield = "manuals/devices/instruments/playfield"
|
||||
|
||||
@@ -41,8 +41,22 @@ const parseSingleParam = (line: string): ParamDeclaration => {
|
||||
if (tokens.length === 3 && tokens[2] === "bool") {
|
||||
return {label, defaultValue: defaultValue >= 0.5 ? 1 : 0, min: 0, max: 1, mapping: "bool", unit: ""}
|
||||
}
|
||||
if (tokens.length === 4) {
|
||||
const min = parseFloat(tokens[2])
|
||||
const max = parseFloat(tokens[3])
|
||||
if (isNaN(min) || isNaN(max)) {
|
||||
throw new Error(`Malformed @param: '${line}' — min/max must be numbers`)
|
||||
}
|
||||
if (max - min < -FLOAT_TOLERANCE) {
|
||||
throw new Error(`Malformed @param: '${line}' — min (${min}) must be less than max (${max})`)
|
||||
}
|
||||
if (defaultValue < min - FLOAT_TOLERANCE || defaultValue > max + FLOAT_TOLERANCE) {
|
||||
throw new Error(`Malformed @param: '${line}' — default (${defaultValue}) must be within [${min}, ${max}]`)
|
||||
}
|
||||
return {label, defaultValue, min, max, mapping: "linear", unit: ""}
|
||||
}
|
||||
if (tokens.length < 5) {
|
||||
throw new Error(`Malformed @param: '${line}' — expected: // @param <name> <default> <min> <max> <type> [unit]`)
|
||||
throw new Error(`Malformed @param: '${line}' — expected: // @param <name> <default> <min> <max> [type] [unit]`)
|
||||
}
|
||||
const min = parseFloat(tokens[2])
|
||||
const max = parseFloat(tokens[3])
|
||||
@@ -73,6 +87,45 @@ export const parseParams = (code: string): ReadonlyArray<ParamDeclaration> => {
|
||||
return params
|
||||
}
|
||||
|
||||
export interface SampleDeclaration {
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const SAMPLE_LINE = /^\/\/ @sample .+$/gm
|
||||
|
||||
export const parseSamples = (code: string): ReadonlyArray<SampleDeclaration> => {
|
||||
const samples: Array<SampleDeclaration> = []
|
||||
let match: Nullable<RegExpExecArray>
|
||||
SAMPLE_LINE.lastIndex = 0
|
||||
while ((match = SAMPLE_LINE.exec(code)) !== null) {
|
||||
const tokens = match[0].replace(/^\/\/ @sample\s+/, "").split(/\s+/)
|
||||
if (tokens.length === 0 || tokens[0].length === 0) {
|
||||
throw new Error(`Malformed @sample: '${match[0]}' — expected: // @sample <name>`)
|
||||
}
|
||||
if (tokens.length > 1) {
|
||||
throw new Error(`Malformed @sample: '${match[0]}' — expected: // @sample <name>`)
|
||||
}
|
||||
samples.push({label: tokens[0]})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
const DECLARATION_LINE = /^\/\/ @(?:param|sample) \S+/gm
|
||||
|
||||
export const parseDeclarationOrder = (code: string): Map<string, number> => {
|
||||
const order = new Map<string, number>()
|
||||
let match: Nullable<RegExpExecArray>
|
||||
DECLARATION_LINE.lastIndex = 0
|
||||
let index = 0
|
||||
while ((match = DECLARATION_LINE.exec(code)) !== null) {
|
||||
const label = match[0].replace(/^\/\/ @(?:param|sample)\s+/, "").split(/\s+/)[0]
|
||||
if (!order.has(label)) {
|
||||
order.set(label, index++)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
export const resolveValueMapping = (declaration: ParamDeclaration): ValueMapping<number> => {
|
||||
switch (declaration.mapping) {
|
||||
case "unipolar": return ValueMapping.unipolar()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {asInstanceOf, isDefined, Option, StringMapping, Terminator, UUID, ValueMapping} from "@opendaw/lib-std"
|
||||
import {Address, BooleanField, StringField} from "@opendaw/lib-box"
|
||||
import {ApparatDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes"
|
||||
import {DeviceHost, Devices, InstrumentDeviceBoxAdapter} from "../../DeviceAdapter"
|
||||
import {LabeledAudioOutput} from "../../LabeledAudioOutputsOwner"
|
||||
import {BoxAdaptersContext} from "../../BoxAdaptersContext"
|
||||
import {DeviceManualUrls} from "../../DeviceManualUrls"
|
||||
import {ParameterAdapterSet} from "../../ParameterAdapterSet"
|
||||
import {TrackType} from "../../timeline/TrackType"
|
||||
import {AudioUnitBoxAdapter} from "../../audio-unit/AudioUnitBoxAdapter"
|
||||
import {parseParams, resolveParamMappings} from "../../ScriptParamDeclaration"
|
||||
|
||||
export class ApparatDeviceBoxAdapter implements InstrumentDeviceBoxAdapter {
|
||||
readonly #terminator = new Terminator()
|
||||
|
||||
readonly type = "instrument"
|
||||
readonly accepts = "midi"
|
||||
readonly manualUrl = DeviceManualUrls.Apparat
|
||||
|
||||
readonly #context: BoxAdaptersContext
|
||||
readonly #box: ApparatDeviceBox
|
||||
readonly #parametric: ParameterAdapterSet
|
||||
|
||||
constructor(context: BoxAdaptersContext, box: ApparatDeviceBox) {
|
||||
this.#context = context
|
||||
this.#box = box
|
||||
this.#parametric = this.#terminator.own(new ParameterAdapterSet(this.#context))
|
||||
this.#terminator.own(
|
||||
box.parameters.pointerHub.catchupAndSubscribe({
|
||||
onAdded: (({box: parameterBox}) => {
|
||||
const paramBox = asInstanceOf(parameterBox, WerkstattParameterBox)
|
||||
const label = paramBox.label.getValue()
|
||||
const declarations = parseParams(box.code.getValue())
|
||||
const declaration = declarations.find(decl => decl.label === label)
|
||||
const {valueMapping, stringMapping} = isDefined(declaration)
|
||||
? resolveParamMappings(declaration)
|
||||
: {
|
||||
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}
|
||||
get uuid(): UUID.Bytes {return this.#box.address.uuid}
|
||||
get address(): Address {return this.#box.address}
|
||||
get labelField(): StringField {return this.#box.label}
|
||||
get iconField(): StringField {return this.#box.icon}
|
||||
get defaultTrackType(): TrackType {return TrackType.Notes}
|
||||
get enabledField(): BooleanField {return this.#box.enabled}
|
||||
get minimizedField(): BooleanField {return this.#box.minimized}
|
||||
get acceptsMidiEvents(): boolean {return true}
|
||||
get parameters(): ParameterAdapterSet {return this.#parametric}
|
||||
|
||||
deviceHost(): DeviceHost {
|
||||
return this.#context.boxAdapters
|
||||
.adapterFor(this.#box.host.targetVertex.unwrap("no device-host").box, Devices.isHost)
|
||||
}
|
||||
|
||||
audioUnitBoxAdapter(): AudioUnitBoxAdapter {return this.deviceHost().audioUnitBoxAdapter()}
|
||||
|
||||
* labeledAudioOutputs(): Iterable<LabeledAudioOutput> {
|
||||
yield {address: this.address, label: this.labelField.getValue(), children: () => Option.None}
|
||||
}
|
||||
|
||||
terminate(): void {this.#terminator.terminate()}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ApparatDeviceBox,
|
||||
MIDIOutputDeviceBox,
|
||||
NanoDeviceBox,
|
||||
PlayfieldDeviceBox,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "@opendaw/studio-boxes"
|
||||
|
||||
export type InstrumentBox =
|
||||
| ApparatDeviceBox
|
||||
| TapeDeviceBox
|
||||
| VaporisateurDeviceBox
|
||||
| NanoDeviceBox
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ApparatDeviceBox,
|
||||
AudioFileBox,
|
||||
BoxIO,
|
||||
MIDIOutputDeviceBox,
|
||||
@@ -186,7 +187,25 @@ export namespace InstrumentFactories {
|
||||
}
|
||||
}
|
||||
|
||||
export const Named = {MIDIOutput, Nano, Playfield, Soundfont, Tape, Vaporisateur}
|
||||
export const Apparat: InstrumentFactory<void, ApparatDeviceBox> = {
|
||||
defaultName: "Apparat",
|
||||
defaultIcon: IconSymbol.Code,
|
||||
description: "User-scripted instrument",
|
||||
manualPage: DeviceManualUrls.Apparat,
|
||||
trackType: TrackType.Notes,
|
||||
create: (boxGraph: BoxGraph,
|
||||
host: Field<Pointers.InstrumentHost | Pointers.AudioOutput>,
|
||||
name: string,
|
||||
icon: IconSymbol,
|
||||
_attachment?: void): ApparatDeviceBox =>
|
||||
ApparatDeviceBox.create(boxGraph, UUID.generate(), box => {
|
||||
box.label.setValue(name)
|
||||
box.icon.setValue(IconSymbol.toName(icon))
|
||||
box.host.refer(host)
|
||||
})
|
||||
}
|
||||
|
||||
export const Named = {Apparat, MIDIOutput, Nano, Playfield, Soundfont, Tape, Vaporisateur}
|
||||
export type Keys = keyof typeof Named
|
||||
|
||||
const useAudioFile = (boxGraph: BoxGraph, fileUUID: UUID.Bytes, name: string, duration: number) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ApparatDeviceBox,
|
||||
ArpeggioDeviceBox,
|
||||
AudioBusBox,
|
||||
BoxVisitor,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from "@opendaw/studio-boxes"
|
||||
import {DelayDeviceProcessor} from "./devices/audio-effects/DelayDeviceProcessor"
|
||||
import {
|
||||
ApparatDeviceBoxAdapter,
|
||||
ArpeggioDeviceBoxAdapter,
|
||||
AudioBusBoxAdapter,
|
||||
MaximizerDeviceBoxAdapter,
|
||||
@@ -95,11 +97,14 @@ import {NeuralAmpDeviceProcessor} from "./devices/audio-effects/NeuralAmpDeviceP
|
||||
import {WaveshaperDeviceProcessor} from "./devices/audio-effects/WaveshaperDeviceProcessor"
|
||||
import {WerkstattDeviceProcessor} from "./devices/audio-effects/WerkstattDeviceProcessor"
|
||||
import {SpielwerkDeviceProcessor} from "./devices/midi-effects/SpielwerkDeviceProcessor"
|
||||
import {ApparatDeviceProcessor} from "./devices/instruments/ApparatDeviceProcessor"
|
||||
|
||||
export namespace InstrumentDeviceProcessorFactory {
|
||||
export const create = (context: EngineContext,
|
||||
box: Box): Maybe<InstrumentDeviceProcessor | AudioBusProcessor> =>
|
||||
box.accept<BoxVisitor<InstrumentDeviceProcessor | AudioBusProcessor>>({
|
||||
visitApparatDeviceBox: (box: ApparatDeviceBox) =>
|
||||
new ApparatDeviceProcessor(context, context.boxAdapters.adapterFor(box, ApparatDeviceBoxAdapter)),
|
||||
visitAudioBusBox: (box: AudioBusBox) =>
|
||||
new AudioBusProcessor(context, context.boxAdapters.adapterFor(box, AudioBusBoxAdapter)),
|
||||
visitVaporisateurDeviceBox: (box: VaporisateurDeviceBox) =>
|
||||
|
||||
+3
-3
@@ -96,10 +96,10 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff
|
||||
this.readAllParameters()
|
||||
}
|
||||
|
||||
#tryLoad(update: int): void {
|
||||
#tryLoad(expectedUpdate: int): void {
|
||||
const registry = (globalThis as any).openDAW?.werkstattProcessors?.[this.#uuid]
|
||||
if (isDefined(registry) && registry.update === update) {
|
||||
this.#swapProcessor(registry.create, update)
|
||||
if (isDefined(registry) && registry.update === expectedUpdate) {
|
||||
this.#swapProcessor(registry.create, expectedUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import {Arrays, asInstanceOf, int, isDefined, isNotNull, Nullable, Option, Terminable, UUID} from "@opendaw/lib-std"
|
||||
import {ApparatDeviceBoxAdapter, SampleLoader} from "@opendaw/studio-adapters"
|
||||
import {WerkstattParameterBox, WerkstattSampleBox} from "@opendaw/studio-boxes"
|
||||
import {AudioBuffer, AudioData, Event, NoteEvent, SimpleLimiter} from "@opendaw/lib-dsp"
|
||||
import {EngineContext} from "../../EngineContext"
|
||||
import {AudioProcessor} from "../../AudioProcessor"
|
||||
import {Block, Processor} from "../../processing"
|
||||
import {PeakBroadcaster} from "../../PeakBroadcaster"
|
||||
import {AutomatableParameter} from "../../AutomatableParameter"
|
||||
import {NoteEventSource, NoteEventTarget, NoteLifecycleEvent} from "../../NoteEventSource"
|
||||
import {NoteEventInstrument} from "../../NoteEventInstrument"
|
||||
import {DeviceProcessor} from "../../DeviceProcessor"
|
||||
import {InstrumentDeviceProcessor} from "../../InstrumentDeviceProcessor"
|
||||
|
||||
const HEADER_PATTERN = /^\/\/ @apparat (\w+) (\d+) (\d+)\n/
|
||||
const MAX_AMPLITUDE = 1000.0
|
||||
|
||||
const parseUpdate = (code: string): int => {
|
||||
const match = code.match(HEADER_PATTERN)
|
||||
return match !== null ? parseInt(match[3]) : -1
|
||||
}
|
||||
|
||||
const validateOutput = (channels: ReadonlyArray<Float32Array>, s0: int, s1: int): Nullable<string> => {
|
||||
for (let ch = 0; ch < channels.length; ch++) {
|
||||
const channel = channels[ch]
|
||||
for (let i = s0; i < s1; i++) {
|
||||
const sample = channel[i]
|
||||
if (sample !== sample) {return `NaN detected in output channel ${ch} at sample ${i}`}
|
||||
if (sample > MAX_AMPLITUDE || sample < -MAX_AMPLITUDE) {
|
||||
return `Signal overflow in channel ${ch} at sample ${i} (amplitude: ${sample.toFixed(1)})`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface UserProcessor {
|
||||
process(output: ReadonlyArray<Float32Array>, block: Block): void
|
||||
noteOn?(pitch: number, velocity: number, cent: number, id: int): void
|
||||
noteOff?(id: int): void
|
||||
reset?(): void
|
||||
paramChanged?(label: string, value: number): void
|
||||
samples: Record<string, Nullable<AudioData>>
|
||||
}
|
||||
|
||||
export class ApparatDeviceProcessor extends AudioProcessor
|
||||
implements InstrumentDeviceProcessor, NoteEventTarget {
|
||||
readonly #adapter: ApparatDeviceBoxAdapter
|
||||
readonly #engineToClient: EngineContext["engineToClient"]
|
||||
readonly #noteEventInstrument: NoteEventInstrument
|
||||
readonly #audioOutput: AudioBuffer
|
||||
readonly #limiter: SimpleLimiter
|
||||
readonly #peakBroadcaster: PeakBroadcaster
|
||||
readonly #uuid: string
|
||||
readonly #boundParameters: Array<AutomatableParameter<number>>
|
||||
readonly #sampleSlots: Map<string, {loader: Option<SampleLoader>, lifecycle: Terminable}> = new Map()
|
||||
|
||||
#userProcessor: Option<UserProcessor> = Option.None
|
||||
#currentUpdate: int = -1
|
||||
#silenced: boolean = false
|
||||
#enabled: boolean = true
|
||||
|
||||
constructor(context: EngineContext, adapter: ApparatDeviceBoxAdapter) {
|
||||
super(context)
|
||||
this.#adapter = adapter
|
||||
this.#engineToClient = context.engineToClient
|
||||
this.#noteEventInstrument = new NoteEventInstrument(this, context.broadcaster, adapter.audioUnitBoxAdapter().address)
|
||||
this.#audioOutput = new AudioBuffer()
|
||||
this.#limiter = new SimpleLimiter(sampleRate)
|
||||
this.#peakBroadcaster = this.own(new PeakBroadcaster(context.broadcaster, adapter.address))
|
||||
this.#uuid = UUID.toString(adapter.uuid)
|
||||
this.#boundParameters = []
|
||||
const {parameters, box} = adapter
|
||||
this.ownAll(
|
||||
box.enabled.catchupAndSubscribe(owner => {
|
||||
this.#enabled = owner.getValue()
|
||||
if (!this.#enabled) {this.reset()}
|
||||
}),
|
||||
box.code.catchupAndSubscribe(owner => {
|
||||
const newUpdate = parseUpdate(owner.getValue())
|
||||
if (newUpdate > 0 && newUpdate !== this.#currentUpdate) {
|
||||
this.#silenced = true
|
||||
this.#userProcessor = Option.None
|
||||
this.#audioOutput.clear()
|
||||
this.#tryLoad(newUpdate)
|
||||
}
|
||||
}),
|
||||
box.parameters.pointerHub.catchupAndSubscribe({
|
||||
onAdded: (({box}) => {
|
||||
const paramBox = asInstanceOf(box, WerkstattParameterBox)
|
||||
const bound = this.bindParameter(parameters.parameterAt(paramBox.value.address))
|
||||
this.#boundParameters.push(bound)
|
||||
this.parameterChanged(bound)
|
||||
}),
|
||||
onRemoved: (({box}) => {
|
||||
const paramBox = asInstanceOf(box, WerkstattParameterBox)
|
||||
Arrays.removeIf(this.#boundParameters, parameter =>
|
||||
parameter.address === paramBox.value.address)
|
||||
})
|
||||
}),
|
||||
box.samples.pointerHub.catchupAndSubscribe({
|
||||
onAdded: (({box: sampleBox}) => {
|
||||
const sample = asInstanceOf(sampleBox, WerkstattSampleBox)
|
||||
const label = sample.label.getValue()
|
||||
const slot: {loader: Option<SampleLoader>, lifecycle: Terminable} = {
|
||||
loader: Option.None,
|
||||
lifecycle: Terminable.Empty
|
||||
}
|
||||
this.#sampleSlots.set(label, slot)
|
||||
slot.lifecycle = sample.file.catchupAndSubscribe(pointer => {
|
||||
const target = pointer.targetVertex.unwrapOrNull()
|
||||
if (target === null) {
|
||||
slot.loader = Option.None
|
||||
} else {
|
||||
slot.loader = Option.wrap(context.sampleManager.getOrCreate(target.box.address.uuid))
|
||||
}
|
||||
})
|
||||
}),
|
||||
onRemoved: (({box: sampleBox}) => {
|
||||
const sample = asInstanceOf(sampleBox, WerkstattSampleBox)
|
||||
const label = sample.label.getValue()
|
||||
const entry = this.#sampleSlots.get(label)
|
||||
if (isDefined(entry)) {
|
||||
entry.lifecycle.terminate()
|
||||
this.#sampleSlots.delete(label)
|
||||
}
|
||||
this.#updateSampleData(label, null)
|
||||
})
|
||||
}),
|
||||
context.registerProcessor(this),
|
||||
context.audioOutputBufferRegistry.register(adapter.address, this.#audioOutput, this.outgoing)
|
||||
)
|
||||
this.readAllParameters()
|
||||
}
|
||||
|
||||
get noteEventTarget(): Option<NoteEventTarget & DeviceProcessor> {return Option.wrap(this)}
|
||||
introduceBlock(block: Block): void {this.#noteEventInstrument.introduceBlock(block)}
|
||||
setNoteEventSource(source: NoteEventSource): Terminable {return this.#noteEventInstrument.setNoteEventSource(source)}
|
||||
get incoming(): Processor {return this}
|
||||
get outgoing(): Processor {return this}
|
||||
get uuid(): UUID.Bytes {return this.#adapter.uuid}
|
||||
get audioOutput(): AudioBuffer {return this.#audioOutput}
|
||||
|
||||
reset(): void {
|
||||
this.#noteEventInstrument.clear()
|
||||
this.#peakBroadcaster.clear()
|
||||
this.#audioOutput.clear()
|
||||
this.eventInput.clear()
|
||||
this.#userProcessor.ifSome(proc => {
|
||||
if (isDefined(proc.reset)) {proc.reset()}
|
||||
})
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
if (this.#silenced || this.#userProcessor.isEmpty()) {return}
|
||||
const proc = this.#userProcessor.unwrap()
|
||||
if (NoteLifecycleEvent.isStart(event)) {
|
||||
if (isDefined(proc.noteOn)) {
|
||||
proc.noteOn(event.pitch, event.velocity, event.cent, event.id)
|
||||
}
|
||||
} else if (NoteLifecycleEvent.isStop(event)) {
|
||||
if (isDefined(proc.noteOff)) {
|
||||
proc.noteOff(event.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if (this.#silenced) {return}
|
||||
}
|
||||
if (this.#userProcessor.isEmpty()) {return}
|
||||
const proc = this.#userProcessor.unwrap()
|
||||
this.#pollSamples(proc)
|
||||
const outL = this.#audioOutput.getChannel(0)
|
||||
const outR = this.#audioOutput.getChannel(1)
|
||||
outL.fill(0.0, block.s0, block.s1)
|
||||
outR.fill(0.0, block.s0, block.s1)
|
||||
try {
|
||||
proc.process([outL, outR], block)
|
||||
} catch (error) {
|
||||
this.#silence(`Runtime error: ${error}`)
|
||||
return
|
||||
}
|
||||
const error = validateOutput([outL, outR], block.s0, block.s1)
|
||||
if (isNotNull(error)) {
|
||||
this.#silence(error)
|
||||
this.#audioOutput.clear(block.s0, block.s1)
|
||||
return
|
||||
}
|
||||
this.#limiter.replace(this.#audioOutput, block.s0, block.s1)
|
||||
}
|
||||
|
||||
finishProcess(): void {
|
||||
this.#peakBroadcaster.process(
|
||||
this.#audioOutput.getChannel(0),
|
||||
this.#audioOutput.getChannel(1), 0, 128)
|
||||
}
|
||||
|
||||
parameterChanged(parameter: AutomatableParameter): void {
|
||||
this.#userProcessor.ifSome(proc => {
|
||||
if (isDefined(proc.paramChanged)) {
|
||||
const paramBox = asInstanceOf(parameter.adapter.field.box, WerkstattParameterBox)
|
||||
proc.paramChanged(paramBox.label.getValue(), parameter.getValue())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
processEvents(_block: Block): void {}
|
||||
|
||||
#tryLoad(expectedUpdate: int): void {
|
||||
const registry = (globalThis as any).openDAW?.apparatProcessors?.[this.#uuid]
|
||||
if (isDefined(registry) && registry.update === expectedUpdate) {
|
||||
this.#swapProcessor(registry.create, expectedUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
#swapProcessor(ProcessorClass: any, update: int): void {
|
||||
try {
|
||||
const proc = new ProcessorClass() as UserProcessor
|
||||
proc.samples = {}
|
||||
for (const [label] of this.#sampleSlots) {
|
||||
proc.samples[label] = null
|
||||
}
|
||||
this.#userProcessor = Option.wrap(proc)
|
||||
this.#currentUpdate = update
|
||||
this.#silenced = false
|
||||
this.#pushAllParameters()
|
||||
this.#pollSamples(proc)
|
||||
} catch (error) {
|
||||
this.#silence(`Failed to instantiate Processor: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
#pushAllParameters(): void {
|
||||
this.#userProcessor.ifSome(proc => {
|
||||
if (isDefined(proc.paramChanged)) {
|
||||
for (const bound of this.#boundParameters) {
|
||||
const paramBox = asInstanceOf(bound.adapter.field.box, WerkstattParameterBox)
|
||||
proc.paramChanged(paramBox.label.getValue(), bound.getValue())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#pollSamples(proc: UserProcessor): void {
|
||||
for (const [label, slot] of this.#sampleSlots) {
|
||||
slot.loader.ifSome(loader => {
|
||||
const data = loader.data.unwrapOrNull()
|
||||
if (proc.samples[label] !== data) {
|
||||
proc.samples[label] = data
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#updateSampleData(label: string, data: Nullable<AudioData>): void {
|
||||
this.#userProcessor.ifSome(proc => {
|
||||
proc.samples[label] = data
|
||||
})
|
||||
}
|
||||
|
||||
#reportError(message: string): void {
|
||||
this.#engineToClient.deviceMessage(this.#uuid, message)
|
||||
}
|
||||
|
||||
#silence(message: string): void {
|
||||
this.#silenced = true
|
||||
this.#audioOutput.clear()
|
||||
this.#reportError(message)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -290,10 +290,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(expectedUpdate: int): void {
|
||||
const registry = (globalThis as any).openDAW?.spielwerkProcessors?.[this.#uuid]
|
||||
if (isDefined(registry) && registry.update === update) {
|
||||
this.#swapProcessor(registry.create, update)
|
||||
if (isDefined(registry) && registry.update === expectedUpdate) {
|
||||
this.#swapProcessor(registry.create, expectedUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
UUID
|
||||
} from "@opendaw/lib-std"
|
||||
import {AudioData, ppqn} from "@opendaw/lib-dsp"
|
||||
import {SpielwerkDeviceBox, WerkstattDeviceBox} from "@opendaw/studio-boxes"
|
||||
import {ApparatDeviceBox, SpielwerkDeviceBox, WerkstattDeviceBox} from "@opendaw/studio-boxes"
|
||||
import {Communicator, Messenger, Wait} from "@opendaw/lib-runtime"
|
||||
import {AnimationFrame} from "@opendaw/lib-dom"
|
||||
import {
|
||||
@@ -191,6 +191,11 @@ export class OfflineEngineRenderer {
|
||||
/^\/\/ @spielwerk (\w+) (\d+) (\d+)\n/,
|
||||
"spielwerkProcessors", "spielwerk",
|
||||
UUID.toString(box.address.uuid))
|
||||
} else if (box instanceof ApparatDeviceBox) {
|
||||
await loadScriptDevice(box.code.getValue(),
|
||||
/^\/\/ @apparat (\w+) (\d+) (\d+)\n/,
|
||||
"apparatProcessors", "apparat",
|
||||
UUID.toString(box.address.uuid))
|
||||
}
|
||||
}
|
||||
engineCommands.setupMIDI(port, sab)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,17 @@ import {
|
||||
Editing,
|
||||
int,
|
||||
isDefined,
|
||||
isInstanceOf,
|
||||
isNotNull,
|
||||
Option,
|
||||
Optional,
|
||||
Provider,
|
||||
RuntimeNotifier
|
||||
RuntimeNotifier,
|
||||
UUID
|
||||
} from "@opendaw/lib-std"
|
||||
import {Box, BoxGraph} from "@opendaw/lib-box"
|
||||
import {Pointers} from "@opendaw/studio-enums"
|
||||
import {TrackBox} from "@opendaw/studio-boxes"
|
||||
import {
|
||||
AudioEffectDeviceAdapter,
|
||||
BoxAdapters,
|
||||
@@ -113,17 +116,39 @@ export namespace DevicesClipboard {
|
||||
...audioEffects.map(adapter => adapter.box)
|
||||
]
|
||||
const dependencies = deviceBoxes.flatMap(box => {
|
||||
const preserved = Array.from(boxGraph.dependenciesOf(box, {
|
||||
alwaysFollowMandatory: true,
|
||||
excludeBox: (dep: Box) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
|
||||
}).boxes).filter(dep => dep.resource === "preserved")
|
||||
const ownedChildren = box.incomingEdges()
|
||||
.filter(pointer => pointer.mandatory && !pointer.box.ephemeral
|
||||
&& !DeviceBoxUtils.isDeviceBox(pointer.box) && !isDefined(pointer.box.resource))
|
||||
&& !isDefined(pointer.box.resource))
|
||||
.map(pointer => pointer.box)
|
||||
return [...preserved, ...ownedChildren]
|
||||
const preserved = [box, ...ownedChildren].flatMap(root =>
|
||||
Array.from(boxGraph.dependenciesOf(root, {
|
||||
alwaysFollowMandatory: true,
|
||||
excludeBox: (dep: Box) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
|
||||
}).boxes).filter(dep => dep.resource === "preserved"))
|
||||
return [...ownedChildren, ...preserved]
|
||||
})
|
||||
const allBoxes = [...deviceBoxes, ...dependencies]
|
||||
const trackContent: Box[] = []
|
||||
if (isNotNull(instrument)) {
|
||||
getHost().ifSome(host => {
|
||||
const tracksField = host.audioUnitBoxAdapter().tracksField
|
||||
for (const pointer of tracksField.pointerHub.filter(Pointers.TrackCollection)) {
|
||||
if (!isInstanceOf(pointer.box, TrackBox)) {continue}
|
||||
const track = pointer.box
|
||||
trackContent.push(track)
|
||||
for (const regionPointer of track.regions.pointerHub.incoming()) {
|
||||
trackContent.push(regionPointer.box)
|
||||
const regionDeps = Array.from(boxGraph.dependenciesOf(regionPointer.box, {
|
||||
alwaysFollowMandatory: true,
|
||||
excludeBox: (dep: Box) => dep.ephemeral
|
||||
|| isInstanceOf(dep, TrackBox)
|
||||
|| DeviceBoxUtils.isDeviceBox(dep)
|
||||
}).boxes)
|
||||
trackContent.push(...regionDeps)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const allBoxes = [...deviceBoxes, ...dependencies, ...trackContent]
|
||||
const instrumentContent = isNotNull(instrument)
|
||||
? (instrument.box.tags.content as Optional<InstrumentContent>) ?? ""
|
||||
: ""
|
||||
@@ -192,6 +217,12 @@ export namespace DevicesClipboard {
|
||||
editing.modify(() => {
|
||||
selection.deselectAll()
|
||||
if (replaceInstrument && isDefined(selectedInstrument)) {
|
||||
const tracksField = host.audioUnitBoxAdapter().tracksField
|
||||
for (const pointer of tracksField.pointerHub.filter(Pointers.TrackCollection)) {
|
||||
if (isInstanceOf(pointer.box, TrackBox)) {
|
||||
pointer.box.delete()
|
||||
}
|
||||
}
|
||||
selectedInstrument.box.delete()
|
||||
}
|
||||
for (const adapter of host.midiEffects.adapters()) {
|
||||
@@ -220,9 +251,16 @@ export namespace DevicesClipboard {
|
||||
if (pointer.pointerType === Pointers.AudioEffectHost) {
|
||||
return Option.wrap(host.audioEffectsField.address)
|
||||
}
|
||||
if (pointer.pointerType === Pointers.TrackCollection && replaceInstrument) {
|
||||
return Option.wrap(host.audioUnitBoxAdapter().tracksField.address)
|
||||
}
|
||||
if (pointer.pointerType === Pointers.Automation && replaceInstrument) {
|
||||
return Option.wrap(host.audioUnitBoxAdapter().address)
|
||||
}
|
||||
return Option.None
|
||||
},
|
||||
excludeBox: box => DeviceBoxUtils.isInstrumentDeviceBox(box) && !replaceInstrument
|
||||
excludeBox: box => !replaceInstrument
|
||||
&& (DeviceBoxUtils.isInstrumentDeviceBox(box) || isInstanceOf(box, TrackBox))
|
||||
}
|
||||
)
|
||||
const deviceBoxes = boxes.filter(box => DeviceBoxUtils.isDeviceBox(box))
|
||||
@@ -236,6 +274,14 @@ export namespace DevicesClipboard {
|
||||
.sort((a, b) => a.index.getValue() - b.index.getValue())
|
||||
newMidiEffects.forEach((box, idx) => box.index.setValue(midiInsertIndex + idx))
|
||||
newAudioEffects.forEach((box, idx) => box.index.setValue(audioInsertIndex + idx))
|
||||
if (replaceInstrument) {
|
||||
const tracksField = host.audioUnitBoxAdapter().tracksField
|
||||
const allTracks = tracksField.pointerHub.filter(Pointers.TrackCollection)
|
||||
.filter(pointer => isInstanceOf(pointer.box, TrackBox))
|
||||
.map(pointer => pointer.box as TrackBox)
|
||||
.sort((trackA, trackB) => trackA.index.getValue() - trackB.index.getValue())
|
||||
allTracks.forEach((track, idx) => track.index.setValue(idx))
|
||||
}
|
||||
selection.select(...deviceBoxes.map(box => boxAdapters.adapterFor(box, Devices.isAny)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import {DeviceFactory} from "../../std/DeviceFactory"
|
||||
|
||||
export const WerkstattDeviceBox: BoxSchema<Pointers> = DeviceFactory.createAudioEffect("WerkstattDeviceBox", {
|
||||
10: {type: "string", name: "code", value: ""},
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}},
|
||||
12: {type: "field", name: "samples", pointerRules: {accepts: [Pointers.Sample], mandatory: false}}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {BoxSchema} from "@opendaw/lib-box-forge"
|
||||
import {Pointers} from "@opendaw/studio-enums"
|
||||
|
||||
export const WerkstattSampleBox: BoxSchema<Pointers> = {
|
||||
type: "box",
|
||||
class: {
|
||||
name: "WerkstattSampleBox",
|
||||
fields: {
|
||||
1: {type: "pointer", name: "owner", pointerType: Pointers.Sample, mandatory: true},
|
||||
2: {type: "string", name: "label", value: ""},
|
||||
3: {type: "int32", name: "index", constraints: "index", unit: ""},
|
||||
4: {type: "pointer", name: "file", pointerType: Pointers.AudioFile, mandatory: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ import {NeuralAmpDeviceBox} from "./audio-effects/NeuralAmpDeviceBox"
|
||||
import {WaveshaperDeviceBox} from "./audio-effects/WaveshaperDeviceBox"
|
||||
import {WerkstattDeviceBox} from "./audio-effects/WerkstattDeviceBox"
|
||||
import {WerkstattParameterBox} from "./audio-effects/WerkstattParameterBox"
|
||||
import {WerkstattSampleBox} from "./audio-effects/WerkstattSampleBox"
|
||||
import {SpielwerkDeviceBox} from "./midi-effects/SpielwerkDeviceBox"
|
||||
import {ApparatDeviceBox} from "./instruments/ApparatDeviceBox"
|
||||
|
||||
export const DeviceDefinitions = [
|
||||
UnknownAudioEffectDevice,
|
||||
@@ -63,5 +65,7 @@ export const DeviceDefinitions = [
|
||||
WaveshaperDeviceBox,
|
||||
WerkstattDeviceBox,
|
||||
WerkstattParameterBox,
|
||||
SpielwerkDeviceBox
|
||||
WerkstattSampleBox,
|
||||
SpielwerkDeviceBox,
|
||||
ApparatDeviceBox
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import {BoxSchema} from "@opendaw/lib-box-forge"
|
||||
import {Pointers} from "@opendaw/studio-enums"
|
||||
import {DeviceFactory} from "../../std/DeviceFactory"
|
||||
|
||||
export const ApparatDeviceBox: BoxSchema<Pointers> = DeviceFactory.createInstrument("ApparatDeviceBox", "notes", {
|
||||
10: {type: "string", name: "code", value: ""},
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}},
|
||||
12: {type: "field", name: "samples", pointerRules: {accepts: [Pointers.Sample], mandatory: false}}
|
||||
})
|
||||
@@ -4,5 +4,6 @@ import {DeviceFactory} from "../../std/DeviceFactory"
|
||||
|
||||
export const SpielwerkDeviceBox: BoxSchema<Pointers> = DeviceFactory.createMidiEffect("SpielwerkDeviceBox", {
|
||||
10: {type: "string", name: "code", value: ""},
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}},
|
||||
12: {type: "field", name: "samples", pointerRules: {accepts: [Pointers.Sample], mandatory: false}}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,359 @@
|
||||
# Apparat — User-Scripted Instrument
|
||||
|
||||
## Concept
|
||||
|
||||
The instrument counterpart to Werkstatt (audio DSP) and Spielwerk (MIDI effect). Users write JavaScript classes that generate audio in response to note events. Reuses the same infrastructure: code editor, compile-via-`addModule()`, version gating, error recovery, `@param` declarations with custom mappings.
|
||||
|
||||
Adds `@sample` declarations for loading audio files into the worklet, enabling sample playback, granular synthesis, and wavetable-like instruments. The `@sample` feature is shared infrastructure — available in Werkstatt and Apparat (and potentially Spielwerk in the future).
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions (from conversation)
|
||||
|
||||
- **Voicing**: The host does NOT manage polyphony. The user manages their own voices in the Processor class. This keeps the API simple and gives full control.
|
||||
- **Note delivery**: `noteOn(pitch, velocity, cent, id)` and `noteOff(id)` are methods on the Processor, called by the host at sample-accurate positions within the block. The host splits blocks at event boundaries and calls `process()` between them (same pattern as Vaporisateur's `AudioProcessor`).
|
||||
- **Output**: `process(output, block)` where `output = [Float32Array, Float32Array]`. The host clears the buffer at the start of each block. The user writes to it however they want (`=` or `+=`).
|
||||
- **Safety**: Same as Werkstatt — NaN detection + amplitude overflow protection. Processor is silenced on dangerous output.
|
||||
- **Reset**: On transport stop/discontinuity, the host sends `noteOff` for all active notes, then calls `reset()`. The user should put all voices into a fast release state (e.g., `gate = false` with a short fade rate like 0.05) to avoid clicks. Do NOT hard-kill voices with `this.voices = []` — that causes clicks. This must be clearly documented in the manual and AI prompt.
|
||||
- **Samples**: `// @sample name` declarations. File picker (drag-and-drop + click-to-browse) on the device panel. Data available as `this.samples.name` in the processor. Returns `null` until loaded.
|
||||
- **Name**: Apparat (German for "apparatus"). Continues the naming: Werkstatt, Spielwerk, Apparat.
|
||||
- **Icon**: `IconSymbol.Code` — same as Werkstatt and Spielwerk.
|
||||
|
||||
---
|
||||
|
||||
## Processor API
|
||||
|
||||
```javascript
|
||||
// @param attack 0.01 0.001 1.0 exp s
|
||||
// @param release 0.3 0.01 2.0 exp s
|
||||
// @sample wavetable
|
||||
|
||||
class Processor {
|
||||
paramChanged(name, value) { } // optional, same as Werkstatt/Spielwerk
|
||||
noteOn(pitch, velocity, cent, id) { } // called at exact sample position
|
||||
noteOff(id) { } // called at exact sample position
|
||||
process(output, block) { } // called between note events
|
||||
reset() { } // called on transport stop/discontinuity — kill all voices
|
||||
}
|
||||
```
|
||||
|
||||
### Method call order within a block
|
||||
|
||||
The host clears the output buffer, then interleaves note events with process calls:
|
||||
|
||||
```
|
||||
[host clears output]
|
||||
process(output, {s0: 0, s1: 47, ...}) // existing voices render
|
||||
noteOn(60, 0.8, 0, 42) // note starts at sample 47
|
||||
process(output, {s0: 47, s1: 100, ...}) // new voice renders 53 samples
|
||||
noteOff(42) // note ends at sample 100
|
||||
process(output, {s0: 100, s1: 128, ...}) // voice in release
|
||||
```
|
||||
|
||||
If no events in a block, `process()` is called once for the full block.
|
||||
|
||||
### Globals
|
||||
|
||||
| Variable | Type | Description |
|
||||
|--------------|----------|------------------------------------------|
|
||||
| `sampleRate` | `number` | Audio sample rate in Hz (e.g. 48000) |
|
||||
|
||||
### Block properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|----------|-------------------------------------------------------|
|
||||
| `s0` | `number` | First sample index to process (inclusive) |
|
||||
| `s1` | `number` | Last sample index to process (exclusive) |
|
||||
| `index` | `number` | Block counter |
|
||||
| `bpm` | `number` | Current project tempo |
|
||||
| `p0` | `number` | Start position in ppqn |
|
||||
| `p1` | `number` | End position in ppqn |
|
||||
| `flags` | `number` | Bitmask: 1=transporting, 2=discontinuous, 4=playing, 8=bpmChanged |
|
||||
|
||||
### Sample data
|
||||
|
||||
When loaded, `this.samples.name` returns the `AudioData` interface from `lib-dsp`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
sampleRate: number, // original sample rate
|
||||
numberOfFrames: number, // number of frames
|
||||
numberOfChannels: number, // 1 (mono) or 2 (stereo)
|
||||
frames: [Float32Array, Float32Array] // per-channel sample data
|
||||
}
|
||||
```
|
||||
|
||||
Access channels via `this.samples.name.frames[0]` (left) and `this.samples.name.frames[1]` (right).
|
||||
|
||||
Returns `null` before the sample is loaded.
|
||||
|
||||
---
|
||||
|
||||
## @sample Declaration
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
// @sample <name>
|
||||
```
|
||||
|
||||
- **name** — identifier, used to access `this.samples.name`
|
||||
- Creates a file picker on the device panel (drag-and-drop + click-to-browse)
|
||||
- Shows filename when loaded
|
||||
|
||||
### Parsing
|
||||
|
||||
Same pattern as `@param`: regex matches `// @sample (\w+)` lines. The compiler extracts them and reconciles sample boxes in the box graph.
|
||||
|
||||
### Storage
|
||||
|
||||
Each `@sample` declaration creates an `WerkstattSampleBox` in the box graph:
|
||||
- `owner`: pointer to Apparat's samples field
|
||||
- `label`: string (the sample name)
|
||||
- `index`: int32 (for ordering on the panel)
|
||||
- `file`: pointer to `AudioFileBox` (the audio data reference)
|
||||
|
||||
This follows the Playfield pattern: `PlayfieldSampleBox.file` → `AudioFileBox`.
|
||||
|
||||
### Loading in the worklet
|
||||
|
||||
Each `@sample` slot references an `AudioFileBox` in the box graph (same as Playfield). The host sets `this.samples` on the user Processor instance after construction. Each declared sample name is a property on this object:
|
||||
- Before data arrives: `this.samples.name = null`
|
||||
- After data arrives: `this.samples.name = AudioData` (from `lib-dsp`)
|
||||
|
||||
The processor uses `SampleLoaderManager.getOrCreate(uuid)` to fetch the `AudioFileBox`'s audio data asynchronously via `engineToClient.fetchAudio(uuid)`. When the data resolves, the host updates the corresponding property on `this.samples`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Forge Schema
|
||||
|
||||
```typescript
|
||||
// New instrument box
|
||||
export const ApparatDeviceBox = DeviceFactory.createInstrument("ApparatDeviceBox", {
|
||||
10: {type: "string", name: "code", value: ""},
|
||||
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}},
|
||||
12: {type: "field", name: "samples", pointerRules: {accepts: [Pointers.Sample], mandatory: false}}
|
||||
})
|
||||
|
||||
// New sample box (similar to PlayfieldSampleBox but simpler)
|
||||
export const WerkstattSampleBox = {
|
||||
type: "box",
|
||||
class: {
|
||||
name: "WerkstattSampleBox",
|
||||
fields: {
|
||||
1: {type: "pointer", name: "owner", pointerType: Pointers.Sample, mandatory: true},
|
||||
2: {type: "string", name: "label", value: ""},
|
||||
3: {type: "int32", name: "index", constraints: "index", unit: ""},
|
||||
4: {type: "pointer", name: "file", pointerType: Pointers.AudioFile, mandatory: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reuses `WerkstattParameterBox` for parameters (same as Werkstatt/Spielwerk).
|
||||
|
||||
**WerkstattDeviceBox extension**: Add `samples` field at key **12** (preserving existing keys 10=code, 11=parameters). Same field added to `SpielwerkDeviceBox` and `ApparatDeviceBox`:
|
||||
```typescript
|
||||
12: {type: "field", name: "samples", pointerRules: {accepts: [Pointers.Sample], mandatory: false}}
|
||||
```
|
||||
|
||||
### Adapter
|
||||
|
||||
`ApparatDeviceBoxAdapter` implements `InstrumentDeviceBoxAdapter`:
|
||||
- `type = "instrument"`, `accepts = "midi"`, `acceptsMidiEvents = true`
|
||||
- `defaultTrackType = TrackType.Notes`
|
||||
- Parses `@param` from code → creates ValueMapping/StringMapping per param (reuses `ScriptParamDeclaration`)
|
||||
- Parses `@sample` from code → tracks sample boxes
|
||||
- Provides `ParameterAdapterSet` for knobs
|
||||
|
||||
### Processor
|
||||
|
||||
`ApparatDeviceProcessor` extends `AudioProcessor` implements `InstrumentDeviceProcessor`:
|
||||
- Watches code version, loads user class from global registry (same pattern)
|
||||
- Creates `NoteEventInstrument` to receive notes from the MIDI chain
|
||||
- On `handleEvent(NoteLifecycleEvent.start)` → calls `userProcessor.noteOn(pitch, velocity, cent, id)`
|
||||
- On `handleEvent(NoteLifecycleEvent.stop)` → calls `userProcessor.noteOff(id)`
|
||||
- On `processAudio(block)` → calls `userProcessor.process(output, block)`
|
||||
- Output goes through `SimpleLimiter` + NaN detection (same as Werkstatt)
|
||||
- Manages `this.samples` object on the user processor — populated via `SampleLoaderManager`
|
||||
- Peak metering via `PeakBroadcaster`
|
||||
|
||||
### Compiler
|
||||
|
||||
Reuses `createScriptCompiler` with config:
|
||||
```typescript
|
||||
{
|
||||
headerTag: "apparat",
|
||||
registryName: "apparatProcessors",
|
||||
functionName: "apparat"
|
||||
}
|
||||
```
|
||||
|
||||
Extends `parseParams` pattern with `parseSamples` for `@sample` declarations. Reconciles both param boxes and sample boxes on compile.
|
||||
|
||||
### Editor
|
||||
|
||||
Reuses `ScriptDeviceEditor` with Apparat-specific config:
|
||||
- `compiler`: apparat config
|
||||
- `defaultCode`: simple sine synth
|
||||
- `examples`: built-in examples
|
||||
- `icon`: instrument icon
|
||||
- `populateMeter`: peak meter (like Werkstatt)
|
||||
|
||||
The device panel additionally shows:
|
||||
- Sample file pickers for each `@sample` declaration (drag-and-drop + click-to-browse, showing filename)
|
||||
- Parameter knobs/checkboxes (same as Werkstatt/Spielwerk)
|
||||
|
||||
### Registration
|
||||
|
||||
Add to all dispatch points:
|
||||
- `Pointers.Sample` — new pointer type in studio-enums
|
||||
- `BoxVisitor.visitApparatDeviceBox` / `visitWerkstattSampleBox`
|
||||
- `BoxAdapters` — create `ApparatDeviceBoxAdapter`
|
||||
- `InstrumentDeviceProcessorFactory` — create `ApparatDeviceProcessor`
|
||||
- `DeviceEditorFactory` — create `ApparatDeviceEditor`
|
||||
- `InstrumentFactories` — register factory for creation menu
|
||||
- `OfflineEngineRenderer` — load apparat code for offline rendering (alongside werkstatt/spielwerk)
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Sine Synth (default code)
|
||||
|
||||
```javascript
|
||||
class Processor {
|
||||
voices = []
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
this.voices.push({
|
||||
id, velocity,
|
||||
freq: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12),
|
||||
phase: 0, gate: true, gain: velocity
|
||||
})
|
||||
}
|
||||
noteOff(id) {
|
||||
this.voices.find(v => v.id === id)
|
||||
if (voice) voice.gate = false
|
||||
}
|
||||
reset() {
|
||||
this.voices = []
|
||||
}
|
||||
process(output, block) {
|
||||
const [outL, outR] = output
|
||||
for (let i = this.voices.length - 1; i >= 0; i--) {
|
||||
const voice = this.voices[i]
|
||||
if (!voice.gate) {
|
||||
voice.gain *= 0.995
|
||||
if (voice.gain < 0.001) {
|
||||
this.voices.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
const sample = Math.sin(voice.phase * Math.PI * 2) * voice.gain * 0.3
|
||||
outL[s] += sample
|
||||
outR[s] += sample
|
||||
voice.phase += voice.freq / sampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sample Player
|
||||
|
||||
```javascript
|
||||
// @sample sound
|
||||
// @param speed 1.0 0.1 4.0 exp
|
||||
|
||||
class Processor {
|
||||
speed = 1.0
|
||||
voices = []
|
||||
paramChanged(name, value) {
|
||||
if (name === "speed") this.speed = value
|
||||
}
|
||||
noteOn(pitch, velocity, cent, id) {
|
||||
const sound = this.samples.sound
|
||||
if (sound === null) return
|
||||
this.voices.push({id, velocity, position: 0, data: sound})
|
||||
}
|
||||
noteOff(id) {
|
||||
this.voices = this.voices.filter(v => v.id !== id)
|
||||
}
|
||||
reset() {
|
||||
this.voices = []
|
||||
}
|
||||
process(output, block) {
|
||||
const [outL, outR] = output
|
||||
for (let i = this.voices.length - 1; i >= 0; i--) {
|
||||
const voice = this.voices[i]
|
||||
const srcL = voice.data.frames[0]
|
||||
const srcR = voice.data.frames[voice.data.numberOfChannels > 1 ? 1 : 0]
|
||||
for (let s = block.s0; s < block.s1; s++) {
|
||||
const pos = Math.floor(voice.position)
|
||||
if (pos >= voice.data.numberOfFrames) {
|
||||
this.voices.splice(i, 1)
|
||||
break
|
||||
}
|
||||
outL[s] += srcL[pos] * voice.velocity
|
||||
outR[s] += srcR[pos] * voice.velocity
|
||||
voice.position += this.speed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Infrastructure Reuse
|
||||
|
||||
| Component | Reuse from |
|
||||
|---|---|
|
||||
| Code editor panel | `ScriptDeviceEditor` (shared Werkstatt/Spielwerk) |
|
||||
| Compiler | `createScriptCompiler` with apparat config |
|
||||
| Parameter parsing | `ScriptParamDeclaration` (parseParams, resolveValueMapping, resolveStringMapping) |
|
||||
| Parameter boxes | `WerkstattParameterBox` (same box, same reconciliation) |
|
||||
| Code validation | `validateCode` via `new Function()` |
|
||||
| Worklet registration | `registerWorklet` via `audioContext.audioWorklet.addModule()` |
|
||||
| Error reporting | `engineToClient.deviceMessage(uuid, message)` |
|
||||
| Sample loading | `SampleLoaderManager` / `engineToClient.fetchAudio(uuid)` pattern from Playfield |
|
||||
| Sample boxes | `WerkstattSampleBox` (shared across Werkstatt/Apparat, like `WerkstattParameterBox`) |
|
||||
| Sample parsing | `parseSamples` in `ScriptParamDeclaration` (shared) |
|
||||
| Sample picker UI | `SampleSelector` + `SampleSelectStrategy.forPointerField` from `@/ui/devices/SampleSelector` (same as Nano) |
|
||||
|
||||
### New components needed
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `ApparatDeviceBox` | Forge schema (instrument with code, parameters, samples) |
|
||||
| `WerkstattSampleBox` | Forge schema (sample slot with label, index, file pointer) |
|
||||
| `ApparatDeviceBoxAdapter` | Adapter (params + samples from code) |
|
||||
| `ApparatDeviceProcessor` | Worklet processor (note events → user code → audio) |
|
||||
| `ApparatDeviceEditor` | Thin wrapper around `ScriptDeviceEditor` + sample pickers |
|
||||
| `Pointers.Sample` | New pointer type |
|
||||
| `parseSamples` | Regex parser for `@sample` declarations (in `ScriptParamDeclaration` — shared, so Werkstatt can use it too) |
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls from Vaporisateur Analysis
|
||||
|
||||
1. **Sample-accurate event timing**: The `AudioProcessor` base class already handles block splitting at event boundaries. Apparat's processor should extend `AudioProcessor` to get this for free.
|
||||
|
||||
2. **NaN/overflow protection**: Must apply `SimpleLimiter` and NaN detection on the output, same as Werkstatt. User code generating audio is high-risk for runaway values.
|
||||
|
||||
3. **Note ID, not pitch**: `noteOff` must use `id` not `pitch` — multiple notes on the same pitch can be active simultaneously. The API makes this explicit by design.
|
||||
|
||||
4. **Voice cleanup**: If the user doesn't remove voices in `noteOff`, they accumulate forever. The error reporting (NaN detection, amplitude overflow) will catch runaway situations. On transport stop, the host calls `noteOff` for all active notes then `reset()` — the user MUST clear all voices in `reset()`.
|
||||
|
||||
5. **Sample not loaded yet**: `this.samples.name` returns `null` before loaded. User must guard access. The host should NOT call `noteOn` if samples are still loading — or should it? (The user handles null checks.)
|
||||
|
||||
6. **Memory in process()**: Like Werkstatt, the manual/AI prompt must emphasize: NEVER allocate in process(). No `new`, no array literals, no closures in the audio hot path.
|
||||
|
||||
7. **sampleRate in constructor**: Available on `globalThis` in the worklet. Instance field initializers and the constructor can use it freely.
|
||||
|
||||
9. **Sample rate mismatch**: The project's `sampleRate` and the sample's `AudioData.sampleRate` can differ (e.g., 48000 Hz project, 44100 Hz sample). When playing back samples, the user must calculate the playback rate as `sample.sampleRate / sampleRate` and advance the read position by this ratio per output sample. The manual and AI prompt must teach this — otherwise samples play at the wrong speed/pitch.
|
||||
|
||||
8. **Offline rendering**: Must register apparat code in `OfflineEngineRenderer` (same pattern as werkstatt/spielwerk).
|
||||
Reference in New Issue
Block a user