Files
alda-mirror/client/model/note.go
Dave Yarwood 4f4f4622ec fix(repl): Resolve accumulating delay when using voices
This commit fixes a bug where an accumulating delay would occur in the
REPL when using voices. The root cause was a faulty offset
synchronization mechanism that failed to account for the effective
offset of an instrument with active voices.

The fix involves several related changes:

1.  **Correct Offset Calculation:**
    - A new `CalculateEffectiveOffset` method on the `Part` struct now
      correctly calculates the furthest point in time reached by an
      instrument or any of its voices.

2.  **Robust Synchronization via Stable IDs:**
    - The synchronization mechanism between the REPL and the player,
      which is responsible for adjusting note offsets, has been
      refactored to be more robust.
    - It now uses a stable, unique string ID for each part instead of
      unstable memory pointers. This prevents synchronization failures.
    - The `syncOffsets` map in the transmitter and related functions
      have been updated from `map[*Part]float64` to `map[string]float64`.

3.  **Voice Logic Correction:**
    - A structural bug in `VoiceMarker.UpdateScore` that caused voices
      to be nested incorrectly has been fixed.
    - The logic in `VoiceGroupEndMarker.UpdateScore` has been corrected
      to properly propagate the instruments state forward after a
      voice group is collapsed.

4.  **User-Friendly Part IDs:**
    - As a follow-on improvement, the part IDs (which are exposed in
      the REPL) have been changed from their previous pointer-based
      representation to short, sequential, human-readable identifiers
      (e.g., "part001", "part002").
2025-11-08 18:31:08 -05:00

255 lines
7.2 KiB
Go

