Merge branch 'refs/heads/dev'

This commit is contained in:
André Michelle
2026-03-20 11:39:47 +01:00
47 changed files with 3524 additions and 105 deletions
+39 -39
View File
@@ -197,9 +197,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -208,9 +208,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2991,9 +2991,9 @@
}
},
"node_modules/@turbo/darwin-64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.19.tgz",
"integrity": "sha512-WNzpqJV7rO/oKmsxjtSFXuV87fZb/qxzeGWXJng4oHFz7iPOFSWrPb2gTazJgoTvXUfe1kOH2o0F+zFrerwzRw==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.20.tgz",
"integrity": "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==",
"cpu": [
"x64"
],
@@ -3005,9 +3005,9 @@
]
},
"node_modules/@turbo/darwin-arm64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.8.19.tgz",
"integrity": "sha512-KTq8/PH0/ml9bNFomZ2VjmOqW6RYPRqBqV5CfNeD5ekJMuNwJUdeQz0qXguxCzW4OkHCnTdPeKIehR0cdQObPg==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.8.20.tgz",
"integrity": "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==",
"cpu": [
"arm64"
],
@@ -3019,9 +3019,9 @@
]
},
"node_modules/@turbo/linux-64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.8.19.tgz",
"integrity": "sha512-d0xcXiGUt1Q/HzMfgkqai5KyXrassLRuUZhxmUs4ZX99tQFbqRL2/CkSKCnMqyDv7c0K5eugLerVUY+DYr1DQg==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.8.20.tgz",
"integrity": "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==",
"cpu": [
"x64"
],
@@ -3033,9 +3033,9 @@
]
},
"node_modules/@turbo/linux-arm64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.8.19.tgz",
"integrity": "sha512-UX3r1iwqsOK8dMgKFtZpDqZk7feqHXZJ/EQAknh0SVQrnc4vkm6mZqjX4j2j3DVoSo4Ho8xGITYlor9Xxsyomw==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.8.20.tgz",
"integrity": "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==",
"cpu": [
"arm64"
],
@@ -3047,9 +3047,9 @@
]
},
"node_modules/@turbo/windows-64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.8.19.tgz",
"integrity": "sha512-3MrvD/gMYWfQKZVZOeilKm1629zPG0+KlErZbI0n9Nr3L32+5qrZOF/cgWErukLnOSR38whgf8t2WaPJQALqxg==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.8.20.tgz",
"integrity": "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==",
"cpu": [
"x64"
],
@@ -3061,9 +3061,9 @@
]
},
"node_modules/@turbo/windows-arm64": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.8.19.tgz",
"integrity": "sha512-j+8mi7kyEgT7cUTPOtPJS8KbTWxu4+OQBiDIo+zklz/RKV4hoN+k4+J8iCM9Nj+o+SVnzc01xSI+s3eBG0MDSA==",
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.8.20.tgz",
"integrity": "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==",
"cpu": [
"arm64"
],
@@ -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.
---
![screenshot](apparat.webp)
---
## 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 → 01, default 0
// @param gain 0.5 → 01, default 0.5
```
### Mapped
```
// @param attack 0.01 0.001 1.0 exp s → exponential 0.0011.0, default 0.01
// @param mode 0 0 3 int → integer 03, 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 01 | `number` (01) |
| `linear` | Linear scaling between min and max | `number` (minmax) |
| `exp` | Exponential scaling (for frequency, time) | `number` (minmax) |
| `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 (0127) |
| `velocity` | `number` | Note velocity (0.01.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 (0127) — MIDI note number
- velocity: number (0.01.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.01.0. For linear/exp: minmax.
For int: integer in minmax. 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 (01).
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",
+49 -6
View File
@@ -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>
))
}))
@@ -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 -4
View File
@@ -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) =>
@@ -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)
}
}
@@ -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}}
})
+400
View File
@@ -0,0 +1,400 @@
# TB-303 Bass Synthesizer — Apparat Emulation
Compiled 2026-03-19 for openDAW Apparat implementation.
## Sources
- **Open303** (Robin Schmidt) — full C++ DSP implementation
- **Robin Whittle** (Devil Fish) — circuit analysis
- **Tim Stinchcombe** — diode ladder filter mathematical model
- **a1k0n/303** — JS/Python resynthesis experiments
- **KVR Audio DSP forums** — community measurements
- **Csound diode_ladder** documentation
## Implementation
Example file: `packages/app/studio/src/ui/devices/instruments/examples/tb-303.js`
### Parameters (Knobs)
| Knob | Default | Range | Type | Description |
|------|---------|-------|------|-------------|
| waveform | 0 | 01 | int | 0=sawtooth, 1=square |
| tuning | 0 | -55 | linear (st) | Pitch offset in semitones |
| cutoff | 500 | 805000 | exp (Hz) | Base filter cutoff frequency |
| resonance | 0.5 | 01 | linear | Filter resonance |
| envmod | 0.5 | 01 | linear | Filter envelope modulation depth |
| decay | 400 | 2002000 | exp (ms) | Filter envelope decay time |
| accent | 0.5 | 01 | linear | Accent amount |
| volume | 0.7 | 01 | linear | Output level |
### Architecture
Monophonic. Overlapping notes trigger slide (portamento). High velocity (>0.78) triggers accent.
---
## 1. Oscillator (VCO)
**Circuit:** Discrete thyristor-based oscillator (Q24, Q25, Q27 as SCR) with integrator capacitor C33. Faster reset time than conventional switched-integrator designs.
### Sawtooth
- NOT mathematically ideal
- Raw saw passes through ~10 high-pass filters (coupling capacitors), the ladder filter, VCA, and mixer
- Even harmonics present due to S-curve shaping in the ramp
- **DSP:** bandlimited sawtooth via polyBLEP with DC blocking
### Square
- Derived from sawtooth via single-transistor waveshaper (Q8, PNP)
- **Frequency-dependent duty cycle:**
- ~71% at lowest pitches (65 Hz)
- ~45% at highest pitches (660 Hz)
- Linear mapping: `duty = 0.71 - 0.26 * clamp((freq - 65) / 595, 0, 1)`
- Two capacitors in waveshaper circuit cause this behavior
- **DSP:** bandlimited pulse via polyBLEP with pitch-dependent duty cycle
### Tuning
- 3 octaves in pattern mode (C2C5), 4 in track mode
- Untransposed low C ≈ 65 Hz (2.0V CV)
- Tuning control: ±500 cents (±5 semitones)
- 6-bit R2R DAC: theoretical range ~16.35 Hz to ~659 Hz
---
## 2. Filter (VCF) — The Key to the 303 Sound
**Architecture:** 4-pole DIODE ladder filter (NOT Moog transistor ladder). Transistors with base-collector shorted to function as diodes.
### The C18 Mismatch (Critical Characteristic)
- **C18 = 0.18 µF**, other three caps = **0.33 µF** each
- First pole sits **~1.83x higher** than the other three (≈ one octave)
- Result: "1+3 pole" filter — three matched poles at nominal cutoff, one pole an octave above
- Effective slope: **~18 dB/oct** near cutoff (not 24 dB/oct)
- Deeper in stopband: transitions toward 24 dB/oct
- **Prevents true self-oscillation** (phase shift never reaches 360°)
### Open303 Filter Structure
```
y1 += 2*b0*(y0 - y1 + y2) // First stage: 2x coeff (halved cap)
y2 += b0*(y1 - 2*y2 + y3) // Stages 2-4: normal coeff
y3 += b0*(y2 - 2*y3 + y4)
y4 += b0*(y3 - 2*y4)
```
Cross-coupling terms model the bidirectional coupling between stages through the diodes. Sequential evaluation (each stage uses updated value of previous stage).
### Filter Coefficient
For native sample rate (no oversampling):
```
g = tan(π * cutoff / sampleRate)
b0 = g / (1 + g)
```
For 2x oversampled inner loop (used in our implementation):
```
g = tan(π * cutoff / (sampleRate * 2))
b0 = g / (1 + g)
```
### Resonance Feedback
- Global feedback from y4 back to input
- **High-pass filter in feedback path** (~150 Hz cutoff) from coupling capacitors
- Tim Stinchcombe: ~6 additional poles/zeros from coupling caps
- Resonant peak around 810 Hz (measured on hardware)
Resonance mapping:
```
resoSkewed = (1 - exp(-3 * resonance)) / (1 - exp(-3))
kFeedback = resoSkewed * 17
```
### Gain Compensation
```
gNorm = kFeedback / 17
gComp = (gNorm - 1) * resoSkewed + 1
gComp *= (1 + resoSkewed)
output = 2 * gComp * y4
```
### Saturation
- tanh() nonlinearity at filter input: `y0 = tanh(input - feedbackHP)`
- Transistor-diode ladder natural saturation
- Csound alternative: `(1/tanh(k_sat)) * tanh(k_sat * input)`
### Cutoff Range
| Condition | Range |
|-----------|-------|
| Env Mod at minimum | ~77 Hz ~620 Hz |
| Env Mod at maximum | up to ~36 kHz |
| Practical maximum | ~20 kHz |
### Post-Filter Chain (Open303 reference values)
- Allpass: 14.008 Hz
- Highpass: 24.167 Hz
- Notch: 7.5164 Hz, BW 4.7
- Pre-filter highpass: 44.486 Hz
---
## 3. Envelopes
Two separate envelope generators.
### VEG (Volume Envelope) → VCA
| Parameter | Normal | Accented |
|-----------|--------|----------|
| Attack | ~3 ms | ~3 ms |
| Decay | 1230 ms | 200 ms |
| Sustain | 0 | 0 |
| Release | 0.5 ms | 50 ms |
RC-filter style:
```
attack: out += coeff * (1.3 - out); transition to decay when out >= 1.0
decay: out *= exp(-1 / (decayTime * sr))
release: out *= exp(-1 / (releaseTime * sr))
```
### MEG (Main Envelope) → Filter Cutoff
- Attack: near-instantaneous (~3 ms smoothing)
- Decay: **variable**, controlled by Decay knob (200 ms 2000 ms)
- On **accented notes**: decay **fixed at ~200 ms** regardless of knob
```
megEnv *= exp(-1 / (decayTime * sr))
```
### VCA + MEG Compound
The MEG contributes to the VCA while gate is on:
```
ampOut = vcaEnv + 0.45 * megEnv + accentGain * 4.0 * megEnv
```
### Retriggering
- Retriggers on each new note gate
- During **slide**: gate stays HIGH → envelope does NOT restart
---
## 4. Accent
Accent simultaneously affects multiple parameters. Triggered when velocity > ~0.78 (100/127).
### Effect on VCA
- MEG voltage → Accent pot → VCA control current
- Through RC: **47k resistor + 0.033 µF capacitor**
- Primary reason accented notes are louder
### Accent Sweep Circuit
Adds to filter control current. Components:
| Component | Value |
|-----------|-------|
| Diode + resistor in series | 47k |
| Pot (second section of Resonance pot) | 100k |
| Capacitor to ground | **1 µF** |
| Mixing resistor to filter | 100k |
**Time constants:**
- Charge: (47k + 100k) × 1 µF = **~147 ms**
- Discharge: 100k × 1 µF = **~100 ms**
### Accent Stacking (Iconic 303 Behavior)
The 1 µF capacitor **does NOT fully discharge** between steps. Each successive accented note creates a **higher filter peak** due to charge accumulation. This "building" effect is one of the most iconic 303 behaviors.
```
if (accented && gate):
accentCap += (1 - exp(-1/(0.147*sr))) * (megEnv * accent - accentCap)
else:
accentCap *= exp(-1/(0.1*sr))
cutoff *= pow(2, accentCap * sweepOctaves)
```
### Accent on Envelopes
- MEG decay: **fixed 200 ms** (regardless of Decay knob)
- VCA decay: **200 ms** (vs 1230 ms normal)
- VCA release: **50 ms** (vs 0.5 ms normal)
### "Gimmick" Circuit
Three transistors pull cutoff **down** at end of envelope decay, creating characteristic dip/pluck. Partially modeled by accent sweep discharge behavior.
---
## 5. Slide (Portamento)
### Characteristics
- **Exponential** glide (NOT linear)
- Equivalent to 6 dB/octave lowpass on pitch CV
- **Constant time** regardless of interval size
- Time constant: **~60 ms** (stock TB-303)
- Devil Fish: 60360 ms via Slide Time pot
### Circuit
RC lowpass on pitch CV. When slide activated, pitch CV charges capacitor through resistor.
### Implementation
```
freqSmoothed += (freqTarget - freqSmoothed) * (1 - exp(-1/(0.012*sr)))
```
Time constant 12 ms for the RC gives ~60 ms effective glide time.
### Behavior
- Overlapping notes trigger slide (gate stays HIGH)
- NO envelope retrigger during slide
- Oscillator phase NOT reset
- Filter state NOT reset
### Sequencer Gate Timing
- 12 internal clock ticks per 16th note
- Gate high at tick 0, low halfway through tick 3 (~50% duty)
- Slide extends gate through current step into next
- Slide activates on the NEXT note after the slid note
- Sequencer does NOT anticipate the next note
---
## 6. Parameter Ranges Summary
| Parameter | Range | Notes |
|-----------|-------|-------|
| Cutoff frequency | ~77 Hz ~620 Hz | Up to ~36 kHz with full envmod |
| Resonance | 0 just below self-osc | 303 can't truly self-oscillate |
| Env Mod | 01 | ~4 octaves sweep at maximum |
| Decay time | ~200 ms ~2000 ms | Fixed ~200 ms on accented notes |
| Tuning | ±500 cents | ±5 semitones |
| Oscillator range | ~32 Hz ~660 Hz | 3-4 octaves |
| Slide time | ~60 ms | Exponential RC lowpass |
| VCA attack | ~3 ms | |
| VCA decay | ~1230 ms / ~200 ms | Normal / accent |
| VCA release | ~0.5 ms / ~50 ms | Normal / accent |
---
## 7. Circuit Quirks Critical for Authentic Emulation
1. **Mismatched first filter pole** — C18=0.18µF vs 0.33µF → first stage 2×b0 coefficient
2. **High-pass in resonance feedback** — ~150 Hz HPF from coupling capacitors
3. **Accent stacking** — 1µF cap doesn't fully discharge between steps
4. **No true self-oscillation** — mismatched poles prevent 360° phase shift
5. **Frequency-dependent square duty cycle** — 45% high pitches, 71% low pitches
6. **VCA = ADSR(sustain=0) + MEG** — compound envelope, not simple gate
7. **tanh saturation at filter input** — transistor-diode ladder natural clipping
8. **Slide prevents envelope retrigger** — gate stays high, smooth transitions
9. **Low-frequency resonance peak (~810 Hz)** — from coupling caps in filter core
10. **BA662 OTA for VCA** — introduces subtle saturation and offset
---
## 8. Open303 Magic Numbers
```
Oversampling: 4x
Wavetable length: 2048 samples
Mip-map tables: 12
Square shaping:
tanhShaperFactor: dB2amp(36.9) ≈ 69.98
tanhShaperOffset: 4.37
Filter:
Feedback HP: 150 Hz (one-pole)
Cutoff clamp: 200 Hz 20000 Hz
Post-filter allpass: 14.008 Hz
Post-filter HP: 24.167 Hz
Post-filter notch: 7.5164 Hz, BW 4.7
Pre-filter HP: 44.486 Hz
Envelopes:
VCA attack: 3 ms
VCA decay: 1230 ms (normal), 200 ms (accent)
VCA release: 0.5 ms (normal), 50 ms (accent)
MEG RC1 smoothing: 0 ms
MEG RC2 smoothing: 15 ms
Envelope modulation scaling (from measurement):
c0 = 3.138152786059267e+002
c1 = 2.394411986817546e+003
oF = 0.048292930943553
oC = 0.294391201442418
sLoF = 3.773996325111173
sLoC = 0.736965594166206
sHiF = 4.194548788411135
sHiC = 0.864344900642434
VCA MEG contribution:
ampEnvOut += 0.45 * mainEnvOut + accentGain * 4.0 * mainEnvOut
Default level: -12 dB
Level by velocity: 12 dB
Default cutoff: 1000 Hz
Default envMod: 25%
envUpFraction: 2/3
AmpDeClicker: Biquad LP 12dB/oct, 200 Hz, Q=sqrt(0.5)
Slide time: 60 ms (tau = 12 ms)
Anti-alias: Elliptic quarter-band
```
---
## 9. a1k0n/303 JS Reference
```
VCA:
attack: 1.0 - 0.94406088 = 0.05593912
decay: 0.99897516
amplitude: 0.5
Update rate: every 64 samples
VCF envelope (measured from x0xb0x):
vcf_e1 = exp(5.55921003 + 2.17788267*cutoff + 1.99224351*envmod) + 103
vcf_e0 = exp(5.22617147 + 1.70418937*cutoff - 0.68382928*envmod) + 103
Filter: 5 poles + 1 zero
Section 1: one-zero + one-pole HP (~100 Hz)
Section 2: complex conjugate pole pair (biquad)
Section 3: complex conjugate pole pair (biquad)
Lookup: 64 resonance × 10 coefficients
Gain: 2/(1+reso_k) + 0.5*reso, where reso_k = reso * 4.0
```
---
## 10. References
- [Tim Stinchcombe — TB-303 Diode Ladder Filter Model](https://www.timstinchcombe.co.uk/index.php?pge=diode2)
- [Robin Whittle — TB-303 Unique Characteristics](https://www.firstpr.com.au/rwi/dfish/303-unique.html)
- [Robin Whittle — TB-303 Slide Analysis](https://www.firstpr.com.au/rwi/dfish/303-slide.html)
- [Open303 (Robin Schmidt)](https://github.com/RobinSchmidt/Open303)
- [a1k0n/303 — Resynthesis Experiments](https://github.com/a1k0n/303)
- [Csound diode_ladder](https://csound.com/manual/opcodes/diode_ladder/)
- [Vadim Zavalishin — The Art of VA Filter Design](https://www.native-instruments.com/fileadmin/ni_media/downloads/pdf/VAFilterDesign_2.1.0.pdf)
- [TB-303 Wikipedia](https://en.wikipedia.org/wiki/Roland_TB-303)
- KVR Audio DSP Forums: [filter](https://www.kvraudio.com/forum/viewtopic.php?t=346155), [envelopes](https://www.kvraudio.com/forum/viewtopic.php?t=258816), [square duty](https://www.kvraudio.com/forum/viewtopic.php?t=261379), [facts](https://www.kvraudio.com/forum/viewtopic.php?t=455469)
- [Eddy Bergman — TB-303 VCF Build](https://www.eddybergman.com/2025/03/TB303-VCF.html)
- [Sequence 15 — How Many Poles?](http://sequence15.blogspot.com/2009/02/how-many-poles-does-tb-303-filter-have.html)
+359
View File
@@ -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).