Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/app/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ For a guided overview of the interface, see the [UI tour](../../docs/docs-user/u
Guidance on saving and importing projects lives in the [file management guide](../../docs/docs-user/features/file-management.md). The [notepad feature](../../docs/docs-user/features/notepad.md) lets you store project notes using Markdown.
Developer details about project storage and sessions can be found in the [projects documentation](../../docs/docs-dev/projects/overview.md).

The capture subsystem for recording audio and MIDI is described in the
[capture architecture docs](../../docs/docs-dev/architecture/capture/overview.md).
Users can follow the [recording workflow guide](../../docs/docs-user/workflows/recording.md)
to learn how to capture takes.

Quickly launch commands using the Spotlight search palette with <kbd>Shift</kbd>+<kbd>Enter</kbd>. Read the [user guide](../../docs/docs-user/features/search.md) or see the [developer docs](../../docs/docs-dev/ui/spotlight/overview.md).
Exchange projects with other DAWs via the `.dawproject` format using the
[DAWproject workflow](../../docs/docs-user/workflows/dawproject.md). Implementation
Expand Down
2 changes: 2 additions & 0 deletions packages/app/studio/src/project/ProjectDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/**
* Collection of dialogs used for common project management operations.
* See the capture architecture docs for details on recording:
* {@link ../../../../docs/docs-dev/architecture/capture/overview.md}.
*/
import {Dialog} from "@/ui/components/Dialog"
import {ExportStemsConfiguration, IconSymbol} from "@opendaw/studio-adapters"
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/docs-dev/architecture/capture/audio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Audio Capture

`CaptureAudio` wraps a browser `MediaStream` to provide microphone and line
input. When armed, it requests a stream matching the desired device and channel
configuration and feeds the data into a [`RecordingWorklet`](./worklet.md).

Levels can be adjusted via a software gain stage before samples are written to
the recording buffer.
6 changes: 6 additions & 0 deletions packages/docs/docs-dev/architecture/capture/midi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# MIDI Capture

`CaptureMidi` listens to Web MIDI input devices and forwards filtered note
messages to the recording subsystem. Each capture can restrict events to a
specific channel and normalises note on/off pairs before they are persisted in
the project timeline.
14 changes: 14 additions & 0 deletions packages/docs/docs-dev/architecture/capture/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Capture Architecture

The capture layer manages audio and MIDI inputs that feed the recording system.
It instantiates `Capture` objects for audio units and coordinates their lifecycles
through the `CaptureManager`.

- [Audio capture](./audio.md) describes how microphone and line inputs are
obtained from the browser.
- [MIDI capture](./midi.md) covers incoming note events.
- [Recording worklet](./worklet.md) explains how raw samples are buffered and
converted into peak data.

This subsystem works in tandem with the [`Recording` API](../../../../studio/core/src/capture/Recording.ts)
which orchestrates the actual recording session.
6 changes: 6 additions & 0 deletions packages/docs/docs-dev/architecture/capture/worklet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Recording Worklet

The `RecordingWorklet` is an `AudioWorkletNode` that receives frames from a
`MediaStream` and writes them into a ring buffer. It also computes peak data so
waveforms can be displayed immediately after recording. Once finalized, the
worklet stores audio and peak information through the sample storage service.
18 changes: 18 additions & 0 deletions packages/docs/docs-user/workflows/recording.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Recording Workflow

Capture audio or MIDI directly onto the timeline.

1. **Choose a track type.** Add an audio or instrument track depending on the
source you want to record.
2. **Arm the track.** Click the record icon on the track header to enable
input monitoring.
3. **Select the device.** Use the track's capture panel to pick the desired
microphone or MIDI device.
4. **Check levels.** Speak or play to ensure the meter responds without
clipping.
5. **Press record.** Hit the transport record button and perform.
6. **Stop when finished.** The take is placed on the timeline where it can be
edited like any other clip.

For details on the underlying system see the
[developer capture docs](../../docs-dev/architecture/capture/overview.md).
10 changes: 10 additions & 0 deletions packages/docs/sidebarsDev.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ module.exports = {
"architecture/headless-vs-studio",
"architecture/persistence",
"architecture/opfs-samples",
{
type: "category",
label: "Capture",
items: [
"architecture/capture/overview",
"architecture/capture/audio",
"architecture/capture/midi",
"architecture/capture/worklet",
],
},
],
},
{
Expand Down
14 changes: 13 additions & 1 deletion packages/studio/core/src/RecordingWorklet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ class PeaksWriter implements Peaks, Peaks.Stage {

/**
* Captures audio from its input and exposes recorded data along with peak
* information.
* information. Recording is performed in the audio worklet thread while the
* class provides a loader style interface for retrieving the resulting
* {@link AudioData} and peak information.
*/
export class RecordingWorklet extends AudioWorkletNode implements Terminable, SampleLoader {
readonly uuid: UUID.Format = UUID.generate()
Expand Down Expand Up @@ -118,13 +120,22 @@ export class RecordingWorklet extends AudioWorkletNode implements Terminable, Sa
})
}

