diff --git a/packages/app/studio/src/ui/devices/audio-effects/WerkstattCompiler.ts b/packages/app/studio/src/ui/devices/audio-effects/WerkstattCompiler.ts index 6e19948d..cebc1622 100644 --- a/packages/app/studio/src/ui/devices/audio-effects/WerkstattCompiler.ts +++ b/packages/app/studio/src/ui/devices/audio-effects/WerkstattCompiler.ts @@ -1,141 +1,7 @@ -import {asInstanceOf, Editing, isDefined, Nullable, UUID} from "@opendaw/lib-std" -import {WerkstattDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes" +import {createScriptCompiler} from "@/ui/werkstatt-editor/ScriptCompiler" -const COMPILER_VERSION = 1 -const HEADER_PATTERN = /^\/\/ @werkstatt (\w+) (\d+) (\d+)\n/ - -interface ParamDeclaration { - label: string - defaultValue: number -} - -const parseHeader = (source: string): { userCode: string, update: number } => { - const match = source.match(HEADER_PATTERN) - return match !== null ? { - userCode: source.slice(match[0].length), - update: parseInt(match[3]) - } : { - userCode: source, - update: 0 - } -} - -const createHeader = (update: number): string => `// @werkstatt js ${COMPILER_VERSION} ${update}\n` - -const PARAM_LINE = /^\/\/ @param .+$/gm - -const parseParams = (code: string): ReadonlyArray => { - const params: Array = [] - let match: Nullable - PARAM_LINE.lastIndex = 0 - while ((match = PARAM_LINE.exec(code)) !== null) { - const valid = match[0].match(/^\/\/ @param (\w+)(?: ([.\d]+))?$/) - if (valid === null) { - throw new Error(`Malformed @param: '${match[0]}' — expected: // @param [defaultValue]`) - } - params.push({ - label: valid[1], - defaultValue: isDefined(valid[2]) ? parseFloat(valid[2]) : 0.0 - }) - } - return params -} - -const reconcileParameters = (deviceBox: WerkstattDeviceBox, declared: ReadonlyArray): void => { - const boxGraph = deviceBox.graph - const existingPointers = deviceBox.parameters.pointerHub.filter() - const existingByLabel = new Map() - for (const pointer of existingPointers) { - const paramBox = asInstanceOf(pointer.box, WerkstattParameterBox) - existingByLabel.set(paramBox.label.getValue(), paramBox) - } - const seen = new Set() - for (const {label} of declared) {seen.add(label)} - for (const [label, paramBox] of existingByLabel) { - if (!seen.has(label)) { - paramBox.delete() - } - } - seen.clear() - for (let index = 0; index < declared.length; index++) { - const {label, defaultValue} = declared[index] - if (seen.has(label)) {continue} - seen.add(label) - const existing = existingByLabel.get(label) - if (isDefined(existing)) { - existing.index.setValue(index) - if (existing.defaultValue.getValue() !== defaultValue) { - existing.defaultValue.setValue(defaultValue) - existing.value.setValue(defaultValue) - } - } else { - WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => { - paramBox.owner.refer(deviceBox.parameters) - paramBox.label.setValue(label) - paramBox.index.setValue(index) - paramBox.value.setValue(defaultValue) - paramBox.defaultValue.setValue(defaultValue) - }) - } - } -} - -const wrapCode = (uuid: string, update: number, userCode: string): string => ` - if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} } - if (typeof globalThis.openDAW.werkstattProcessors === "undefined") { globalThis.openDAW.werkstattProcessors = {} } - globalThis.openDAW.werkstattProcessors["${uuid}"] = { - update: ${update}, - create: (function werkstatt() { - ${userCode} - return Processor - })() - } -` - -const validateCode = (wrappedCode: string): void => {new Function(wrappedCode)} - -const registerWorklet = async (audioContext: BaseAudioContext, - wrappedCode: string): Promise => { - const blob = new Blob([wrappedCode], {type: "application/javascript"}) - const blobUrl = URL.createObjectURL(blob) - try { - await audioContext.audioWorklet.addModule(blobUrl) - } finally { - URL.revokeObjectURL(blobUrl) - } -} - -export namespace WerkstattCompiler { - export const stripHeader = (source: string): string => parseHeader(source).userCode - export const load = async (audioContext: BaseAudioContext, deviceBox: WerkstattDeviceBox): Promise => { - const {userCode, update} = parseHeader(deviceBox.code.getValue()) - if (update === 0) {return} - const uuid = UUID.toString(deviceBox.address.uuid) - const wrappedCode = wrapCode(uuid, update, userCode) - validateCode(wrappedCode) - return registerWorklet(audioContext, wrappedCode) - } - export const compile = async (audioContext: BaseAudioContext, - editing: Editing, - deviceBox: WerkstattDeviceBox, - source: string, - append: boolean = false): Promise => { - const userCode = parseHeader(source).userCode - const currentUpdate = parseHeader(deviceBox.code.getValue()).update - const newUpdate = currentUpdate + 1 - const uuid = UUID.toString(deviceBox.address.uuid) - const params = parseParams(userCode) - const wrappedCode = wrapCode(uuid, newUpdate, userCode) - validateCode(wrappedCode) - const modifier = () => { - deviceBox.code.setValue(createHeader(newUpdate) + userCode) - reconcileParameters(deviceBox, params) - } - if (append) { - editing.append(modifier) - } else { - editing.modify(modifier) - } - return registerWorklet(audioContext, wrappedCode) - } -} \ No newline at end of file +export const WerkstattCompiler = createScriptCompiler({ + headerTag: "werkstatt", + registryName: "werkstattProcessors", + functionName: "werkstatt" +}) diff --git a/packages/app/studio/src/ui/devices/midi-effects/SpielwerkCompiler.ts b/packages/app/studio/src/ui/devices/midi-effects/SpielwerkCompiler.ts index cf5f5531..ff5f130c 100644 --- a/packages/app/studio/src/ui/devices/midi-effects/SpielwerkCompiler.ts +++ b/packages/app/studio/src/ui/devices/midi-effects/SpielwerkCompiler.ts @@ -1,141 +1,7 @@ -import {asInstanceOf, Editing, isDefined, Nullable, UUID} from "@opendaw/lib-std" -import {SpielwerkDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes" +import {createScriptCompiler} from "@/ui/werkstatt-editor/ScriptCompiler" -const COMPILER_VERSION = 1 -const HEADER_PATTERN = /^\/\/ @spielwerk (\w+) (\d+) (\d+)\n/ - -interface ParamDeclaration { - label: string - defaultValue: number -} - -const parseHeader = (source: string): { userCode: string, update: number } => { - const match = source.match(HEADER_PATTERN) - return match !== null ? { - userCode: source.slice(match[0].length), - update: parseInt(match[3]) - } : { - userCode: source, - update: 0 - } -} - -const createHeader = (update: number): string => `// @spielwerk js ${COMPILER_VERSION} ${update}\n` - -const PARAM_LINE = /^\/\/ @param .+$/gm - -const parseParams = (code: string): ReadonlyArray => { - const params: Array = [] - let match: Nullable - PARAM_LINE.lastIndex = 0 - while ((match = PARAM_LINE.exec(code)) !== null) { - const valid = match[0].match(/^\/\/ @param (\w+)(?: ([.\d]+))?$/) - if (valid === null) { - throw new Error(`Malformed @param: '${match[0]}' — expected: // @param [defaultValue]`) - } - params.push({ - label: valid[1], - defaultValue: isDefined(valid[2]) ? parseFloat(valid[2]) : 0.0 - }) - } - return params -} - -const reconcileParameters = (deviceBox: SpielwerkDeviceBox, declared: ReadonlyArray): void => { - const boxGraph = deviceBox.graph - const existingPointers = deviceBox.parameters.pointerHub.filter() - const existingByLabel = new Map() - for (const pointer of existingPointers) { - const paramBox = asInstanceOf(pointer.box, WerkstattParameterBox) - existingByLabel.set(paramBox.label.getValue(), paramBox) - } - const seen = new Set() - for (const {label} of declared) {seen.add(label)} - for (const [label, paramBox] of existingByLabel) { - if (!seen.has(label)) { - paramBox.delete() - } - } - seen.clear() - for (let index = 0; index < declared.length; index++) { - const {label, defaultValue} = declared[index] - if (seen.has(label)) {continue} - seen.add(label) - const existing = existingByLabel.get(label) - if (isDefined(existing)) { - existing.index.setValue(index) - if (existing.defaultValue.getValue() !== defaultValue) { - existing.defaultValue.setValue(defaultValue) - existing.value.setValue(defaultValue) - } - } else { - WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => { - paramBox.owner.refer(deviceBox.parameters) - paramBox.label.setValue(label) - paramBox.index.setValue(index) - paramBox.value.setValue(defaultValue) - paramBox.defaultValue.setValue(defaultValue) - }) - } - } -} - -const wrapCode = (uuid: string, update: number, userCode: string): string => ` - if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} } - if (typeof globalThis.openDAW.spielwerkProcessors === "undefined") { globalThis.openDAW.spielwerkProcessors = {} } - globalThis.openDAW.spielwerkProcessors["${uuid}"] = { - update: ${update}, - create: (function spielwerk() { - ${userCode} - return Processor - })() - } -` - -const validateCode = (wrappedCode: string): void => {new Function(wrappedCode)} - -const registerWorklet = async (audioContext: BaseAudioContext, - wrappedCode: string): Promise => { - const blob = new Blob([wrappedCode], {type: "application/javascript"}) - const blobUrl = URL.createObjectURL(blob) - try { - await audioContext.audioWorklet.addModule(blobUrl) - } finally { - URL.revokeObjectURL(blobUrl) - } -} - -export namespace SpielwerkCompiler { - export const stripHeader = (source: string): string => parseHeader(source).userCode - export const load = async (audioContext: BaseAudioContext, deviceBox: SpielwerkDeviceBox): Promise => { - const {userCode, update} = parseHeader(deviceBox.code.getValue()) - if (update === 0) {return} - const uuid = UUID.toString(deviceBox.address.uuid) - const wrappedCode = wrapCode(uuid, update, userCode) - validateCode(wrappedCode) - return registerWorklet(audioContext, wrappedCode) - } - export const compile = async (audioContext: BaseAudioContext, - editing: Editing, - deviceBox: SpielwerkDeviceBox, - source: string, - append: boolean = false): Promise => { - const userCode = parseHeader(source).userCode - const currentUpdate = parseHeader(deviceBox.code.getValue()).update - const newUpdate = currentUpdate + 1 - const uuid = UUID.toString(deviceBox.address.uuid) - const params = parseParams(userCode) - const wrappedCode = wrapCode(uuid, newUpdate, userCode) - validateCode(wrappedCode) - const modifier = () => { - deviceBox.code.setValue(createHeader(newUpdate) + userCode) - reconcileParameters(deviceBox, params) - } - if (append) { - editing.append(modifier) - } else { - editing.modify(modifier) - } - return registerWorklet(audioContext, wrappedCode) - } -} +export const SpielwerkCompiler = createScriptCompiler({ + headerTag: "spielwerk", + registryName: "spielwerkProcessors", + functionName: "spielwerk" +}) diff --git a/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts new file mode 100644 index 00000000..cb8be82a --- /dev/null +++ b/packages/app/studio/src/ui/werkstatt-editor/ScriptCompiler.ts @@ -0,0 +1,158 @@ +import {asInstanceOf, Editing, isDefined, Nullable, UUID} from "@opendaw/lib-std" +import {BoxGraph, Field, StringField} from "@opendaw/lib-box" +import {Pointers} from "@opendaw/studio-enums" +import {WerkstattParameterBox} from "@opendaw/studio-boxes" + +export interface ScriptDeviceBox { + readonly graph: BoxGraph + readonly address: {readonly uuid: UUID.Bytes} + readonly code: StringField + readonly parameters: Field +} + +interface ParamDeclaration { + label: string + defaultValue: number +} + +export type ScriptCompilerConfig = { + readonly headerTag: string + readonly registryName: string + readonly functionName: string +} + +const COMPILER_VERSION = 1 +const PARAM_LINE = /^\/\/ @param .+$/gm + +const createHeaderPattern = (tag: string): RegExp => new RegExp(`^// @${tag} (\\w+) (\\d+) (\\d+)\n`) + +const parseHeader = (source: string, pattern: RegExp): {userCode: string, update: number} => { + const match = source.match(pattern) + return match !== null ? { + userCode: source.slice(match[0].length), + update: parseInt(match[3]) + } : { + userCode: source, + update: 0 + } +} + +const parseParams = (code: string): ReadonlyArray => { + const params: Array = [] + let match: Nullable + PARAM_LINE.lastIndex = 0 + while ((match = PARAM_LINE.exec(code)) !== null) { + const valid = match[0].match(/^\/\/ @param (\w+)(?: ([.\d]+))?$/) + if (valid === null) { + throw new Error(`Malformed @param: '${match[0]}' — expected: // @param [defaultValue]`) + } + params.push({ + label: valid[1], + defaultValue: isDefined(valid[2]) ? parseFloat(valid[2]) : 0.0 + }) + } + return params +} + +const reconcileParameters = (deviceBox: ScriptDeviceBox, declared: ReadonlyArray): void => { + const boxGraph = deviceBox.graph + const existingPointers = deviceBox.parameters.pointerHub.filter() + const existingByLabel = new Map() + for (const pointer of existingPointers) { + const paramBox = asInstanceOf(pointer.box, WerkstattParameterBox) + existingByLabel.set(paramBox.label.getValue(), paramBox) + } + const seen = new Set() + for (const {label} of declared) {seen.add(label)} + for (const [label, paramBox] of existingByLabel) { + if (!seen.has(label)) { + paramBox.delete() + } + } + seen.clear() + for (let index = 0; index < declared.length; index++) { + const {label, defaultValue} = declared[index] + if (seen.has(label)) {continue} + seen.add(label) + const existing = existingByLabel.get(label) + if (isDefined(existing)) { + existing.index.setValue(index) + if (existing.defaultValue.getValue() !== defaultValue) { + existing.defaultValue.setValue(defaultValue) + existing.value.setValue(defaultValue) + } + } else { + WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => { + paramBox.owner.refer(deviceBox.parameters) + paramBox.label.setValue(label) + paramBox.index.setValue(index) + paramBox.value.setValue(defaultValue) + paramBox.defaultValue.setValue(defaultValue) + }) + } + } +} + +const wrapCode = (config: ScriptCompilerConfig, uuid: string, update: number, userCode: string): string => ` + if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} } + if (typeof globalThis.openDAW.${config.registryName} === "undefined") { globalThis.openDAW.${config.registryName} = {} } + globalThis.openDAW.${config.registryName}["${uuid}"] = { + update: ${update}, + create: (function ${config.functionName}() { + ${userCode} + return Processor + })() + } +` + +const validateCode = (wrappedCode: string): void => {new Function(wrappedCode)} + +const registerWorklet = async (audioContext: BaseAudioContext, wrappedCode: string): Promise => { + const blob = new Blob([wrappedCode], {type: "application/javascript"}) + const blobUrl = URL.createObjectURL(blob) + try { + await audioContext.audioWorklet.addModule(blobUrl) + } finally { + URL.revokeObjectURL(blobUrl) + } +} + +export const createScriptCompiler = (config: ScriptCompilerConfig) => { + const headerPattern = createHeaderPattern(config.headerTag) + const createHeader = (update: number): string => + `// @${config.headerTag} js ${COMPILER_VERSION} ${update}\n` + return { + stripHeader: (source: string): string => parseHeader(source, headerPattern).userCode, + load: async (audioContext: BaseAudioContext, deviceBox: ScriptDeviceBox): Promise => { + const {userCode, update} = parseHeader(deviceBox.code.getValue(), headerPattern) + if (update === 0) {return} + const uuid = UUID.toString(deviceBox.address.uuid) + const wrappedCode = wrapCode(config, uuid, update, userCode) + validateCode(wrappedCode) + return registerWorklet(audioContext, wrappedCode) + }, + compile: async (audioContext: BaseAudioContext, + editing: Editing, + deviceBox: ScriptDeviceBox, + source: string, + append: boolean = false): Promise => { + const userCode = parseHeader(source, headerPattern).userCode + const currentUpdate = parseHeader(deviceBox.code.getValue(), headerPattern).update + const newUpdate = currentUpdate + 1 + const uuid = UUID.toString(deviceBox.address.uuid) + const params = parseParams(userCode) + const wrappedCode = wrapCode(config, uuid, newUpdate, userCode) + validateCode(wrappedCode) + const modifier = () => { + deviceBox.code.setValue(createHeader(newUpdate) + userCode) + reconcileParameters(deviceBox, params) + } + if (append) { + editing.append(modifier) + } else { + editing.modify(modifier) + } + return registerWorklet(audioContext, wrappedCode) + } + } +}