This commit is contained in:
André Michelle
2026-02-10 14:27:40 +01:00
parent 2a932939ab
commit f3db8a771c
14 changed files with 15664 additions and 15313 deletions
+15253 -15222
View File
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 }
}
)
+29 -16
View File
@@ -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()
+5 -5
View File
@@ -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))
+146
View File
@@ -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