diff --git a/packages/app/studio/public/manuals/devices/instruments/apparat.md b/packages/app/studio/public/manuals/devices/instruments/apparat.md new file mode 100644 index 00000000..29b66c31 --- /dev/null +++ b/packages/app/studio/public/manuals/devices/instruments/apparat.md @@ -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 [default] [min max type [unit]] +``` + +### Simple (unipolar) + +``` +// @param gain → 0–1, default 0 +// @param gain 0.5 → 0–1, default 0.5 +``` + +### Mapped + +``` +// @param attack 0.01 0.001 1.0 exp s → exponential 0.001–1.0, default 0.01 +// @param mode 0 0 3 int → integer 0–3, default 0 +``` + +The knob displays the mapped value with the unit. `paramChanged` receives the mapped value directly. + +### Boolean + +``` +// @param bypass false → Off/On, default Off +// @param bypass true → Off/On, default On +``` + +`paramChanged` receives `0` or `1`. + +### Supported mapping types + +| Type | Description | paramChanged receives | +|---|---|---| +| *(none)* | Unipolar 0–1 | `number` (0–1) | +| `linear` | Linear scaling between min and max | `number` (min–max) | +| `exp` | Exponential scaling (for frequency, time) | `number` (min–max) | +| `int` | Integer snapping between min and max | `number` (integer) | +| `bool` | On/Off toggle | `number` (0 or 1) | + +Parameters are reconciled on each compile: new parameters are added, removed parameters are deleted, and existing parameters keep their current value. Multiple spaces between tokens are allowed for alignment. + +--- + +## 3. Samples + +Declare samples using `// @sample` comments: + +```javascript +// @sample wavetable +// @sample grain +``` + +Each `@sample` creates a file picker on the device panel. Drag an audio file onto it or click to browse. The sample data is available in the processor as `this.samples.`. + +When loaded, `this.samples.wavetable` returns an `AudioData` object: + +```javascript +{ + sampleRate: number, // original sample rate + numberOfFrames: number, // number of frames + numberOfChannels: number, // 1 (mono) or 2 (stereo) + frames: [Float32Array, Float32Array] // per-channel sample data +} +``` + +Returns `null` before the sample is loaded. Always check: + +```javascript +const data = this.samples.grain +if (data === null) return +``` + +When playing back samples, account for sample rate differences between the sample and the project: + +```javascript +const playbackRate = data.sampleRate / sampleRate +``` + +--- + +## 4. Keyboard Shortcuts + +| Shortcut | Action | +|---------------------|-------------------------------------| +| `Alt+Enter` | Compile and run | +| `Ctrl+S` / `Cmd+S` | Compile, run, and save to project | + +--- + +## 5. Safety + +The engine validates your output on every audio block: + +- **NaN detection** — If any output sample is NaN, the processor is silenced and the error is reported. +- **Overflow protection** — If any sample exceeds ~60 dB (amplitude > 1000), the processor is silenced. +- **Runtime errors** — If `process()` throws an exception, the processor is silenced and the error is shown. + +When silenced, the device outputs silence until the next successful compile. + +--- + +## 6. API Reference + +Your code must define a `class Processor` with a `process` method. Optionally implement `noteOn`, `noteOff`, `reset`, and `paramChanged`. + +### Globals + +| Variable | Type | Description | +|--------------|----------|------------------------------------------| +| `sampleRate` | `number` | Audio sample rate in Hz (e.g. 48000) | + +### Processor class + +```javascript +class Processor { + noteOn(pitch, velocity, cent, id) { } // note starts (sample-accurate) + noteOff(id) { } // note ends (sample-accurate) + reset() { } // transport stop — fast-release all voices + paramChanged(label, value) { } // parameter knob changed + process(output, block) { } // generate audio +} +``` + +### Note methods + +`noteOn` and `noteOff` are called at the exact sample position within the block. The host splits the block at event boundaries and calls `process()` between them: + +``` +[host clears output buffer] +process(output, {s0: 0, s1: 47, ...}) // existing voices render +noteOn(60, 0.8, 0, 42) // note starts at sample 47 +process(output, {s0: 47, s1: 128, ...}) // new voice renders +``` + +| Parameter | Type | Description | +|------------|----------|--------------------------------------------| +| `pitch` | `number` | MIDI note number (0–127) | +| `velocity` | `number` | Note velocity (0.0–1.0) | +| `cent` | `number` | Fine pitch offset in cents | +| `id` | `number` | Unique note identifier (use for noteOff) | + +Always use `id` to identify notes — not pitch. Multiple notes on the same pitch can be active simultaneously. + +### reset() + +Called on transport stop and position jumps. Put all voices into a fast release (e.g., 5ms fade) to avoid clicks. Do NOT hard-kill voices with `this.voices = []`. + +### process(output, block) + +The host clears the output buffer before each block. You write to it with `=` or `+=`. + +- `output[0]` — left channel (`Float32Array`) +- `output[1]` — right channel (`Float32Array`) + +### Block properties + +| Property | Type | Description | +|----------|----------|-------------------------------------------------------| +| `s0` | `number` | First sample index to process (inclusive) | +| `s1` | `number` | Last sample index to process (exclusive) | +| `index` | `number` | Block counter | +| `bpm` | `number` | Current project tempo | +| `p0` | `number` | Start position in ppqn | +| `p1` | `number` | End position in ppqn | +| `flags` | `number` | Bitmask: 1=transporting, 2=discontinuous, 4=playing, 8=bpmChanged | + +--- + +## 7. Examples + +Select **Examples** in the code editor toolbar to load ready-made instruments (Simple Sine Synth, Grain Synthesizer). + +--- + +## 8. AI Prompt + +Copy the following prompt into an AI assistant to get help writing Apparat instruments: + +``` +You are helping the user write an instrument processor for the openDAW Apparat device. +The user writes plain JavaScript (no imports, no modules). The code runs inside an AudioWorklet. + +The code MUST define a class called `Processor` with the following interface: + +class Processor { + noteOn(pitch, velocity, cent, id) { } // called at exact sample position + noteOff(id) { } // called at exact sample position + reset() { } // called on transport stop — fast-release all voices + paramChanged(label, value) { } // optional + process(output, block) { } // generate audio +} + +## noteOn(pitch, velocity, cent, id) +Called when a note starts, at the exact sample position within the audio block. + +- pitch: number (0–127) — MIDI note number +- velocity: number (0.0–1.0) — note velocity +- cent: number — fine pitch offset in cents +- id: number — unique identifier for this note instance + +Convert pitch to frequency: 440 * Math.pow(2, (pitch - 69 + cent / 100) / 12) + +IMPORTANT: Always use `id` to identify notes, not `pitch`. Multiple notes on the same +pitch can be active simultaneously (e.g. overlapping regions, multiple MIDI sources). + +## noteOff(id) +Called when a note ends. Find the voice by `id` and release it. + +## reset() +Called on transport stop and position jumps. Put all voices into a fast release +state (e.g., set a short fade rate like 0.05) to avoid clicks. Do NOT hard-kill +voices with `this.voices = []` — that causes clicks. + +Example: +reset() { + for (const voice of this.voices) { + voice.gate = false + voice.fadeRate = 0.05 // fast ~5ms fade + } +} + +## process(output, block) +Called between note events. The host clears the output buffer before each block. +You write audio samples to the output buffers. + +- output[0]: Float32Array — left channel (write to this) +- output[1]: Float32Array — right channel (write to this) + +block (timing and transport): +- block.s0 — first sample index to process (inclusive) +- block.s1 — last sample index to process (exclusive) +- block.index — block counter +- block.bpm — current project tempo in BPM +- block.p0 — start position in ppqn (480 ppqn per quarter note) +- block.p1 — end position in ppqn +- block.flags — bitmask: 1=transporting, 2=discontinuous, 4=playing, 8=bpmChanged + +The host interleaves noteOn/noteOff calls with process() calls at exact sample +positions. Your process() may be called multiple times per block: + + process(output, {s0: 0, s1: 47}) // render existing voices + noteOn(60, 0.8, 0, 42) // note starts at sample 47 + process(output, {s0: 47, s1: 128}) // render with new voice + +## paramChanged(label, value) +Called when a parameter knob changes value. +- label — string, matches the name from the @param comment +- value — the mapped value (number). For unipolar: 0.0–1.0. For linear/exp: min–max. + For int: integer in min–max. For bool: 0 or 1. + +## Declaring parameters +Parameters are declared as comments at the top of the file: + +// @param [default] [min max type [unit]] + +Supported types: linear, exp, int, bool. +If no type is given, the parameter is unipolar (0–1). +If the default is "true" or "false", the type is bool. +Multiple spaces between tokens are allowed for alignment. + +Examples: +// @param attack 0.01 0.001 1.0 exp s +// @param mode 0 0 3 int +// @param bypass false + +## Declaring samples +Samples are declared as comments: + +// @sample + +Each @sample creates a file picker on the device panel. Access the loaded audio via +this.samples.. Returns null before loaded. + +When loaded, this.samples. 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 + } + } + } +} +``` diff --git a/packages/app/studio/public/manuals/devices/instruments/apparat.webp b/packages/app/studio/public/manuals/devices/instruments/apparat.webp new file mode 100644 index 00000000..7035c526 Binary files /dev/null and b/packages/app/studio/public/manuals/devices/instruments/apparat.webp differ diff --git a/packages/app/studio/src/ui/pages/Manuals.ts b/packages/app/studio/src/ui/pages/Manuals.ts index cd468966..93bd0478 100644 --- a/packages/app/studio/src/ui/pages/Manuals.ts +++ b/packages/app/studio/src/ui/pages/Manuals.ts @@ -153,6 +153,12 @@ export const Manuals: ReadonlyArray = [ type: "folder", label: "Instruments", files: [ + { + type: "page", + label: "Apparat", + path: "/manuals/devices/instruments/apparat", + icon: InstrumentFactories.Apparat.defaultIcon + }, { type: "page", label: "MIDIOutput", diff --git a/packages/studio/adapters/src/DeviceManualUrls.ts b/packages/studio/adapters/src/DeviceManualUrls.ts index c54c5f40..7e8c1105 100644 --- a/packages/studio/adapters/src/DeviceManualUrls.ts +++ b/packages/studio/adapters/src/DeviceManualUrls.ts @@ -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" diff --git a/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts b/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts index 388d6f16..8fc34174 100644 --- a/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts +++ b/packages/studio/adapters/src/devices/instruments/ApparatDeviceBoxAdapter.ts @@ -16,7 +16,7 @@ export class ApparatDeviceBoxAdapter implements InstrumentDeviceBoxAdapter { readonly type = "instrument" readonly accepts = "midi" - readonly manualUrl = "" + readonly manualUrl = DeviceManualUrls.Apparat readonly #context: BoxAdaptersContext readonly #box: ApparatDeviceBox diff --git a/packages/studio/adapters/src/factories/InstrumentFactories.ts b/packages/studio/adapters/src/factories/InstrumentFactories.ts index cf97da11..ef24e9fb 100644 --- a/packages/studio/adapters/src/factories/InstrumentFactories.ts +++ b/packages/studio/adapters/src/factories/InstrumentFactories.ts @@ -191,7 +191,7 @@ export namespace InstrumentFactories { defaultName: "Apparat", defaultIcon: IconSymbol.Code, description: "User-scripted instrument", - manualPage: "", + manualPage: DeviceManualUrls.Apparat, trackType: TrackType.Notes, create: (boxGraph: BoxGraph, host: Field,