fixes #162
This commit is contained in:
Generated
+15253
-15222
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ export namespace ProjectDialogs {
|
||||
include: !unit.isOutput,
|
||||
includeAudioEffects: true,
|
||||
includeSends: true,
|
||||
useInstrumentOutput: false,
|
||||
fileName: ExportStemsConfiguration.sanitizeFileName(unit.input.label.unwrap())
|
||||
}
|
||||
])))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {Arrays, Errors, isDefined, Option, RuntimeNotifier, UUID} from "@opendaw/lib-std"
|
||||
import {AudioData, RegionCollection} from "@opendaw/lib-dsp"
|
||||
import {Promises} from "@opendaw/lib-runtime"
|
||||
import {AudioRegionBoxAdapter, ExportStemsConfiguration} from "@opendaw/studio-adapters"
|
||||
import {
|
||||
AudioContentFactory,
|
||||
AudioFileBoxFactory,
|
||||
OfflineEngineRenderer,
|
||||
Project,
|
||||
SampleService,
|
||||
WavFile,
|
||||
Workers
|
||||
} from "@opendaw/studio-core"
|
||||
|
||||
export namespace AudioConsolidation {
|
||||
export const flatten = async (project: Project,
|
||||
sampleService: SampleService,
|
||||
regions: ReadonlyArray<AudioRegionBoxAdapter>,
|
||||
abortSignal?: AbortSignal): Promise<void> => {
|
||||
if (regions.length === 0) {return}
|
||||
const sorted = regions.toSorted(RegionCollection.Comparator)
|
||||
const first = Arrays.getFirst(sorted, "No first region")
|
||||
const last = Arrays.getLast(sorted, "No last region")
|
||||
const rangeMin = first.position
|
||||
const rangeMax = last.complete
|
||||
const trackBoxAdapter = first.trackBoxAdapter.unwrap("Has no trackAdapter")
|
||||
const audioUnitUuid = UUID.toString(trackBoxAdapter.audioUnit.address.uuid)
|
||||
const sampleRate = sampleService.audioContext.sampleRate
|
||||
const durationSeconds = project.tempoMap.intervalToSeconds(rangeMin, rangeMax)
|
||||
const numSamples = Math.ceil(durationSeconds * sampleRate)
|
||||
const exportConfiguration: ExportStemsConfiguration = {
|
||||
[audioUnitUuid]: {
|
||||
includeAudioEffects: false,
|
||||
includeSends: false,
|
||||
useInstrumentOutput: true,
|
||||
fileName: `Merged ${first.file.fileName}`
|
||||
}
|
||||
}
|
||||
const selectedUuids = UUID.newSet<UUID.Bytes>(uuid => uuid)
|
||||
sorted.forEach(region => selectedUuids.add(region.uuid))
|
||||
const allRegionsInRange = [...trackBoxAdapter.regions.collection.iterateRange(rangeMin, rangeMax)]
|
||||
const nonSelectedUuids = allRegionsInRange
|
||||
.filter(region => !selectedUuids.hasKey(region.uuid))
|
||||
.map(region => region.uuid)
|
||||
const copiedProject = project.copy()
|
||||
copiedProject.boxGraph.beginTransaction()
|
||||
nonSelectedUuids.forEach(uuid => copiedProject.boxGraph.findBox(uuid).ifSome(box => box.delete()))
|
||||
copiedProject.boxGraph.endTransaction()
|
||||
const abortController = new AbortController()
|
||||
if (isDefined(abortSignal)) {
|
||||
abortSignal.addEventListener("abort", () => abortController.abort())
|
||||
}
|
||||
const dialog = RuntimeNotifier.progress({
|
||||
headline: "Flattening Audio Regions...",
|
||||
cancel: () => abortController.abort()
|
||||
})
|
||||
const renderResult = await Promises.tryCatch((async () => {
|
||||
const renderer = await OfflineEngineRenderer.create(copiedProject, Option.wrap(exportConfiguration), sampleRate)
|
||||
try {
|
||||
await renderer.waitForLoading()
|
||||
renderer.setPosition(rangeMin)
|
||||
renderer.play()
|
||||
await renderer.waitForLoading()
|
||||
const channels = await renderer.step(numSamples)
|
||||
const audioData = AudioData.create(sampleRate, numSamples, 2)
|
||||
audioData.frames[0].set(channels[0])
|
||||
audioData.frames[1].set(channels[1])
|
||||
return audioData
|
||||
} finally {
|
||||
renderer.terminate()
|
||||
}
|
||||
})())
|
||||
if (renderResult.status === "rejected") {
|
||||
dialog.terminate()
|
||||
if (!Errors.isAbort(renderResult.error)) {
|
||||
await RuntimeNotifier.info({headline: "Flatten Failed", message: String(renderResult.error)})
|
||||
}
|
||||
return
|
||||
}
|
||||
const audioData = renderResult.value
|
||||
dialog.message = "Importing sample..."
|
||||
const importResult = await Promises.tryCatch(
|
||||
sampleService.importFile({name: "flatten", arrayBuffer: WavFile.encodeFloats(audioData)}))
|
||||
if (importResult.status === "rejected") {
|
||||
dialog.terminate()
|
||||
await RuntimeNotifier.info({headline: "Flatten Failed", message: String(importResult.error)})
|
||||
return
|
||||
}
|
||||
const sample = importResult.value
|
||||
const sampleUuid = UUID.parse(sample.uuid)
|
||||
dialog.message = "Creating region..."
|
||||
const audioFileBoxModifier = await AudioFileBoxFactory.createModifier(
|
||||
Workers.Transients, project.boxGraph, audioData, sampleUuid, sample.name)
|
||||
dialog.terminate()
|
||||
project.editing.modify(() => {
|
||||
const audioFileBox = audioFileBoxModifier()
|
||||
allRegionsInRange.forEach(region => region.box.delete())
|
||||
AudioContentFactory.createNotStretchedRegion({
|
||||
boxGraph: project.boxGraph,
|
||||
targetTrack: trackBoxAdapter.box,
|
||||
audioFileBox,
|
||||
sample,
|
||||
position: rangeMin,
|
||||
name: first.label
|
||||
})
|
||||
project.trackUserCreatedSample(sampleUuid)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {Dialogs} from "@/ui/components/dialogs.tsx"
|
||||
import {StudioService} from "@/service/StudioService"
|
||||
import {Promises} from "@opendaw/lib-runtime"
|
||||
import {RegionsShortcuts} from "@/ui/shortcuts/RegionsShortcuts"
|
||||
import {AudioConsolidation} from "@/service/AudioConsolidation"
|
||||
|
||||
type Construct = {
|
||||
element: Element
|
||||
@@ -66,12 +67,11 @@ export const installRegionContextMenu =
|
||||
ColorMenu.createItem(hue => editing.modify(() =>
|
||||
selection.selected().slice().forEach(adapter => adapter.box.hue.setValue(hue)))),
|
||||
MenuItem.default({label: "Rename"})
|
||||
.setTriggerProcedure(() => Surface.get(element).requestFloatingTextInput(client, region.label).then(value => {
|
||||
NameValidator.validate(value, {
|
||||
.setTriggerProcedure(() => Surface.get(element).requestFloatingTextInput(client, region.label)
|
||||
.then(value => NameValidator.validate(value, {
|
||||
success: name => editing.modify(() => selection.selected()
|
||||
.forEach(adapter => adapter.box.label.setValue(name)))
|
||||
})
|
||||
}, EmptyExec)),
|
||||
}), EmptyExec)),
|
||||
MenuItem.default({label: "Loop Selection"})
|
||||
.setTriggerProcedure(() => {
|
||||
const [min, max] = computeSelectionRange()
|
||||
@@ -92,8 +92,18 @@ export const installRegionContextMenu =
|
||||
}).setTriggerProcedure(() => editing.modify(() => selection.selected().slice()
|
||||
.forEach(adapter => adapter.consolidate()))),
|
||||
MenuItem.default({label: "Flatten", selectable: region.canFlatten(selection.selected())})
|
||||
.setTriggerProcedure(() => editing.modify(() =>
|
||||
region.flatten(selection.selected()).ifSome(box => project.selection.select(box)))),
|
||||
.setTriggerProcedure(() => {
|
||||
if (region instanceof AudioRegionBoxAdapter) {
|
||||
const audioRegions = selection.selected()
|
||||
.filter((adapter): adapter is AudioRegionBoxAdapter =>
|
||||
isInstanceOf(adapter, AudioRegionBoxAdapter))
|
||||
AudioConsolidation.flatten(project, service.sampleService, audioRegions)
|
||||
.then(EmptyExec, console.warn)
|
||||
} else {
|
||||
editing.modify(() =>
|
||||
region.flatten(selection.selected()).ifSome(box => project.selection.select(box)))
|
||||
}
|
||||
}),
|
||||
MenuItem.default({label: "Convert to Clip"})
|
||||
.setTriggerProcedure(() => editing.modify(() => {
|
||||
service.timeline.clips.visible.setValue(true)
|
||||
|
||||
@@ -15,6 +15,7 @@ export type EngineProcessorAttachment = {
|
||||
export type ExportStemConfiguration = {
|
||||
includeAudioEffects: boolean
|
||||
includeSends: boolean
|
||||
useInstrumentOutput: boolean
|
||||
fileName: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Arrays,
|
||||
asEnumValue,
|
||||
int,
|
||||
isInstanceOf,
|
||||
@@ -261,10 +262,12 @@ export class AudioRegionBoxAdapter implements AudioContentBoxAdapter, LoopableRe
|
||||
return adapter
|
||||
}
|
||||
|
||||
consolidate(): void {
|
||||
// TODO This needs to done by creating a new audio file
|
||||
consolidate(): void {}
|
||||
canFlatten(regions: ReadonlyArray<RegionBoxAdapter<unknown>>): boolean {
|
||||
return regions.length > 0
|
||||
&& Arrays.satisfy(regions, (a, b) => a.trackBoxAdapter.contains(b.trackBoxAdapter.unwrap()))
|
||||
&& regions.every(region => region.isSelected && region instanceof AudioRegionBoxAdapter)
|
||||
}
|
||||
canFlatten(_regions: ReadonlyArray<RegionBoxAdapter<unknown>>): boolean {return false}
|
||||
flatten(_regions: ReadonlyArray<RegionBoxAdapter<unknown>>): Option<AudioRegionBox> {
|
||||
// TODO This needs to done by creating a new audio file
|
||||
return Option.None
|
||||
|
||||
@@ -122,15 +122,24 @@ export class AudioDeviceChain implements DeviceChain {
|
||||
toString(): string {return `{${this.constructor.name}}`}
|
||||
|
||||
#wire(): void {
|
||||
const isOutputUnit = this.#audioUnit.adapter.isOutput
|
||||
const context = this.#audioUnit.context
|
||||
const optInput = this.#audioUnit.input()
|
||||
if (optInput.isEmpty()) {return}
|
||||
const isOutputUnit = this.#audioUnit.adapter.isOutput
|
||||
const optOutput = this.#audioUnit.adapter.output.adapter.map(adapter =>
|
||||
context.getAudioUnit(adapter.deviceHost().uuid).inputAsAudioBus())
|
||||
if (optInput.isEmpty() || (optOutput.isEmpty() && !isOutputUnit)) {return}
|
||||
if (this.#options.useInstrumentOutput) {
|
||||
if (optOutput.nonEmpty() && !isOutputUnit) {
|
||||
const source = optInput.unwrap()
|
||||
const audioBus = optOutput.unwrap()
|
||||
this.#disconnector.own(audioBus.addAudioSource(source.audioOutput))
|
||||
this.#disconnector.own(context.registerEdge(source.outgoing, audioBus))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (optOutput.isEmpty() && !isOutputUnit) {return}
|
||||
let source: AudioDeviceProcessor = optInput.unwrap()
|
||||
let edgeSource: Processor = source.outgoing
|
||||
// Insert monitoring mixer into edge chain if active
|
||||
if (this.#monitoringMixer.nonEmpty() && this.#monitoringMixer.unwrap().isActive) {
|
||||
const mixer = this.#monitoringMixer.unwrap()
|
||||
this.#disconnector.own(mixer.setAudioSource(source.audioOutput))
|
||||
@@ -150,7 +159,6 @@ export class AudioDeviceChain implements DeviceChain {
|
||||
}
|
||||
}
|
||||
if (this.#options.includeSends) {
|
||||
// Connect with aux sends (pre-mode) TODO Post-Mode
|
||||
this.#auxSends.forEach(auxSend => {
|
||||
const target = context.getAudioUnit(auxSend.adapter.targetBus.deviceHost().uuid)
|
||||
this.#disconnector.own(auxSend.setAudioSource(source.audioOutput))
|
||||
|
||||
@@ -24,9 +24,12 @@ export class AudioUnit implements Terminable {
|
||||
|
||||
#input: Option<InstrumentDeviceProcessor | AudioBusProcessor> = Option.None
|
||||
|
||||
readonly #useInstrumentOutput: boolean
|
||||
|
||||
constructor(context: EngineContext, adapter: AudioUnitBoxAdapter, options: AudioUnitOptions) {
|
||||
this.#context = context
|
||||
this.#adapter = adapter
|
||||
this.#useInstrumentOutput = options.useInstrumentOutput
|
||||
|
||||
this.#midiDeviceChain = this.#terminator.own(new MidiDeviceChain(this))
|
||||
this.#audioDeviceChain = this.#terminator.own(new AudioDeviceChain(this, options))
|
||||
@@ -44,7 +47,11 @@ export class AudioUnit implements Terminable {
|
||||
|
||||
input(): Option<InstrumentDeviceProcessor | AudioBusProcessor> {return this.#input}
|
||||
inputAsAudioBus(): AudioBusProcessor {return asInstanceOf(this.#input.unwrap("No input available"), AudioBusProcessor)}
|
||||
audioOutput(): AudioBuffer {return this.#audioDeviceChain.channelStrip.audioOutput}
|
||||
audioOutput(): AudioBuffer {
|
||||
return this.#useInstrumentOutput
|
||||
? this.#input.unwrap().audioOutput
|
||||
: this.#audioDeviceChain.channelStrip.audioOutput
|
||||
}
|
||||
|
||||
get midiDeviceChain(): MidiDeviceChain {return this.#midiDeviceChain}
|
||||
get audioDeviceChain(): AudioDeviceChain {return this.#audioDeviceChain}
|
||||
|
||||
@@ -3,5 +3,9 @@ import {ExportStemConfiguration} from "@opendaw/studio-adapters"
|
||||
export type AudioUnitOptions = Omit<ExportStemConfiguration, "fileName">
|
||||
|
||||
export namespace AudioUnitOptions {
|
||||
export const Default: AudioUnitOptions = {includeAudioEffects: true, includeSends: true}
|
||||
export const Default: AudioUnitOptions = {
|
||||
includeAudioEffects: true,
|
||||
includeSends: true,
|
||||
useInstrumentOutput: false
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,59 @@
|
||||
import {Arrays, isDefined, Nullable, TimeSpan} from "@opendaw/lib-std"
|
||||
import {Arrays, int, isDefined, Option, TimeSpan} from "@opendaw/lib-std"
|
||||
import {Communicator, Messenger, Wait} from "@opendaw/lib-runtime"
|
||||
import {dbToGain, RenderQuantum} from "@opendaw/lib-dsp"
|
||||
import {OfflineEngineInitializeConfig, OfflineEngineProtocol, OfflineEngineRenderConfig} from "@opendaw/studio-adapters"
|
||||
import {setupWorkletGlobals, updateFrameTime} from "./worklet-env"
|
||||
import {AudioWorkletProcessor, setupWorkletGlobals, updateFrameTime, WorkletGlobals} from "./worklet-env"
|
||||
|
||||
let processor: any = null
|
||||
let progressPort: Nullable<MessagePort> = null
|
||||
let sampleRate = 48000
|
||||
let numberOfChannels = 2
|
||||
let running = false
|
||||
let totalFrames = 0
|
||||
const globals = globalThis as unknown as WorkletGlobals
|
||||
|
||||
type EngineState = {
|
||||
readonly processor: AudioWorkletProcessor
|
||||
readonly progressPort: MessagePort
|
||||
readonly sampleRate: int
|
||||
readonly numberOfChannels: int
|
||||
totalFrames: int
|
||||
running: boolean
|
||||
}
|
||||
|
||||
let state: Option<EngineState> = Option.None
|
||||
|
||||
Communicator.executor<OfflineEngineProtocol>(
|
||||
Messenger.for(self).channel("offline-engine"), {
|
||||
async initialize(enginePort: MessagePort, progressPortParameter: MessagePort, config: OfflineEngineInitializeConfig) {
|
||||
sampleRate = config.sampleRate
|
||||
numberOfChannels = config.numberOfChannels
|
||||
progressPort = progressPortParameter
|
||||
totalFrames = 0
|
||||
setupWorkletGlobals({sampleRate})
|
||||
;(globalThis as any).__workletPort__ = enginePort
|
||||
async initialize(enginePort: MessagePort, progressPort: MessagePort, config: OfflineEngineInitializeConfig) {
|
||||
setupWorkletGlobals({sampleRate: config.sampleRate})
|
||||
globals.__workletPort__ = enginePort
|
||||
await import(config.processorsUrl)
|
||||
const ProcessorClass = (globalThis as any).__registeredProcessors__["engine-processor"]
|
||||
processor = new ProcessorClass({
|
||||
processorOptions: {
|
||||
syncStreamBuffer: config.syncStreamBuffer,
|
||||
controlFlagsBuffer: config.controlFlagsBuffer,
|
||||
project: config.project,
|
||||
exportConfiguration: config.exportConfiguration
|
||||
}
|
||||
const ProcessorClass = globals.__registeredProcessors__["engine-processor"]
|
||||
state = Option.wrap({
|
||||
processor: new ProcessorClass({
|
||||
processorOptions: {
|
||||
syncStreamBuffer: config.syncStreamBuffer,
|
||||
controlFlagsBuffer: config.controlFlagsBuffer,
|
||||
hrClockBuffer: new SharedArrayBuffer(32),
|
||||
project: config.project,
|
||||
exportConfiguration: config.exportConfiguration
|
||||
}
|
||||
}),
|
||||
progressPort,
|
||||
sampleRate: config.sampleRate,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
totalFrames: 0,
|
||||
running: false
|
||||
})
|
||||
},
|
||||
async step(samples: number): Promise<Float32Array[]> {
|
||||
const result: Float32Array[] = Arrays.create(() => new Float32Array(samples), numberOfChannels)
|
||||
const outputChannels: Float32Array[] = Arrays.create(() => new Float32Array(RenderQuantum), numberOfChannels)
|
||||
let offset = 0
|
||||
while (offset < samples) {
|
||||
async step(numSamples: int): Promise<Float32Array[]> {
|
||||
const engine = state.unwrap()
|
||||
const result: Float32Array[] = Arrays.create(() => new Float32Array(numSamples), engine.numberOfChannels)
|
||||
const outputChannels: Float32Array[] = Arrays.create(() => new Float32Array(RenderQuantum), engine.numberOfChannels)
|
||||
let offset = 0 | 0
|
||||
while (offset < numSamples) {
|
||||
const outputs: Float32Array[][] = [outputChannels]
|
||||
updateFrameTime(totalFrames, sampleRate)
|
||||
processor.process([[]], outputs)
|
||||
totalFrames += RenderQuantum
|
||||
const needed = samples - offset
|
||||
updateFrameTime(engine.totalFrames, engine.sampleRate)
|
||||
engine.processor.process([[]], outputs)
|
||||
engine.totalFrames += RenderQuantum
|
||||
const needed = numSamples - offset
|
||||
const toCopy = Math.min(needed, RenderQuantum)
|
||||
for (let ch = 0; ch < numberOfChannels; ch++) {
|
||||
for (let ch = 0; ch < engine.numberOfChannels; ch++) {
|
||||
result[ch].set(outputs[0][ch].subarray(0, toCopy), offset)
|
||||
}
|
||||
offset += toCopy
|
||||
@@ -50,24 +61,22 @@ Communicator.executor<OfflineEngineProtocol>(
|
||||
return result
|
||||
},
|
||||
async render(config: OfflineEngineRenderConfig) {
|
||||
const engine = state.unwrap()
|
||||
const {silenceThresholdDb, silenceDurationSeconds, maxDurationSeconds} = config
|
||||
const threshold = dbToGain(silenceThresholdDb ?? -72.0)
|
||||
const silenceFramesNeeded = Math.ceil((silenceDurationSeconds ?? 10) * sampleRate)
|
||||
const maxFrames = isDefined(maxDurationSeconds) ? Math.ceil(maxDurationSeconds * sampleRate) : Infinity
|
||||
const chunks: Float32Array[][] = Arrays.create(() => [], numberOfChannels)
|
||||
const silenceFramesNeeded = Math.ceil((silenceDurationSeconds ?? 10) * engine.sampleRate)
|
||||
const maxFrames = isDefined(maxDurationSeconds) ? Math.ceil(maxDurationSeconds * engine.sampleRate) : Infinity
|
||||
const chunks: Float32Array[][] = Arrays.create(() => [], engine.numberOfChannels)
|
||||
let consecutiveSilentFrames = 0
|
||||
let hasHadAudio = false
|
||||
let lastYield = 0
|
||||
|
||||
running = true
|
||||
|
||||
engine.running = true
|
||||
await Wait.timeSpan(TimeSpan.seconds(0))
|
||||
|
||||
while (running && totalFrames < maxFrames) {
|
||||
const outputChannels: Float32Array[] = Arrays.create(() => new Float32Array(RenderQuantum), numberOfChannels)
|
||||
while (engine.running && engine.totalFrames < maxFrames) {
|
||||
const outputChannels: Float32Array[] = Arrays.create(() => new Float32Array(RenderQuantum), engine.numberOfChannels)
|
||||
const outputs: Float32Array[][] = [outputChannels]
|
||||
updateFrameTime(totalFrames, sampleRate)
|
||||
const keepRunning = processor.process([[]], outputs)
|
||||
updateFrameTime(engine.totalFrames, engine.sampleRate)
|
||||
const keepRunning = engine.processor.process([[]], outputs)
|
||||
let maxSample = 0
|
||||
for (const channel of outputs[0]) {
|
||||
for (const sample of channel) {
|
||||
@@ -83,20 +92,18 @@ Communicator.executor<OfflineEngineProtocol>(
|
||||
} else {
|
||||
consecutiveSilentFrames = 0
|
||||
}
|
||||
for (let ch = 0; ch < numberOfChannels; ch++) {
|
||||
for (let ch = 0; ch < engine.numberOfChannels; ch++) {
|
||||
chunks[ch].push(outputs[0][ch].slice())
|
||||
}
|
||||
totalFrames += RenderQuantum
|
||||
engine.totalFrames += RenderQuantum
|
||||
if (!keepRunning) {break}
|
||||
if (totalFrames - lastYield >= sampleRate) {
|
||||
lastYield = totalFrames
|
||||
if (isDefined(progressPort)) {
|
||||
progressPort.postMessage({frames: totalFrames})
|
||||
}
|
||||
if (engine.totalFrames - lastYield >= engine.sampleRate) {
|
||||
lastYield = engine.totalFrames
|
||||
engine.progressPort.postMessage({frames: engine.totalFrames})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
const framesToKeep = totalFrames - consecutiveSilentFrames + Math.min(sampleRate / 4, consecutiveSilentFrames)
|
||||
const framesToKeep = engine.totalFrames - consecutiveSilentFrames + Math.min(engine.sampleRate / 4, consecutiveSilentFrames)
|
||||
return Arrays.create(channelIndex => {
|
||||
const total = new Float32Array(framesToKeep)
|
||||
let offset = 0
|
||||
@@ -107,8 +114,8 @@ Communicator.executor<OfflineEngineProtocol>(
|
||||
offset += toCopy
|
||||
}
|
||||
return total
|
||||
}, numberOfChannels)
|
||||
}, engine.numberOfChannels)
|
||||
},
|
||||
stop() { running = false }
|
||||
stop() { state.unwrap().running = false }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
export class AudioWorkletProcessor {
|
||||
readonly port: MessagePort
|
||||
constructor() { this.port = (globalThis as any).__workletPort__ }
|
||||
type WorkletProcessorConstructor = new (options: Record<string, unknown>) => AudioWorkletProcessor
|
||||
|
||||
export type WorkletGlobals = {
|
||||
sampleRate: number
|
||||
currentFrame: number
|
||||
currentTime: number
|
||||
AudioWorkletProcessor: typeof AudioWorkletProcessor
|
||||
registerProcessor: (name: string, ctor: WorkletProcessorConstructor) => void
|
||||
__registeredProcessors__: Record<string, WorkletProcessorConstructor>
|
||||
__workletPort__: MessagePort
|
||||
}
|
||||
|
||||
export function setupWorkletGlobals(config: { sampleRate: number }): void {
|
||||
const g = globalThis as any
|
||||
g.sampleRate = config.sampleRate
|
||||
g.currentFrame = 0
|
||||
g.currentTime = 0
|
||||
g.AudioWorkletProcessor = AudioWorkletProcessor
|
||||
g.registerProcessor = (name: string, ctor: any) => {
|
||||
g.__registeredProcessors__ = g.__registeredProcessors__ || {}
|
||||
g.__registeredProcessors__[name] = ctor
|
||||
const globals = globalThis as unknown as WorkletGlobals
|
||||
|
||||
export class AudioWorkletProcessor {
|
||||
readonly port: MessagePort
|
||||
constructor() { this.port = globals.__workletPort__ }
|
||||
process(_inputs: Float32Array[][], _outputs: Float32Array[][]): boolean { return false }
|
||||
}
|
||||
|
||||
export const setupWorkletGlobals = (config: { sampleRate: number }): void => {
|
||||
globals.sampleRate = config.sampleRate
|
||||
globals.currentFrame = 0
|
||||
globals.currentTime = 0
|
||||
globals.AudioWorkletProcessor = AudioWorkletProcessor
|
||||
globals.registerProcessor = (name: string, ctor: WorkletProcessorConstructor) => {
|
||||
globals.__registeredProcessors__ = globals.__registeredProcessors__ || {}
|
||||
globals.__registeredProcessors__[name] = ctor
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFrameTime(frame: number, sampleRate: number): void {
|
||||
const g = globalThis as any
|
||||
g.currentFrame = frame
|
||||
g.currentTime = frame / sampleRate
|
||||
export const updateFrameTime = (frame: number, sampleRate: number): void => {
|
||||
globals.currentFrame = frame
|
||||
globals.currentTime = frame / sampleRate
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {AudioData, ppqn} from "@opendaw/lib-dsp"
|
||||
import {Communicator, Messenger, Wait} from "@opendaw/lib-runtime"
|
||||
import {
|
||||
EngineCommands,
|
||||
EngineStateSchema,
|
||||
EngineToClient,
|
||||
ExportStemsConfiguration,
|
||||
MonitoringMapEntry,
|
||||
@@ -54,7 +55,7 @@ export class OfflineEngineRenderer {
|
||||
)
|
||||
const channel = new MessageChannel()
|
||||
const progressChannel = new MessageChannel()
|
||||
const syncStreamBuffer = new SharedArrayBuffer(1024)
|
||||
const syncStreamBuffer = new SharedArrayBuffer(EngineStateSchema().bytesTotal + 1)
|
||||
const controlFlagsBuffer = new SharedArrayBuffer(4)
|
||||
const terminator = new Terminator()
|
||||
const engineMessenger = Messenger.for(channel.port2)
|
||||
@@ -207,6 +208,16 @@ export class OfflineEngineRenderer {
|
||||
this.#protocol.stop()
|
||||
}
|
||||
|
||||
setPosition(position: ppqn): void {
|
||||
this.#engineCommands.setPosition(position)
|
||||
}
|
||||
|
||||
async waitForLoading(): Promise<void> {
|
||||
while (!await this.#engineCommands.queryLoadingComplete()) {
|
||||
await Wait.timeSpan(TimeSpan.millis(100))
|
||||
}
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.#terminator.terminate()
|
||||
this.#worker.terminate()
|
||||
|
||||
@@ -361,11 +361,11 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner
|
||||
if (!this.#userCreatedSamples.hasKey(uuid)) {return}
|
||||
this.#userCreatedSamples.removeByKey(uuid)
|
||||
const autoDelete = StudioPreferences.settings.storage["auto-delete-orphaned-samples"]
|
||||
if (!autoDelete && !await RuntimeNotifier.approve({
|
||||
headline: "Delete Sample?",
|
||||
message: "The sample is no longer used. Delete it from storage? This cannot be undone!",
|
||||
approveText: "Delete",
|
||||
cancelText: "Keep"
|
||||
if (!autoDelete && await RuntimeNotifier.approve({
|
||||
headline: "Keep Sample?",
|
||||
message: "The sample is no longer used. Do you want to keep it in storage? Deletion of samples cannot be undone!",
|
||||
approveText: "Keep",
|
||||
cancelText: "Delete"
|
||||
})) {return}
|
||||
SampleStorage.get().deleteItem(uuid).catch((reason: unknown) =>
|
||||
console.warn("Failed to delete sample from storage", reason))
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Consolidate Audio Clips (Issue #162)
|
||||
|
||||
## Context
|
||||
|
||||
Audio regions on a track can have different time bases, pitch/time stretching, fading, and gain. Users need to combine multiple selected audio regions into a single region. Unlike note regions (which can be flattened synchronously by copying events), audio consolidation requires **offline rendering** of the TapeDeviceProcessor's dry output, then replacing the original regions with one region referencing the new audio file.
|
||||
|
||||
The "Flatten" context menu item already exists but `canFlatten` returns `false` for audio regions. We'll reuse it, branching to an async path for audio regions.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Add `useInstrumentOutput` to `ExportStemConfiguration`
|
||||
|
||||
**File:** `packages/studio/adapters/src/EngineProcessorAttachment.ts`
|
||||
|
||||
Add `useInstrumentOutput: boolean` to the type. This taps the instrument output directly (before effects/channel strip).
|
||||
|
||||
**File:** `packages/studio/core-processors/src/AudioUnitOptions.ts`
|
||||
|
||||
Add `useInstrumentOutput: false` to `Default`.
|
||||
|
||||
## Step 2: Wire `useInstrumentOutput` in `EngineProcessor`
|
||||
|
||||
**File:** `packages/studio/core-processors/src/EngineProcessor.ts`
|
||||
|
||||
- Change `#stemExports` from `ReadonlyArray<AudioUnit>` to `ReadonlyArray<{unit: AudioUnit, useInstrumentOutput: boolean}>`
|
||||
- Update construction (~line 320) to extract the flag from configuration
|
||||
- Update render output (~line 374): when `useInstrumentOutput` is true, read from `unit.input().unwrap().audioOutput` (dry signal) instead of `unit.audioOutput()` (wet signal)
|
||||
|
||||
## Step 3: Add `setPosition()` and `waitForLoading()` to `OfflineEngineRenderer`
|
||||
|
||||
**File:** `packages/studio/core/src/OfflineEngineRenderer.ts`
|
||||
|
||||
Add two public methods:
|
||||
|
||||
```typescript
|
||||
setPosition(position: ppqn): void {
|
||||
this.#engineCommands.setPosition(position)
|
||||
}
|
||||
|
||||
async waitForLoading(): Promise<void> {
|
||||
while (!await this.#engineCommands.queryLoadingComplete()) {
|
||||
await Wait.timeSpan(TimeSpan.millis(100))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This enables the consolidation service to use the `step()` API (exact frame count, no silence detection/trimming) instead of `render()`.
|
||||
|
||||
## Step 4: Add `flattenAudioRegions` to `Mixdowns.ts`
|
||||
|
||||
**File:** `packages/app/studio/src/service/Mixdowns.ts`
|
||||
|
||||
Add a new exported function alongside `exportMixdown` and `exportStems`:
|
||||
|
||||
```
|
||||
async flattenAudioRegions(project, sampleService, regions):
|
||||
1. Validate: all AudioRegionBoxAdapter, same track
|
||||
2. Sort regions, compute rangeMin/rangeMax (ppqn)
|
||||
3. Get AudioUnit UUID: track.audioUnit.address.uuid
|
||||
4. Copy project: project.copy()
|
||||
5. Build ExportStemsConfiguration:
|
||||
{ [uuid]: { includeAudioEffects: false, includeSends: false,
|
||||
useInstrumentOutput: true, fileName: "flatten" } }
|
||||
6. Compute duration:
|
||||
durationSeconds = project.tempoMap.intervalToSeconds(rangeMin, rangeMax)
|
||||
numSamples = Math.ceil(durationSeconds * sampleRate)
|
||||
7. Show progress dialog (RuntimeNotifier.progress)
|
||||
8. Create OfflineEngineRenderer with config
|
||||
9. renderer.waitForLoading() → renderer.setPosition(rangeMin)
|
||||
→ renderer.play() → renderer.step(numSamples)
|
||||
10. Build AudioData from Float32Array[] (stereo)
|
||||
11. Encode to WAV: WavFile.encodeFloats(audioData)
|
||||
12. Import via sampleService.importFile({name, arrayBuffer})
|
||||
→ returns Sample with uuid, duration, etc.
|
||||
13. Terminate renderer, dismiss dialog
|
||||
14. Inside project.editing.modify():
|
||||
a. AudioFileBoxFactory.createModifier() → AudioFileBox
|
||||
b. Delete original region boxes
|
||||
c. AudioContentFactory.createNotStretchedRegion() → new region at rangeMin
|
||||
d. project.trackUserCreatedSample(uuid)
|
||||
15. Select the new region
|
||||
```
|
||||
|
||||
Key reusable APIs:
|
||||
- `OfflineEngineRenderer.create()` + `step()` — `packages/studio/core/src/OfflineEngineRenderer.ts`
|
||||
- `SampleService.importFile()` — `packages/studio/core/src/samples/SampleService.ts`
|
||||
- `AudioFileBoxFactory.createModifier()` — `packages/studio/core/src/project/audio/AudioFileBoxFactory.ts`
|
||||
- `AudioContentFactory.createNotStretchedRegion()` — `packages/studio/core/src/project/audio/AudioContentFactory.ts`
|
||||
- `WavFile.encodeFloats()` — `packages/studio/core/src/WavFile.ts`
|
||||
- `Project.trackUserCreatedSample()` — `packages/studio/core/src/project/Project.ts`
|
||||
- `RuntimeNotifier.progress()` — progress dialog (already used in Mixdowns)
|
||||
|
||||
## Step 5: Update `AudioRegionBoxAdapter.canFlatten()`
|
||||
|
||||
**File:** `packages/studio/adapters/src/timeline/region/AudioRegionBoxAdapter.ts`
|
||||
|
||||
Update `canFlatten` (~line 267) to return `true` when:
|
||||
- `regions.length > 0`
|
||||
- All regions are `AudioRegionBoxAdapter`
|
||||
- All on same track (`Arrays.satisfy` pattern from NoteRegionBoxAdapter)
|
||||
|
||||
Leave `flatten()` returning `Option.None` — the async work happens in `Mixdowns.flattenAudioRegions()`.
|
||||
|
||||
## Step 6: Wire in RegionContextMenu
|
||||
|
||||
**File:** `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionContextMenu.ts`
|
||||
|
||||
Replace the Flatten trigger procedure (~line 94-96) with branching logic:
|
||||
|
||||
```typescript
|
||||
MenuItem.default({label: "Flatten", selectable: region.canFlatten(selection.selected())})
|
||||
.setTriggerProcedure(() => {
|
||||
if (region instanceof AudioRegionBoxAdapter) {
|
||||
// Async path: offline render → import sample → replace regions
|
||||
Mixdowns.flattenAudioRegions(project, service.sampleService, selection.selected())
|
||||
} else {
|
||||
// Sync path: note/value region flattening (existing behavior)
|
||||
editing.modify(() =>
|
||||
region.flatten(selection.selected()).ifSome(box => project.selection.select(box)))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- Audio path is fire-and-forget (progress dialog handles user feedback)
|
||||
- `service: StudioService` is already in the menu's `Construct` type → `service.sampleService`
|
||||
- Import `AudioRegionBoxAdapter` and `Mixdowns` at the top of the file
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/studio/adapters/src/EngineProcessorAttachment.ts` | Add `useInstrumentOutput` to `ExportStemConfiguration` |
|
||||
| `packages/studio/core-processors/src/AudioUnitOptions.ts` | Add `useInstrumentOutput: false` to `Default` |
|
||||
| `packages/studio/core-processors/src/EngineProcessor.ts` | Wire `useInstrumentOutput` in stem exports + render |
|
||||
| `packages/studio/core/src/OfflineEngineRenderer.ts` | Add `setPosition()` and `waitForLoading()` |
|
||||
| `packages/studio/adapters/src/timeline/region/AudioRegionBoxAdapter.ts` | Update `canFlatten` |
|
||||
| `packages/app/studio/src/service/Mixdowns.ts` | Add `flattenAudioRegions` function |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionContextMenu.ts` | Branch to async for audio flatten |
|
||||
|
||||
## Verification
|
||||
|
||||
1. Type-check all modified packages with `npx tsc --noEmit`
|
||||
2. Existing stem export should still work (`useInstrumentOutput` defaults to `false`)
|
||||
3. Manual test: select multiple audio regions on same track → right-click → Flatten → progress dialog appears → new single region created with rendered audio
|
||||
Reference in New Issue
Block a user