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 +4 -4
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +2 -1
- data/head_music.gemspec +1 -0
- data/lib/head_music/content/composition.rb +8 -0
- data/lib/head_music/content/voice.rb +20 -0
- data/lib/head_music/notation/abc/duration_writer.rb +63 -0
- data/lib/head_music/notation/abc/key_mapper.rb +45 -0
- data/lib/head_music/notation/abc/pitch_writer.rb +72 -0
- data/lib/head_music/notation/abc/writer.rb +129 -0
- data/lib/head_music/notation/abc.rb +9 -1
- data/lib/head_music/notation/music_xml/clef_selector.rb +26 -0
- data/lib/head_music/notation/music_xml/divisions.rb +43 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +82 -0
- data/lib/head_music/notation/music_xml/key_mapper.rb +41 -0
- data/lib/head_music/notation/music_xml/pitch_writer.rb +21 -0
- data/lib/head_music/notation/music_xml/writer.rb +369 -0
- data/lib/head_music/notation/music_xml.rb +14 -0
- data/lib/head_music/notation.rb +4 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +9 -1
- data/user-stories/backlog/lilypond-export.md +74 -0
- data/user-stories/done/abc-notation-export.md +203 -0
- data/user-stories/done/music-xml-export.md +201 -0
- data/user-stories/index.html +15 -2
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd3edd30892b4392257993e45de98e7c27645b61480c6f478b464005f89b10a7
|
|
4
|
+
data.tar.gz: 9755bd71aae10839f15d9a7f73cd6f2526265672c03907e2e2b9db75d6fd4bb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|