head_music 15.0.0 → 15.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c29fa7f976d16091512b8bdc9eb8befcb4766c4bb70d387a67991e9b9897a5c
4
- data.tar.gz: b43c0fed22c3ed7c79a7a0195847740ba7b3bea608a24921893a329f0c66ba7f
3
+ metadata.gz: cd3edd30892b4392257993e45de98e7c27645b61480c6f478b464005f89b10a7
4
+ data.tar.gz: 9755bd71aae10839f15d9a7f73cd6f2526265672c03907e2e2b9db75d6fd4bb0
5
5
  SHA512:
6
- metadata.gz: 47d395cc1709167182e0810c7383120395e927629a2e5e5076e197bdacfd377445546a842a032fecabd9865f62e878f969fec301535758072b45db229c80e8e4
7
- data.tar.gz: '0950f0d5a6a2b830a1733d8d0a7d19eb9fcb87815e25d3e562dc6aa94bc95c16a2d7710ca31890e23d1dcc07fc64247030565741d2edf38da6cb177f7e091fb9'
6
+ metadata.gz: 45706fe9753a6907b79378a7b5334c76fb6cc55f282fe16b77940fe0d8dbe01197c111a452e0974ff2ebe9d5536255858e81da7fb15141a8540ad2600148f817
7
+ data.tar.gz: e97830d0f4d8f102ebae608701d5db6a1bdaaa905545a6b14143d2a36f7273ba0398e960b0986d2a05bf5aa704273abea31f89b08db59875ddb059e150b2753b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [15.1.0] - 2026-07-07
11
+
12
+ ### Added
13
+
14
+ - `HeadMusic::Content::Composition.to_musicxml` for MusicXML export
15
+ - `HeadMusic::Content::Composition.to_abc` for ABC Notation export. ABC can round-trip to and from Composition.
16
+
10
17
  ## [15.0.0] - 2026-07-06
11
18
 
