Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

Commit d0e0ba3

Browse files
committed
spec: add comprehensive musical time conversion specification
- Define getMusicalTime() interface for converting real time to musical time - Support arbitrary hierarchical depth with clear level naming - Add optional referenceLevel parameter for fractional calculation at any hierarchy level - Use pulse-based fractional calculation to handle rubato and irregular timing - Include comprehensive test cases and edge case handling - Designed for identical implementation across Python and TypeScript
1 parent 2a45111 commit d0e0ba3

1 file changed

Lines changed: 393 additions & 0 deletions

File tree

docs/musical-time-spec.md

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
# Musical Time Conversion Specification
2+
3+
## Overview
4+
5+
This specification defines the interface and behavior for converting real time (seconds) to musical time within a hierarchical meter system. This functionality enables precise positioning of events within complex rhythmic structures while accounting for expressive timing variations (rubato).
6+
7+
## Goals
8+
9+
1. Convert real time to hierarchical musical position within a meter
10+
2. Handle arbitrary depth of rhythmic hierarchy
11+
3. Account for non-periodic pulse spacing due to rubato/expression
12+
4. Provide human-readable musical time representations
13+
5. Maintain identical behavior across Python and TypeScript implementations
14+
15+
## Core Data Structures
16+
17+
### MusicalTime
18+
19+
Represents a position within musical time relative to a meter.
20+
21+
#### Properties
22+
- `cycleNumber: number` - Zero-indexed cycle number
23+
- `hierarchicalPosition: number[]` - Position at each hierarchical level [beat, subdivision, sub-subdivision, ...]
24+
- `fractionalBeat: number` - Fractional position between current pulse and next pulse (0.0 to 1.0)
25+
26+
#### Methods
27+
- `toString(): string` - Compact format: `"C{cycle}:{hierarchy}+{fraction}"`
28+
- `toReadableString(): string` - Human-readable format with level names
29+
- `getBeat(): number` - Get beat position (hierarchicalPosition[0])
30+
- `getSubdivision(): number | null` - Get subdivision (hierarchicalPosition[1] or null)
31+
- `getSubSubdivision(): number | null` - Get sub-subdivision (hierarchicalPosition[2] or null)
32+
- `getLevel(level: number): number | null` - Get position at arbitrary level
33+
- `getHierarchyDepth(): number` - Number of hierarchical levels
34+
35+
#### String Format Examples
36+
```
37+
Compact: "C0:2.1+0.500" (Cycle 0, Beat 2, Subdivision 1, 0.5 to next)
38+
Readable: "Cycle 1: Beat 3, Subdivision 2 + 0.500 to next pulse"
39+
```
40+
41+
## Core Interface
42+
43+
### Meter.getMusicalTime()
44+
45+
**Signature:**
46+
```
47+
getMusicalTime(realTime: number, referenceLevel?: number): MusicalTime | false
48+
```
49+
50+
**Parameters:**
51+
- `realTime: number` - Time in seconds
52+
- `referenceLevel: number` (optional) - Hierarchical level to use as fractional reference (0=beat, 1=subdivision, etc.). Defaults to finest level (hierarchy.length - 1)
53+
54+
**Returns:**
55+
- `MusicalTime` - Musical position if time falls within meter boundaries
56+
- `false` - If time is before start_time or after end_time
57+
58+
**Reference Level Behavior:**
59+
- `referenceLevel=0`: Fractional position within beat duration
60+
- `referenceLevel=1`: Fractional position within subdivision duration
61+
- `referenceLevel=n`: Fractional position within level-n duration
62+
- Default: Fractional position within finest subdivision (between pulses)
63+
64+
**Boundaries:**
65+
- **Start**: `realTime >= meter.startTime`
66+
- **End**: `realTime < meter.startTime + meter.repetitions * meter.cycleDur`
67+
68+
## Algorithm Specification
69+
70+
### Step 1: Boundary Validation
71+
```
72+
if realTime < meter.startTime:
73+
return false
74+
75+
endTime = meter.startTime + meter.repetitions * meter.cycleDur
76+
if realTime >= endTime:
77+
return false
78+
```
79+
80+
### Step 2: Cycle Calculation
81+
```
82+
relativeTime = realTime - meter.startTime
83+
cycleNumber = floor(relativeTime / meter.cycleDur)
84+
cycleOffset = relativeTime % meter.cycleDur
85+
```
86+
87+
### Step 3: Hierarchical Position Calculation
88+
89+
For each level in the hierarchy, calculate the position within that level:
90+
91+
```
92+
positions = []
93+
remainingTime = cycleOffset
94+
95+
totalFinestSubdivisions = meter.getPulsesPerCycle()
96+
currentGroupSize = totalFinestSubdivisions
97+
98+
for each level in hierarchy:
99+
levelSize = hierarchy[level] (or sum if array)
100+
currentGroupSize = currentGroupSize / levelSize
101+
subdivisionDuration = currentGroupSize * meter.getPulseDur()
102+
103+
positionAtLevel = floor(remainingTime / subdivisionDuration)
104+
positions.append(positionAtLevel)
105+
106+
remainingTime = remainingTime % subdivisionDuration
107+
```
108+
109+
### Step 4: Fractional Beat Calculation (Level-Based)
110+
111+
The fractional beat calculation depends on the specified reference level:
112+
113+
#### Default Behavior (Pulse-Based)
114+
When `referenceLevel` is not specified or equals `hierarchy.length - 1`, calculate fraction between pulses:
115+
116+
```
117+
currentPulseIndex = hierarchicalPositionToPulseIndex(positions, cycleNumber)
118+
currentPulseTime = meter.allPulses[currentPulseIndex].realTime
119+
120+
// Handle next pulse (accounting for cycle boundaries)
121+
if currentPulseIndex + 1 < meter.allPulses.length:
122+
nextPulseTime = meter.allPulses[currentPulseIndex + 1].realTime
123+
else:
124+
// Last pulse - use next cycle start
125+
nextCycleStart = meter.startTime + (cycleNumber + 1) * meter.cycleDur
126+
nextPulseTime = nextCycleStart
127+
128+
pulseDuration = nextPulseTime - currentPulseTime
129+
if pulseDuration <= 0:
130+
fractionalBeat = 0.0
131+
else:
132+
timeFromCurrentPulse = realTime - currentPulseTime
133+
fractionalBeat = timeFromCurrentPulse / pulseDuration
134+
135+
// Clamp to [0, 1] range
136+
fractionalBeat = max(0.0, min(1.0, fractionalBeat))
137+
```
138+
139+
#### Reference Level Behavior
140+
When `referenceLevel` is specified and < `hierarchy.length - 1`:
141+
142+
```
143+
// Truncate hierarchical position to reference level + 1
144+
truncatedPosition = positions[0..referenceLevel]
145+
146+
// Calculate start time of current reference-level unit
147+
currentLevelStartTime = calculateLevelStartTime(truncatedPosition, cycleNumber, referenceLevel)
148+
149+
// Calculate duration of reference-level unit (accounting for actual pulse timing)
150+
levelDuration = calculateLevelDuration(truncatedPosition, cycleNumber, referenceLevel)
151+
152+
if levelDuration <= 0:
153+
fractionalBeat = 0.0
154+
else:
155+
timeFromLevelStart = realTime - currentLevelStartTime
156+
fractionalBeat = timeFromLevelStart / levelDuration
157+
158+
// Clamp to [0, 1] range
159+
fractionalBeat = max(0.0, min(1.0, fractionalBeat))
160+
161+
// Update hierarchical position to only include levels up to reference
162+
positions = truncatedPosition
163+
```
164+
165+
### Step 5: Result Construction
166+
```
167+
return MusicalTime {
168+
cycleNumber: cycleNumber,
169+
hierarchicalPosition: positions,
170+
fractionalBeat: fractionalBeat
171+
}
172+
```
173+
174+
## Helper Functions
175+
176+
### hierarchicalPositionToPulseIndex()
177+
178+
**Purpose:** Convert hierarchical position to pulse index within a cycle.
179+
180+
**Signature:**
181+
```
182+
hierarchicalPositionToPulseIndex(positions: number[], cycleNumber: number): number
183+
```
184+
185+
**Algorithm:**
186+
```
187+
pulseIndex = 0
188+
multiplier = 1
189+
190+
// Work from finest to coarsest level
191+
for level = positions.length - 1 down to 0:
192+
position = positions[level]
193+
hierarchySize = hierarchy[level] (or sum if array)
194+
195+
pulseIndex += position * multiplier
196+
multiplier *= hierarchySize
197+
198+
// Add offset for cycle
199+
cycleOffset = cycleNumber * meter.getPulsesPerCycle()
200+
return pulseIndex + cycleOffset
201+
```
202+
203+
### calculateLevelStartTime()
204+
205+
**Purpose:** Calculate the start time of a hierarchical unit at a given reference level.
206+
207+
**Signature:**
208+
```
209+
calculateLevelStartTime(positions: number[], cycleNumber: number, referenceLevel: number): number
210+
```
211+
212+
**Algorithm:**
213+
```
214+
// Find the pulse index for the start of this reference-level unit
215+
startPositions = positions.slice(0, referenceLevel + 1)
216+
// Zero out all positions below the reference level
217+
for i = referenceLevel + 1 to hierarchy.length - 1:
218+
startPositions[i] = 0
219+
220+
startPulseIndex = hierarchicalPositionToPulseIndex(startPositions, cycleNumber)
221+
return meter.allPulses[startPulseIndex].realTime
222+
```
223+
224+
### calculateLevelDuration()
225+
226+
**Purpose:** Calculate the actual duration of a hierarchical unit based on pulse timing.
227+
228+
**Signature:**
229+
```
230+
calculateLevelDuration(positions: number[], cycleNumber: number, referenceLevel: number): number
231+
```
232+
233+
**Algorithm:**
234+
```
235+
// Get start time of current unit
236+
startTime = calculateLevelStartTime(positions, cycleNumber, referenceLevel)
237+
238+
// Calculate start time of next unit at same level
239+
nextPositions = positions.slice()
240+
nextPositions[referenceLevel]++
241+
242+
// Handle overflow - if we've exceeded this level, move to next cycle or higher level
243+
if nextPositions[referenceLevel] >= hierarchy[referenceLevel]:
244+
if referenceLevel == 0:
245+
// Next beat is in next cycle
246+
nextCycleNumber = cycleNumber + 1
247+
if nextCycleNumber >= meter.repetitions:
248+
// Use meter end time
249+
return meter.startTime + meter.repetitions * meter.cycleDur - startTime
250+
nextPositions[0] = 0
251+
return calculateLevelStartTime(nextPositions, nextCycleNumber, referenceLevel) - startTime
252+
else:
253+
// Carry over to higher level
254+
nextPositions[referenceLevel] = 0
255+
nextPositions[referenceLevel - 1]++
256+
return calculateLevelDuration(nextPositions, cycleNumber, referenceLevel - 1)
257+
258+
endTime = calculateLevelStartTime(nextPositions, cycleNumber, referenceLevel)
259+
return endTime - startTime
260+
```
261+
262+
## Edge Cases & Error Handling
263+
264+
### Time Boundaries
265+
- **Before start**: Return `false`
266+
- **At or after end**: Return `false`
267+
- **Exactly at start**: Return valid MusicalTime
268+
- **Exactly at cycle boundary**: Belongs to the starting cycle
269+
270+
### Pulse Spacing
271+
- **Zero duration between pulses**: fractionalBeat = 0.0
272+
- **Negative duration** (shouldn't happen): fractionalBeat = 0.0
273+
- **Last pulse in meter**: Use next cycle start for duration calculation
274+
275+
### Hierarchy Validation
276+
- **Empty hierarchy**: Should not occur (validated in Meter constructor)
277+
- **Single level hierarchy**: hierarchicalPosition has length 1
278+
- **Array notation** (`[[2,2]]`): Treat as sum for calculations
279+
280+
## Test Cases
281+
282+
### Test Case 1: Regular Meter - Default (Finest Level)
283+
```
284+
Meter: hierarchy=[4, 4], tempo=240, startTime=0, repetitions=2
285+
Query: getMusicalTime(2.375)
286+
287+
Expected:
288+
- cycleNumber: 0
289+
- hierarchicalPosition: [2, 1] (Beat 3, Subdivision 2)
290+
- fractionalBeat: 0.5 (halfway to next pulse)
291+
- toString(): "C0:2.1+0.500"
292+
```
293+
294+
### Test Case 2: Reference Level - Beat Level
295+
```
296+
Meter: hierarchy=[4, 4], tempo=240, startTime=0, repetitions=2
297+
Query: getMusicalTime(2.375, referenceLevel=0)
298+
299+
Expected:
300+
- cycleNumber: 0
301+
- hierarchicalPosition: [2] (Beat 3)
302+
- fractionalBeat: 0.375 (0.375 through beat duration of 1.0 second)
303+
- toString(): "C0:2+0.375"
304+
- Readable: "Cycle 1: Beat 3 + 0.375 through beat"
305+
```
306+
307+
### Test Case 3: Reference Level - Subdivision Level
308+
```
309+
Meter: hierarchy=[4, 4], tempo=240, startTime=0, repetitions=2
310+
Query: getMusicalTime(2.375, referenceLevel=1)
311+
312+
Expected:
313+
- cycleNumber: 0
314+
- hierarchicalPosition: [2, 1] (Beat 3, Subdivision 2)
315+
- fractionalBeat: 0.5 (0.5 through subdivision duration of 0.25 seconds)
316+
- toString(): "C0:2.1+0.500"
317+
- Readable: "Cycle 1: Beat 3, Subdivision 2 + 0.500 through subdivision"
318+
```
319+
320+
### Test Case 4: Complex Hierarchy with Reference Levels
321+
```
322+
Meter: hierarchy=[3, 2, 4], tempo=960, startTime=0, repetitions=1
323+
Query: getMusicalTime(0.15625, referenceLevel=1)
324+
325+
Expected:
326+
- cycleNumber: 0
327+
- hierarchicalPosition: [1, 0] (Beat 2, Subdivision 1)
328+
- fractionalBeat: 0.25 (0.25 through subdivision duration)
329+
- toString(): "C0:1.0+0.250"
330+
```
331+
332+
### Test Case 5: Rubato with Reference Level
333+
```
334+
Meter: hierarchy=[4, 2], tempo=120, startTime=0, repetitions=1
335+
Pulses modified: beat 2 is stretched by 0.5 seconds
336+
337+
Query: getMusicalTime(1.75, referenceLevel=0)
338+
Expected: fractionalBeat calculated from actual beat boundaries (accounting for rubato)
339+
```
340+
341+
### Test Case 6: Boundary Conditions
342+
```
343+
Meter: startTime=10.0, endTime=20.0
344+
345+
Query: realTime=9.99 → Expected: false
346+
Query: realTime=10.0 → Expected: valid MusicalTime
347+
Query: realTime=19.99 → Expected: valid MusicalTime
348+
Query: realTime=20.0 → Expected: false
349+
```
350+
351+
### Test Case 7: Reference Level Validation
352+
```
353+
Meter: hierarchy=[4, 4] (2 levels: 0, 1)
354+
355+
Query: getMusicalTime(1.0, referenceLevel=0) → Valid
356+
Query: getMusicalTime(1.0, referenceLevel=1) → Valid
357+
Query: getMusicalTime(1.0, referenceLevel=2) → Error (level doesn't exist)
358+
Query: getMusicalTime(1.0, referenceLevel=-1) → Error (invalid level)
359+
```
360+
361+
## Implementation Notes
362+
363+
### Precision Considerations
364+
- Use appropriate floating-point comparison tolerances
365+
- Handle potential precision errors in time calculations
366+
- Ensure consistent behavior across platforms
367+
368+
### Performance Considerations
369+
- Cache calculated values where appropriate
370+
- Avoid recalculating pulse indices for repeated queries
371+
- Consider optimization for large numbers of pulses
372+
373+
### Type Safety
374+
- Return type should be union/optional type (`MusicalTime | false`)
375+
- All numeric parameters should be validated
376+
- Handle null/undefined inputs gracefully
377+
378+
## Level Naming Convention
379+
380+
For string representations, use this naming pattern:
381+
- Level 0: "Beat"
382+
- Level 1: "Subdivision"
383+
- Level 2: "Sub-subdivision"
384+
- Level 3: "Sub-sub-subdivision"
385+
- Level N (N > 3): "Sub^{N-1}-subdivision"
386+
387+
## Version Requirements
388+
389+
This specification should be implemented identically in:
390+
- Python: `idtap.classes.meter.Meter`
391+
- TypeScript: `src/ts/model/meter/Meter`
392+
393+
Both implementations must pass identical test suites to ensure behavioral consistency across platforms.

0 commit comments

Comments
 (0)