package model
import (
"alda.io/client/help"
"alda.io/client/json"
log "alda.io/client/logging"
)
// A Note represents a single pitch being sustained for a period of time.
type Note struct {
SourceContext AldaSourceContext
Pitch PitchIdentifier
Duration Duration
// When a note is slurred, it means there is minimal space between that note
// and the next.
Slurred bool
}
// GetSourceContext implements HasSourceContext.GetSourceContext.
func (note Note) GetSourceContext() AldaSourceContext {
return note.SourceContext
}
// JSON implements RepresentableAsJSON.JSON.
func (note Note) JSON() *json.Container {
value := json.Object("pitch", note.Pitch.JSON())
if note.Duration.Components != nil {
value.Set(note.Duration.JSON(), "duration")
}
if note.Slurred {
value.Set(true, "slurred?")
}
return json.Object("type", "note", "value", value)
}
// A NoteEvent is a Note expressed in absolute terms with the goal of performing
// the note e.g. on a MIDI sequencer/synthesizer.
type NoteEvent struct {
Part *Part
MidiChannel int32
MidiNote int32
Offset float64
Duration float64
AudibleDuration float64
Volume float64
TrackVolume float64
Panning float64
}
// JSON implements RepresentableAsJSON.JSON.
func (note NoteEvent) JSON() *json.Container {
return json.Object(
"part", note.Part.ID,
"midi-channel", note.MidiChannel,
"midi-note", note.MidiNote,
"offset", note.Offset,
"duration", note.Duration,
"audible-duration", note.AudibleDuration,
"volume", note.Volume,
"track-volume", note.TrackVolume,
"panning", note.Panning,
)
}
// EventOffset implements ScoreEvent.EventOffset by returning the offset of the
// note.
func (note NoteEvent) EventOffset() float64 {
return note.Offset
}
func effectiveDuration(specifiedDuration Duration, part *Part) Duration {
// If no duration is specified, use the part's default duration.
if specifiedDuration.Components == nil {
return part.Duration
}
return specifiedDuration
}
// Note/rest duration is "sticky." Any subsequent notes/rests without a
// specified duration will take on the duration of the part's last note/rest.
func updateDefaultDuration(part *Part, duration Duration) {
if duration.Components != nil {
part.Duration = duration
}
}
func addNoteOrRest(score *Score, noteOrRest ScoreUpdate) error {
// Avoid applying the same global attribute change multiple times if we're in
// a chord.
//
// (To apply the change one time in the case of a chord, we call
// score.ApplyGlobalAttributes() as part of the chord score update).
if !score.chordMode {
if err := score.ApplyGlobalAttributes(); err != nil {
return err
}
}
var specifiedDuration Duration
switch noteOrRest := noteOrRest.(type) {
case Note:
specifiedDuration = noteOrRest.Duration
case Rest:
specifiedDuration = noteOrRest.Duration
}
if err := specifiedDuration.Validate(); err != nil {
return err
}
for _, part := range score.CurrentParts {
duration := effectiveDuration(specifiedDuration, part)
durationMs := duration.Ms(part.Tempo) * part.TimeScale
switch noteOrRest := noteOrRest.(type) {
case Note:
audibleDurationMs := durationMs
if !noteOrRest.Slurred {
audibleDurationMs *= part.Quantization
}
if audibleDurationMs > 0 {
midiNote := noteOrRest.Pitch.CalculateMidiNote(
part.Octave, part.KeySignature, part.Transposition,
)
if midiNote < 0 || midiNote > 127 {
return help.UserFacingErrorf("MIDI note out of the 0-127 range. Input note: %d", midiNote)
}
midiChannel, err := score.assignMidiChannel(part, audibleDurationMs)
if err != nil {
return err
}
part.MidiChannel = midiChannel
part.origin.MidiChannel = midiChannel
noteEvent := NoteEvent{
Part: part.origin,
MidiChannel: midiChannel,
MidiNote: midiNote,
Offset: part.CurrentOffset,
Duration: durationMs,
AudibleDuration: audibleDurationMs,
Volume: part.Volume,
TrackVolume: part.TrackVolume,
Panning: part.Panning,
}
log.Debug().
Int32("MidiNote", noteEvent.MidiNote).
Float64("Offset", noteEvent.Offset).
Float64("AudibleDuration", noteEvent.AudibleDuration).
Float64("Duration", noteEvent.Duration).
Msg("Adding note.")
// TODO: Consider proactively sorting `score.Events` by offset. This
// might help to speed up MIDI channel availability checks, if we need
// to.
//
// It might also be helpful to proactively sort by MIDI channel.
//
// See `complexCheck` in `model/midi.go`.
score.Events = append(score.Events, noteEvent)
}
}
if !score.chordMode {
part.LastOffset = part.CurrentOffset
part.CurrentOffset += durationMs
}
updateDefaultDuration(part, duration)
}
return nil
}
// UpdateScore implements ScoreUpdate.UpdateScore by adding a note to the score
// for all current parts and adjusting the parts' CurrentOffset, LastOffset, and
// Duration accordingly.
func (note Note) UpdateScore(score *Score) error {
return addNoteOrRest(score, note)
}
// DurationMs implements ScoreUpdate.DurationMs by returning the duration of the
// note (if specified) or the current duration of the part, within the context
// of the part's current tempo.
//
// Also updates the part's default duration, so that it can be correctly
// considered when tallying the duration of subsequent events.
func (note Note) DurationMs(part *Part) float64 {
durationMs := effectiveDuration(note.Duration, part).Ms(part.Tempo)
updateDefaultDuration(part, note.Duration)
return durationMs
}
// VariableValue implements ScoreUpdate.VariableValue.
func (note Note) VariableValue(score *Score) (ScoreUpdate, error) {
return note, nil
}
// A Rest represents a period of time spent waiting.
//
// The function of a rest is to synchronize the following note so that it starts
// at a particular point in time.
type Rest struct {
SourceContext AldaSourceContext
Duration Duration
}
// GetSourceContext implements HasSourceContext.GetSourceContext.
func (rest Rest) GetSourceContext() AldaSourceContext {
return rest.SourceContext
}
// JSON implements RepresentableAsJSON.JSON.
func (rest Rest) JSON() *json.Container {
value := json.Object()
if rest.Duration.Components != nil {
value.Set(rest.Duration.JSON(), "duration")
}
return json.Object("type", "rest", "value", value)
}
// UpdateScore implements ScoreUpdate.UpdateScore by adjusting the
// CurrentOffset, LastOffset, and Duration of all current parts.
func (rest Rest) UpdateScore(score *Score) error {
return addNoteOrRest(score, rest)
}
// DurationMs implements ScoreUpdate.DurationMs by returning the duration of the
// rest (if specified) or the current duration of the part, within the context
// of the part's current tempo.
//
// Also updates the part's default duration, so that it can be correctly
// considered when tallying the duration of subsequent events.
func (rest Rest) DurationMs(part *Part) float64 {
durationMs := effectiveDuration(rest.Duration, part).Ms(part.Tempo)
updateDefaultDuration(part, rest.Duration)
return durationMs
}
// VariableValue implements ScoreUpdate.VariableValue.
func (rest Rest) VariableValue(score *Score) (ScoreUpdate, error) {
return rest, nil
}