entangled-worlds-orbiters-shared 1.4.0 → 1.5.1
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 +20 -4
- package/package.json +5 -1
- package/src/clock/meter.ts +177 -0
- package/src/clock/track-metadata.ts +21 -7
- package/src/loaders/cubeMapLoader.js +6 -6
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The slice of a track's data these resolvers read
|
|
3
|
-
* because the runtime objects come from different app layers
|
|
4
|
-
* settings, per-user overrides, audio analysis). Resolvers coerce
|
|
5
|
-
* anything
|
|
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.
|
|
6
7
|
*/
|
|
7
8
|
export interface SyncSettingsLike {
|
|
8
9
|
trackBpm?: unknown;
|
|
10
|
+
/** Per-track time signature (e.g. `'4/4'` or `{ numerator, denominator }`) — see `./clock/meter`. */
|
|
11
|
+
meter?: unknown;
|
|
9
12
|
primaryGridMarkerTimeSec?: unknown;
|
|
10
13
|
gridMarkers?: ReadonlyArray<{
|
|
11
14
|
sourceTimeSec?: unknown;
|
|
@@ -14,6 +17,8 @@ export interface SyncSettingsLike {
|
|
|
14
17
|
}
|
|
15
18
|
export interface TrackMetadataLike {
|
|
16
19
|
trackBpm?: unknown;
|
|
20
|
+
/** Per-track time signature (owner canonical) — see `./clock/meter`. */
|
|
21
|
+
meter?: unknown;
|
|
17
22
|
primaryGridMarkerTimeSec?: unknown;
|
|
18
23
|
effectiveSettings?: {
|
|
19
24
|
sync?: SyncSettingsLike | null;
|
|
@@ -21,6 +26,8 @@ export interface TrackMetadataLike {
|
|
|
21
26
|
audioAnalysis?: {
|
|
22
27
|
analysis?: {
|
|
23
28
|
tempo_bpm?: unknown;
|
|
29
|
+
time_signature?: unknown;
|
|
30
|
+
timeSignature?: unknown;
|
|
24
31
|
} | null;
|
|
25
32
|
} | null;
|
|
26
33
|
wrap?: {
|
|
@@ -33,11 +40,20 @@ export interface TrackMetadataLike {
|
|
|
33
40
|
export interface TrackDataLike {
|
|
34
41
|
track?: {
|
|
35
42
|
metadata?: TrackMetadataLike | null;
|
|
43
|
+
effectiveSettings?: {
|
|
44
|
+
sync?: SyncSettingsLike | null;
|
|
45
|
+
} | null;
|
|
36
46
|
} | null;
|
|
37
47
|
release?: {
|
|
38
48
|
metadata?: TrackMetadataLike | null;
|
|
49
|
+
effectiveSettings?: {
|
|
50
|
+
sync?: SyncSettingsLike | null;
|
|
51
|
+
} | null;
|
|
39
52
|
} | null;
|
|
40
53
|
metadata?: TrackMetadataLike | null;
|
|
54
|
+
effectiveSettings?: {
|
|
55
|
+
sync?: SyncSettingsLike | null;
|
|
56
|
+
} | null;
|
|
41
57
|
trackUserSettings?: {
|
|
42
58
|
sync?: SyncSettingsLike | null;
|
|
43
59
|
} | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/clock/track-metadata.d.ts",
|
|
26
26
|
"import": "./dist/clock/track-metadata.js"
|
|
27
27
|
},
|
|
28
|
+
"./clock/meter": {
|
|
29
|
+
"types": "./dist/clock/meter.d.ts",
|
|
30
|
+
"import": "./dist/clock/meter.js"
|
|
31
|
+
},
|
|
28
32
|
"./clock/sync": {
|
|
29
33
|
"types": "./dist/clock/sync.d.ts",
|
|
30
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
|
+
}
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
// startSec: gridSec, bpm }] })`. Promoted out of the orbiters app (ORB-137) so EW reuses it verbatim.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The slice of a track's data these resolvers read
|
|
9
|
-
* because the runtime objects come from different app layers
|
|
10
|
-
* settings, per-user overrides, audio analysis). Resolvers coerce
|
|
11
|
-
* anything
|
|
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.
|
|
12
13
|
*/
|
|
13
14
|
export interface SyncSettingsLike {
|
|
14
15
|
trackBpm?: unknown;
|
|
16
|
+
/** Per-track time signature (e.g. `'4/4'` or `{ numerator, denominator }`) — see `./clock/meter`. */
|
|
17
|
+
meter?: unknown;
|
|
15
18
|
primaryGridMarkerTimeSec?: unknown;
|
|
16
19
|
gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
|
|
17
20
|
gridStartTimeSec?: unknown;
|
|
@@ -19,9 +22,13 @@ export interface SyncSettingsLike {
|
|
|
19
22
|
|
|
20
23
|
export interface TrackMetadataLike {
|
|
21
24
|
trackBpm?: unknown;
|
|
25
|
+
/** Per-track time signature (owner canonical) — see `./clock/meter`. */
|
|
26
|
+
meter?: unknown;
|
|
22
27
|
primaryGridMarkerTimeSec?: unknown;
|
|
23
28
|
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
24
|
-
audioAnalysis?: {
|
|
29
|
+
audioAnalysis?: {
|
|
30
|
+
analysis?: { tempo_bpm?: unknown; time_signature?: unknown; timeSignature?: unknown } | null;
|
|
31
|
+
} | null;
|
|
25
32
|
wrap?: {
|
|
26
33
|
gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
|
|
27
34
|
gridStartTimeSec?: unknown;
|
|
@@ -29,9 +36,16 @@ export interface TrackMetadataLike {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface TrackDataLike {
|
|
32
|
-
track?: {
|
|
33
|
-
|
|
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;
|
|
34
47
|
metadata?: TrackMetadataLike | null;
|
|
48
|
+
effectiveSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
35
49
|
trackUserSettings?: { sync?: SyncSettingsLike | null } | null;
|
|
36
50
|
}
|
|
37
51
|
|
|
@@ -175,19 +175,19 @@ export async function loadCubeMap({ faceKeys, skycubeName, preferKTX2 = true, fo
|
|
|
175
175
|
try {
|
|
176
176
|
return await loadFacesKTX2(resolvedUrls, renderer);
|
|
177
177
|
} catch (e) {
|
|
178
|
-
console.warn('[CubeMapLoader] KTX2 load failed, trying
|
|
179
|
-
// Fall through to
|
|
178
|
+
console.warn('[CubeMapLoader] KTX2 load failed, trying PNG fallback:', e);
|
|
179
|
+
// Fall through to PNG attempt
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
// Try
|
|
184
|
-
const
|
|
183
|
+
// Try PNG fallback (convert URLs if needed)
|
|
184
|
+
const pngUrls = {};
|
|
185
185
|
FACE_ORDER.forEach(face => {
|
|
186
|
-
|
|
186
|
+
pngUrls[face] = resolvedUrls[face].replace(/\.ktx2$/, '.png');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
189
|
try {
|
|
190
|
-
return await loadFacesJPG(
|
|
190
|
+
return await loadFacesJPG(pngUrls);
|
|
191
191
|
} catch (e) {
|
|
192
192
|
console.error('[CubeMapLoader] All loading attempts failed:', e);
|
|
193
193
|
throw new Error('Failed to load cube map with any format');
|