entangled-worlds-orbiters-shared 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clock/meter.d.ts +50 -0
- package/dist/clock/meter.js +145 -0
- package/dist/clock/track-metadata.d.ts +73 -0
- package/dist/clock/track-metadata.js +66 -0
- package/package.json +9 -1
- package/src/clock/meter.ts +177 -0
- package/src/clock/track-metadata.ts +127 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TrackDataLike } from './track-metadata.js';
|
|
2
|
+
export interface MeterPreset {
|
|
3
|
+
id: string;
|
|
4
|
+
numerator: number;
|
|
5
|
+
denominator: number;
|
|
6
|
+
}
|
|
7
|
+
/** Quick-pick reference meters. `id` is the persisted/display value, not a closed enum. */
|
|
8
|
+
export declare const METER_PRESETS: MeterPreset[];
|
|
9
|
+
export declare const DEFAULT_METER_ID = "4/4";
|
|
10
|
+
export declare const MIN_METER_NUMERATOR = 1;
|
|
11
|
+
export declare const MAX_METER_NUMERATOR = 64;
|
|
12
|
+
export declare const VALID_METER_DENOMINATORS: readonly number[];
|
|
13
|
+
interface MeterFraction {
|
|
14
|
+
numerator: number;
|
|
15
|
+
denominator: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ParsedMeter {
|
|
18
|
+
id: string;
|
|
19
|
+
numerator: number;
|
|
20
|
+
denominator: number;
|
|
21
|
+
beatsPerBar: number;
|
|
22
|
+
beatUnit: number;
|
|
23
|
+
sharedBeatsPerBar: number;
|
|
24
|
+
quarterBeatsPerBar: number;
|
|
25
|
+
clickIntervalQuarterBeats: number;
|
|
26
|
+
clicksPerBar: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function formatMeterId(value?: Partial<MeterFraction>): string;
|
|
29
|
+
/** True when `value` is a valid arbitrary meter string or `{ numerator, denominator }`. */
|
|
30
|
+
export declare function isValidMeterId(value: unknown): boolean;
|
|
31
|
+
/** Normalize an arbitrary value to a persisted meter id (`n/d`), falling back to the default. */
|
|
32
|
+
export declare function normalizeMeterId(value: unknown): string;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a meter id to its numbers, including `sharedBeatsPerBar` — the count of shared (quarter-note)
|
|
35
|
+
* beats in one bar, which is what the engine uses for the downbeat accent and the launch bar.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseMeter(value: unknown): ParsedMeter;
|
|
38
|
+
/** Shared (quarter-note) beats per bar for a meter id — the engine's bar length. */
|
|
39
|
+
export declare function sharedBeatsPerBar(id: unknown): number;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the per-track meter from assembled track data — the LOAD side, walking the SAME precedence
|
|
42
|
+
* as the shared tempo resolver (`resolveTrackBpmFromTrackData`): owner CANONICAL first, then the
|
|
43
|
+
* per-user override, then auto-detected analysis, then the default. First valid wins:
|
|
44
|
+
* 1) owner canonical — `metadata.meter` → `metadata.effectiveSettings.sync.meter`
|
|
45
|
+
* 2) per-user — `trackUserSettings.sync.meter`
|
|
46
|
+
* 3) auto-detected — `metadata.audioAnalysis.analysis` time signature
|
|
47
|
+
* 4) default (4/4)
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveTrackMeterFromTrackData(trackData?: TrackDataLike | null): string;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ./clock/meter — time signature / meter model. A per-track musical property (how many beats make a
|
|
2
|
+
// bar), resolved with the SAME owner → per-user → auto-detected → default precedence as the tempo
|
|
3
|
+
// resolver in `./clock/track-metadata`. Promoted out of the orbiters app so EW reuses it verbatim.
|
|
4
|
+
// Framework-free: no three, no react.
|
|
5
|
+
//
|
|
6
|
+
// Units: `sharedBeatsPerBar` is quarter-note beats per bar = numerator * 4 / denominator — the same
|
|
7
|
+
// beat unit `MusicalProjection` (./clock/musical) counts, so a resolved meter feeds its `signatureMap`
|
|
8
|
+
// directly: `{ startBar: 0, numerator, denominator }`.
|
|
9
|
+
/** Quick-pick reference meters. `id` is the persisted/display value, not a closed enum. */
|
|
10
|
+
export const METER_PRESETS = [
|
|
11
|
+
{ id: '4/4', numerator: 4, denominator: 4 },
|
|
12
|
+
{ id: '3/4', numerator: 3, denominator: 4 },
|
|
13
|
+
{ id: '6/8', numerator: 6, denominator: 8 },
|
|
14
|
+
];
|
|
15
|
+
export const DEFAULT_METER_ID = '4/4';
|
|
16
|
+
export const MIN_METER_NUMERATOR = 1;
|
|
17
|
+
export const MAX_METER_NUMERATOR = 64;
|
|
18
|
+
export const VALID_METER_DENOMINATORS = [1, 2, 4, 8, 16, 32];
|
|
19
|
+
const BY_ID = new Map(METER_PRESETS.map((m) => [m.id, m]));
|
|
20
|
+
const DENOMINATORS = new Set(VALID_METER_DENOMINATORS);
|
|
21
|
+
function isValidNumerator(n) {
|
|
22
|
+
return Number.isInteger(n) && n >= MIN_METER_NUMERATOR;
|
|
23
|
+
}
|
|
24
|
+
function isValidDenominator(n) {
|
|
25
|
+
return Number.isInteger(n) && DENOMINATORS.has(n);
|
|
26
|
+
}
|
|
27
|
+
function parseMeterString(value) {
|
|
28
|
+
if (typeof value !== 'string')
|
|
29
|
+
return null;
|
|
30
|
+
const match = value.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
const numerator = Number(match[1]);
|
|
34
|
+
const denominator = Number(match[2]);
|
|
35
|
+
if (!isValidNumerator(numerator) || !isValidDenominator(denominator))
|
|
36
|
+
return null;
|
|
37
|
+
return { numerator, denominator };
|
|
38
|
+
}
|
|
39
|
+
function parseMeterObject(value) {
|
|
40
|
+
if (!value || typeof value !== 'object')
|
|
41
|
+
return null;
|
|
42
|
+
const numerator = Number(value.numerator);
|
|
43
|
+
const denominator = Number(value.denominator);
|
|
44
|
+
if (!isValidNumerator(numerator) || !isValidDenominator(denominator))
|
|
45
|
+
return null;
|
|
46
|
+
return { numerator, denominator };
|
|
47
|
+
}
|
|
48
|
+
export function formatMeterId(value = {}) {
|
|
49
|
+
const n = Number(value.numerator);
|
|
50
|
+
const d = Number(value.denominator);
|
|
51
|
+
return isValidNumerator(n) && isValidDenominator(d) ? `${n}/${d}` : DEFAULT_METER_ID;
|
|
52
|
+
}
|
|
53
|
+
/** True when `value` is a valid arbitrary meter string or `{ numerator, denominator }`. */
|
|
54
|
+
export function isValidMeterId(value) {
|
|
55
|
+
return parseMeterString(value) !== null || parseMeterObject(value) !== null;
|
|
56
|
+
}
|
|
57
|
+
/** Normalize an arbitrary value to a persisted meter id (`n/d`), falling back to the default. */
|
|
58
|
+
export function normalizeMeterId(value) {
|
|
59
|
+
const parsed = parseMeterString(value) ?? parseMeterObject(value);
|
|
60
|
+
return parsed ? formatMeterId(parsed) : DEFAULT_METER_ID;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a meter id to its numbers, including `sharedBeatsPerBar` — the count of shared (quarter-note)
|
|
64
|
+
* beats in one bar, which is what the engine uses for the downbeat accent and the launch bar.
|
|
65
|
+
*/
|
|
66
|
+
export function parseMeter(value) {
|
|
67
|
+
const id = normalizeMeterId(value);
|
|
68
|
+
const preset = BY_ID.get(id);
|
|
69
|
+
const parsed = preset ?? parseMeterString(id) ?? parseMeterString(DEFAULT_METER_ID);
|
|
70
|
+
const numerator = parsed.numerator;
|
|
71
|
+
const denominator = parsed.denominator;
|
|
72
|
+
const sharedBeatsPerBar = (numerator * 4) / denominator;
|
|
73
|
+
const clickIntervalQuarterBeats = 4 / denominator;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
numerator,
|
|
77
|
+
denominator,
|
|
78
|
+
beatsPerBar: numerator,
|
|
79
|
+
beatUnit: denominator,
|
|
80
|
+
sharedBeatsPerBar,
|
|
81
|
+
quarterBeatsPerBar: sharedBeatsPerBar,
|
|
82
|
+
clickIntervalQuarterBeats,
|
|
83
|
+
clicksPerBar: numerator,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function resolveAnalysisMeter(analysis) {
|
|
87
|
+
if (!analysis || typeof analysis !== 'object')
|
|
88
|
+
return null;
|
|
89
|
+
const a = analysis;
|
|
90
|
+
const candidates = [
|
|
91
|
+
a.time_signature,
|
|
92
|
+
a.timeSignature,
|
|
93
|
+
a.meter,
|
|
94
|
+
a.time_signature?.meter,
|
|
95
|
+
a.timeSignature?.meter,
|
|
96
|
+
a.time_signature?.value,
|
|
97
|
+
a.timeSignature?.value,
|
|
98
|
+
];
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (isValidMeterId(candidate)) {
|
|
101
|
+
return normalizeMeterId(candidate);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** Shared (quarter-note) beats per bar for a meter id — the engine's bar length. */
|
|
107
|
+
export function sharedBeatsPerBar(id) {
|
|
108
|
+
return parseMeter(id).sharedBeatsPerBar;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the per-track meter from assembled track data — the LOAD side, walking the SAME precedence
|
|
112
|
+
* as the shared tempo resolver (`resolveTrackBpmFromTrackData`): owner CANONICAL first, then the
|
|
113
|
+
* per-user override, then auto-detected analysis, then the default. First valid wins:
|
|
114
|
+
* 1) owner canonical — `metadata.meter` → `metadata.effectiveSettings.sync.meter`
|
|
115
|
+
* 2) per-user — `trackUserSettings.sync.meter`
|
|
116
|
+
* 3) auto-detected — `metadata.audioAnalysis.analysis` time signature
|
|
117
|
+
* 4) default (4/4)
|
|
118
|
+
*/
|
|
119
|
+
export function resolveTrackMeterFromTrackData(trackData = null) {
|
|
120
|
+
const td = trackData;
|
|
121
|
+
const candidates = [
|
|
122
|
+
// 1) owner canonical — top-level metadata (mirrors tempo's `metadata.trackBpm`)
|
|
123
|
+
td?.track?.metadata?.meter,
|
|
124
|
+
td?.release?.metadata?.meter,
|
|
125
|
+
td?.metadata?.meter,
|
|
126
|
+
// 1) owner canonical — effectiveSettings (mirrors tempo's `effectiveSettings.sync.trackBpm`)
|
|
127
|
+
td?.track?.metadata?.effectiveSettings?.sync?.meter,
|
|
128
|
+
td?.release?.metadata?.effectiveSettings?.sync?.meter,
|
|
129
|
+
td?.metadata?.effectiveSettings?.sync?.meter,
|
|
130
|
+
td?.track?.effectiveSettings?.sync?.meter,
|
|
131
|
+
td?.release?.effectiveSettings?.sync?.meter,
|
|
132
|
+
td?.effectiveSettings?.sync?.meter,
|
|
133
|
+
// 2) per-user override (mirrors tempo's `trackUserSettings.sync.trackBpm`)
|
|
134
|
+
td?.trackUserSettings?.sync?.meter,
|
|
135
|
+
// 3) auto-detected analysis (mirrors tempo's `audioAnalysis.analysis.tempo_bpm`)
|
|
136
|
+
resolveAnalysisMeter(td?.track?.metadata?.audioAnalysis?.analysis),
|
|
137
|
+
resolveAnalysisMeter(td?.release?.metadata?.audioAnalysis?.analysis),
|
|
138
|
+
resolveAnalysisMeter(td?.metadata?.audioAnalysis?.analysis),
|
|
139
|
+
];
|
|
140
|
+
for (const c of candidates) {
|
|
141
|
+
if (isValidMeterId(c))
|
|
142
|
+
return normalizeMeterId(c);
|
|
143
|
+
}
|
|
144
|
+
return DEFAULT_METER_ID;
|
|
145
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of a track's data these resolvers read (tempo here; meter in `./clock/meter`). Every field
|
|
3
|
+
* is optional and untyped at the leaf because the runtime objects come from different app layers
|
|
4
|
+
* (release metadata, effective owner settings, per-user overrides, audio analysis). Resolvers coerce
|
|
5
|
+
* leaves with Number(...)/String(...) and ignore anything invalid, so a permissive shape here is
|
|
6
|
+
* intentional — not every consumer fills every path.
|
|
7
|
+
*/
|
|
8
|
+
export interface SyncSettingsLike {
|
|
9
|
+
trackBpm?: unknown;
|
|
10
|
+
/** Per-track time signature (e.g. `'4/4'` or `{ numerator, denominator }`) — see `./clock/meter`. */
|
|
11
|
+
meter?: unknown;
|
|
12
|
+
primaryGridMarkerTimeSec?: unknown;
|
|
13
|
+
gridMarkers?: ReadonlyArray<{
|
|
14
|
+
sourceTimeSec?: unknown;
|
|
15
|
+
} | null | undefined> | null;
|
|
16
|
+
gridStartTimeSec?: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface TrackMetadataLike {
|
|
19
|
+
trackBpm?: unknown;
|
|
20
|
+
/** Per-track time signature (owner canonical) — see `./clock/meter`. */
|
|
21
|
+
meter?: unknown;
|
|
22
|
+
primaryGridMarkerTimeSec?: unknown;
|
|
23
|
+
effectiveSettings?: {
|
|
24
|
+
sync?: SyncSettingsLike | null;
|
|
25
|
+
} | null;
|
|
26
|
+
audioAnalysis?: {
|
|
27
|
+
analysis?: {
|
|
28
|
+
tempo_bpm?: unknown;
|
|
29
|
+
time_signature?: unknown;
|
|
30
|
+
timeSignature?: unknown;
|
|
31
|
+
} | null;
|
|
32
|
+
} | null;
|
|
33
|
+
wrap?: {
|
|
34
|
+
gridMarkers?: ReadonlyArray<{
|
|
35
|
+
sourceTimeSec?: unknown;
|
|
36
|
+
} | null | undefined> | null;
|
|
37
|
+
gridStartTimeSec?: unknown;
|
|
38
|
+
} | null;
|
|
39
|
+
}
|
|
40
|
+
export interface TrackDataLike {
|
|
41
|
+
track?: {
|
|
42
|
+
metadata?: TrackMetadataLike | null;
|
|
43
|
+
effectiveSettings?: {
|
|
44
|
+
sync?: SyncSettingsLike | null;
|
|
45
|
+
} | null;
|
|
46
|
+
} | null;
|
|
47
|
+
release?: {
|
|
48
|
+
metadata?: TrackMetadataLike | null;
|
|
49
|
+
effectiveSettings?: {
|
|
50
|
+
sync?: SyncSettingsLike | null;
|
|
51
|
+
} | null;
|
|
52
|
+
} | null;
|
|
53
|
+
metadata?: TrackMetadataLike | null;
|
|
54
|
+
effectiveSettings?: {
|
|
55
|
+
sync?: SyncSettingsLike | null;
|
|
56
|
+
} | null;
|
|
57
|
+
trackUserSettings?: {
|
|
58
|
+
sync?: SyncSettingsLike | null;
|
|
59
|
+
} | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the effective track BPM. Precedence (first finite, > 0 wins):
|
|
63
|
+
* owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
|
|
64
|
+
* → auto-detected `audioAnalysis.analysis.tempo_bpm`. Returns null when nothing resolves.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveTrackBpmFromTrackData(trackData?: TrackDataLike | null): number | null;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the primary grid-marker (downbeat) time in seconds — the grid offset that becomes
|
|
69
|
+
* `tempoMap[0].startSec`. Precedence (first finite, >= 0 wins) walks the explicit
|
|
70
|
+
* `primaryGridMarkerTimeSec`, then per-user grid markers / start time, then the wrap fallback.
|
|
71
|
+
* Returns null when nothing resolves (consumers default to 0).
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData?: TrackDataLike | null): number | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ./clock/track-metadata — pure resolution of a track's tempo (BPM) and its primary grid-marker
|
|
2
|
+
// (downbeat) time from the loosely-typed metadata both apps already carry. Framework-free, no three,
|
|
3
|
+
// no react: it walks a fixed precedence (owner setting → per-user setting → auto-detected analysis)
|
|
4
|
+
// and returns the first finite value, so a consumer can feed `new MusicalProjection({ tempoMap: [{
|
|
5
|
+
// startSec: gridSec, bpm }] })`. Promoted out of the orbiters app (ORB-137) so EW reuses it verbatim.
|
|
6
|
+
function toPositiveNumber(value) {
|
|
7
|
+
const numeric = Number(value);
|
|
8
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
9
|
+
}
|
|
10
|
+
function toNonNegativeNumber(value) {
|
|
11
|
+
const numeric = Number(value);
|
|
12
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
|
13
|
+
}
|
|
14
|
+
function firstResolvedValue(candidates, parser) {
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
const resolved = parser(candidate);
|
|
17
|
+
if (resolved !== null) {
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the effective track BPM. Precedence (first finite, > 0 wins):
|
|
25
|
+
* owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
|
|
26
|
+
* → auto-detected `audioAnalysis.analysis.tempo_bpm`. Returns null when nothing resolves.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveTrackBpmFromTrackData(trackData = null) {
|
|
29
|
+
return firstResolvedValue([
|
|
30
|
+
trackData?.track?.metadata?.trackBpm,
|
|
31
|
+
trackData?.release?.metadata?.trackBpm,
|
|
32
|
+
trackData?.metadata?.trackBpm,
|
|
33
|
+
trackData?.track?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
34
|
+
trackData?.release?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
35
|
+
trackData?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
36
|
+
trackData?.trackUserSettings?.sync?.trackBpm,
|
|
37
|
+
trackData?.track?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
38
|
+
trackData?.release?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
39
|
+
trackData?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
40
|
+
], toPositiveNumber);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the primary grid-marker (downbeat) time in seconds — the grid offset that becomes
|
|
44
|
+
* `tempoMap[0].startSec`. Precedence (first finite, >= 0 wins) walks the explicit
|
|
45
|
+
* `primaryGridMarkerTimeSec`, then per-user grid markers / start time, then the wrap fallback.
|
|
46
|
+
* Returns null when nothing resolves (consumers default to 0).
|
|
47
|
+
*/
|
|
48
|
+
export function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData = null) {
|
|
49
|
+
return firstResolvedValue([
|
|
50
|
+
trackData?.track?.metadata?.primaryGridMarkerTimeSec,
|
|
51
|
+
trackData?.release?.metadata?.primaryGridMarkerTimeSec,
|
|
52
|
+
trackData?.metadata?.primaryGridMarkerTimeSec,
|
|
53
|
+
trackData?.track?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
54
|
+
trackData?.release?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
55
|
+
trackData?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
56
|
+
trackData?.trackUserSettings?.sync?.primaryGridMarkerTimeSec,
|
|
57
|
+
trackData?.trackUserSettings?.sync?.gridMarkers?.[0]?.sourceTimeSec,
|
|
58
|
+
trackData?.trackUserSettings?.sync?.gridStartTimeSec,
|
|
59
|
+
trackData?.track?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
60
|
+
trackData?.release?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
61
|
+
trackData?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
62
|
+
trackData?.track?.metadata?.wrap?.gridStartTimeSec,
|
|
63
|
+
trackData?.release?.metadata?.wrap?.gridStartTimeSec,
|
|
64
|
+
trackData?.metadata?.wrap?.gridStartTimeSec,
|
|
65
|
+
], toNonNegativeNumber);
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
"types": "./dist/clock/musical.d.ts",
|
|
22
22
|
"import": "./dist/clock/musical.js"
|
|
23
23
|
},
|
|
24
|
+
"./clock/track-metadata": {
|
|
25
|
+
"types": "./dist/clock/track-metadata.d.ts",
|
|
26
|
+
"import": "./dist/clock/track-metadata.js"
|
|
27
|
+
},
|
|
28
|
+
"./clock/meter": {
|
|
29
|
+
"types": "./dist/clock/meter.d.ts",
|
|
30
|
+
"import": "./dist/clock/meter.js"
|
|
31
|
+
},
|
|
24
32
|
"./clock/sync": {
|
|
25
33
|
"types": "./dist/clock/sync.d.ts",
|
|
26
34
|
"import": "./dist/clock/sync.js"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ./clock/meter — time signature / meter model. A per-track musical property (how many beats make a
|
|
2
|
+
// bar), resolved with the SAME owner → per-user → auto-detected → default precedence as the tempo
|
|
3
|
+
// resolver in `./clock/track-metadata`. Promoted out of the orbiters app so EW reuses it verbatim.
|
|
4
|
+
// Framework-free: no three, no react.
|
|
5
|
+
//
|
|
6
|
+
// Units: `sharedBeatsPerBar` is quarter-note beats per bar = numerator * 4 / denominator — the same
|
|
7
|
+
// beat unit `MusicalProjection` (./clock/musical) counts, so a resolved meter feeds its `signatureMap`
|
|
8
|
+
// directly: `{ startBar: 0, numerator, denominator }`.
|
|
9
|
+
|
|
10
|
+
import type { TrackDataLike } from './track-metadata.js';
|
|
11
|
+
|
|
12
|
+
export interface MeterPreset {
|
|
13
|
+
id: string;
|
|
14
|
+
numerator: number;
|
|
15
|
+
denominator: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Quick-pick reference meters. `id` is the persisted/display value, not a closed enum. */
|
|
19
|
+
export const METER_PRESETS: MeterPreset[] = [
|
|
20
|
+
{ id: '4/4', numerator: 4, denominator: 4 },
|
|
21
|
+
{ id: '3/4', numerator: 3, denominator: 4 },
|
|
22
|
+
{ id: '6/8', numerator: 6, denominator: 8 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_METER_ID = '4/4';
|
|
26
|
+
export const MIN_METER_NUMERATOR = 1;
|
|
27
|
+
export const MAX_METER_NUMERATOR = 64;
|
|
28
|
+
export const VALID_METER_DENOMINATORS: readonly number[] = [1, 2, 4, 8, 16, 32];
|
|
29
|
+
|
|
30
|
+
const BY_ID = new Map(METER_PRESETS.map((m) => [m.id, m]));
|
|
31
|
+
const DENOMINATORS = new Set(VALID_METER_DENOMINATORS);
|
|
32
|
+
|
|
33
|
+
interface MeterFraction {
|
|
34
|
+
numerator: number;
|
|
35
|
+
denominator: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParsedMeter {
|
|
39
|
+
id: string;
|
|
40
|
+
numerator: number;
|
|
41
|
+
denominator: number;
|
|
42
|
+
beatsPerBar: number;
|
|
43
|
+
beatUnit: number;
|
|
44
|
+
sharedBeatsPerBar: number;
|
|
45
|
+
quarterBeatsPerBar: number;
|
|
46
|
+
clickIntervalQuarterBeats: number;
|
|
47
|
+
clicksPerBar: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isValidNumerator(n: number): boolean {
|
|
51
|
+
return Number.isInteger(n) && n >= MIN_METER_NUMERATOR;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isValidDenominator(n: number): boolean {
|
|
55
|
+
return Number.isInteger(n) && DENOMINATORS.has(n);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseMeterString(value: unknown): MeterFraction | null {
|
|
59
|
+
if (typeof value !== 'string') return null;
|
|
60
|
+
const match = value.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
|
|
61
|
+
if (!match) return null;
|
|
62
|
+
const numerator = Number(match[1]);
|
|
63
|
+
const denominator = Number(match[2]);
|
|
64
|
+
if (!isValidNumerator(numerator) || !isValidDenominator(denominator)) return null;
|
|
65
|
+
return { numerator, denominator };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseMeterObject(value: unknown): MeterFraction | null {
|
|
69
|
+
if (!value || typeof value !== 'object') return null;
|
|
70
|
+
const numerator = Number((value as Record<string, unknown>).numerator);
|
|
71
|
+
const denominator = Number((value as Record<string, unknown>).denominator);
|
|
72
|
+
if (!isValidNumerator(numerator) || !isValidDenominator(denominator)) return null;
|
|
73
|
+
return { numerator, denominator };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatMeterId(value: Partial<MeterFraction> = {}): string {
|
|
77
|
+
const n = Number(value.numerator);
|
|
78
|
+
const d = Number(value.denominator);
|
|
79
|
+
return isValidNumerator(n) && isValidDenominator(d) ? `${n}/${d}` : DEFAULT_METER_ID;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when `value` is a valid arbitrary meter string or `{ numerator, denominator }`. */
|
|
83
|
+
export function isValidMeterId(value: unknown): boolean {
|
|
84
|
+
return parseMeterString(value) !== null || parseMeterObject(value) !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Normalize an arbitrary value to a persisted meter id (`n/d`), falling back to the default. */
|
|
88
|
+
export function normalizeMeterId(value: unknown): string {
|
|
89
|
+
const parsed = parseMeterString(value) ?? parseMeterObject(value);
|
|
90
|
+
return parsed ? formatMeterId(parsed) : DEFAULT_METER_ID;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a meter id to its numbers, including `sharedBeatsPerBar` — the count of shared (quarter-note)
|
|
95
|
+
* beats in one bar, which is what the engine uses for the downbeat accent and the launch bar.
|
|
96
|
+
*/
|
|
97
|
+
export function parseMeter(value: unknown): ParsedMeter {
|
|
98
|
+
const id = normalizeMeterId(value);
|
|
99
|
+
const preset = BY_ID.get(id);
|
|
100
|
+
const parsed = preset ?? parseMeterString(id) ?? (parseMeterString(DEFAULT_METER_ID) as MeterFraction);
|
|
101
|
+
const numerator = parsed.numerator;
|
|
102
|
+
const denominator = parsed.denominator;
|
|
103
|
+
const sharedBeatsPerBar = (numerator * 4) / denominator;
|
|
104
|
+
const clickIntervalQuarterBeats = 4 / denominator;
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
numerator,
|
|
108
|
+
denominator,
|
|
109
|
+
beatsPerBar: numerator,
|
|
110
|
+
beatUnit: denominator,
|
|
111
|
+
sharedBeatsPerBar,
|
|
112
|
+
quarterBeatsPerBar: sharedBeatsPerBar,
|
|
113
|
+
clickIntervalQuarterBeats,
|
|
114
|
+
clicksPerBar: numerator,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveAnalysisMeter(analysis: unknown): string | null {
|
|
119
|
+
if (!analysis || typeof analysis !== 'object') return null;
|
|
120
|
+
const a = analysis as Record<string, any>;
|
|
121
|
+
const candidates = [
|
|
122
|
+
a.time_signature,
|
|
123
|
+
a.timeSignature,
|
|
124
|
+
a.meter,
|
|
125
|
+
a.time_signature?.meter,
|
|
126
|
+
a.timeSignature?.meter,
|
|
127
|
+
a.time_signature?.value,
|
|
128
|
+
a.timeSignature?.value,
|
|
129
|
+
];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
if (isValidMeterId(candidate)) {
|
|
132
|
+
return normalizeMeterId(candidate);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Shared (quarter-note) beats per bar for a meter id — the engine's bar length. */
|
|
139
|
+
export function sharedBeatsPerBar(id: unknown): number {
|
|
140
|
+
return parseMeter(id).sharedBeatsPerBar;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the per-track meter from assembled track data — the LOAD side, walking the SAME precedence
|
|
145
|
+
* as the shared tempo resolver (`resolveTrackBpmFromTrackData`): owner CANONICAL first, then the
|
|
146
|
+
* per-user override, then auto-detected analysis, then the default. First valid wins:
|
|
147
|
+
* 1) owner canonical — `metadata.meter` → `metadata.effectiveSettings.sync.meter`
|
|
148
|
+
* 2) per-user — `trackUserSettings.sync.meter`
|
|
149
|
+
* 3) auto-detected — `metadata.audioAnalysis.analysis` time signature
|
|
150
|
+
* 4) default (4/4)
|
|
151
|
+
*/
|
|
152
|
+
export function resolveTrackMeterFromTrackData(trackData: TrackDataLike | null = null): string {
|
|
153
|
+
const td = trackData;
|
|
154
|
+
const candidates = [
|
|
155
|
+
// 1) owner canonical — top-level metadata (mirrors tempo's `metadata.trackBpm`)
|
|
156
|
+
td?.track?.metadata?.meter,
|
|
157
|
+
td?.release?.metadata?.meter,
|
|
158
|
+
td?.metadata?.meter,
|
|
159
|
+
// 1) owner canonical — effectiveSettings (mirrors tempo's `effectiveSettings.sync.trackBpm`)
|
|
160
|
+
td?.track?.metadata?.effectiveSettings?.sync?.meter,
|
|
161
|
+
td?.release?.metadata?.effectiveSettings?.sync?.meter,
|
|
162
|
+
td?.metadata?.effectiveSettings?.sync?.meter,
|
|
163
|
+
td?.track?.effectiveSettings?.sync?.meter,
|
|
164
|
+
td?.release?.effectiveSettings?.sync?.meter,
|
|
165
|
+
td?.effectiveSettings?.sync?.meter,
|
|
166
|
+
// 2) per-user override (mirrors tempo's `trackUserSettings.sync.trackBpm`)
|
|
167
|
+
td?.trackUserSettings?.sync?.meter,
|
|
168
|
+
// 3) auto-detected analysis (mirrors tempo's `audioAnalysis.analysis.tempo_bpm`)
|
|
169
|
+
resolveAnalysisMeter(td?.track?.metadata?.audioAnalysis?.analysis),
|
|
170
|
+
resolveAnalysisMeter(td?.release?.metadata?.audioAnalysis?.analysis),
|
|
171
|
+
resolveAnalysisMeter(td?.metadata?.audioAnalysis?.analysis),
|
|
172
|
+
];
|
|
173
|
+
for (const c of candidates) {
|
|
174
|
+
if (isValidMeterId(c)) return normalizeMeterId(c);
|
|
175
|
+
}
|
|
176
|
+
return DEFAULT_METER_ID;
|
|
177
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ./clock/track-metadata — pure resolution of a track's tempo (BPM) and its primary grid-marker
|
|
2
|
+
// (downbeat) time from the loosely-typed metadata both apps already carry. Framework-free, no three,
|
|
3
|
+
// no react: it walks a fixed precedence (owner setting → per-user setting → auto-detected analysis)
|
|
4
|
+
// and returns the first finite value, so a consumer can feed `new MusicalProjection({ tempoMap: [{
|
|
5
|
+
// startSec: gridSec, bpm }] })`. Promoted out of the orbiters app (ORB-137) so EW reuses it verbatim.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The slice of a track's data these resolvers read (tempo here; meter in `./clock/meter`). Every field
|
|
9
|
+
* is optional and untyped at the leaf because the runtime objects come from different app layers
|
|
10
|
+
* (release metadata, effective owner settings, per-user overrides, audio analysis). Resolvers coerce
|
|
11
|
+
* leaves with Number(...)/String(...) and ignore anything invalid, so a permissive shape here is
|
|
12
|
+
* intentional — not every consumer fills every path.
|
|
13
|
+
*/
|
|
14
|
+
export interface SyncSettingsLike {
|
|
15
|
+
trackBpm?: unknown;
|
|
16
|
+
/** Per-track time signature (e.g. `'4/4'` or `{ numerator, denominator }`) — see `./clock/meter`. */
|
|
17
|
+
meter?: unknown;
|
|
18
|
+
primaryGridMarkerTimeSec?: unknown;
|
|
19
|
+
gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
|
|
20
|
+
gridStartTimeSec?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TrackMetadataLike {
|
|
24
|
+
trackBpm?: unknown;
|
|
25
|
+
/** Per-track time signature (owner canonical) — see `./clock/meter`. */
|
|
26
|
+
meter?: unknown;
|
|
27
|
+
primaryGridMarkerTimeSec?: unknown;
|
|
28
|
+
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
29
|
+
audioAnalysis?: {
|
|
30
|
+
analysis?: { tempo_bpm?: unknown; time_signature?: unknown; timeSignature?: unknown } | null;
|
|
31
|
+
} | null;
|
|
32
|
+
wrap?: {
|
|
33
|
+
gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
|
|
34
|
+
gridStartTimeSec?: unknown;
|
|
35
|
+
} | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TrackDataLike {
|
|
39
|
+
track?: {
|
|
40
|
+
metadata?: TrackMetadataLike | null;
|
|
41
|
+
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
42
|
+
} | null;
|
|
43
|
+
release?: {
|
|
44
|
+
metadata?: TrackMetadataLike | null;
|
|
45
|
+
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
46
|
+
} | null;
|
|
47
|
+
metadata?: TrackMetadataLike | null;
|
|
48
|
+
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
49
|
+
trackUserSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toPositiveNumber(value: unknown): number | null {
|
|
53
|
+
const numeric = Number(value);
|
|
54
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toNonNegativeNumber(value: unknown): number | null {
|
|
58
|
+
const numeric = Number(value);
|
|
59
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstResolvedValue(
|
|
63
|
+
candidates: ReadonlyArray<unknown>,
|
|
64
|
+
parser: (value: unknown) => number | null,
|
|
65
|
+
): number | null {
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const resolved = parser(candidate);
|
|
68
|
+
if (resolved !== null) {
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the effective track BPM. Precedence (first finite, > 0 wins):
|
|
77
|
+
* owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
|
|
78
|
+
* → auto-detected `audioAnalysis.analysis.tempo_bpm`. Returns null when nothing resolves.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveTrackBpmFromTrackData(trackData: TrackDataLike | null = null): number | null {
|
|
81
|
+
return firstResolvedValue(
|
|
82
|
+
[
|
|
83
|
+
trackData?.track?.metadata?.trackBpm,
|
|
84
|
+
trackData?.release?.metadata?.trackBpm,
|
|
85
|
+
trackData?.metadata?.trackBpm,
|
|
86
|
+
trackData?.track?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
87
|
+
trackData?.release?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
88
|
+
trackData?.metadata?.effectiveSettings?.sync?.trackBpm,
|
|
89
|
+
trackData?.trackUserSettings?.sync?.trackBpm,
|
|
90
|
+
trackData?.track?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
91
|
+
trackData?.release?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
92
|
+
trackData?.metadata?.audioAnalysis?.analysis?.tempo_bpm,
|
|
93
|
+
],
|
|
94
|
+
toPositiveNumber,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the primary grid-marker (downbeat) time in seconds — the grid offset that becomes
|
|
100
|
+
* `tempoMap[0].startSec`. Precedence (first finite, >= 0 wins) walks the explicit
|
|
101
|
+
* `primaryGridMarkerTimeSec`, then per-user grid markers / start time, then the wrap fallback.
|
|
102
|
+
* Returns null when nothing resolves (consumers default to 0).
|
|
103
|
+
*/
|
|
104
|
+
export function resolvePrimaryGridMarkerTimeSecFromTrackData(
|
|
105
|
+
trackData: TrackDataLike | null = null,
|
|
106
|
+
): number | null {
|
|
107
|
+
return firstResolvedValue(
|
|
108
|
+
[
|
|
109
|
+
trackData?.track?.metadata?.primaryGridMarkerTimeSec,
|
|
110
|
+
trackData?.release?.metadata?.primaryGridMarkerTimeSec,
|
|
111
|
+
trackData?.metadata?.primaryGridMarkerTimeSec,
|
|
112
|
+
trackData?.track?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
113
|
+
trackData?.release?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
114
|
+
trackData?.metadata?.effectiveSettings?.sync?.primaryGridMarkerTimeSec,
|
|
115
|
+
trackData?.trackUserSettings?.sync?.primaryGridMarkerTimeSec,
|
|
116
|
+
trackData?.trackUserSettings?.sync?.gridMarkers?.[0]?.sourceTimeSec,
|
|
117
|
+
trackData?.trackUserSettings?.sync?.gridStartTimeSec,
|
|
118
|
+
trackData?.track?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
119
|
+
trackData?.release?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
120
|
+
trackData?.metadata?.wrap?.gridMarkers?.[0]?.sourceTimeSec,
|
|
121
|
+
trackData?.track?.metadata?.wrap?.gridStartTimeSec,
|
|
122
|
+
trackData?.release?.metadata?.wrap?.gridStartTimeSec,
|
|
123
|
+
trackData?.metadata?.wrap?.gridStartTimeSec,
|
|
124
|
+
],
|
|
125
|
+
toNonNegativeNumber,
|
|
126
|
+
);
|
|
127
|
+
}
|