updates plans

This commit is contained in:
André Michelle
2026-03-09 15:50:34 +01:00
parent 0dda367423
commit 0993332188
2 changed files with 53 additions and 53 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
An educational device where students write DSP code and see the frequency/harmonic response in a connected spectrum analyser. Inspired by Plugin Doctor's static response analysis.
Built on top of the [Script Device](script-device.md) — same Processor class contract, same parameter system, same worklet integration. Formular adds an onboard analyser that runs measurement signals through the user's code and visualises the results.
Built on top of the [Werkstatt](werkstatt.md) — same Processor class contract, same parameter system, same worklet integration. Formular adds an onboard analyser that runs measurement signals through the user's code and visualises the results.
## Analysis Techniques
@@ -47,7 +47,7 @@ Impulse method breaks down because the output depends on signal level.
## Proposed Device Architecture
- **Code editor**: student writes DSP code (init + process functions) — inherited from Script Device
- **Code editor**: student writes DSP code (init + process functions) — inherited from Werkstatt
- **Mode 1 — Impulse**: Dirac → FFT → frequency + phase response (filters)
- **Mode 2 — Single tone**: adjustable frequency sine → FFT → harmonic spectrum (distortion)
- **Mode 3 — Swept sine**: log chirp → deconvolution → linear IR + harmonic separation (advanced)
+51 -51
View File
@@ -1,4 +1,4 @@
# Script Device — User-Scripted DSP Processor
# Werkstatt — User-Scripted DSP Processor
## Concept
@@ -70,10 +70,10 @@ class Processor {
### Forge Schema
#### ScriptDeviceBox
#### WerkstattBox
```typescript
export const ScriptDeviceBox: BoxSchema<Pointers> = DeviceFactory.createAudioEffect("ScriptDeviceBox", {
export const WerkstattBox: BoxSchema<Pointers> = DeviceFactory.createAudioEffect("WerkstattBox", {
10: {type: "string", name: "code", value: ""},
11: {type: "int32", name: "version", constraints: "any", unit: ""}
})
@@ -84,20 +84,20 @@ No `parameters` hook field yet — added in iteration 2.
### Adapter
```typescript
export class ScriptDeviceBoxAdapter implements AudioEffectDeviceAdapter {
export class WerkstattBoxAdapter implements AudioEffectDeviceAdapter {
readonly type = "audio-effect"
readonly accepts = "audio"
readonly manualUrl = DeviceManualUrls.ScriptDevice
readonly manualUrl = DeviceManualUrls.Werkstatt
readonly #context: BoxAdaptersContext
readonly #box: ScriptDeviceBox
readonly #box: WerkstattBox
constructor(context: BoxAdaptersContext, box: ScriptDeviceBox) {
constructor(context: BoxAdaptersContext, box: WerkstattBox) {
this.#context = context
this.#box = box
}
get box(): ScriptDeviceBox {return this.#box}
get box(): WerkstattBox {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}
@@ -114,18 +114,18 @@ export class ScriptDeviceBoxAdapter implements AudioEffectDeviceAdapter {
### Recompile Flow (Main Thread)
```typescript
export namespace ScriptDeviceCompiler {
export namespace WerkstattCompiler {
export const compile = async (
audioContext: BaseAudioContext,
deviceBox: ScriptDeviceBox
deviceBox: WerkstattBox
): Promise<void> => {
const code = deviceBox.code.getValue()
const uuid = UUID.toString(deviceBox.address.uuid)
const version = deviceBox.version.getValue() + 1
const wrappedCode = `
globalThis.openDAW.scriptProcessors["${uuid}"] = {
globalThis.openDAW.werkstattProcessors["${uuid}"] = {
version: ${version},
create: (function script() {
create: (function werkstatt() {
${code}
return Processor
})()
@@ -146,8 +146,8 @@ export namespace ScriptDeviceCompiler {
### Device Processor (Worklet)
```typescript
export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffectDeviceProcessor {
readonly #adapter: ScriptDeviceBoxAdapter
export class WerkstattProcessor extends AudioProcessor implements AudioEffectDeviceProcessor {
readonly #adapter: WerkstattBoxAdapter
readonly #output: AudioBuffer
readonly #peaks: PeakBroadcaster
@@ -157,7 +157,7 @@ export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffect
#silenced: boolean = false
#error: Option<string> = Option.None
constructor(context: EngineContext, adapter: ScriptDeviceBoxAdapter) {
constructor(context: EngineContext, adapter: WerkstattBoxAdapter) {
super(context)
this.#adapter = adapter
this.#output = new AudioBuffer()
@@ -178,7 +178,7 @@ export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffect
#tryLoadVersion(version: number): void {
const uuid = UUID.toString(this.#adapter.uuid)
const registry = (globalThis as any).openDAW?.scriptProcessors?.[uuid]
const registry = (globalThis as any).openDAW?.werkstattProcessors?.[uuid]
if (isDefined(registry) && registry.version === version) {
this.#swapProcessor(registry.create, version)
}
@@ -219,7 +219,7 @@ export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffect
processAudio(block: Block): void {
if (this.#silenced) {
const uuid = UUID.toString(this.#adapter.uuid)
const registry = (globalThis as any).openDAW?.scriptProcessors?.[uuid]
const registry = (globalThis as any).openDAW?.werkstattProcessors?.[uuid]
const expectedVersion = this.#adapter.box.version.getValue()
if (isDefined(registry) && registry.version === expectedVersion) {
this.#swapProcessor(registry.create, expectedVersion)
@@ -243,7 +243,7 @@ export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffect
this.#peaks.process(outL, outR, s0, s1)
}
toString(): string {return `{ScriptDeviceProcessor}`}
toString(): string {return `{WerkstattProcessor}`}
}
```
@@ -251,36 +251,36 @@ export class ScriptDeviceProcessor extends AudioProcessor implements AudioEffect
```typescript
// In EffectFactories.ts
export const ScriptDevice: EffectFactory = {
defaultName: "Script",
export const Werkstatt: EffectFactory = {
defaultName: "Werkstatt",
defaultIcon: IconSymbol.Code,
description: "User-scripted DSP processor",
manualPage: DeviceManualUrls.ScriptDevice,
manualPage: DeviceManualUrls.Werkstatt,
separatorBefore: false,
type: "audio",
create: ({boxGraph}, hostField, index): ScriptDeviceBox =>
ScriptDeviceBox.create(boxGraph, UUID.generate(), box => {
box.label.setValue("Script")
create: ({boxGraph}, hostField, index): WerkstattBox =>
WerkstattBox.create(boxGraph, UUID.generate(), box => {
box.label.setValue("Werkstatt")
box.index.setValue(index)
box.host.refer(hostField)
})
}
// In DeviceProcessorFactory.ts
visitScriptDeviceBox: (box: ScriptDeviceBox): AudioEffectDeviceProcessor =>
new ScriptDeviceProcessor(context, context.boxAdapters.adapterFor(box, ScriptDeviceBoxAdapter))
visitWerkstattBox: (box: WerkstattBox): AudioEffectDeviceProcessor =>
new WerkstattProcessor(context, context.boxAdapters.adapterFor(box, WerkstattBoxAdapter))
// In BoxAdapters.ts
visitScriptDeviceBox: (box: ScriptDeviceBox) => new ScriptDeviceBoxAdapter(this.#context, box)
visitWerkstattBox: (box: WerkstattBox) => new WerkstattBoxAdapter(this.#context, box)
// In BoxVisitor
visitScriptDeviceBox?(box: ScriptDeviceBox): R
visitWerkstattBox?(box: WerkstattBox): R
// In DeviceEditorFactory.tsx
visitScriptDeviceBox: (box: ScriptDeviceBox) => (
<ScriptDeviceEditor lifecycle={lifecycle}
visitWerkstattBox: (box: WerkstattBox) => (
<WerkstattEditor lifecycle={lifecycle}
service={service}
adapter={service.project.boxAdapters.adapterFor(box, ScriptDeviceBoxAdapter)}
adapter={service.project.boxAdapters.adapterFor(box, WerkstattBoxAdapter)}
deviceHost={deviceHost}/>
)
```
@@ -288,7 +288,7 @@ visitScriptDeviceBox: (box: ScriptDeviceBox) => (
### Editor (Sketch)
```typescript
export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
export const WerkstattEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
const {project} = service
return (
<DeviceEditor lifecycle={lifecycle}
@@ -299,7 +299,7 @@ export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Co
code={adapter.box.code}
onCompile={async (code) => {
adapter.box.code.setValue(code)
await ScriptDeviceCompiler.compile(
await WerkstattCompiler.compile(
service.audioContext, adapter.box)
}}/>
)}
@@ -308,7 +308,7 @@ export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Co
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
icon={EffectFactories.ScriptDevice.defaultIcon}/>
icon={EffectFactories.Werkstatt.defaultIcon}/>
)
}
```
@@ -321,18 +321,18 @@ Adds automatable parameters via +/- buttons in the editor. The code references t
### What Changes
**Forge schema**: Add `parameters` hook field to `ScriptDeviceBox`, add `ScriptParameterBox`.
**Forge schema**: Add `parameters` hook field to `WerkstattBox`, add `WerkstattParameterBox`.
```typescript
// Add to ScriptDeviceBox
// Add to WerkstattBox
12: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
```
```typescript
export const ScriptParameterBox: BoxSchema<Pointers> = {
export const WerkstattParameterBox: BoxSchema<Pointers> = {
type: "box",
class: {
name: "ScriptParameterBox",
name: "WerkstattParameterBox",
fields: {
1: {type: "pointer", name: "owner", pointerType: Pointers.Parameter, mandatory: true},
2: {type: "string", name: "name"},
@@ -357,14 +357,14 @@ this.#parametric = this.#terminator.own(new ParameterAdapterSet(this.#context))
this.#terminator.own(
box.parameters.pointerHub.catchupAndSubscribe({
onAdded: ({box}) => {
const paramBox = asInstanceOf(box, ScriptParameterBox)
const paramBox = asInstanceOf(box, WerkstattParameterBox)
const mapping = this.#resolveMapping(paramBox)
this.#parametric.createParameter(
paramBox.value, mapping.valueMapping, mapping.stringMapping,
paramBox.name.getValue())
},
onRemoved: ({box}) => this.#parametric
.removeParameter(asInstanceOf(box, ScriptParameterBox).value.address)
.removeParameter(asInstanceOf(box, WerkstattParameterBox).value.address)
})
)
```
@@ -383,13 +383,13 @@ The mapping defines how the knob's 0→1 range maps to min→max. Parameter valu
// Add to constructor
box.parameters.pointerHub.catchupAndSubscribe({
onAdded: ({box}) => {
const paramBox = asInstanceOf(box, ScriptParameterBox)
const paramBox = asInstanceOf(box, WerkstattParameterBox)
const param = this.bindParameter(
parameters.parameterAt(paramBox.value.address))
this.#parameters.push(param)
},
onRemoved: ({box}) => {
const paramBox = asInstanceOf(box, ScriptParameterBox)
const paramBox = asInstanceOf(box, WerkstattParameterBox)
Arrays.removeIf(this.#parameters, parameter =>
parameter.address === paramBox.value.address)
}
@@ -397,7 +397,7 @@ box.parameters.pointerHub.catchupAndSubscribe({
// Add method
parameterChanged(parameter: AutomatableParameter): void {
const paramBox = asInstanceOf(parameter.adapter.field.box, ScriptParameterBox)
const paramBox = asInstanceOf(parameter.adapter.field.box, WerkstattParameterBox)
const name = paramBox.name.getValue()
const value = parameter.getValue()
this.#userProcessor.ifSome(proc => {
@@ -413,7 +413,7 @@ parameterChanged(parameter: AutomatableParameter): void {
```typescript
const addParameter = () => {
editing.modify(() => {
ScriptParameterBox.create(boxGraph, UUID.generate(), paramBox => {
WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => {
paramBox.owner.refer(adapter.box.parameters)
paramBox.name.setValue("param")
paramBox.min.setValue(0)
@@ -423,7 +423,7 @@ const addParameter = () => {
})
})
}
const removeParameter = (paramBox: ScriptParameterBox) => {
const removeParameter = (paramBox: WerkstattParameterBox) => {
editing.modify(() => paramBox.delete())
}
```
@@ -528,21 +528,21 @@ Use plain TypeScript — no custom language, no parser, no WASM compiler. The us
The `AudioWorkletGlobalScope` has no `eval()`, `new Function()`, `import()`, or `fetch()`. The only way to load code is `audioContext.audioWorklet.addModule(url)` from the main thread.
**Scoping**: The generated wrapper wraps user code in a **named IIFE**. All user-defined classes and variables are trapped in the closure — nothing leaks to the global except the factory registration. The registry key is the `ScriptDeviceBox` UUID, so each device instance has its own slot. The named IIFE (`function script()`) ensures meaningful error stack traces instead of `<anonymous>`:
**Scoping**: The generated wrapper wraps user code in a **named IIFE**. All user-defined classes and variables are trapped in the closure — nothing leaks to the global except the factory registration. The registry key is the `WerkstattBox` UUID, so each device instance has its own slot. The named IIFE (`function werkstatt()`) ensures meaningful error stack traces instead of `<anonymous>`:
```javascript
globalThis.openDAW.scriptProcessors["<ScriptDeviceBox.uuid>"] = {
globalThis.openDAW.werkstattProcessors["<WerkstattBox.uuid>"] = {
version: 42,
create: (function script() {
create: (function werkstatt() {
class Processor { /* ... */ }
return Processor
})()
}
```
Two Script Device instances can both define `class Biquad` without collision. Each is inside its own IIFE closure.
Two Werkstatt instances can both define `class Biquad` without collision. Each is inside its own IIFE closure.
**Version gating**: Each recompile increments a version counter stored in the Box. The `ScriptDeviceProcessor` in the worklet:
**Version gating**: Each recompile increments a version counter stored in the Box. The `WerkstattProcessor` in the worklet:
- Watches the Box version field
- When it changes, **immediately silences the old processor** (outputs zeros)
- Polls the registry each `processAudio()` call for a matching version
@@ -569,7 +569,7 @@ Runtime errors caught in `processAudio()` need to reach the editor UI. Options:
## Design Decisions
- **Parameters managed via UI, not code**: +/- buttons in the editor create/remove `ScriptParameterBox` instances. The code references them by name via `paramChanged()`. This eliminates host-side reflection, main-thread eval, and parameter reconciliation entirely.
- **Parameters managed via UI, not code**: +/- buttons in the editor create/remove `WerkstattParameterBox` instances. The code references them by name via `paramChanged()`. This eliminates host-side reflection, main-thread eval, and parameter reconciliation entirely.
- **Block-based stereo processing**: `process(inputL, inputR, outputL, outputR, fromIndex, toIndex)` — the host extracts `s0`/`s1` from the per-chunk `Block`. No per-sample function call overhead, full stereo/cross-channel capability.
- **Error recovery is host-injected**: broken code silences the processor until the next successful recompile.
- **Peak metering is host-injected**: `PeakBroadcaster` runs on the output after the user's `process()` call.