Class: Musa::Datasets::Score

Inherits:
Object
  • Object
show all
Includes:
Enumerable, AbsD, Queriable, Render, ToMXML
Defined in:
lib/musa-dsl/datasets/score.rb,
lib/musa-dsl/datasets/score/render.rb,
lib/musa-dsl/datasets/score/queriable.rb,
lib/musa-dsl/datasets/score/to-mxml/to-mxml.rb

Overview

Time-indexed container for musical events.

Score organizes musical events along a timeline, storing them at specific time points and providing efficient queries for time intervals. Implements Enumerable for iteration over time slots.

Purpose

Score provides:

  • Time-indexed storage: Events organized by start time (Rational)
  • Interval queries: Find events in time ranges (#between, #changes_between)
  • Duration tracking: Automatically tracks event durations
  • Export formats: MusicXML export via ToMXML
  • Rendering: MIDI rendering via Render
  • Filtering: Create subsets via #subset

Structure

Internally maintains two structures:

  • @score: Hash mapping time → Array of events
  • @indexer: Array of { start, finish, dataset } for interval queries

Event Requirements

Events must:

  • Extend Abs (absolute values, not deltas)
  • Have a :duration key (from AbsD)

Time Representation

All times are stored as Rational numbers for exact arithmetic:

score.at(0r, add: event)    # At time 0
score.at(1/4r, add: event)  # At quarter note

Examples:

Create empty score

score = Musa::Datasets::Score.new

Create from hash

score = Score.new({
  0r => [{ pitch: 60, duration: 1.0 }.extend(PDV)],
  1r => [{ pitch: 64, duration: 1.0 }.extend(PDV)]
})

Add events

score = Score.new
gdv1 = { grade: 0, duration: 1.0 }.extend(GDV)
gdv2 = { grade: 2, duration: 1.0 }.extend(GDV)
score.at(0r, add: gdv1)
score.at(1r, add: gdv2)

Query time interval

events = score.between(0r, 2r)
# Returns all events starting in [0, 2) or overlapping interval

Filter events

high_notes = score.subset { |event| event[:pitch] > 60 }

Get all positions

score.positions  # => [0r, 1r, 2r, ...]

Get duration

score.duration  # => Latest finish time - 1r

See Also:

Defined Under Namespace

Modules: Queriable, Render, ToMXML

Constant Summary collapse

NaturalKeys =
NaturalKeys.freeze

Instance Method Summary collapse

Constructor Details

#initialize(hash = nil) ⇒ Score

Creates new score.

Examples:

Empty score

score = Score.new

With initial events

score = Score.new({
  0r => [{ pitch: 60, duration: 1.0 }.extend(PDV)],
  1r => [{ pitch: 64, duration: 1.0 }.extend(PDV)]
})

Parameters:

  • hash (Hash{Rational => Array<Abs>}, nil) (defaults to: nil)

    optional initial events Hash mapping times to arrays of events

Raises:

  • (ArgumentError)

    if hash values aren't Arrays



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/musa-dsl/datasets/score.rb', line 109

def initialize(hash = nil)
  raise ArgumentError, "'hash' parameter should be a Hash with time and events information" unless hash.nil? || hash.is_a?(Hash)

  @score = {}
  @indexer = []

  if hash
    hash.sort.each do |k, v|
      raise ArgumentError, "'hash' values for time #{k} should be an Array of events" unless v.is_a?(Array)

      v.each do |vv|
        at(k, add: vv)
      end
    end
  end
end

Instance Method Details

#[](key) ⇒ Object?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Gets attribute value.

Supports accessing natural keys like :duration, :finish.

Parameters:

  • key (Symbol)

    attribute name

Returns:

  • (Object, nil)

    attribute value



146
147
148
149
150
# File 'lib/musa-dsl/datasets/score.rb', line 146

def [](key)
  if NaturalKeys.include?(key) && self.respond_to?(key)
    self.send(key)
  end
end

#at(time, add: nil) ⇒ Array<Abs>?

Adds event at time or gets time slot.

Without add parameter, returns array of events at that time. With add parameter, adds event to that time slot.

Examples:

Add event

gdv = { grade: 0, duration: 1.0 }.extend(GDV)
score.at(0r, add: gdv)

Get time slot

events = score.at(0r)  # => Array of events at time 0

Multiple events at same time (chord)

score.at(0r, add: { pitch: 60, duration: 1.0 }.extend(PDV))
score.at(0r, add: { pitch: 64, duration: 1.0 }.extend(PDV))
score.at(0r).size  # => 2

Parameters:

  • time (Numeric)

    time position (converted to Rational)

  • add (Abs, nil) (defaults to: nil)

    event to add (must extend Abs and have :duration)

Returns:

  • (Array<Abs>, nil)

    time slot if no add, nil if adding

Raises:

  • (ArgumentError)

    if add is not an Abs dataset



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/musa-dsl/datasets/score.rb', line 199

def at(time, add: nil)
  time = time.rationalize

  if add
    raise ArgumentError, "#{add} is not a Abs dataset" unless add&.is_a?(Musa::Datasets::Abs)

    slot = @score[time] ||= [].extend(QueryableByTimeSlot)

    slot << add

    @indexer << { start: time,
                  finish: time + add.duration.rationalize,
                  dataset: add }

    nil
  else
    @score[time] ||= [].extend(QueryableByTimeSlot)
  end
end

#between(closed_interval_start, open_interval_finish) ⇒ Array<Hash>

Queries events overlapping time interval.

Returns events that are active (playing) during the interval [start, finish). Interval uses closed start (included) and open finish (excluded).

Events are included if they:

  • Start before interval finish AND finish after interval start
  • OR are instant events (start == finish) at interval instant

Examples:

Query bar

events = score.between(0r, 4r)
# Returns all events overlapping [0, 4)

Long note spans interval

score.at(0r, add: { duration: 10.0 }.extend(AbsD))
events = score.between(2r, 4r)
# Event included (started before 4, finishes after 2)
# start_in_interval: 2r, finish_in_interval: 4r

Parameters:

  • closed_interval_start (Rational)

    interval start (included)

  • open_interval_finish (Rational)

    interval finish (excluded)

Returns:

  • (Array<Hash>)

    array of event info hashes with:

    • :start: Event start time
    • :finish: Event finish time
    • :start_in_interval: Effective start within interval
    • :finish_in_interval: Effective finish within interval
    • :dataset: The event dataset


301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/musa-dsl/datasets/score.rb', line 301

def between(closed_interval_start, open_interval_finish)
  @indexer
    .select { |i| i[:start] < open_interval_finish && i[:finish] > closed_interval_start ||
                  closed_interval_start == open_interval_finish &&
                      i[:start] == i[:finish] &&
                      i[:start] == closed_interval_start }
    .sort_by { |i| i[:start] }
    .collect { |i| { start: i[:start],
                     finish: i[:finish],
                     start_in_interval: i[:start] > closed_interval_start ? i[:start] : closed_interval_start,
                     finish_in_interval: i[:finish] < open_interval_finish ? i[:finish] : open_interval_finish,
                     dataset: i[:dataset] } }.extend(QueryableByDataset)
end

#changes_between(closed_interval_start, open_interval_finish) ⇒ Array<Hash>

Queries start/finish change events in interval.

Returns timeline of note-on/note-off style events for the interval. Useful for real-time rendering or event-based processing.

Returns events sorted by time, with :finish events before :start events at the same time (to avoid gaps).

Examples:

Get all changes in bar

changes = score.changes_between(0r, 4r)
changes.each do |change|
  case change[:change]
  when :start
    puts "Note ON at #{change[:time]}"
  when :finish
    puts "Note OFF at #{change[:time]}"
  end
end

Parameters:

  • closed_interval_start (Rational)

    interval start (included)

  • open_interval_finish (Rational)

    interval finish (excluded)

Returns:

  • (Array<Hash>)

    array of change event hashes with:

    • :change: :start or :finish
    • :time: When change occurs
    • :start: Event start time
    • :finish: Event finish time
    • :start_in_interval: Effective start within interval
    • :finish_in_interval: Effective finish within interval
    • :time_in_interval: Effective change time within interval
    • :dataset: The event dataset


348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/musa-dsl/datasets/score.rb', line 348

def changes_between(closed_interval_start, open_interval_finish)
  (
    #
    # we have a start event if the element
    # begins between queried interval start (included) and interval finish (excluded)
    #
    @indexer
      .select { |i| i[:start] >= closed_interval_start && i[:start] < open_interval_finish }
      .collect { |i| i.clone.merge({ change: :start, time: i[:start] }) } +

    #
    # we have a finish event if the element interval finishes
    # between queried interval start (excluded) and queried interval finish (included) or
    # element interval finishes exactly on queried interval start
    # but the element interval started before queried interval start
    # (the element is not an instant)
    #
    @indexer
      .select { |i| ( i[:finish] > closed_interval_start ||
                      i[:finish] == closed_interval_start && i[:finish] == i[:start])   &&
                    ( i[:finish] < open_interval_finish ||
                      i[:finish] == open_interval_finish && i[:start] < open_interval_finish) }
      .collect { |i| i.clone.merge({ change: :finish, time: i[:finish] }) } +

    #
    # when the queried interval has no duration (it's an instant) we have a start and a finish event
    # if the element also is an instant exactly coincident with the queried interval
    #
    @indexer
      .select { |i| ( closed_interval_start == open_interval_finish &&
                      i[:start] == closed_interval_start &&
                      i[:finish] == open_interval_finish) }
      .collect { |i| [i.clone.merge({ change: :start, time: i[:start] }),
                      i.clone.merge({ change: :finish, time: i[:finish] })] }
      .flatten(1)
  )
    .sort_by { |i| [ i[:time],
                     i[:start] < i[:finish] && i[:change] == :finish ? 0 : 1] }
    .collect { |i| { change: i[:change],
                     time: i[:time],
                     start: i[:start],
                     finish: i[:finish],
                     start_in_interval: i[:start] > closed_interval_start ? i[:start] : closed_interval_start,
                     finish_in_interval: i[:finish] < open_interval_finish ? i[:finish] : open_interval_finish,
                     time_in_interval: if i[:time] < closed_interval_start
                                         closed_interval_start
                                       elsif i[:time] > open_interval_finish
                                         open_interval_finish
                                       else
                                         i[:time]
                                       end,
                     dataset: i[:dataset] } }.extend(QueryableByDataset)
end

#durationRational

Returns total duration of score.

Calculated as finish time minus 1.

Examples:

score.at(0r, add: { duration: 2.0 }.extend(AbsD))
score.duration  # => 1r (finish 2r - 1r)

Returns:



172
173
174
# File 'lib/musa-dsl/datasets/score.rb', line 172

def duration
  (finish || 1r) - 1r
end

#each {|time, events| ... } ⇒ void

This method returns an undefined value.

Iterates over time slots in order.

Yields [time, events] pairs sorted by time. Implements Enumerable.

Examples:

score.each do |time, events|
  puts "At #{time}: #{events.size} event(s)"
end

Yield Parameters:



258
259
260
# File 'lib/musa-dsl/datasets/score.rb', line 258

def each(&block)
  @score.sort.each(&block)
end

#finishRational?

Returns latest finish time of all events.

Examples:

score.at(0r, add: { duration: 2.0 }.extend(AbsD))
score.finish  # => 2r

Returns:

  • (Rational, nil)

    latest finish time, or nil if score is empty



159
160
161
# File 'lib/musa-dsl/datasets/score.rb', line 159

def finish
  @indexer.collect { |i| i[:finish] }.max
end

#forward_durationNumeric Originally defined in module AbsD

Returns forward duration (time until next event).

Defaults to :duration if :forward_duration not specified.

Examples:

event.forward_duration  # => 1.0

Returns:

  • (Numeric)

    forward duration

#inspectString

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns formatted string representation.

Produces multiline representation suitable for inspection.

Returns:

  • (String)

    formatted score representation



465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/musa-dsl/datasets/score.rb', line 465

def inspect
  s = StringIO.new

  first_level1 = true

  s.write "Musa::Datasets::Score.new({\n"

  @score.each do |k, v|
    s.write "#{ ", \n" unless first_level1 }  #{ k.inspect } => [\n"
    first_level1 = false
    first_level2 = true

    v.each do |vv|
      s.write "#{ ", \n" unless first_level2 }\t#{ vv }"
      first_level2 = false
    end

    s.write  " ]"
  end
  s.write "\n})"

  s.string
end

#note_durationNumeric Originally defined in module AbsD

Returns actual note duration.

Defaults to :duration if :note_duration not specified.

Examples:

event.note_duration  # => 0.5 (staccato)

Returns:

  • (Numeric)

    note duration

#positionsArray<Rational>

Returns all time positions sorted.

Examples:

score.at(1r, add: event1)
score.at(0r, add: event2)
score.positions  # => [0r, 1r]

Returns:



240
241
242
# File 'lib/musa-dsl/datasets/score.rb', line 240

def positions
  @score.keys.sort
end

#render(on:) {|event| ... } ⇒ nil Originally defined in module Render

Renders score on sequencer.

Schedules all events in the score on the sequencer, calling the block for each event at its scheduled time. Score times are converted to sequencer wait times (score_time - 1).

Supports nested scores recursively.

Examples:

MIDI output

require 'midi-communications'

score = Musa::Datasets::Score.new
score.at(1r, add: { pitch: 60, duration: 1.0, velocity: 64 }.extend(Musa::Datasets::PDV))

midi_out = MIDICommunications::Output.gets
sequencer = Musa::Sequencer::Sequencer.new(4, 24)

score.render(on: sequencer) do |event|
  if event[:pitch]
    midi_out.puts(0x90, event[:pitch], event[:velocity] || 64)
    sequencer.at event[:duration] do
      midi_out.puts(0x80, event[:pitch], event[:velocity] || 64)
    end
  end
end

sequencer.run

Console output

score = Musa::Datasets::Score.new
score.at(1r, add: { pitch: 60, duration: 1.0 }.extend(Musa::Datasets::PDV))

seq = Musa::Sequencer::Sequencer.new(4, 24)
score.render(on: seq) do |event|
  puts "Time #{seq.position}: #{event.inspect}"
end
seq.run

Nested score rendering

inner = Musa::Datasets::Score.new
inner.at(1r, add: { pitch: 67 }.extend(Musa::Datasets::PDV))

outer = Musa::Datasets::Score.new
outer.at(1r, add: { pitch: 60 }.extend(Musa::Datasets::PDV))
outer.at(2r, add: inner)

seq = Musa::Sequencer::Sequencer.new(4, 24)
outer.render(on: seq) do |event|
  puts "Event: #{event[:pitch]}"
end
seq.run
# Inner scores automatically rendered at their scheduled times

Parameters:

Yield Parameters:

  • event (Abs)

    each event to process Block is called at the scheduled time with the event dataset

Returns:

  • (nil)

Raises:

  • (ArgumentError)

    if element is not Abs or Score

#resetvoid

This method returns an undefined value.

Clears all events from score.

Examples:

score.reset
score.size  # => 0


133
134
135
136
# File 'lib/musa-dsl/datasets/score.rb', line 133

def reset
  @score.clear
  @indexer.clear
end

#sizeInteger

Returns number of time positions.

Examples:

score.at(0r, add: event1)
score.at(0r, add: event2)  # Same time
score.at(1r, add: event3)  # Different time
score.size  # => 2 (two time positions)

Returns:

  • (Integer)

    number of distinct time positions



228
229
230
# File 'lib/musa-dsl/datasets/score.rb', line 228

def size
  @score.keys.size
end

#subset {|dataset| ... } ⇒ Score

Creates filtered subset of score.

Returns new Score containing only events matching the condition.

Examples:

Filter by pitch

high_notes = score.subset { |event| event[:pitch] > 60 }

Filter by attribute presence

staccato_notes = score.subset { |event| event[:staccato] }

Filter by grade

tonic_notes = score.subset { |event| event[:grade] == 0 }

Yield Parameters:

  • dataset (Abs)

    each event dataset

Yield Returns:

  • (Boolean)

    true to include event

Returns:

  • (Score)

    new filtered score

Raises:

  • (ArgumentError)

    if no block given



444
445
446
447
448
449
450
451
452
453
454
455
456
# File 'lib/musa-dsl/datasets/score.rb', line 444

def subset
  raise ArgumentError, "subset needs a block with the inclusion condition on the dataset" unless block_given?

  filtered_score = Score.new

  @score.each_pair do |time, datasets|
    datasets.each do |dataset|
      filtered_score.at time, add: dataset if yield(dataset)
    end
  end

  filtered_score
end

#to_hHash{Rational => Array<Abs>}

Converts to hash representation.

Examples:

hash = score.to_h
# => { 0r => [event1, event2], 1r => [event3] }

Returns:



269
270
271
# File 'lib/musa-dsl/datasets/score.rb', line 269

def to_h
  @score.sort.to_h
end

#to_mxml(beats_per_bar, ticks_per_beat, bpm: nil, title: nil, creators: nil, encoding_date: nil, parts:, logger: nil, do_log: nil) ⇒ Musa::MusicXML::Builder::ScorePartwise Originally defined in module ToMXML

Converts score to MusicXML.

Creates complete MusicXML document with metadata, parts, measures, notes, rests, and dynamics markings.

Examples:

Simple piano score

score = Musa::Datasets::Score.new
score.at(1r, add: { pitch: 60, duration: 1.0 }.extend(Musa::Datasets::PDV))

mxml = score.to_mxml(
  4, 24,
  bpm: 120,
  title: 'Invention',
  creators: { composer: 'J.S. Bach' },
  parts: { piano: { name: 'Piano', clefs: { g: 2, f: 4 } } }
)

String quartet

score = Musa::Datasets::Score.new
score.at(1r, add: { instrument: :vln1, pitch: 67, duration: 1.0 }.extend(Musa::Datasets::PDV))
score.at(1r, add: { instrument: :vln2, pitch: 64, duration: 1.0 }.extend(Musa::Datasets::PDV))
score.at(1r, add: { instrument: :vla, pitch: 60, duration: 1.0 }.extend(Musa::Datasets::PDV))
score.at(1r, add: { instrument: :vc, pitch: 48, duration: 1.0 }.extend(Musa::Datasets::PDV))

mxml = score.to_mxml(
  4, 24,
  parts: {
    vln1: { name: 'Violin I', abbreviation: 'Vln. I', clefs: { g: 2 } },
    vln2: { name: 'Violin II', abbreviation: 'Vln. II', clefs: { g: 2 } },
    vla: { name: 'Viola', abbreviation: 'Vla.', clefs: { c: 3 } },
    vc: { name: 'Cello', abbreviation: 'Vc.', clefs: { f: 4 } }
  }
)

Export to file

score = Musa::Datasets::Score.new
score.at(1r, add: { pitch: 60, duration: 1.0 }.extend(Musa::Datasets::PDV))

mxml = score.to_mxml(4, 24, parts: { piano: { name: 'Piano' } })
File.write('output.musicxml', mxml.to_xml.string)

Parameters:

  • beats_per_bar (Integer)

    time signature numerator (e.g., 4 for 4/4)

  • ticks_per_beat (Integer)

    resolution per beat (typically 24)

  • bpm (Integer) (defaults to: nil)

    tempo in beats per minute (default: 90)

  • title (String) (defaults to: nil)

    work title (default: 'Untitled')

  • creators (Hash{Symbol => String}) (defaults to: nil)

    creator roles and names (default: { composer: 'Unknown' })

  • encoding_date (DateTime, nil) (defaults to: nil)

    encoding date for metadata

  • parts (Hash{Symbol => Hash})

    part definitions Each part: { name: String, abbreviation: String, clefs: Hash } Clefs: { clef_sign: line_number } (e.g., { g: 2, f: 4 } for piano)

  • logger (Musa::Logger::Logger, nil) (defaults to: nil)

    logger for debugging

  • do_log (Boolean, nil) (defaults to: nil)

    enable logging output

Returns:

#valid?Boolean Originally defined in module E

Checks if event is valid.

Base implementation always returns true. Subclasses should override to implement specific validation logic.

Examples:

event.valid?  # => true

Returns:

  • (Boolean)

    true if valid

#validate!void Originally defined in module E

This method returns an undefined value.

Validates event, raising if invalid.

Examples:

event.validate!  # Raises if invalid

Raises:

  • (RuntimeError)

    if event is not valid

#values_of(attribute) ⇒ Set

Collects all values for an attribute.

Returns set of all unique values across all events.

Examples:

Get all pitches

pitches = score.values_of(:pitch)
# => #<Set: {60, 64, 67}>

Get all grades

grades = score.values_of(:grade)
# => #<Set: {0, 2, 4}>

Parameters:

  • attribute (Symbol)

    attribute key

Returns:

  • (Set)

    set of unique values



417
418
419
420
421
422
423
# File 'lib/musa-dsl/datasets/score.rb', line 417

def values_of(attribute)
  values = Set[]
  @score.each_value do |slot|
    slot.each { |dataset| values << dataset[attribute] }
  end
  values
end