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
10 changes: 9 additions & 1 deletion packages/app/studio/src/ui/header/TimeStateDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
9 changes: 7 additions & 2 deletions packages/app/studio/src/ui/timeline/SnapSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -22,6 +25,8 @@ type Construct = {
* ```tsx
* <SnapSelector lifecycle={lifecycle} snapping={snapping} />
* ```
*
* @public
*/
export const SnapSelector = ({lifecycle, snapping}: Construct) => {
const snappingName = Inject.value(snapping.unit.name)
Expand All @@ -35,4 +40,4 @@ export const SnapSelector = ({lifecycle, snapping}: Construct) => {
</MenuButton>
</div>
)
}
}
12 changes: 9 additions & 3 deletions packages/app/studio/src/ui/timeline/TimeAxis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
}
Expand All @@ -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<ppqn> = null
Expand Down Expand Up @@ -165,4 +171,4 @@ export const TimeAxis = ({lifecycle, service, snapping, range, mapper}: Construc
{cursorElement}
</div>
)
}
}
7 changes: 6 additions & 1 deletion packages/app/studio/src/ui/timeline/TimeGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -49,4 +54,4 @@ export namespace TimeGrid {
designer({bars, beats, ticks, isBar, isBeat, pulse})
}
}
}
}
15 changes: 15 additions & 0 deletions packages/docs/docs-dev/architecture/tempo.md
Original file line number Diff line number Diff line change
@@ -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.

13 changes: 13 additions & 0 deletions packages/docs/docs-user/features/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/lib/dsp/src/bpm-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion packages/lib/dsp/src/fractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<Fraction> = []

/** Adds a fraction to the collection. */
add(fraction: Fraction): this {
this.#list.push(fraction)
return this
}

/** Returns fractions in insertion order. */
asArray(): ReadonlyArray<Fraction> {return this.#list}
/** Returns fractions sorted ascending by ratio. */
asAscendingArray(): ReadonlyArray<Fraction> {return this.#list.toSorted((a, b) => toDouble(a) - toDouble(b))}
/** Returns fractions sorted descending by ratio. */
asDescendingArray(): ReadonlyArray<Fraction> {return this.#list.toSorted((a, b) => toDouble(b) - toDouble(a))}
}
}
}
22 changes: 21 additions & 1 deletion packages/lib/dsp/src/ppqn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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. */
Expand All @@ -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
} as const
22 changes: 22 additions & 0 deletions packages/lib/std/src/time-span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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()),
Expand All @@ -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);
Expand All @@ -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";
Expand Down Expand Up @@ -132,3 +153,4 @@ export class TimeSpan {
}
};
}

4 changes: 4 additions & 0 deletions packages/studio/core/src/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ 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 }
| { type: "note-off", uuid: UUID.Format, pitch: byte }

/**
* Abstraction of the playback engine exposing transport and sequencing control.
*
* @public
*/
export interface Engine extends Terminable {
/** Starts playback. */
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/core/src/RenderQuantum.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down