12
19
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (15.0.0)
4
+ head_music (15.1.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -220,6 +220,7 @@ DEPENDENCIES
220
220
  head_music!
221
221
  kramdown
222
222
  rake (~> 13.0)
223
+ rexml (~> 3.4)
223
224
  rspec (~> 3.0)
224
225
  rspec-its (~> 2.0)
225
226
  rubocop
data/head_music.gemspec CHANGED
@@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.add_runtime_dependency "i18n", "~> 1.8"
38
38
 
39
39
  spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "rexml", "~> 3.4"
40
41
  spec.add_development_dependency "rspec", "~> 3.0"
41
42
  spec.add_development_dependency "rspec-its", "~> 2.0"
42
43
  spec.add_development_dependency "bundler-audit", "~> 0.9"
@@ -69,6 +69,14 @@ class HeadMusic::Content::Composition
69
69
  "#{name} — #{voices.count} #{(voices.count == 1) ? "voice" : "voices"}"
70
70
  end
71
71
 
72
+ def to_abc(**options)
73
+ HeadMusic::Notation::ABC.render(self, **options)
74
+ end
75
+
76
+ def to_musicxml
77
+ HeadMusic::Notation::MusicXML.render(self)
78
+ end
79
+
72
80
  private
73
81
 
74
82
  def ensure_attributes(name, key_signature, meter)
@@ -118,6 +118,22 @@ class HeadMusic::Content::Voice
118
118
  last_placement ? last_placement.next_position : HeadMusic::Content::Position.new(composition, 1, 1, 0)
119
119
  end
120
120
 
121
+ # Returns nil if placements are contiguous, or [expected_position, found_placement]
122
+ # for the first gap: either the first placement not starting its bar, or the
123
+ # first pair of consecutive placements where the second doesn't begin where
124
+ # the first one ends.
125
+ def first_gap
126
+ first = placements.first
127
+ return unless first
128
+
129
+ return [bar_start_position(first), first] unless first.position.count == 1 && first.position.tick.zero?
130
+
131
+ placements.each_cons(2) do |previous, current|
132
+ return [previous.next_position, current] unless current.position == previous.next_position
133
+ end
134
+ nil
135
+ end
136
+
121
137
  def to_s
122
138
  return pitches_string if role.to_s.strip == ""
123
139
 
@@ -126,6 +142,10 @@ class HeadMusic::Content::Voice
126
142
 
127
143
  private
128
144
 
145
+ def bar_start_position(placement)
146
+ HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
147
+ end
148
+
129
149
  def insert_into_placements(placement)
130
150
  @placements << placement
131
151
  @placements = @placements.sort
@@ -0,0 +1,63 @@
1
+ # Parses and renders ABC notation as HeadMusic::Content compositions
2
+ module HeadMusic::Notation::ABC
3
+ # Converts a HeadMusic::Rudiment::RhythmicValue back into the ABC note-length
4
+ # multiplier string relative to the tune's unit note length — the inverse of
5
+ # DurationResolver.
6
+ class DurationWriter
7
+ attr_reader :unit_note_length
8
+
9
+ def initialize(unit_note_length)
10
+ @unit_note_length = Rational(unit_note_length)
11
+ end
12
+
13
+ def multiplier_string(rhythmic_value)
14
+ fraction = total_fraction(rhythmic_value)
15
+ validate_fraction!(fraction, rhythmic_value)
16
+ format_multiplier(fraction / unit_note_length)
17
+ end
18
+
19
+ private
20
+
21
+ # RhythmicValue's own value methods return Floats; rebuild the fraction
22
+ # from integer parts so the multiplier arithmetic stays exact. A tied
23
+ # chain collapses to one multiplier, round-tripping tokens like "A5".
24
+ def total_fraction(rhythmic_value)
25
+ fraction = single_fraction(rhythmic_value)
26
+ tied_value = rhythmic_value.tied_value
27
+ fraction += total_fraction(tied_value) if tied_value
28
+ fraction
29
+ end
30
+
31
+ def single_fraction(rhythmic_value)
32
+ unit = rhythmic_value.unit
33
+ dots = rhythmic_value.dots
34
+ # A value with d dots spans (2^(d+1) - 1) / 2^d of its unit.
35
+ Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots)
36
+ end
37
+
38
+ def validate_fraction!(fraction, rhythmic_value)
39
+ max_fraction = DurationResolver::MAX_FRACTION
40
+ if fraction > max_fraction
41
+ raise_error("note length exceeds #{max_fraction.to_i} whole notes", rhythmic_value)
42
+ end
43
+ return if power_of_two?(fraction.denominator)
44
+
45
+ raise_error("note length #{fraction} is not expressible in binary note values", rhythmic_value)
46
+ end
47
+
48
+ def format_multiplier(multiplier)
49
+ return "" if multiplier == 1
50
+ return multiplier.numerator.to_s if multiplier.denominator == 1
51
+
52
+ "#{multiplier.numerator}/#{multiplier.denominator}"
53
+ end
54
+
55
+ def power_of_two?(integer)
56
+ (integer & (integer - 1)).zero?
57
+ end
58
+
59
+ def raise_error(message, rhythmic_value)
60
+ raise HeadMusic::Notation::ABC::RenderError, "#{message}: #{rhythmic_value}"
61
+ end
62
+ end
63
+ end
@@ -20,6 +20,51 @@ class HeadMusic::Notation::ABC::KeyMapper
20
20
  "loc" => "locrian"
21
21
  }.freeze
22
22
 
