adds grain and fixes

This commit is contained in:
André Michelle
2026-03-19 14:33:30 +01:00
parent b8f356ba0f
commit 7bfdfdb7e3
7 changed files with 161 additions and 47 deletions
@@ -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)
@@ -198,8 +198,17 @@ export const ScriptDeviceEditor = ({lifecycle, service, adapter, deviceHost, con
<span>{label}</span>
</div>
)
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"),
@@ -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<CodeEditorExample> = [
{name: "Simple Sine Synth", code: SimpleSine}
{name: "Simple Sine Synth", code: SimpleSine},
{name: "Grain Synthesizer", code: GrainSynth}
]
@@ -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++
}
}
}
}
@@ -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<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>()
@@ -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<SampleDeclaration>): void => {
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>()
@@ -91,18 +91,18 @@ const reconcileSamples = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray<Sa
}
}
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)
if (isDefined(existing)) {
existing.index.setValue(index)
existing.index.setValue(unifiedIndex)
} else {
WerkstattSampleBox.create(boxGraph, UUID.generate(), sampleBox => {
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)
@@ -96,6 +96,22 @@ export const parseSamples = (code: string): ReadonlyArray<SampleDeclaration> =>
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()
@@ -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<AutomatableParameter<number>>
readonly #sampleLoaders: Map<string, {label: string, lifecycle: Terminable}> = new Map()
readonly #sampleSlots: Map<string, {loader: Option<SampleLoader>, lifecycle: Terminable}> = new Map()
#userProcessor: Option<UserProcessor> = 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<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))
}
})
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<AudioData>): void {