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

Commit 6ed0bd1

Browse files
committed
docs: update musical-time spec to reflect pulse-based implementation [skip release]
- Update algorithm specification to match current pulse-based approach - Document fromTimePoints static method with timing regularization - Add [skip release] support to GitHub Actions workflow - Remove theoretical calculation references that were replaced with pulse-based logic - Clarify critical design decisions for Issue #40 resolution This is a documentation update that aligns the specification with the implemented pulse-based approach without changing functionality.
1 parent 32518b3 commit 6ed0bd1

2 files changed

Lines changed: 119 additions & 44 deletions

File tree

.github/workflows/release.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,17 @@ jobs:
6060
- name: Check if version bump is needed
6161
id: release-check
6262
run: |
63-
# Always bump patch version on push to main
64-
echo "should_release=true" >> $GITHUB_OUTPUT
65-
echo "Version will be bumped"
63+
# Check if commit message contains [skip release] or [skip ci]
64+
COMMIT_MESSAGE=$(git log -1 --pretty=%B)
65+
echo "Latest commit message: $COMMIT_MESSAGE"
66+
67+
if echo "$COMMIT_MESSAGE" | grep -q "\[skip release\]\|\[skip ci\]"; then
68+
echo "should_release=false" >> $GITHUB_OUTPUT
69+
echo "🚫 Skipping release due to [skip release] or [skip ci] in commit message"
70+
else
71+
echo "should_release=true" >> $GITHUB_OUTPUT
72+
echo "✅ Version will be bumped"
73+
fi
6674
6775
- name: Run tests
6876
if: steps.release-check.outputs.should_release == 'true'

docs/musical-time-spec.md

