|
| 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