23
+ # MODE_NAMES_BY_PREFIX is many-to-one, so rendering uses this explicit
24
+ # inverse map. Every suffix here parses back to an equal key signature
25
+ # (ionian/aeolian differ from major/minor only in name, not alterations).
26
+ ABC_SUFFIXES_BY_MODE = {
27
+ "major" => "",
28
+ "ionian" => "",
29
+ "minor" => "m",
30
+ "aeolian" => "m",
31
+ "dorian" => "dor",
32
+ "phrygian" => "phr",
33
+ "lydian" => "lyd",
34
+ "mixolydian" => "mix",
35
+ "locrian" => "loc"
36
+ }.freeze
37
+
38
+ # Returns the ABC K: field value for a key signature.
39
+ def self.abc_value(key_signature)
40
+ key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
41
+ "#{tonic_string(key_signature)}#{mode_suffix(key_signature)}"
42
+ end
43
+
44
+ def self.tonic_string(key_signature)
45
+ spelling = key_signature.tonic_spelling
46
+ alteration = spelling.alteration
47
+ if alteration && (alteration.double_sharp? || alteration.double_flat?)
48
+ raise_render_error("Cannot render double-altered tonic #{spelling} in an ABC K: field")
49
+ end
50
+
51
+ # ABC convention uses ASCII "#"/"b" rather than the unicode signs
52
+ # that Spelling#to_s produces.
53
+ "#{spelling.letter_name}#{alteration&.ascii}"
54
+ end
55
+ private_class_method :tonic_string
56
+
57
+ def self.mode_suffix(key_signature)
58
+ ABC_SUFFIXES_BY_MODE[key_signature.scale_type.name.to_s] ||
59
+ raise_render_error("Cannot render scale type #{key_signature.scale_type} in an ABC K: field")
60
+ end
61
+ private_class_method :mode_suffix
62
+
63
+ def self.raise_render_error(message)
64
+ raise HeadMusic::Notation::ABC::RenderError, message
65
+ end
66
+ private_class_method :raise_render_error
67
+
23
68
  attr_reader :value, :line_number
24
69
 
25
70
  def initialize(value, line_number: nil)
