head_music 9.0.1 → 11.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/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +35 -15
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/dyad.rb +229 -0
- data/lib/head_music/analysis/melodic_interval.rb +1 -1
- data/lib/head_music/analysis/pitch_class_set.rb +111 -14
- data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
- data/lib/head_music/analysis/sonority.rb +50 -12
- data/lib/head_music/content/cantus_firmus_examples.rb +58 -0
- data/lib/head_music/content/staff.rb +1 -1
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/alternate_tuning.rb +102 -0
- data/lib/head_music/instruments/alternate_tunings.yml +78 -0
- data/lib/head_music/instruments/instrument.rb +251 -82
- data/lib/head_music/instruments/instrument_configuration.rb +66 -0
- data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
- data/lib/head_music/instruments/instrument_configurations.yml +288 -0
- data/lib/head_music/instruments/instrument_families.yml +77 -0
- data/lib/head_music/instruments/instrument_family.rb +3 -4
- data/lib/head_music/instruments/instruments.yml +795 -965
- data/lib/head_music/instruments/playing_technique.rb +75 -0
- data/lib/head_music/instruments/playing_techniques.yml +826 -0
- data/lib/head_music/instruments/score_order.rb +2 -5
- data/lib/head_music/instruments/staff.rb +61 -1
- data/lib/head_music/instruments/staff_scheme.rb +6 -4
- data/lib/head_music/instruments/stringing.rb +115 -0
- data/lib/head_music/instruments/stringing_course.rb +58 -0
- data/lib/head_music/instruments/stringings.yml +168 -0
- data/lib/head_music/instruments/variant.rb +0 -1
- data/lib/head_music/locales/de.yml +23 -0
- data/lib/head_music/locales/en.yml +100 -0
- data/lib/head_music/locales/es.yml +23 -0
- data/lib/head_music/locales/fr.yml +23 -0
- data/lib/head_music/locales/it.yml +23 -0
- data/lib/head_music/locales/ru.yml +23 -0
- data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
- data/lib/head_music/notation/staff_mapping.rb +70 -0
- data/lib/head_music/notation/staff_position.rb +62 -0
- data/lib/head_music/notation.rb +7 -0
- data/lib/head_music/rudiment/alteration.rb +17 -47
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +1 -1
- data/lib/head_music/rudiment/clef.rb +1 -1
- data/lib/head_music/rudiment/consonance.rb +14 -13
- data/lib/head_music/rudiment/key_signature.rb +0 -26
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +2 -2
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +1 -1
- data/lib/head_music/rudiment/rhythmic_value.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +3 -0
- data/lib/head_music/rudiment/tempo.rb +1 -1
- data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
- data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
- data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
- data/lib/head_music/rudiment/tuning.rb +20 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/modern_tradition.rb +8 -11
- data/lib/head_music/style/tradition.rb +1 -1
- data/lib/head_music/time/clock_position.rb +84 -0
- data/lib/head_music/time/conductor.rb +264 -0
- data/lib/head_music/time/meter_event.rb +37 -0
- data/lib/head_music/time/meter_map.rb +173 -0
- data/lib/head_music/time/musical_position.rb +188 -0
- data/lib/head_music/time/smpte_timecode.rb +164 -0
- data/lib/head_music/time/tempo_event.rb +40 -0
- data/lib/head_music/time/tempo_map.rb +187 -0
- data/lib/head_music/time.rb +32 -0
- data/lib/head_music/utilities/case.rb +27 -0
- data/lib/head_music/utilities/hash_key.rb +1 -1
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +42 -13
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/{todo → backlog}/organizing-content.md +9 -1
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{todo → done}/dyad-analysis.md +4 -6
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/{active → done}/handle-time.rb +5 -19
- data/user_stories/done/instrument-architecture.md +238 -0
- data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
- data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
- data/user_stories/done/move-staff-position-to-notation.md +141 -0
- data/user_stories/done/notation-module-foundation.md +102 -0
- data/user_stories/done/percussion_set.md +260 -0
- data/user_stories/{todo → done}/pitch-class-set-analysis.md +0 -40
- data/user_stories/done/sonority-identification.md +37 -0
- data/user_stories/done/string-pitches.md +41 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
- metadata +56 -20
- data/check_instrument_consistency.rb +0 -0
- data/lib/head_music/instruments/instrument_type.rb +0 -188
- data/test_translations.rb +0 -15
- data/user_stories/todo/consonance-dissonance-classification.md +0 -57
- data/user_stories/todo/material-and-scores.md +0 -10
- data/user_stories/todo/percussion_set.md +0 -1
- data/user_stories/todo/pitch-set-classification.md +0 -72
- data/user_stories/todo/sonority-identification.md +0 -67
- /data/user_stories/{active → done}/handle-time.md +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Manages tempo changes along a musical timeline
|
|
6
|
+
#
|
|
7
|
+
# A TempoMap maintains a sorted list of tempo changes at specific musical
|
|
8
|
+
# positions, allowing you to determine which tempo is active at any point
|
|
9
|
+
# and iterate through tempo segments for time calculations.
|
|
10
|
+
#
|
|
11
|
+
# This is essential for converting between clock time and musical position
|
|
12
|
+
# when the tempo changes during a composition.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# tempo_map = HeadMusic::Time::TempoMap.new
|
|
16
|
+
# tempo_map.add_change(MusicalPosition.new(5, 1, 0, 0), "quarter", 96)
|
|
17
|
+
# tempo_map.add_change(MusicalPosition.new(9, 1, 0, 0), "quarter", 140)
|
|
18
|
+
#
|
|
19
|
+
# tempo = tempo_map.tempo_at(MusicalPosition.new(7, 1, 0, 0))
|
|
20
|
+
# tempo.beats_per_minute # => 96.0
|
|
21
|
+
#
|
|
22
|
+
# @example Iterating through segments
|
|
23
|
+
# from = MusicalPosition.new(1, 1, 0, 0)
|
|
24
|
+
# to = MusicalPosition.new(10, 1, 0, 0)
|
|
25
|
+
# tempo_map.each_segment(from, to) do |start_pos, end_pos, tempo|
|
|
26
|
+
# # Calculate clock time for this segment
|
|
27
|
+
# end
|
|
28
|
+
class TempoMap
|
|
29
|
+
# @return [Array<TempoEvent>] all tempo events in chronological order
|
|
30
|
+
attr_reader :events
|
|
31
|
+
|
|
32
|
+
# Create a new tempo map
|
|
33
|
+
#
|
|
34
|
+
# @param starting_tempo [HeadMusic::Rudiment::Tempo] initial tempo (default: quarter = 120)
|
|
35
|
+
# @param starting_position [MusicalPosition] where the initial tempo begins (default: 1:1:0:0)
|
|
36
|
+
def initialize(starting_tempo: nil, starting_position: nil)
|
|
37
|
+
starting_tempo ||= HeadMusic::Rudiment::Tempo.new("quarter", 120)
|
|
38
|
+
starting_position ||= MusicalPosition.new
|
|
39
|
+
@events = [TempoEvent.new(starting_position, starting_tempo.beat_value.to_s, starting_tempo.beats_per_minute)]
|
|
40
|
+
@meter = nil # Will be set when used with a MeterMap
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add a tempo change at the specified position
|
|
44
|
+
#
|
|
45
|
+
# If a tempo change already exists at this position, it will be replaced.
|
|
46
|
+
# Events are automatically maintained in sorted order.
|
|
47
|
+
#
|
|
48
|
+
# @param position [MusicalPosition] where the tempo change occurs
|
|
49
|
+
# @param beat_value_or_tempo [String, HeadMusic::Rudiment::Tempo] either a beat value like "quarter" or a Tempo object
|
|
50
|
+
# @param beats_per_minute [Numeric, nil] BPM (required if beat_value_or_tempo is a string)
|
|
51
|
+
# @return [TempoEvent] the created event
|
|
52
|
+
def add_change(position, beat_value_or_tempo, beats_per_minute = nil)
|
|
53
|
+
# Remove any existing event at this position (except the first)
|
|
54
|
+
remove_change(position)
|
|
55
|
+
|
|
56
|
+
# Create the new event
|
|
57
|
+
event = if beat_value_or_tempo.is_a?(HeadMusic::Rudiment::Tempo)
|
|
58
|
+
TempoEvent.new(position, beat_value_or_tempo.beat_value.to_s, beat_value_or_tempo.beats_per_minute).tap do |e|
|
|
59
|
+
e.tempo = beat_value_or_tempo
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
TempoEvent.new(position, beat_value_or_tempo, beats_per_minute)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@events << event
|
|
66
|
+
sort_events!
|
|
67
|
+
event
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Remove a tempo change at the specified position
|
|
71
|
+
#
|
|
72
|
+
# The starting tempo (first event) cannot be removed.
|
|
73
|
+
#
|
|
74
|
+
# @param position [MusicalPosition] the position of the event to remove
|
|
75
|
+
# @return [void]
|
|
76
|
+
def remove_change(position)
|
|
77
|
+
@events.reject! do |event|
|
|
78
|
+
event != @events.first && positions_equal?(event.position, position)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Remove all tempo changes except the starting tempo
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
def clear_changes
|
|
86
|
+
@events = [@events.first]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find the tempo active at a given position
|
|
90
|
+
#
|
|
91
|
+
# Returns the tempo from the most recent tempo event at or before
|
|
92
|
+
# the specified position.
|
|
93
|
+
#
|
|
94
|
+
# @param position [MusicalPosition] the position to query
|
|
95
|
+
# @return [HeadMusic::Rudiment::Tempo] the active tempo
|
|
96
|
+
def tempo_at(position)
|
|
97
|
+
# Normalize positions for comparison if we have a meter
|
|
98
|
+
normalized_pos = @meter ? position.dup.tap { |p| p.normalize!(@meter) } : position
|
|
99
|
+
|
|
100
|
+
# Find the last event at or before this position
|
|
101
|
+
active_event = @events.reverse.find do |event|
|
|
102
|
+
normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
|
|
103
|
+
normalized_event_pos <= normalized_pos
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
active_event&.tempo || @events.first.tempo
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Iterate through tempo segments between two positions
|
|
110
|
+
#
|
|
111
|
+
# Yields each segment with its start position, end position, and tempo.
|
|
112
|
+
# Segments are created wherever a tempo change occurs within the range.
|
|
113
|
+
#
|
|
114
|
+
# @param from_position [MusicalPosition] start of the range
|
|
115
|
+
# @param to_position [MusicalPosition] end of the range
|
|
116
|
+
# @yield [start_position, end_position, tempo] for each segment
|
|
117
|
+
# @yieldparam start_position [MusicalPosition] segment start
|
|
118
|
+
# @yieldparam end_position [MusicalPosition] segment end
|
|
119
|
+
# @yieldparam tempo [HeadMusic::Rudiment::Tempo] active tempo
|
|
120
|
+
# @return [void]
|
|
121
|
+
def each_segment(from_position, to_position)
|
|
122
|
+
# Normalize positions if we have a meter
|
|
123
|
+
from_pos = @meter ? from_position.dup.tap { |p| p.normalize!(@meter) } : from_position
|
|
124
|
+
to_pos = @meter ? to_position.dup.tap { |p| p.normalize!(@meter) } : to_position
|
|
125
|
+
|
|
126
|
+
# Find events that affect this range
|
|
127
|
+
relevant_events = @events.select do |event|
|
|
128
|
+
normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
|
|
129
|
+
normalized_event_pos < to_pos
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Start with the tempo active at from_position
|
|
133
|
+
current_pos = from_pos
|
|
134
|
+
current_tempo = tempo_at(from_pos)
|
|
135
|
+
|
|
136
|
+
# Iterate through relevant events
|
|
137
|
+
relevant_events.each do |event|
|
|
138
|
+
normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
|
|
139
|
+
|
|
140
|
+
# Skip events before our starting position
|
|
141
|
+
next if normalized_event_pos <= from_pos
|
|
142
|
+
|
|
143
|
+
# Yield the segment up to this event
|
|
144
|
+
yield current_pos, normalized_event_pos, current_tempo
|
|
145
|
+
|
|
146
|
+
# Move to next segment
|
|
147
|
+
current_pos = normalized_event_pos
|
|
148
|
+
current_tempo = event.tempo
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Yield the final segment to the end position
|
|
152
|
+
yield current_pos, to_pos, current_tempo
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Set the meter for position normalization
|
|
156
|
+
#
|
|
157
|
+
# @param meter [HeadMusic::Rudiment::Meter] the meter to use
|
|
158
|
+
# @return [void]
|
|
159
|
+
# @api private
|
|
160
|
+
attr_writer :meter
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Sort events by position
|
|
165
|
+
#
|
|
166
|
+
# @return [void]
|
|
167
|
+
def sort_events!
|
|
168
|
+
@events.sort_by! do |event|
|
|
169
|
+
pos = event.position
|
|
170
|
+
[pos.bar, pos.beat, pos.tick, pos.subtick]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Check if two positions are equal
|
|
175
|
+
#
|
|
176
|
+
# @param pos1 [MusicalPosition] first position
|
|
177
|
+
# @param pos2 [MusicalPosition] second position
|
|
178
|
+
# @return [Boolean] true if positions are equal
|
|
179
|
+
def positions_equal?(pos1, pos2)
|
|
180
|
+
pos1.bar == pos2.bar &&
|
|
181
|
+
pos1.beat == pos2.beat &&
|
|
182
|
+
pos1.tick == pos2.tick &&
|
|
183
|
+
pos1.subtick == pos2.subtick
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
# The Time module provides classes and methods to handle representations
|
|
5
|
+
# of musical time and its relationship to clock time and SMPTE Time Code.
|
|
6
|
+
#
|
|
7
|
+
# This module enables synchronization between three time representations:
|
|
8
|
+
# - Clock time: elapsed nanoseconds (source of truth)
|
|
9
|
+
# - Musical position: bars:beats:ticks:subticks notation
|
|
10
|
+
# - SMPTE timecode: hours:minutes:seconds:frames for video/audio sync
|
|
11
|
+
#
|
|
12
|
+
# @example Converting between time representations
|
|
13
|
+
# conductor = HeadMusic::Time::Conductor.new
|
|
14
|
+
# clock_pos = HeadMusic::Time::ClockPosition.new(1_000_000_000) # 1 second
|
|
15
|
+
# musical_pos = conductor.clock_to_musical(clock_pos)
|
|
16
|
+
module Time
|
|
17
|
+
# Ticks per quarter note value (MIDI standard)
|
|
18
|
+
PPQN = PULSES_PER_QUARTER_NOTE = 960
|
|
19
|
+
|
|
20
|
+
# Subticks provide finer resolution than ticks for precise timing
|
|
21
|
+
SUBTICKS_PER_TICK = 240
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative "time/clock_position"
|
|
26
|
+
require_relative "time/musical_position"
|
|
27
|
+
require_relative "time/smpte_timecode"
|
|
28
|
+
require_relative "time/meter_event"
|
|
29
|
+
require_relative "time/tempo_event"
|
|
30
|
+
require_relative "time/tempo_map"
|
|
31
|
+
require_relative "time/meter_map"
|
|
32
|
+
require_relative "time/conductor"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# A namespace for utilities classes and modules
|
|
2
|
+
module HeadMusic::Utilities; end
|
|
3
|
+
|
|
4
|
+
# Util for converting an object to a particular case
|
|
5
|
+
class HeadMusic::Utilities::Case
|
|
6
|
+
def self.to_snake_case(text)
|
|
7
|
+
text.to_s
|
|
8
|
+
.gsub("::", "/")
|
|
9
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
10
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
11
|
+
.tr("-", "_")
|
|
12
|
+
.tr(" ", "_")
|
|
13
|
+
.gsub(/[^\w\/]+/, "_")
|
|
14
|
+
.squeeze("_")
|
|
15
|
+
.gsub(/^_|_$/, "")
|
|
16
|
+
.downcase
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.to_kebab_case(text)
|
|
20
|
+
to_snake_case(text).tr("_", "-")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.to_camel_case(text)
|
|
24
|
+
str = to_snake_case(text)
|
|
25
|
+
str.split("_").map.with_index { |word, index| index.zero? ? word : word.capitalize }.join
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/head_music/version.rb
CHANGED
data/lib/head_music.rb
CHANGED
|
@@ -13,19 +13,36 @@ require "humanize"
|
|
|
13
13
|
require "i18n"
|
|
14
14
|
require "i18n/backend/fallbacks"
|
|
15
15
|
|
|
16
|
-
I18n
|
|
16
|
+
# Configure I18n for HeadMusic locales
|
|
17
|
+
# Include fallbacks backend if not already included
|
|
18
|
+
I18n::Backend::Simple.include(I18n::Backend::Fallbacks) unless I18n::Backend::Simple.included_modules.include?(I18n::Backend::Fallbacks)
|
|
19
|
+
|
|
20
|
+
# Add HeadMusic locale files to the load path (additive, doesn't overwrite)
|
|
17
21
|
I18n.load_path += Dir[File.join(File.dirname(__dir__), "lib", "head_music", "locales", "*.yml")]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
I18n.
|
|
22
|
-
I18n.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
|
|
23
|
+
# Add HeadMusic locales to available locales (additive, doesn't overwrite existing)
|
|
24
|
+
HEAD_MUSIC_LOCALES = %i[en fr de it ru es en_US en_GB].freeze
|
|
25
|
+
existing_locales = I18n.config.available_locales || []
|
|
26
|
+
I18n.config.available_locales = (existing_locales + HEAD_MUSIC_LOCALES).uniq
|
|
27
|
+
|
|
28
|
+
# Configure fallbacks for HeadMusic locales (only if not already configured)
|
|
29
|
+
# These provide sensible defaults for music terminology translations
|
|
30
|
+
HEAD_MUSIC_FALLBACKS = {
|
|
31
|
+
de: %i[de en_GB en],
|
|
32
|
+
en_US: %i[en_US en en_GB],
|
|
33
|
+
en_GB: %i[en_GB en en_US],
|
|
34
|
+
es: %i[es en],
|
|
35
|
+
fr: %i[fr en_GB en],
|
|
36
|
+
it: %i[it en_GB en],
|
|
37
|
+
ru: %i[ru en_GB en]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
HEAD_MUSIC_FALLBACKS.each do |locale, fallbacks|
|
|
41
|
+
I18n.fallbacks[locale] = fallbacks if I18n.fallbacks[locale].empty? || I18n.fallbacks[locale] == [locale]
|
|
42
|
+
end
|
|
27
43
|
|
|
28
44
|
# utilities
|
|
45
|
+
require "head_music/utilities/case"
|
|
29
46
|
require "head_music/utilities/hash_key"
|
|
30
47
|
|
|
31
48
|
# modules
|
|
@@ -58,7 +75,6 @@ require "head_music/rudiment/mode"
|
|
|
58
75
|
require "head_music/rudiment/key_signature"
|
|
59
76
|
require "head_music/rudiment/key_signature/enharmonic_equivalence"
|
|
60
77
|
require "head_music/rudiment/meter"
|
|
61
|
-
require "head_music/rudiment/musical_symbol"
|
|
62
78
|
require "head_music/rudiment/pitch/enharmonic_equivalence"
|
|
63
79
|
require "head_music/rudiment/pitch/octave_equivalence"
|
|
64
80
|
require "head_music/rudiment/pitch_class"
|
|
@@ -75,17 +91,26 @@ require "head_music/rudiment/tuning/just_intonation"
|
|
|
75
91
|
require "head_music/rudiment/tuning/meantone"
|
|
76
92
|
require "head_music/rudiment/tuning/pythagorean"
|
|
77
93
|
|
|
94
|
+
# time
|
|
95
|
+
require "head_music/time"
|
|
96
|
+
|
|
78
97
|
# instruments
|
|
79
98
|
require "head_music/instruments/instrument_family"
|
|
80
|
-
require "head_music/instruments/instrument_type"
|
|
81
99
|
require "head_music/instruments/variant"
|
|
100
|
+
require "head_music/instruments/playing_technique"
|
|
82
101
|
require "head_music/instruments/staff_scheme"
|
|
83
102
|
require "head_music/instruments/staff"
|
|
103
|
+
require "head_music/instruments/instrument_configuration_option"
|
|
104
|
+
require "head_music/instruments/instrument_configuration"
|
|
84
105
|
require "head_music/instruments/instrument"
|
|
106
|
+
require "head_music/instruments/stringing_course"
|
|
107
|
+
require "head_music/instruments/stringing"
|
|
108
|
+
require "head_music/instruments/alternate_tuning"
|
|
85
109
|
require "head_music/instruments/score_order"
|
|
86
110
|
|
|
87
111
|
# content
|
|
88
112
|
require "head_music/content/bar"
|
|
113
|
+
require "head_music/content/cantus_firmus_examples"
|
|
89
114
|
require "head_music/content/composition"
|
|
90
115
|
require "head_music/content/note"
|
|
91
116
|
require "head_music/content/placement"
|
|
@@ -93,6 +118,9 @@ require "head_music/content/position"
|
|
|
93
118
|
require "head_music/content/staff"
|
|
94
119
|
require "head_music/content/voice"
|
|
95
120
|
|
|
121
|
+
# notation
|
|
122
|
+
require "head_music/notation"
|
|
123
|
+
|
|
96
124
|
# analysis
|
|
97
125
|
require "head_music/analysis/circle"
|
|
98
126
|
require "head_music/analysis/diatonic_interval"
|
|
@@ -101,13 +129,14 @@ require "head_music/analysis/diatonic_interval/naming"
|
|
|
101
129
|
require "head_music/analysis/diatonic_interval/parser"
|
|
102
130
|
require "head_music/analysis/diatonic_interval/semitones"
|
|
103
131
|
require "head_music/analysis/diatonic_interval/size"
|
|
132
|
+
require "head_music/analysis/dyad"
|
|
104
133
|
require "head_music/analysis/harmonic_interval"
|
|
105
134
|
require "head_music/analysis/interval_consonance"
|
|
106
135
|
require "head_music/analysis/interval_cycle"
|
|
107
136
|
require "head_music/analysis/melodic_interval"
|
|
108
137
|
require "head_music/analysis/motion"
|
|
109
138
|
require "head_music/analysis/pitch_class_set"
|
|
110
|
-
require "head_music/analysis/
|
|
139
|
+
require "head_music/analysis/pitch_collection"
|
|
111
140
|
require "head_music/analysis/sonority"
|
|
112
141
|
|
|
113
142
|
# style analysis
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Extract Staff Schemes to NotationStyle
|
|
2
|
+
|
|
3
|
+
AS a developer
|
|
4
|
+
|
|
5
|
+
I WANT staff schemes and notation conventions to live in a NotationStyle model
|
|
6
|
+
|
|
7
|
+
SO THAT notation concerns are separated from instrument definition and can vary independently
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
This story depends on **000-overlay-architecture.md**. The NotationStyle class implements the **notation style layer** in the overlay stack, which sits between configuration and instance layers.
|
|
12
|
+
|
|
13
|
+
## Background
|
|
14
|
+
|
|
15
|
+
The current architecture embeds staff schemes (clefs, transposition conventions, number of staves) within instrument variants. This conflates two independent concerns:
|
|
16
|
+
|
|
17
|
+
1. **What the instrument is** - Its pitch, range, family, physical characteristics
|
|
18
|
+
2. **How it's notated** - Which clefs, transposed or concert pitch, regional conventions
|
|
19
|
+
|
|
20
|
+
These concerns are orthogonal. A French horn is the same instrument whether notated in treble clef (transposed) or bass clef (concert pitch). A euphonium in a British brass band uses treble clef transposed notation, while the same instrument in an orchestra uses bass clef at concert pitch. The notation choice depends on the **tradition or context**, not the instrument itself.
|
|
21
|
+
|
|
22
|
+
## Current State
|
|
23
|
+
|
|
24
|
+
Staff schemes are nested under variants in `instruments.yml`:
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
french_horn:
|
|
28
|
+
family_key: horn
|
|
29
|
+
variants:
|
|
30
|
+
default:
|
|
31
|
+
pitch_designation: F
|
|
32
|
+
staff_schemes:
|
|
33
|
+
bass_clef:
|
|
34
|
+
- clef: bass_clef
|
|
35
|
+
sounding_transposition: 5
|
|
36
|
+
default:
|
|
37
|
+
- clef: treble_clef
|
|
38
|
+
sounding_transposition: -7
|
|
39
|
+
|
|
40
|
+
euphonium:
|
|
41
|
+
family_key: tuba
|
|
42
|
+
variants:
|
|
43
|
+
british_band:
|
|
44
|
+
staff_schemes:
|
|
45
|
+
default:
|
|
46
|
+
- clef: treble_clef
|
|
47
|
+
sounding_transposition: -14
|
|
48
|
+
default:
|
|
49
|
+
staff_schemes:
|
|
50
|
+
default:
|
|
51
|
+
- clef: bass_clef
|
|
52
|
+
sounding_transposition: 0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Problems with current approach:
|
|
56
|
+
- Euphonium has two "variants" that are really notation conventions, not different instruments
|
|
57
|
+
- Adding a new notation style requires modifying every instrument's variant data
|
|
58
|
+
- Staff scheme choices are duplicated across variants that share the same options
|
|
59
|
+
- Sounding transposition (a notation concern) is mixed with pitch designation (an instrument property)
|
|
60
|
+
|
|
61
|
+
## Proposed State
|
|
62
|
+
|
|
63
|
+
Extract notation concerns to a separate NotationStyle model:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
# instruments.yml - now purely about the instrument
|
|
67
|
+
euphonium:
|
|
68
|
+
family_key: tuba
|
|
69
|
+
# No variants needed - there's only one euphonium
|
|
70
|
+
|
|
71
|
+
french_horn:
|
|
72
|
+
family_key: horn
|
|
73
|
+
default_pitched_variant: f
|
|
74
|
+
pitched_variants:
|
|
75
|
+
f:
|
|
76
|
+
pitch_designation: F
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# notation_styles.yml - notation conventions by tradition
|
|
81
|
+
orchestral:
|
|
82
|
+
name: "Orchestral"
|
|
83
|
+
instrument_notations:
|
|
84
|
+
french_horn:
|
|
85
|
+
clef: treble
|
|
86
|
+
transposition: written # written pitch, not concert
|
|
87
|
+
euphonium:
|
|
88
|
+
clef: bass
|
|
89
|
+
transposition: concert
|
|
90
|
+
clarinet:
|
|
91
|
+
clef: treble
|
|
92
|
+
transposition: written
|
|
93
|
+
|
|
94
|
+
british_brass_band:
|
|
95
|
+
name: "British Brass Band"
|
|
96
|
+
instrument_notations:
|
|
97
|
+
euphonium:
|
|
98
|
+
clef: treble
|
|
99
|
+
transposition: written
|
|
100
|
+
tuba:
|
|
101
|
+
clef: treble
|
|
102
|
+
transposition: written
|
|
103
|
+
|
|
104
|
+
concert_pitch:
|
|
105
|
+
name: "Concert Pitch Score"
|
|
106
|
+
default_transposition: concert
|
|
107
|
+
instrument_notations:
|
|
108
|
+
french_horn:
|
|
109
|
+
clef: bass
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## User Stories
|
|
113
|
+
|
|
114
|
+
**STORY 1: Create NotationStyle class**
|
|
115
|
+
|
|
116
|
+
AS a developer
|
|
117
|
+
WHEN I need to specify how instruments should be notated
|
|
118
|
+
I WANT to use a NotationStyle object
|
|
119
|
+
SO THAT notation conventions are explicit and reusable
|
|
120
|
+
|
|
121
|
+
**STORY 2: NotationStyle defines instrument notation**
|
|
122
|
+
|
|
123
|
+
AS a developer
|
|
124
|
+
WHEN I have a NotationStyle and an Instrument
|
|
125
|
+
I WANT to query the appropriate clef and transposition convention
|
|
126
|
+
SO THAT I can notate the instrument correctly for that tradition
|
|
127
|
+
|
|
128
|
+
**STORY 3: Remove notation from Variant**
|
|
129
|
+
|
|
130
|
+
AS a developer
|
|
131
|
+
WHEN I define an instrument's pitched variants
|
|
132
|
+
I WANT to specify only pitch-related properties
|
|
133
|
+
SO THAT variants are purely about the instrument, not its notation
|
|
134
|
+
|
|
135
|
+
**STORY 4: Remove euphonium "variants"**
|
|
136
|
+
|
|
137
|
+
AS a developer
|
|
138
|
+
WHEN I look up a euphonium
|
|
139
|
+
I WANT a single instrument definition
|
|
140
|
+
SO THAT the british_band vs orchestral distinction is handled by NotationStyle
|
|
141
|
+
|
|
142
|
+
**STORY 5: Instrument uses NotationStyle**
|
|
143
|
+
|
|
144
|
+
AS a developer
|
|
145
|
+
WHEN I create an Instrument for a score
|
|
146
|
+
I WANT to specify the notation style
|
|
147
|
+
SO THAT the configuration knows how to notate the instrument
|
|
148
|
+
|
|
149
|
+
## Implementation Notes
|
|
150
|
+
|
|
151
|
+
1. Create `HeadMusic::Notation::NotationStyle` class that responds to `[]` for layer resolution
|
|
152
|
+
2. Create `notation_styles.yml` with common traditions (orchestral, british_brass_band, concert_pitch)
|
|
153
|
+
3. NotationStyle provides instrument-specific overrides for notation attributes:
|
|
154
|
+
- `clef` - Which clef to use
|
|
155
|
+
- `transposition` - Sounding transposition for this notation context
|
|
156
|
+
- `staves` - Staff configuration (for grand staff instruments)
|
|
157
|
+
4. Applied via `instrument.with_notation_style(style)` fluent builder
|
|
158
|
+
5. Sounding transposition is calculated from:
|
|
159
|
+
- The instrument's pitch designation (e.g., Bb = -2 semitones from C)
|
|
160
|
+
- The notation style's transposition convention (written vs concert)
|
|
161
|
+
- The clef's octave displacement if any
|
|
162
|
+
6. Migration path: keep backward compatibility while new system is built
|
|
163
|
+
|
|
164
|
+
## Acceptance Criteria
|
|
165
|
+
|
|
166
|
+
- [ ] `HeadMusic::Notation::NotationStyle` class exists
|
|
167
|
+
- [ ] `notation_styles.yml` defines common notation traditions
|
|
168
|
+
- [ ] `NotationStyle.get(:orchestral)` returns appropriate style
|
|
169
|
+
- [ ] `notation_style.notation_for(instrument)` returns clef and transposition info
|
|
170
|
+
- [ ] Euphonium no longer has multiple variants
|
|
171
|
+
- [ ] Staff schemes removed from pitched variants
|
|
172
|
+
- [ ] `Instrument` accepts optional notation_style parameter
|
|
173
|
+
- [ ] All existing tests pass (with appropriate updates)
|
|
174
|
+
- [ ] New tests cover notation style functionality
|
|
175
|
+
- [ ] Maintains 90%+ test coverage
|
|
176
|
+
|
|
177
|
+
## Open Questions
|
|
178
|
+
|
|
179
|
+
1. **Percussion mappings** - Are drum kit staff mappings (bass drum on space 1, snare on line 3) part of NotationStyle or intrinsic to the instrument? Different publishers use different mappings, suggesting it's a notation concern.
|
|
180
|
+
|
|
181
|
+
2. **Grand staff instruments** - Piano always uses grand staff. Is this intrinsic to the instrument or still a notation choice? Perhaps instruments can declare a "minimum staff structure" that notation styles must respect.
|
|
182
|
+
|
|
183
|
+
3. **Default notation style** - What's the default if none is specified? Probably "orchestral" for most use cases.
|
|
@@ -5,7 +5,7 @@ Sequence = neutral term for the editing canvas (2D space).
|
|
|
5
5
|
Timeline = strictly the axis.
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
ScorePart
|
|
11
11
|
@name
|
|
@@ -59,6 +59,8 @@ ScorePartPlayer
|
|
|
59
59
|
|
|
60
60
|
Player
|
|
61
61
|
> person
|
|
62
|
+
- identity? // is a person really a person or a particular name
|
|
63
|
+
- distinction between a unique person and a name
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
|
|
@@ -66,7 +68,13 @@ Fragment < MusicContent
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
Material?
|
|
69
72
|
|
|
70
73
|
|
|
74
|
+
Material
|
|
71
75
|
|
|
76
|
+
Fragment < Material
|
|
72
77
|
|
|
78
|
+
Score < Material
|
|
79
|
+
- name
|
|
80
|
+
-
|