diff --git a/packages/app/studio/src/ui/devices/ScriptDeviceEditor.sass b/packages/app/studio/src/ui/devices/ScriptDeviceEditor.sass index 29e2e7ac..67ba8014 100644 --- a/packages/app/studio/src/ui/devices/ScriptDeviceEditor.sass +++ b/packages/app/studio/src/ui/devices/ScriptDeviceEditor.sass @@ -36,17 +36,21 @@ component align-items: center justify-content: center outline: 1px dashed rgba(white, 0.1) - margin: 0.375em + margin: 0.5em position: relative cursor: pointer pointer-events: all > svg - width: 2em - height: 2em + width: 1em + height: 1em pointer-events: none > span + text-align: center + width: 75% + overflow: hidden + text-overflow: ellipsis font-size: 0.5em pointer-events: none @@ -60,9 +64,13 @@ component text-overflow: ellipsis overflow: hidden position: absolute - bottom: -1.5em + bottom: -1em width: 100% text-align: center + padding: 1px 2px + border-radius: 3px + color: black + background-color: var(--color-blue) &.accept color: var(--color-black) diff --git a/packages/app/studio/src/ui/devices/ScriptDeviceEditor.tsx b/packages/app/studio/src/ui/devices/ScriptDeviceEditor.tsx index 0fff9a67..bebc0217 100644 --- a/packages/app/studio/src/ui/devices/ScriptDeviceEditor.tsx +++ b/packages/app/studio/src/ui/devices/ScriptDeviceEditor.tsx @@ -198,8 +198,17 @@ export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost, con {label} ) - const sampleSelector = new SampleSelector(service, - SampleSelectStrategy.forPointerField(sample.file)) + 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"), diff --git a/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts b/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts index a69ce6b6..1f4bc70c 100644 --- a/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts +++ b/packages/app/studio/src/ui/devices/instruments/apparat-examples.ts @@ -1,6 +1,8 @@ import SimpleSine from "./examples/simple-sine.js?raw" +import GrainSynth from "./examples/grain-synth.js?raw" import {CodeEditorExample} from "@/ui/werkstatt-editor/CodeEditorState" export const ApparatExamples: ReadonlyArray = [ - {name: "Simple Sine Synth", code: SimpleSine} + {name: "Simple Sine Synth", code: SimpleSine}, + {name: "Grain Synthesizer", code: GrainSynth} ] diff --git a/packages/app/studio/src/ui/devices/instruments/examples/grain-synth.js b/packages/app/studio/src/ui/devices/instruments/examples/grain-synth.js new file mode 100644 index 00000000..744dd05e --- /dev/null +++ b/packages/app/studio/src/ui/devices/instruments/examples/grain-synth.js @@ -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++ + } + } + } +} \ No newline at end of file diff --git a/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts index b845479c..078fc2e8 100644 --- a/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts +++ b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts @@ -2,7 +2,7 @@ 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, WerkstattSampleBox} from "@opendaw/studio-boxes" -import {parseParams, parseSamples} from "@opendaw/studio-adapters" +import {parseDeclarationOrder, parseParams, parseSamples} from "@opendaw/studio-adapters" import type {ParamDeclaration, SampleDeclaration} from "@opendaw/studio-adapters" export interface ScriptDeviceBox { @@ -35,7 +35,7 @@ const parseHeader = (source: string, pattern: RegExp): {userCode: string, update } } -const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray): void => { +const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray, order: Map): void => { const boxGraph = deviceBox.graph const existingPointers = deviceBox.parameters.pointerHub.filter() const existingByLabel = new Map() @@ -51,14 +51,14 @@ 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) + existing.index.setValue(unifiedIndex) if (Math.abs(existing.defaultValue.getValue() - mappedDefault) > FLOAT_TOLERANCE) { existing.defaultValue.setValue(mappedDefault) existing.value.setValue(mappedDefault) @@ -67,7 +67,7 @@ const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray 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) }) @@ -75,7 +75,7 @@ const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray } } -const reconcileSamples = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray): void => { +const reconcileSamples = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray, order: Map): void => { const boxGraph = deviceBox.graph const existingPointers = deviceBox.samples.pointerHub.filter() const existingByLabel = new Map() @@ -91,18 +91,18 @@ const reconcileSamples = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray { sampleBox.owner.refer(deviceBox.samples) sampleBox.label.setValue(declaration.label) - sampleBox.index.setValue(index) + sampleBox.index.setValue(unifiedIndex) }) } } @@ -157,12 +157,13 @@ export const createScriptCompiler = (config: ScriptCompilerConfig) => { 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) - reconcileSamples(deviceBox, samples) + reconcileParameters(deviceBox, params, order) + reconcileSamples(deviceBox, samples, order) } if (append) { editing.append(modifier) diff --git a/packages/studio/adapters/src/ScriptParamDeclaration.ts b/packages/studio/adapters/src/ScriptParamDeclaration.ts index 88ffe63c..d02996d2 100644 --- a/packages/studio/adapters/src/ScriptParamDeclaration.ts +++ b/packages/studio/adapters/src/ScriptParamDeclaration.ts @@ -96,6 +96,22 @@ export const parseSamples = (code: string): ReadonlyArray => return samples } +const DECLARATION_LINE = /^\/\/ @(?:param|sample) \S+/gm + +export const parseDeclarationOrder = (code: string): Map => { + const order = new Map() + let match: Nullable + 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 => { switch (declaration.mapping) { case "unipolar": return ValueMapping.unipolar() diff --git a/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts b/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts index d50a7f24..e72d6f1b 100644 --- a/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts +++ b/packages/studio/core-processors/src/devices/instruments/ApparatDeviceProcessor.ts @@ -1,5 +1,5 @@ import {Arrays, asInstanceOf, int, isDefined, isNotNull, Nullable, Option, Terminable, UUID} from "@opendaw/lib-std" -import {ApparatDeviceBoxAdapter} from "@opendaw/studio-adapters" +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" @@ -53,7 +53,7 @@ export class ApparatDeviceProcessor extends AudioProcessor readonly #peakBroadcaster: PeakBroadcaster readonly #uuid: string readonly #boundParameters: Array> - readonly #sampleLoaders: Map = new Map() + readonly #sampleSlots: Map, lifecycle: Terminable}> = new Map() #userProcessor: Option = Option.None #currentUpdate: int = -1 @@ -102,29 +102,27 @@ export class ApparatDeviceProcessor extends AudioProcessor onAdded: (({box: sampleBox}) => { const sample = asInstanceOf(sampleBox, WerkstattSampleBox) const label = sample.label.getValue() - const subscription = sample.file.catchupAndSubscribe(pointer => { - pointer.targetVertex.match({ - none: () => this.#updateSampleData(label, null), - some: ({box: fileBox}) => { - const fileUUID = fileBox.address.uuid - const loader = context.sampleManager.getOrCreate(fileUUID) - loader.subscribe(state => { - if (state.type === "loaded") { - this.#updateSampleData(label, loader.data.unwrap()) - } - }) - } - }) + const slot: {loader: Option, 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)) + } }) - this.#sampleLoaders.set(label, {label, lifecycle: subscription}) }), onRemoved: (({box: sampleBox}) => { const sample = asInstanceOf(sampleBox, WerkstattSampleBox) const label = sample.label.getValue() - const entry = this.#sampleLoaders.get(label) + const entry = this.#sampleSlots.get(label) if (isDefined(entry)) { entry.lifecycle.terminate() - this.#sampleLoaders.delete(label) + this.#sampleSlots.delete(label) } this.#updateSampleData(label, null) }) @@ -178,6 +176,7 @@ export class ApparatDeviceProcessor extends AudioProcessor } 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) @@ -225,14 +224,14 @@ export class ApparatDeviceProcessor extends AudioProcessor try { const proc = new ProcessorClass() as UserProcessor proc.samples = {} - for (const [label] of this.#sampleLoaders) { + for (const [label] of this.#sampleSlots) { proc.samples[label] = null } this.#userProcessor = Option.wrap(proc) this.#currentUpdate = update this.#silenced = false this.#pushAllParameters() - this.#pushAllSamples() + this.#pollSamples(proc) } catch (error) { this.#silence(`Failed to instantiate Processor: ${error}`) } @@ -249,13 +248,15 @@ export class ApparatDeviceProcessor extends AudioProcessor }) } - #pushAllSamples(): void { - this.#userProcessor.ifSome(proc => { - for (const [label] of this.#sampleLoaders) { - const existing = proc.samples[label] - if (isDefined(existing)) {continue} - } - }) + #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): void {