@@ -0,0 +1,72 @@
1
+ # A namespace for ABC-notation parsing helpers
2
+ module HeadMusic::Notation::ABC
3
+ # Converts pitches into ABC note tokens, emitting the minimal accidental
4
+ # marks required under the key signature and ABC's bar-persistent rules.
5
+ #
6
+ # A live PitchBuilder acts as an oracle: if an unmarked token would already
7
+ # parse back to the target pitch, no accidental is written. Emitted tokens
8
+ # are fed back through the oracle so its bar state matches what a parser
9
+ # will accumulate when re-reading the output.
10
+ class PitchWriter
11
+ attr_reader :key_signature
12
+
13
+ def initialize(key_signature)
14
+ @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
15
+ @oracle = PitchBuilder.new(@key_signature)
16
+ end
17
+
18
+ # Returns the ABC note token for the pitch, without any duration multiplier.
19
+ def token(pitch)
20
+ pitch = HeadMusic::Rudiment::Pitch.get(pitch)
21
+ letter = letter_token(pitch)
22
+ octave_marks = octave_marks(pitch)
23
+ return "#{letter}#{octave_marks}" if oracle.pitch(letter, octave_marks) == pitch
24
+
25
+ marks = accidental_marks(pitch)
26
+ commit_and_verify(pitch, letter, octave_marks, marks)
27
+ "#{marks}#{letter}#{octave_marks}"
28
+ end
29
+
30
+ # In ABC, accidentals persist only to the end of the bar.
31
+ def start_new_bar
32
+ oracle.start_new_bar
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :oracle
38
+
39
+ def letter_token(pitch)
40
+ letter = pitch.letter_name.to_s
41
+ (pitch.register >= 5) ? letter.downcase : letter
42
+ end
43
+
44
+ def octave_marks(pitch)
45
+ if pitch.register >= 5
46
+ "'" * (pitch.register - 5)
47
+ else
48
+ "," * (4 - pitch.register)
49
+ end
50
+ end
51
+
52
+ def accidental_marks(pitch)
53
+ fragment = pitch.alteration&.ascii.to_s
54
+ accidental_marks_by_fragment.fetch(fragment) do
55
+ raise RenderError, "cannot express #{pitch} in ABC notation"
56
+ end
57
+ end
58
+
59
+ def accidental_marks_by_fragment
60
+ @accidental_marks_by_fragment ||= PitchBuilder::ACCIDENTAL_FRAGMENTS.invert
61
+ end
62
+
63
+ # Feeding the marked token through the oracle updates its bar state
64
+ # exactly as a parser reading the output would — this call is load-bearing,
65
+ # not just a verification; later unmarked notes depend on the state it writes.
66
+ def commit_and_verify(pitch, letter, octave_marks, marks)
67
+ return if oracle.pitch(letter, octave_marks, marks) == pitch
68
+
69
+ raise RenderError, "cannot express #{pitch} in ABC notation"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,129 @@
1
+ # Parses and renders ABC notation as HeadMusic::Content compositions
2
+ module HeadMusic::Notation::ABC
3
+ # Renders a HeadMusic::Content::Composition as an ABC tune string.
4
+ #
5
+ # Whole-composition problems (multiple voices, mid-piece meter or key
6
+ # changes, positional gaps) raise before any string assembly, and #to_s
7
+ # only returns a fully assembled document, so callers never receive a
8
+ # truncated tune.
9
+ #
10
+ # Repeat barlines and voltas are deliberately not rendered; bars carrying
11
+ # repeat flags degrade to plain bar lines.
12
+ class Writer
13
+ # A fixed unit note length keeps the L: field and the duration
14
+ # multiplier arithmetic in sync.
15
+ UNIT_NOTE_LENGTH = Rational(1, 8)
16
+ BARS_PER_LINE = 4
17
+
18
+ attr_reader :composition, :reference_number
19
+
20
+ def initialize(composition, reference_number: 1)
21
+ @composition = composition
22
+ @reference_number = reference_number
23
+ end
24
+
25
+ def to_s
26
+ validate!
27
+ (header_lines + body_lines).join("\n") + "\n"
28
+ end
29
+
30
+ private
31
+
32
+ def validate!
33
+ ensure_single_voice
34
+ ensure_no_mid_piece_changes
35
+ ensure_contiguous_placements
36
+ end
37
+
38
+ def ensure_single_voice
39
+ return if composition.voices.length <= 1
40
+
41
+ raise RenderError, "multi-voice ABC output is not supported"
42
+ end
43
+
44
+ def ensure_no_mid_piece_changes
45
+ composition.bars.each_with_index do |bar, index|
46
+ bar_number = composition.earliest_bar_number + index
47
+ if bar.meter
48
+ raise RenderError, "cannot render the meter change at bar #{bar_number} in ABC output"
49
+ end
50
+ next unless bar.key_signature
51
+
52
+ raise RenderError, "cannot render the key signature change at bar #{bar_number} in ABC output"
53
+ end
54
+ end
55
+
56
+ def ensure_contiguous_placements
57
+ first = placements.first
58
+ return unless first
59
+
60
+ ensure_placement_starts_bar(first)
61
+ placements.each_cons(2) do |previous, current|
62
+ next if current.position == previous.next_position
63
+
64
+ raise RenderError, "expected a placement at #{previous.next_position}, " \
65
+ "found one at #{current.position}; insert explicit rests to fill gaps"
66
+ end
67
+ end
68
+
69
+ def ensure_placement_starts_bar(placement)
70
+ position = placement.position
71
+ return if position.count == 1 && position.tick.zero?
72
+
73
+ raise RenderError, "the first placement must start its bar " \
74
+ "(found #{position}); insert explicit rests to fill the gap"
75
+ end
76
+
77
+ def placements
78
+ voice = composition.voices.first
79
+ voice ? voice.placements : []
80
+ end
81
+
82
+ def header_lines
83
+ [
84
+ "X:#{reference_number}",
85
+ "T:#{composition.name}",
86
+ composition.composer && "C:#{composition.composer}",
87
+ composition.origin && "O:#{composition.origin}",
88
+ "M:#{composition.meter}",
89
+ "L:#{UNIT_NOTE_LENGTH.numerator}/#{UNIT_NOTE_LENGTH.denominator}",
90
+ # The parser requires K: to terminate the header.
91
+ "K:#{KeyMapper.abc_value(composition.key_signature)}"
92
+ ].compact
93
+ end
94
+
95
+ def body_lines
96
+ return [] if bar_strings.empty?
97
+
98
+ lines = bar_strings.each_slice(BARS_PER_LINE).map do |line_bars|
99
+ line_bars.join("|") + "|"
100
+ end
101
+ lines[-1] = lines[-1].sub(/\|\z/, "|]")
102
+ lines
103
+ end
104
+
105
+ def bar_strings
106
+ @bar_strings ||= build_bar_strings
107
+ end
108
+
109
+ def build_bar_strings
110
+ pitch_writer = PitchWriter.new(composition.key_signature)
111
+ duration_writer = DurationWriter.new(UNIT_NOTE_LENGTH)
112
+ bar_groups = placements.chunk_while do |previous, current|
113
+ previous.position.bar_number == current.position.bar_number
114
+ end
115
+ bar_groups.each_with_index.map do |bar_placements, index|
116
+ # Accidental state must mirror what a re-parse accumulates bar by bar.
117
+ pitch_writer.start_new_bar if index.positive?
118
+ bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }.join(" ")
119
+ end
120
+ end
121
+
122
+ def token(placement, pitch_writer, duration_writer)
123
+ multiplier = duration_writer.multiplier_string(placement.rhythmic_value)
124
+ return "z#{multiplier}" if placement.rest?
125
+
126
+ "#{pitch_writer.token(placement.pitch)}#{multiplier}"
127
+ end
128
+ end
129
+ end
@@ -1,4 +1,4 @@
1
- # Parses ABC notation into HeadMusic::Content compositions
1
+ # Parses and renders ABC notation as HeadMusic::Content compositions
2
2
  module HeadMusic::Notation::ABC