Lines changed: 108 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ This specification defines the interface and behavior for converting real time (
1212
4. Provide human-readable musical time representations
1313
5. Maintain identical behavior across Python and TypeScript implementations
1414

15+
## Pulse-Based Approach
16+
17+
**Critical Design Decision**: This implementation uses a **fully pulse-based approach** rather than theoretical timing calculations. This ensures accurate results when pulse data contains timing variations (rubato):
18+
19+
- **Cycle boundaries**: Determined by actual pulse positions, not `cycleDur` calculations
20+
- **Hierarchical positions**: Derived from actual pulse found, not theoretical subdivision timing
21+
- **Fractional beat**: Uses actual pulse-to-pulse durations, not theoretical `pulseDur`
22+
23+
This approach correctly handles Issue #40 where theoretical calculations returned incorrect cycle numbers at boundaries with rubato timing.
24+
1525
## Core Data Structures
1626

1727
### MusicalTime
@@ -40,6 +50,34 @@ Readable: "Cycle 1: Beat 3, Subdivision 2 + 0.500 to next pulse"
4050

4151
## Core Interface
4252

53+
### Meter.fromTimePoints() [Static Method]
54+
55+
**Signature:**
56+
```
57+
fromTimePoints(timePoints: number[], hierarchy: number[], repetitions?: number, layer?: number): Meter
58+
```
59+
60+
**Purpose:** Create a Meter from actual pulse time points, handling timing variations (rubato).
61+
62+
**Parameters:**
63+
- `timePoints: number[]` - List of actual pulse times in seconds
64+
- `hierarchy: number[]` - Meter hierarchy (e.g., [4, 4, 2])
65+
- `repetitions: number` (optional) - Number of cycle repetitions (default: 1)
66+
- `layer: number` (optional) - Which hierarchical layer the time points represent (default: 0)
67+
68+
**Features:**
69+
- **Timing Regularization**: Automatically handles extreme rubato deviations (>40% of pulse duration) by inserting intermediate time points
70+
- **Pulse Duration Calculation**: Derives tempo from actual timing data
71+
- **Extrapolation**: Extends pulse data when fewer time points provided than needed
72+
73+
**Algorithm:**
74+
1. Sort and validate time points
75+
2. Calculate average pulse duration
76+
3. Apply timing regularization (insert intermediate points for >40% deviations)
77+
4. Create theoretical meter with calculated tempo
78+
5. Adjust all pulses to match actual time points
79+
6. Extrapolate remaining pulses if needed
80+
4381
### Meter.getMusicalTime()
4482

4583
**Signature:**
@@ -65,7 +103,8 @@ getMusicalTime(realTime: number, referenceLevel?: number): MusicalTime | false
65103

66104
**Boundaries:**
67105
- **Start**: `realTime >= meter.startTime`
68-
- **End**: `realTime < meter.startTime + meter.repetitions * meter.cycleDur`
106+
- **End**: `realTime < meter.startTime + meter.repetitions * meter.cycleDur` (theoretical end used for boundary validation compatibility)
107+
- **Internal**: All cycle boundaries determined by actual pulse positions
69108

70109
## Algorithm Specification
71110

@@ -79,65 +118,93 @@ if realTime >= endTime:
79118
return false
80119
```
81120

82-
### Step 2: Cycle Calculation
83-
```
84-
relativeTime = realTime - meter.startTime
85-
cycleNumber = floor(relativeTime / meter.cycleDur)
86-
cycleOffset = relativeTime % meter.cycleDur
87-
```
88-
89-
### Step 3: Hierarchical Position Calculation
121+
### Step 2: Pulse-Based Cycle Calculation
90122

91-
For each level in the hierarchy, calculate the position within that level:
123+
**Critical**: Use actual pulse timing boundaries, not theoretical calculations. This correctly handles rubato and timing variations:
92124

93125
```
94-
positions = []
95-
remainingTime = cycleOffset
126+
cycleNumber = null
127+
cycleOffset = null
96128
97-
totalFinestSubdivisions = meter.getPulsesPerCycle()
98-
currentGroupSize = totalFinestSubdivisions
99-
100-
for each level in hierarchy:
101-
levelSize = hierarchy[level] (or sum if array)
102-
currentGroupSize = currentGroupSize / levelSize
103-
subdivisionDuration = currentGroupSize * meter.getPulseDur()
104-
105-
positionAtLevel = floor(remainingTime / subdivisionDuration)
106-
positions.append(positionAtLevel)
129+
for cycle in range(meter.repetitions):
130+
cycleStartPulseIdx = cycle * meter.getPulsesPerCycle()
107131
108-
remainingTime = remainingTime % subdivisionDuration
132+
if cycleStartPulseIdx < meter.allPulses.length:
133+
cycleStartTime = meter.allPulses[cycleStartPulseIdx].realTime
134+
135+
// Get actual cycle end time using next cycle's first pulse
136+
nextCycleStartPulseIdx = (cycle + 1) * meter.getPulsesPerCycle()
137+
if nextCycleStartPulseIdx < meter.allPulses.length:
138+
cycleEndTime = meter.allPulses[nextCycleStartPulseIdx].realTime
139+
else:
140+
// Final cycle - use theoretical end
141+
cycleEndTime = meter.startTime + meter.repetitions * meter.cycleDur
142+
143+
// Check if time falls within this cycle's actual boundaries
144+
if cycle == meter.repetitions - 1:
145+
// Final cycle: include exact end time
146+
if cycleStartTime <= realTime <= cycleEndTime:
147+
cycleNumber = cycle
148+
cycleOffset = realTime - cycleStartTime
149+
break
150+
else:
151+
// Intermediate cycles: exclude end time (belongs to next cycle)
152+
if cycleStartTime <= realTime < cycleEndTime:
153+
cycleNumber = cycle
154+
cycleOffset = realTime - cycleStartTime
155+
break
156+
157+
if cycleNumber == null:
158+
throw Error("Unable to determine cycle using pulse data")
159+
```
160+
161+
### Step 3: Pulse-Based Hierarchical Position Calculation
162+
163+
**Critical**: Find the actual pulse that corresponds to the query time, then derive hierarchical position from that pulse:
164+
165+
```
166+
// Find the pulse at or before the query time within the current cycle
167+
cycleStartPulseIdx = cycleNumber * meter.getPulsesPerCycle()
168+
cycleEndPulseIdx = min((cycleNumber + 1) * meter.getPulsesPerCycle(), meter.allPulses.length)
169+
170+
currentPulseIndex = null
171+
for pulseIdx in range(cycleStartPulseIdx, cycleEndPulseIdx):
172+
if meter.allPulses[pulseIdx].realTime <= realTime:
173+
currentPulseIndex = pulseIdx
174+
else:
175+
break
176+
177+
if currentPulseIndex == null:
178+
currentPulseIndex = cycleStartPulseIdx // Fallback to cycle start
179+
180+
// Derive hierarchical position from the actual pulse found
181+
positions = pulseIndexToHierarchicalPosition(currentPulseIndex, cycleNumber)
109182
```
110183

111184
### Step 4: Fractional Beat Calculation (Always Pulse-Based)
112185

113-
The fractional beat ALWAYS represents the position between pulses (finest level), regardless of reference level:
186+
The fractional beat ALWAYS represents the position between pulses (finest level), using the pulse found in Step 3:
114187

115188
```
116-
currentPulseIndex = hierarchicalPositionToPulseIndex(positions, cycleNumber)
189+
// Use the current pulse index found in Step 3
190+
currentPulseTime = meter.allPulses[currentPulseIndex].realTime
117191
118-
// Bounds checking
119-
if currentPulseIndex < 0 or currentPulseIndex >= meter.allPulses.length:
120-
fractionalBeat = 0.0
121-
else:
122-
currentPulseTime = meter.allPulses[currentPulseIndex].realTime
123-
124-
// Handle next pulse (accounting for cycle boundaries)
125-
if currentPulseIndex + 1 < meter.allPulses.length:
126-
nextPulseTime = meter.allPulses[currentPulseIndex + 1].realTime
127-
else:
128-
// Last pulse - use next cycle start
129-
nextCycleStart = meter.startTime + (cycleNumber + 1) * meter.cycleDur
130-
nextPulseTime = nextCycleStart
131-
192+
// Find next pulse for fractional calculation - always use pulse-based logic
193+
if currentPulseIndex + 1 < meter.allPulses.length:
194+
nextPulseTime = meter.allPulses[currentPulseIndex + 1].realTime
132195
pulseDuration = nextPulseTime - currentPulseTime
196+
133197
if pulseDuration <= 0:
134198
fractionalBeat = 0.0
135199
else:
136200
timeFromCurrentPulse = realTime - currentPulseTime
137201
fractionalBeat = timeFromCurrentPulse / pulseDuration
202+
else:
203+
// This is the last pulse - can't calculate duration
204+
fractionalBeat = 0.0
138205
139-
// Clamp to [0, 1] range
140-
fractionalBeat = max(0.0, min(1.0, fractionalBeat))
206+
// Clamp to [0, 1) range (exclusive upper bound for MusicalTime)
207+
fractionalBeat = max(0.0, min(0.9999999999999999, fractionalBeat))
141208
```
142209

143210
### Step 5: Handle Reference Level Truncation

0 commit comments

Comments
 (0)