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 {