/** Total number of frames recorded so far. */
get numberOfFrames(): int {return this.#output.length * RenderQuantum}
/** Recorded audio data once {@link finalize} has been called. */
get data(): Option<AudioData> {return this.#data}
/** Peak information for the recorded data if available. */
get peaks(): Option<Peaks> {return this.#peaks}
/** Loading state for consumers implementing {@link SampleLoader}. */
get state(): SampleLoaderState {return this.#state}

/** Part of {@link SampleLoader}; no-op for recordings. */
invalidate(): void {}

/**
* Observe loader state changes. Once loaded, the observer is invoked
* immediately and no subscription is kept.
*/
subscribe(observer: Observer<SampleLoaderState>): Subscription {
if (this.#state.type === "loaded") {
observer(this.#state)
Expand Down Expand Up @@ -162,6 +173,7 @@ export class RecordingWorklet extends AudioWorkletNode implements Terminable, Sa
this.#setState({type: "loaded"})
}

/** Stop recording and release any buffered data. */
terminate(): void {
this.#reader.stop()
this.#isRecording = false
Expand Down
22 changes: 22 additions & 0 deletions packages/studio/core/src/capture/Capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {CaptureBox} from "@opendaw/studio-adapters"
import {RecordingContext} from "./RecordingContext"
import {CaptureManager} from "./CaptureManager"

/**
* Base class for audio or MIDI capture units. A {@link Capture} wraps a
* {@link CaptureBox} and manages device selection and arming state for the
* associated {@link AudioUnitBox}.
*/
export abstract class Capture<BOX extends CaptureBox = CaptureBox> implements Terminable {
readonly #terminator = new Terminator()

Expand All @@ -23,6 +28,11 @@ export abstract class Capture<BOX extends CaptureBox = CaptureBox> implements Te
readonly #deviceId: MutableObservableValue<Option<string>>
readonly #armed: DefaultObservableValue<boolean>

/**
* @param manager Parent {@link CaptureManager} instance.
* @param audioUnitBox The audio unit this capture belongs to.
* @param captureBox Backing box containing persisted capture settings.
*/
protected constructor(manager: CaptureManager, audioUnitBox: AudioUnitBox, captureBox: BOX) {
this.#manager = manager
this.#audioUnitBox = audioUnitBox
Expand All @@ -42,18 +52,30 @@ export abstract class Capture<BOX extends CaptureBox = CaptureBox> implements Te
)
}

/** Human readable label of the current device, if available. */
abstract get deviceLabel(): Option<string>
/** Prepare the capture source for recording (e.g. open streams). */
abstract prepareRecording(context: RecordingContext): Promise<void>
/** Begin capturing and return a handle that stops the session when terminated. */
abstract startRecording(context: RecordingContext): Terminable

/** UUID of the owning {@link AudioUnitBox}. */
get uuid(): UUID.Format {return this.#audioUnitBox.address.uuid}
/** Owning manager for this capture. */
get manager(): CaptureManager {return this.#manager}
/** The {@link AudioUnitBox} that this capture wraps. */
get audioUnitBox(): AudioUnitBox {return this.#audioUnitBox}
/** Underlying {@link CaptureBox} containing state. */
get captureBox(): BOX {return this.#captureBox}
/** Observable arming state. */
get armed(): MutableObservableValue<boolean> {return this.#armed}
/** Observable selected device id. */
get deviceId(): MutableObservableValue<Option<string>> {return this.#deviceId}

/** Register a terminable that is disposed with this capture. */
own<T extends Terminable>(terminable: T): T {return this.#terminator.own(terminable)}
/** Register multiple terminables to be disposed with this capture. */
ownAll<T extends Terminable>(...terminables: ReadonlyArray<T>): void {this.#terminator.ownAll(...terminables)}
/** Clean up all resources associated with this capture. */
terminate(): void {this.#terminator.terminate()}
}
21 changes: 21 additions & 0 deletions packages/studio/core/src/capture/CaptureAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {RecordAudio} from "./RecordAudio"
import {RecordingContext} from "./RecordingContext"
import {AudioDevices} from "../AudioDevices"

/**
* Handles capturing audio from a {@link MediaStream}. The capture will lazily
* create and update the stream based on the selected device and arming state.
*/
export class CaptureAudio extends Capture<CaptureAudioBox> {
readonly #stream: MutableObservableOption<MediaStream>

Expand All @@ -15,6 +19,11 @@ export class CaptureAudio extends Capture<CaptureAudioBox> {
#requestChannels: Option<1 | 2> = Option.None
#gainDb: number = 0.0

/**
* @param manager Parent {@link CaptureManager}.
* @param audioUnitBox Audio unit the capture belongs to.
* @param captureBox Box storing capture configuration.
*/
constructor(manager: CaptureManager, audioUnitBox: AudioUnitBox, captureBox: CaptureAudioBox) {
super(manager, audioUnitBox, captureBox)

Expand Down Expand Up @@ -43,26 +52,38 @@ export class CaptureAudio extends Capture<CaptureAudioBox> {
)
}

/** Gain to apply to the media stream in decibels. */
get gainDb(): number {return this.#gainDb}

/** Observable wrapper around the active `MediaStream`. */
get stream(): MutableObservableOption<MediaStream> {return this.#stream}

/** Device id reported by the active {@link MediaStreamTrack}. */
get streamDeviceId(): Option<string> {
return this.streamMediaTrack.map(settings => settings.getSettings().deviceId ?? "")
}

/** Human readable label of the active input device. */
get deviceLabel(): Option<string> {
return this.streamMediaTrack.map(track => track.label ?? "")
}

/** First audio track from the current media stream. */
get streamMediaTrack(): Option<MediaStreamTrack> {
return this.#stream.flatMap(stream => Option.wrap(stream.getAudioTracks().at(0)))
}

/**
* Ensure a media stream exists for the current device selection. Called
* before a recording session starts.
*/
async prepareRecording(_: RecordingContext): Promise<void> {
return this.#streamGenerator()
}

/**
* Begin recording the current stream into a {@link RecordingWorklet}.
*/
startRecording({audioContext, worklets, project, engine, sampleManager}: RecordingContext): Terminable {
const streamOption = this.#stream
assert(streamOption.nonEmpty(), "Stream not prepared.")
Expand Down
14 changes: 14 additions & 0 deletions packages/studio/core/src/capture/CaptureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import {Capture} from "./Capture"
import {CaptureMidi} from "./CaptureMidi"
import {CaptureAudio} from "./CaptureAudio"

/**
* Tracks all {@link Capture} instances within a {@link Project}. The manager
* observes the project's audio units and creates the appropriate capture
* implementation for each unit.
*/
export class CaptureManager implements Terminable {
readonly #project: Project
readonly #subscription: Subscription
readonly #captures: SortedSet<UUID.Format, Capture>

/**
* @param project Project owning the capture devices.
*/
constructor(project: Project) {
this.#project = project
this.#captures = UUID.newSet<Capture>(unit => unit.uuid)
Expand All @@ -27,15 +35,21 @@ export class CaptureManager implements Terminable {
})
}

/** Owning project. */
get project(): Project {return this.#project}

/** Lookup a capture by the UUID of its audio unit. */
get(uuid: UUID.Format): Option<Capture> {return this.#captures.opt(uuid)}

/**
* Return all captures that are armed and connected to an input.
*/
filterArmed(): ReadonlyArray<Capture> {
return this.#captures.values()
.filter(capture => capture.armed.getValue() && capture.audioUnitBox.input.pointerHub.nonEmpty())
}

/** Dispose all captures and internal subscriptions. */
terminate(): void {
this.#subscription.terminate()
this.#captures.forEach(capture => capture.terminate())
Expand Down
14 changes: 14 additions & 0 deletions packages/studio/core/src/capture/CaptureMidi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {CaptureManager} from "./CaptureManager"
import {MidiDevices} from "../MidiDevices"
import {Promises} from "@opendaw/lib-runtime"

/**
* Capture implementation for MIDI input devices. Streams MIDI messages from
* selected devices and forwards them to the recording pipeline.
*/
export class CaptureMidi extends Capture<CaptureMidiBox> {
readonly #streamGenerator: Func<void, Promise<void>>
readonly #notifier = new Notifier<MIDIMessageEvent>()
Expand All @@ -28,6 +32,11 @@ export class CaptureMidi extends Capture<CaptureMidiBox> {

#streaming: Option<Subscription> = Option.None

/**
* @param manager Parent {@link CaptureManager}.
* @param audioUnitBox Owning audio unit.
* @param captureMidiBox Backing box containing configuration.
*/
constructor(manager: CaptureManager, audioUnitBox: AudioUnitBox, captureMidiBox: CaptureMidiBox) {
super(manager, audioUnitBox, captureMidiBox)

Expand All @@ -54,8 +63,12 @@ export class CaptureMidi extends Capture<CaptureMidiBox> {
)
}

/** Human readable label of the current MIDI device. */
get deviceLabel(): Option<string> {return Option.wrap("MIDI coming soon.")}

/**
* Ensure the selected MIDI device is available before recording starts.
*/
async prepareRecording(_: RecordingContext): Promise<void> {
const availableMidiDevices = MidiDevices.get()
if (availableMidiDevices.isEmpty()) {
Expand All @@ -72,6 +85,7 @@ export class CaptureMidi extends Capture<CaptureMidiBox> {
}
}

/** Start streaming MIDI events into the project engine. */
startRecording({project, engine}: RecordingContext): Terminable {
const availableMidiDevices = MidiDevices.inputs()
assert(availableMidiDevices.nonEmpty(), "No MIDI input devices found")
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/core/src/capture/RecordAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {RecordTrack} from "./RecordTrack"
import {RecordingWorklet} from "../RecordingWorklet"
import {ColorCodes} from "../ColorCodes"

/** Utilities for recording audio into a project. */
export namespace RecordAudio {
/** Context required to start recording audio. */
type RecordAudioContext = {
recordingWorklet: RecordingWorklet
mediaStream: MediaStream
Expand All @@ -21,6 +23,9 @@ export namespace RecordAudio {
gainDb: number
}

/**
* Begin recording the supplied media stream into the project timeline.
*/
export const start = (
{
recordingWorklet, mediaStream, sampleManager, audioContext, engine, project, capture, gainDb
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/core/src/capture/RecordMidi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@ import {Capture} from "./Capture"
import {RecordTrack} from "./RecordTrack"
import {ColorCodes} from "../ColorCodes"

/** Utilities for recording MIDI note data. */
export namespace RecordMidi {
/** Context required to start a MIDI recording. */
type RecordMidiContext = {
notifier: Notifier<MIDIMessageEvent>,
engine: Engine,
project: Project,
capture: Capture
}

/**
* Begin recording MIDI events into the project timeline.
*/
export const start = ({notifier, engine, project, capture}: RecordMidiContext): Terminable => {
console.debug("RecordMidi.start")
const beats = PPQN.fromSignature(1, project.timelineBox.signature.denominator.getValue())
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/core/src/capture/RecordTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {asInstanceOf, int, UUID} from "@opendaw/lib-std"
import {TrackType} from "@opendaw/studio-adapters"
import {Editing} from "@opendaw/lib-box"

/** Helper utilities for locating tracks used during recording. */
export namespace RecordTrack {
/**
* Find an existing empty track matching the requested type or create a new
* track for the given {@link AudioUnitBox}.
*/
export const findOrCreate = (editing: Editing, audioUnitBox: AudioUnitBox, type: TrackType): TrackBox => {
let index: int = 0 | 0
for (const trackBox of audioUnitBox.tracks.pointerHub.incoming()
Expand Down
Loading