diff --git a/packages/app/studio/src/ui/header/TimeStateDisplay.tsx b/packages/app/studio/src/ui/header/TimeStateDisplay.tsx index ab734cea8..63a5e5e6c 100644 --- a/packages/app/studio/src/ui/header/TimeStateDisplay.tsx +++ b/packages/app/studio/src/ui/header/TimeStateDisplay.tsx @@ -29,14 +29,22 @@ import { Propagation } from "@opendaw/lib-box"; const className = Html.adoptStyleSheet(css, "TimeStateDisplay"); -type Construct = { +/** Parameters for constructing {@link TimeStateDisplay}. */ +export type Construct = { + /** Lifecycle managing subscriptions. */ lifecycle: Lifecycle; + /** Service providing access to engine and project. */ service: StudioService; }; const minBpm = 30.0; const maxBpm = 1000.0; +/** + * Displays transport position, tempo and meter. + * + * @public + */ export const TimeStateDisplay = ({ lifecycle, service }: Construct) => { const barDigits = Inject.value("001"); const beatDigit = Inject.value("1"); diff --git a/packages/app/studio/src/ui/timeline/SnapSelector.tsx b/packages/app/studio/src/ui/timeline/SnapSelector.tsx index 7f2b25cbe..3d0155b70 100644 --- a/packages/app/studio/src/ui/timeline/SnapSelector.tsx +++ b/packages/app/studio/src/ui/timeline/SnapSelector.tsx @@ -10,8 +10,11 @@ import {Colors} from "@opendaw/studio-core" const className = Html.adoptStyleSheet(css, "SnapSelector") -type Construct = { +/** Parameters for constructing {@link SnapSelector}. */ +export type Construct = { + /** Lifecycle managing subscriptions. */ lifecycle: Lifecycle + /** Snap settings that provide available options. */ snapping: Snapping } @@ -22,6 +25,8 @@ type Construct = { * ```tsx * * ``` + * + * @public */ export const SnapSelector = ({lifecycle, snapping}: Construct) => { const snappingName = Inject.value(snapping.unit.name) @@ -35,4 +40,4 @@ export const SnapSelector = ({lifecycle, snapping}: Construct) => { ) -} \ No newline at end of file +} diff --git a/packages/app/studio/src/ui/timeline/TimeAxis.tsx b/packages/app/studio/src/ui/timeline/TimeAxis.tsx index 262419034..c290052d9 100644 --- a/packages/app/studio/src/ui/timeline/TimeAxis.tsx +++ b/packages/app/studio/src/ui/timeline/TimeAxis.tsx @@ -21,7 +21,7 @@ const MIN_TRACK_DURATION = 8 * PPQN.Bar const MAX_TRACK_DURATION = 1024 * PPQN.Bar /** Parameters for constructing {@link TimeAxis}. */ -type Construct = { +export type Construct = { /** Lifecycle managing subscriptions. */ lifecycle: Lifecycle /** Service providing access to engine and project. */ @@ -34,7 +34,11 @@ type Construct = { mapper?: TimeAxisCursorMapper } -/** Maps playback position to a different pulse value when drawing the cursor. */ +/** + * Maps playback position to a different pulse value when drawing the cursor. + * + * @public + */ export interface TimeAxisCursorMapper { mapPlaybackCursor(position: ppqn): ppqn } @@ -47,6 +51,8 @@ export interface TimeAxisCursorMapper { * @param snapping - Snap settings for cursor interactions. * @param range - Range object describing current viewport. * @param mapper - Optional mapper for custom cursor rendering. + * + * @public */ export const TimeAxis = ({lifecycle, service, snapping, range, mapper}: Construct) => { let endMarkerPosition: Nullable = null @@ -165,4 +171,4 @@ export const TimeAxis = ({lifecycle, service, snapping, range, mapper}: Construc {cursorElement} ) -} \ No newline at end of file +} diff --git a/packages/app/studio/src/ui/timeline/TimeGrid.ts b/packages/app/studio/src/ui/timeline/TimeGrid.ts index 9d7f45d09..099bae94c 100644 --- a/packages/app/studio/src/ui/timeline/TimeGrid.ts +++ b/packages/app/studio/src/ui/timeline/TimeGrid.ts @@ -8,10 +8,15 @@ import {TimelineRange} from "@/ui/timeline/TimelineRange.ts" /** Utilities for iterating over timeline grid divisions. */ export namespace TimeGrid { + /** + * Time signature expressed as `[nominator, denominator]`. + */ export type Signature = [int, int] /** Optional parameters for {@link fragment}. */ export type Options = { minLength?: number } + /** Information about a single grid fragment. */ export type Fragment = { bars: int, beats: int, ticks: int, isBar: boolean, isBeat: boolean, pulse: number } + /** Callback invoked for each grid fragment. */ export type Designer = (fragment: Fragment) => void /** @@ -49,4 +54,4 @@ export namespace TimeGrid { designer({bars, beats, ticks, isBar, isBeat, pulse}) } } -} \ No newline at end of file +} diff --git a/packages/docs/docs-dev/architecture/tempo.md b/packages/docs/docs-dev/architecture/tempo.md new file mode 100644 index 000000000..c78863fd6 --- /dev/null +++ b/packages/docs/docs-dev/architecture/tempo.md @@ -0,0 +1,15 @@ +# Tempo + +openDAW expresses musical time in pulses per quarter note (PPQN). A bar in 4/4 time contains 3,840 pulses, providing a stable grid for sequencing and rendering. + +## Conversions + +```ts +import {PPQN} from "@opendaw/lib-dsp"; + +const pulses = PPQN.secondsToPulses(1.5, 120); +const seconds = PPQN.pulsesToSeconds(PPQN.Bar, 90); +``` + +The timeline renders this grid via `TimeGrid` and `TimeAxis`, while `BPMTools.detect` can estimate the tempo of audio buffers. + diff --git a/packages/docs/docs-user/features/timeline.md b/packages/docs/docs-user/features/timeline.md index 4aadf47f6..7bfd6160a 100644 --- a/packages/docs/docs-user/features/timeline.md +++ b/packages/docs/docs-user/features/timeline.md @@ -28,6 +28,19 @@ Drag handles on the navigation bar to resize the visible range. Double-click the end marker on the time axis to set the project length. Scroll the mouse wheel over the time axis to zoom and pan. +## Tempo + +The transport header shows the current BPM and time signature. Double‑click +either value to enter a new tempo or meter. The timeline grid adapts +immediately to these changes. + +```ts +import {PPQN} from "@opendaw/lib-dsp"; + +// Seconds per bar at 128 BPM +const barSeconds = PPQN.pulsesToSeconds(PPQN.Bar, 128); +``` + ## Example Integration ```ts diff --git a/packages/lib/dsp/src/bpm-tools.ts b/packages/lib/dsp/src/bpm-tools.ts index 3f3fe6ecb..ddab7b524 100644 --- a/packages/lib/dsp/src/bpm-tools.ts +++ b/packages/lib/dsp/src/bpm-tools.ts @@ -11,8 +11,10 @@ export namespace BPMTools { /** * Options controlling the tempo search space and analysis parameters. * All fields are optional and fall back to sensible defaults. + * + * @public */ - type Options = Partial<{ + export type Options = Partial<{ /** Number of samples between energy taps. */ interval: number /** Number of coarse scan steps across the search interval. */ @@ -35,6 +37,7 @@ export namespace BPMTools { * @param sampleRate - Sampling rate of `buf`. * @param options - Configuration such as search bounds. * @returns Detected BPM. + * @public */ export function detect(buf: Float32Array, sampleRate: number, options: Options = {}): number { const { diff --git a/packages/lib/dsp/src/fractions.ts b/packages/lib/dsp/src/fractions.ts index b18d68e2b..ac544500a 100644 --- a/packages/lib/dsp/src/fractions.ts +++ b/packages/lib/dsp/src/fractions.ts @@ -4,6 +4,7 @@ import {PPQN, ppqn} from "./ppqn" /** Tuple representing a musical fraction `n/d`. */ export type Fraction = Readonly<[int, int]> +/** Helpers for working with musical fractions. */ export namespace Fraction { /** Creates a builder for accumulating fractions. */ export const builder = () => new Builder() @@ -12,16 +13,21 @@ export namespace Fraction { /** Converts a time fraction to {@link ppqn} pulses. */ export const toPPQN = ([n, d]: Fraction): ppqn => PPQN.fromSignature(n, d) + /** Mutable accumulator for composing fraction sets. */ class Builder { readonly #list: Array = [] + /** Adds a fraction to the collection. */ add(fraction: Fraction): this { this.#list.push(fraction) return this } + /** Returns fractions in insertion order. */ asArray(): ReadonlyArray {return this.#list} + /** Returns fractions sorted ascending by ratio. */ asAscendingArray(): ReadonlyArray {return this.#list.toSorted((a, b) => toDouble(a) - toDouble(b))} + /** Returns fractions sorted descending by ratio. */ asDescendingArray(): ReadonlyArray {return this.#list.toSorted((a, b) => toDouble(b) - toDouble(a))} } -} \ No newline at end of file +} diff --git a/packages/lib/dsp/src/ppqn.ts b/packages/lib/dsp/src/ppqn.ts index 1cb897ffa..af58aef19 100644 --- a/packages/lib/dsp/src/ppqn.ts +++ b/packages/lib/dsp/src/ppqn.ts @@ -6,10 +6,26 @@ import {int} from "@opendaw/lib-std" /** Musical position expressed in pulses per quarter note. */ export type ppqn = number +/** Pulses contained in one quarter note. */ const Quarter = 960 as const +/** Pulses contained in a 4/4 bar. */ const Bar = Quarter << 2 // 3_840 +/** Pulses contained in a semiquaver. */ const SemiQuaver = Quarter >>> 2 // 240 +/** + * Calculates pulses for a time signature. + * + * @param nominator - Beats per bar. + * @param denominator - Note value representing one beat. + */ const fromSignature = (nominator: int, denominator: int) => Math.floor(Bar / denominator) * nominator +/** + * Breaks a pulse value down into musical parts. + * + * @param ppqn - Pulse count to decompose. + * @param nominator - Beats per bar. + * @param denominator - Note value representing one beat. + */ const toParts = (ppqn: ppqn, nominator: int = 4, denominator: int = 4) => { const lowerPulses = fromSignature(1, denominator) const beats = Math.floor(ppqn / lowerPulses) @@ -26,9 +42,13 @@ const toParts = (ppqn: ppqn, nominator: int = 4, denominator: int = 4) => { } as const } +/** Converts seconds to pulses for a given tempo. */ const secondsToPulses = (seconds: number, bpm: number): ppqn => seconds * bpm / 60.0 * Quarter +/** Converts pulses to seconds for a given tempo. */ const pulsesToSeconds = (pulses: ppqn, bpm: number): number => (pulses * 60.0 / Quarter) / bpm +/** Converts sample counts to pulses. */ const samplesToPulses = (samples: number, bpm: number, sampleRate: number): ppqn => secondsToPulses(samples / sampleRate, bpm) +/** Converts pulses to sample counts. */ const pulsesToSamples = (pulses: ppqn, bpm: number, sampleRate: number): number => pulsesToSeconds(pulses, bpm) * sampleRate /** Utility conversions for {@link ppqn} timing. */ @@ -46,4 +66,4 @@ export const PPQN = { const {bars, beats, semiquavers, ticks} = toParts(pulses | 0, nominator, denominator) return `${bars + 1}.${beats + 1}.${semiquavers + 1}:${ticks}` } -} as const \ No newline at end of file +} as const diff --git a/packages/lib/std/src/time-span.ts b/packages/lib/std/src/time-span.ts index 900999b15..b42f0b0dd 100644 --- a/packages/lib/std/src/time-span.ts +++ b/packages/lib/std/src/time-span.ts @@ -3,15 +3,25 @@ */ import { int, Unhandled } from "./lang"; +/** + * Represents a span of time with millisecond precision and provides + * convenient factory methods and conversions. + */ export class TimeSpan { + /** Time span representing positive infinity. */ static readonly POSITIVE_INFINITY = new TimeSpan(Number.POSITIVE_INFINITY); + /** Creates a span from milliseconds. */ static readonly millis = (value: number) => new TimeSpan(value); + /** Creates a span from seconds. */ static readonly seconds = (value: number) => new TimeSpan(value * TimeSpan.#MILLI_SECONDS_PER_SECOND); + /** Creates a span from minutes. */ static readonly minutes = (value: number) => new TimeSpan(value * TimeSpan.#MILLI_SECONDS_PER_MINUTE); + /** Creates a span from hours. */ static readonly hours = (value: number) => new TimeSpan(value * TimeSpan.#MILLI_SECONDS_PER_HOUR); + /** Creates a span from days. */ static readonly days = (value: number) => new TimeSpan(value * TimeSpan.#MILLI_SECONDS_PER_DAY); @@ -26,21 +36,27 @@ export class TimeSpan { this.#ms = ms; } + /** Returns the span in milliseconds. */ millis(): number { return this.#ms; } + /** Absolute value in seconds. */ absSeconds(): number { return Math.abs(this.#ms) / TimeSpan.#MILLI_SECONDS_PER_SECOND; } + /** Absolute value in minutes. */ absMinutes(): number { return Math.abs(this.#ms) / TimeSpan.#MILLI_SECONDS_PER_MINUTE; } + /** Absolute value in hours. */ absHours(): number { return Math.abs(this.#ms) / TimeSpan.#MILLI_SECONDS_PER_HOUR; } + /** Absolute value in days. */ absDays(): number { return Math.abs(this.#ms) / TimeSpan.#MILLI_SECONDS_PER_DAY; } + /** Splits the span into days, hours, minutes and seconds. */ split(): { d: int; h: int; m: int; s: int } { return { d: Math.floor(this.absDays()), @@ -49,15 +65,19 @@ export class TimeSpan { s: Math.floor(this.absSeconds()) % 60, }; } + /** Checks whether the span equals zero. */ isNow(): boolean { return this.#ms === 0.0; } + /** Checks whether the span is negative. */ isPast(): boolean { return this.#ms < 0.0; } + /** Checks whether the span is positive. */ isFuture(): boolean { return this.#ms > 0.0; } + /** Formats the span as a relative unit string such as `2 minutes`. */ toUnitString(): string { let value: number, unit: Intl.RelativeTimeFormatUnit; const seconds = Math.floor(Math.abs(this.#ms) / 1000); @@ -82,6 +102,7 @@ export class TimeSpan { style: "long", }).format(value * Math.sign(this.#ms), unit); } + /** Human readable representation such as `1 h, 2 m`. */ toString(): string { if (isNaN(this.#ms)) { return "NaN"; @@ -132,3 +153,4 @@ export class TimeSpan { } }; } + diff --git a/packages/studio/core/src/Engine.ts b/packages/studio/core/src/Engine.ts index cfeb3a95d..e4f8b9ce9 100644 --- a/packages/studio/core/src/Engine.ts +++ b/packages/studio/core/src/Engine.ts @@ -18,6 +18,8 @@ import {Project} from "./Project" /** * Event describing a note-on or note-off trigger emitted by the engine. + * + * @public */ export type NoteTrigger = | { type: "note-on", uuid: UUID.Format, pitch: byte, velocity: unitValue } @@ -25,6 +27,8 @@ export type NoteTrigger = /** * Abstraction of the playback engine exposing transport and sequencing control. + * + * @public */ export interface Engine extends Terminable { /** Starts playback. */ diff --git a/packages/studio/core/src/RenderQuantum.ts b/packages/studio/core/src/RenderQuantum.ts index 5647a3541..fbbef64b0 100644 --- a/packages/studio/core/src/RenderQuantum.ts +++ b/packages/studio/core/src/RenderQuantum.ts @@ -1,6 +1,9 @@ /** * Number of frames processed per Web Audio render quantum. * + * @remarks + * This constant matches the fixed buffer size used by the Web Audio API. + * * @public */ export const RenderQuantum = 128