3
3
  # Parses exactly one tune. Raises when the input holds more than one.
4
4
  def self.parse(abc_string)
@@ -10,6 +10,11 @@ module HeadMusic::Notation::ABC
10
10
  BookParser.new(abc_string).compositions
11
11
  end
12
12
 
13
+ # Renders a composition as an ABC tune string.
14
+ def self.render(composition, **options)
15
+ Writer.new(composition, **options).to_s
16
+ end
17
+
13
18
  # Raised when an ABC string cannot be interpreted
14
19
  class ParseError < HeadMusic::Notation::ParseError
15
20
  attr_reader :line_number, :snippet
@@ -24,6 +29,9 @@ module HeadMusic::Notation::ABC
24
29
 
25
30
  # Raised for valid ABC constructs that this parser does not support
26
31
  class UnsupportedFeatureError < ParseError; end
32
+
33
+ # Raised when a composition cannot be expressed in the supported ABC subset
34
+ class RenderError < HeadMusic::Notation::RenderError; end
27
35
  end
28
36
 
29
37
  # Helper classes load in name order; they reference one another only at runtime.
@@ -0,0 +1,26 @@
1
+ # A namespace for MusicXML-notation rendering helpers
2
+ module HeadMusic::Notation::MusicXML
3
+ # Chooses a clef for a voice based on the midpoint of its pitch range.
4
+ class ClefSelector
5
+ MIDDLE_C_MIDI_NOTE_NUMBER = 60
6
+
7
+ # Returns the treble clef for a voice with no notes (rest-only or empty),
8
+ # and otherwise the clef whose side of middle C matches the midpoint of
9
+ # the voice's lowest and highest pitches. A midpoint exactly on middle C
10
+ # is treated as treble.
11
+ def self.for(voice)
12
+ return HeadMusic::Rudiment::Clef.get(:treble_clef) unless voice.lowest_pitch
13
+
14
+ if midpoint(voice) < MIDDLE_C_MIDI_NOTE_NUMBER
15
+ HeadMusic::Rudiment::Clef.get(:bass_clef)
16
+ else
17
+ HeadMusic::Rudiment::Clef.get(:treble_clef)
18
+ end
19
+ end
20
+
21
+ def self.midpoint(voice)
22
+ (voice.lowest_pitch.midi_note_number + voice.highest_pitch.midi_note_number) / 2.0
23
+ end
24
+ private_class_method :midpoint
25
+ end
26
+ end
@@ -0,0 +1,43 @@
1
+ # A namespace for MusicXML-notation rendering helpers
2
+ module HeadMusic::Notation::MusicXML
3
+ # Computes the smallest MusicXML <divisions> value (subdivisions of a
4
+ # quarter note) that lets every note, rest, and whole-measure duration
5
+ # in a composition be expressed as an exact integer.
6
+ class Divisions
7
+ def self.for(composition)
8
+ denominators(composition).reduce(1) { |lcm, denominator| lcm.lcm(denominator) }
9
+ end
10
+
11
+ def self.denominators(composition)
12
+ meter_denominators(composition) + note_denominators(composition)
13
+ end
14
+ private_class_method :denominators
15
+
16
+ # A whole measure of rest must also be expressible as an integer, so the
17
+ # base meter and every meter change's quarter-note-equivalent duration
18
+ # contributes a denominator too (e.g. 3/8 needs divisions divisible by 2).
19
+ # Bar#meter is a plain attr_accessor, so a bar's meter is normalized
20
+ # through Meter.get here in case a caller assigned it a bare string.
21
+ def self.meter_denominators(composition)
22
+ meters = [composition.meter] + composition.bars.map(&:meter).compact
23
+ meters.map { |meter| HeadMusic::Rudiment::Meter.get(meter) }
24
+ .map { |meter| Rational(4 * meter.top_number, meter.bottom_number).denominator }
25
+ end
26
+ private_class_method :meter_denominators
27
+
28
+ def self.note_denominators(composition)
29
+ composition.voices.flat_map do |voice|
30
+ voice.placements.flat_map { |placement| chain_denominators(placement.rhythmic_value) }
31
+ end
32
+ end
33
+ private_class_method :note_denominators
34
+
35
+ def self.chain_denominators(rhythmic_value)
36
+ denominators = [DurationWriter.single_quarter_fraction(rhythmic_value).denominator]
37
+ tied_value = rhythmic_value.tied_value
38
+ denominators += chain_denominators(tied_value) if tied_value
39
+ denominators
40
+ end
41
+ private_class_method :chain_denominators
42
+ end
43
+ end
@@ -0,0 +1,82 @@
1
+ # A namespace for MusicXML-notation rendering helpers
2
+ module HeadMusic::Notation::MusicXML
3
+ # Converts a HeadMusic::Rudiment::RhythmicValue into the <duration>,
4
+ # <type>, dot count, and tie flags MusicXML needs for each link of a
5
+ # tied chain. A value with no tied chain yields a single component.
6
+ class DurationWriter
7
+ Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop, keyword_init: true)
8
+
9
+ # MusicXML's <type> element uses these fixed names rather than the
10
+ # gem's American duration names.
11
+ TYPES_BY_UNIT_NAME = {
12
+ "maxima" => "maxima",
13
+ "longa" => "long",
14
+ "double whole" => "breve",
15
+ "whole" => "whole",
16
+ "half" => "half",
17
+ "quarter" => "quarter",
18
+ "eighth" => "eighth",
19
+ "sixteenth" => "16th",
20
+ "thirty-second" => "32nd",
21
+ "sixty-fourth" => "64th",
22
+ "hundred twenty-eighth" => "128th",
23
+ "two hundred fifty-sixth" => "256th"
24
+ }.freeze
25
+
26
+ attr_reader :divisions
27
+
28
+ def initialize(divisions)
29
+ @divisions = divisions
30
+ end
31
+
32
+ # A rhythmic value's own duration in quarter notes, ignoring any tied
33
+ # chain. RhythmicValue's own methods (relative_value, total_value,
34
+ # ticks) return Floats, so the fraction is rebuilt here from the
35
+ # unit's integer numerator and denominator to keep the divisions
36
+ # arithmetic exact.
37
+ def self.single_quarter_fraction(rhythmic_value)
38
+ unit = rhythmic_value.unit
39
+ dots = rhythmic_value.dots
40
+ # A value with d dots spans (2^(d+1) - 1) / 2^d of its own unit.
41
+ Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots) * 4
42
+ end
43
+
44
+ def components(rhythmic_value)
45
+ links = chain(rhythmic_value)
46
+ links.each_with_index.map { |link, index| build_component(link, index, links.length) }
47
+ end
48
+
49
+ private
50
+
51
+ def chain(rhythmic_value)
52
+ links = [rhythmic_value]
53
+ links << links.last.tied_value while links.last.tied_value
54
+ links
55
+ end
56
+
57
+ def build_component(link, index, length)
58
+ Component.new(
59
+ duration: integer_duration(link),
60
+ type: type_for(link),
61
+ dots: link.dots,
62
+ tie_start: index != length - 1,
63
+ tie_stop: index != 0
64
+ )
65
+ end
66
+
67
+ def integer_duration(link)
68
+ fraction = self.class.single_quarter_fraction(link) * divisions
69
+ unless fraction.denominator == 1
70
+ raise HeadMusic::Notation::MusicXML::RenderError,
71
+ "cannot express #{link} as an integer duration at #{divisions} divisions per quarter note"
72
+ end
73
+ fraction.numerator
74
+ end
75
+
76
+ def type_for(link)
77
+ TYPES_BY_UNIT_NAME.fetch(link.unit_name) do
78
+ raise HeadMusic::Notation::MusicXML::RenderError, "no MusicXML note type is known for #{link.unit_name}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,41 @@
1
+ # A namespace for MusicXML-notation rendering helpers
2
+ module HeadMusic::Notation::MusicXML
3
+ # Converts a key signature into the fifths/mode pair MusicXML's
4
+ # <key> element needs.
5
+ class KeyMapper
6
+ # MusicXML's <mode> element only names these seven modes, so scale
7
+ # types outside this set (harmonic minor, whole tone, etc.) cannot
8
+ # be rendered as a <key> element and must raise instead.
9
+ MODE_NAMES_BY_SCALE_TYPE = {
10
+ "major" => "major",
11
+ "ionian" => "major",
12
+ "minor" => "minor",
13
+ "aeolian" => "minor",
14
+ "dorian" => "dorian",
15
+ "phrygian" => "phrygian",
16
+ "lydian" => "lydian",
17
+ "mixolydian" => "mixolydian",
18
+ "locrian" => "locrian"
19
+ }.freeze
20
+
21
+ # Returns the number of sharps (positive) or flats (negative) for the
22
+ # <fifths> element. Theoretical keys with double accidentals (e.g. G#
23
+ # major) count each double accidental twice, which is correct here.
24
+ def self.fifths(key_signature)
25
+ key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
26
+ key_signature.num_sharps - key_signature.num_flats
27
+ end
28
+
29
+ # Returns the <mode> element value for a key signature.
30
+ def self.mode(key_signature)
31
+ key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
32
+ MODE_NAMES_BY_SCALE_TYPE[key_signature.scale_type.name.to_s] ||
33
+ raise_render_error("Cannot render scale type #{key_signature.scale_type} in a MusicXML <key> element")
34
+ end
35
+
36
+ def self.raise_render_error(message)
37
+ raise HeadMusic::Notation::MusicXML::RenderError, message
38
+ end
39
+ private_class_method :raise_render_error
40
+ end
41
+ end
@@ -0,0 +1,21 @@
1
+ # A namespace for MusicXML-notation rendering helpers
2
+ module HeadMusic::Notation::MusicXML
3
+ # Converts pitches into the trio of values MusicXML's <pitch> element needs.
4
+ #
5
+ # Unlike ABC, MusicXML's <alter> is absolute rather than bar-persistent, so
6
+ # no oracle or mutable state is required: each pitch maps to its attributes
7
+ # independent of any other pitch that came before it.
8
+ class PitchWriter
9
+ # Returns a Hash of the step, alter, and octave for the pitch's
10
+ # <pitch> element. Alter is nil when the pitch is natural, in which
11
+ # case the caller should omit the <alter> element entirely.
12
+ def self.attributes(pitch)
13
+ pitch = HeadMusic::Rudiment::Pitch.get(pitch)
14
+ {
15
+ step: pitch.letter_name.to_s,
16
+ alter: pitch.alteration_semitones,
17
+ octave: pitch.register
18
+ }
19
+ end
20
+ end
21
+ end