musa-dsl 0.40.0 → 0.42.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/Gemfile +0 -1
  4. data/README.md +15 -1
  5. data/docs/README.md +1 -0
  6. data/docs/subsystems/datasets.md +75 -0
  7. data/docs/subsystems/generative.md +92 -6
  8. data/docs/subsystems/music.md +349 -19
  9. data/docs/subsystems/transport.md +26 -0
  10. data/lib/musa-dsl/datasets/dataset.rb +2 -0
  11. data/lib/musa-dsl/datasets/gdv.rb +3 -3
  12. data/lib/musa-dsl/datasets/p.rb +1 -1
  13. data/lib/musa-dsl/datasets/score/to-mxml/process-time.rb +4 -2
  14. data/lib/musa-dsl/datasets/score.rb +3 -1
  15. data/lib/musa-dsl/generative/darwin.rb +36 -1
  16. data/lib/musa-dsl/generative/generative-grammar.rb +31 -1
  17. data/lib/musa-dsl/generative/markov.rb +3 -1
  18. data/lib/musa-dsl/generative/rules.rb +54 -0
  19. data/lib/musa-dsl/generative/variatio.rb +69 -0
  20. data/lib/musa-dsl/midi/midi-recorder.rb +4 -0
  21. data/lib/musa-dsl/midi/midi-voices.rb +13 -1
  22. data/lib/musa-dsl/music/chord-definition.rb +7 -5
  23. data/lib/musa-dsl/music/chord-definitions.rb +37 -0
  24. data/lib/musa-dsl/music/chords.rb +88 -21
  25. data/lib/musa-dsl/music/equally-tempered-12-tone-scale-system.rb +70 -521
  26. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_dominant_scale_kind.rb +110 -0
  27. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_major_scale_kind.rb +110 -0
  28. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_minor_scale_kind.rb +110 -0
  29. data/lib/musa-dsl/music/scale_kinds/blues/blues_major_scale_kind.rb +100 -0
  30. data/lib/musa-dsl/music/scale_kinds/blues/blues_scale_kind.rb +99 -0
  31. data/lib/musa-dsl/music/scale_kinds/chromatic_scale_kind.rb +79 -0
  32. data/lib/musa-dsl/music/scale_kinds/ethnic/double_harmonic_scale_kind.rb +102 -0
  33. data/lib/musa-dsl/music/scale_kinds/ethnic/hungarian_minor_scale_kind.rb +102 -0
  34. data/lib/musa-dsl/music/scale_kinds/ethnic/neapolitan_major_scale_kind.rb +102 -0
  35. data/lib/musa-dsl/music/scale_kinds/ethnic/neapolitan_minor_scale_kind.rb +101 -0
  36. data/lib/musa-dsl/music/scale_kinds/ethnic/phrygian_dominant_scale_kind.rb +103 -0
  37. data/lib/musa-dsl/music/scale_kinds/harmonic_major/harmonic_major_scale_kind.rb +104 -0
  38. data/lib/musa-dsl/music/scale_kinds/major_scale_kind.rb +110 -0
  39. data/lib/musa-dsl/music/scale_kinds/melodic_minor/altered_scale_kind.rb +106 -0
  40. data/lib/musa-dsl/music/scale_kinds/melodic_minor/dorian_b2_scale_kind.rb +104 -0
  41. data/lib/musa-dsl/music/scale_kinds/melodic_minor/locrian_sharp2_scale_kind.rb +103 -0
  42. data/lib/musa-dsl/music/scale_kinds/melodic_minor/lydian_augmented_scale_kind.rb +103 -0
  43. data/lib/musa-dsl/music/scale_kinds/melodic_minor/lydian_dominant_scale_kind.rb +106 -0
  44. data/lib/musa-dsl/music/scale_kinds/melodic_minor/melodic_minor_scale_kind.rb +104 -0
  45. data/lib/musa-dsl/music/scale_kinds/melodic_minor/mixolydian_b6_scale_kind.rb +103 -0
  46. data/lib/musa-dsl/music/scale_kinds/minor_harmonic_scale_kind.rb +125 -0
  47. data/lib/musa-dsl/music/scale_kinds/minor_natural_scale_kind.rb +123 -0
  48. data/lib/musa-dsl/music/scale_kinds/modes/dorian_scale_kind.rb +111 -0
  49. data/lib/musa-dsl/music/scale_kinds/modes/locrian_scale_kind.rb +114 -0
  50. data/lib/musa-dsl/music/scale_kinds/modes/lydian_scale_kind.rb +111 -0
  51. data/lib/musa-dsl/music/scale_kinds/modes/mixolydian_scale_kind.rb +111 -0
  52. data/lib/musa-dsl/music/scale_kinds/modes/phrygian_scale_kind.rb +111 -0
  53. data/lib/musa-dsl/music/scale_kinds/pentatonic/pentatonic_major_scale_kind.rb +93 -0
  54. data/lib/musa-dsl/music/scale_kinds/pentatonic/pentatonic_minor_scale_kind.rb +99 -0
  55. data/lib/musa-dsl/music/scale_kinds/symmetric/diminished_hw_scale_kind.rb +110 -0
  56. data/lib/musa-dsl/music/scale_kinds/symmetric/diminished_wh_scale_kind.rb +110 -0
  57. data/lib/musa-dsl/music/scale_kinds/symmetric/whole_tone_scale_kind.rb +99 -0
  58. data/lib/musa-dsl/music/scale_systems/equally_tempered_12_tone_scale_system.rb +80 -0
  59. data/lib/musa-dsl/music/scale_systems/twelve_semitones_scale_system.rb +60 -0
  60. data/lib/musa-dsl/music/scales.rb +606 -67
  61. data/lib/musa-dsl/musicxml/builder/note.rb +31 -92
  62. data/lib/musa-dsl/musicxml/builder/pitched-note.rb +33 -94
  63. data/lib/musa-dsl/musicxml/builder/rest.rb +30 -91
  64. data/lib/musa-dsl/musicxml/builder/unpitched-note.rb +31 -91
  65. data/lib/musa-dsl/neumas/array-to-neumas.rb +1 -1
  66. data/lib/musa-dsl/neumas/neuma-gdv-decoder.rb +2 -2
  67. data/lib/musa-dsl/sequencer/sequencer-dsl.rb +367 -3
  68. data/lib/musa-dsl/series/base-series.rb +250 -240
  69. data/lib/musa-dsl/series/buffer-serie.rb +16 -5
  70. data/lib/musa-dsl/series/hash-or-array-serie-splitter.rb +29 -3
  71. data/lib/musa-dsl/series/main-serie-constructors.rb +19 -15
  72. data/lib/musa-dsl/series/main-serie-operations.rb +74 -29
  73. data/lib/musa-dsl/series/proxy-serie.rb +5 -1
  74. data/lib/musa-dsl/series/quantizer-serie.rb +16 -2
  75. data/lib/musa-dsl/series/queue-serie.rb +15 -1
  76. data/lib/musa-dsl/series/series-composer.rb +5 -2
  77. data/lib/musa-dsl/series/timed-serie.rb +8 -4
  78. data/lib/musa-dsl/transport/timer-clock.rb +4 -2
  79. data/lib/musa-dsl/transport/timer.rb +27 -4
  80. data/lib/musa-dsl/version.rb +1 -1
  81. data/musa-dsl.gemspec +18 -15
  82. metadata +85 -22
@@ -104,7 +104,7 @@ module Musa
104
104
  # Makes the scale system available via symbol lookup and dynamic method.
105
105
  # Optionally marks it as the default system.
106
106
  #
107
- # @param scale_system [Class] the ScaleSystem subclass to register
107
+ # @param scale_system [Class<ScaleSystem>] the ScaleSystem subclass to register
108
108
  # @param default [Boolean] whether to set as default system
109
109
  # @return [self]
110
110
  #
@@ -126,26 +126,55 @@ module Musa
126
126
  # Retrieves a registered scale system by ID.
127
127
  #
128
128
  # @param id [Symbol] the scale system identifier
129
- # @return [Class] the ScaleSystem subclass
129
+ # @return [Class<ScaleSystem>] the ScaleSystem subclass
130
130
  # @raise [KeyError] if scale system not found
131
131
  #
132
132
  # @example
133
133
  # Scales[:et12] # => EquallyTempered12ToneScaleSystem
134
- def self.[](id)
134
+ def self.get(id)
135
135
  raise KeyError, "Scale system :#{id} not found" unless @scale_systems.key?(id)
136
136
 
137
137
  @scale_systems[id]
138
138
  end
139
139
 
140
+ class << self
141
+ alias_method :[], :get
142
+ end
143
+
140
144
  # Returns the default scale system.
141
145
  #
142
- # @return [Class] the default ScaleSystem subclass
146
+ # @return [Class<ScaleSystem>] the default ScaleSystem subclass
143
147
  #
144
148
  # @example
145
149
  # Scales.default_system # => EquallyTempered12ToneScaleSystem
146
150
  def self.default_system
147
151
  @default_scale_system
148
152
  end
153
+
154
+ # Convenience method to extend metadata for a scale kind by ID.
155
+ #
156
+ # Finds the ScaleKind class by its ID symbol and adds custom metadata to it.
157
+ # This is a shortcut for accessing the class directly and calling extend_metadata.
158
+ #
159
+ # @param scale_kind_id [Symbol] the scale kind identifier (e.g., :major, :dorian)
160
+ # @param metadata [Hash] key-value pairs to add as custom metadata
161
+ # @return [Hash] the updated custom_metadata hash
162
+ # @raise [KeyError] if scale kind not found
163
+ #
164
+ # @example
165
+ # Scales.extend_metadata(:major, my_tag: :favorite)
166
+ # Scales.extend_metadata(:dorian, mood: :nostalgic, suitable_for: [:jazz])
167
+ #
168
+ # @see ScaleKind.extend_metadata
169
+ def self.extend_metadata(scale_kind_id, **metadata)
170
+ system = default_system
171
+ raise KeyError, "No default scale system registered" unless system
172
+
173
+ klass = system.scale_kind_class(scale_kind_id)
174
+ raise KeyError, "Scale kind :#{scale_kind_id} not found" unless klass
175
+
176
+ klass.extend_metadata(**metadata)
177
+ end
149
178
  end
150
179
 
151
180
  # Abstract base class for musical scale systems.
@@ -294,7 +323,7 @@ module Musa
294
323
  #
295
324
  # @example Modern high pitch
296
325
  # modern = ScaleSystem[442.0]
297
- def self.[](a_frequency)
326
+ def self.get(a_frequency)
298
327
  a_frequency = a_frequency.to_f
299
328
 
300
329
  @a_tunings ||= {}
@@ -303,6 +332,10 @@ module Musa
303
332
  @a_tunings[a_frequency]
304
333
  end
305
334
 
335
+ class << self
336
+ alias_method :[], :get
337
+ end
338
+
306
339
  # Returns semitone offset for a named interval.
307
340
  #
308
341
  # @param name [Symbol] interval name (e.g., :M3, :P5)
@@ -326,7 +359,7 @@ module Musa
326
359
 
327
360
  # Registers a scale kind (major, minor, etc.) with this system.
328
361
  #
329
- # @param scale_kind_class [Class] ScaleKind subclass to register
362
+ # @param scale_kind_class [Class<ScaleKind>] ScaleKind subclass to register
330
363
  # @return [self]
331
364
  #
332
365
  # @example
@@ -343,7 +376,7 @@ module Musa
343
376
  # Retrieves a registered scale kind by ID.
344
377
  #
345
378
  # @param id [Symbol] scale kind identifier
346
- # @return [Class] ScaleKind subclass
379
+ # @return [Class<ScaleKind>] ScaleKind subclass
347
380
  # @raise [KeyError] if not found
348
381
  def self.scale_kind_class(id)
349
382
  raise KeyError, "Scale kind class [#{id}] not found in scale system [#{self.id}]" unless @scale_kind_classes.key? id
@@ -368,7 +401,7 @@ module Musa
368
401
 
369
402
  # Returns the chromatic scale kind class.
370
403
  #
371
- # @return [Class] chromatic ScaleKind subclass
404
+ # @return [Class<ScaleKind>] chromatic ScaleKind subclass
372
405
  # @raise [RuntimeError] if chromatic scale not defined
373
406
  def self.chromatic_class
374
407
  raise "Chromatic scale kind class for [#{self.id}] scale system undefined" if @chromatic_scale_kind_class.nil?
@@ -427,6 +460,12 @@ module Musa
427
460
  class ScaleSystemTuning
428
461
  extend Forwardable
429
462
 
463
+ # Creates a tuning instance for a scale system.
464
+ #
465
+ # @param scale_system [Class<ScaleSystem>] the ScaleSystem subclass
466
+ # @param a_frequency [Numeric] reference A frequency in Hz
467
+ #
468
+ # @api private
430
469
  def initialize(scale_system, a_frequency)
431
470
  @scale_system = scale_system
432
471
  @a_frequency = a_frequency
@@ -443,28 +482,214 @@ module Musa
443
482
 
444
483
  # TODO: allow scales not based in octaves but in other intervals (like fifths or other ratios). Possibly based on intervals definition of ScaleSystem plus a "generator interval" attribute
445
484
 
485
+ # @!method notes_in_octave
486
+ # Returns the number of notes in one octave.
487
+ # Delegated from {ScaleSystem.notes_in_octave}.
488
+ # @return [Integer] notes per octave (e.g., 12 for chromatic)
489
+
490
+ # @!method offset_of_interval(name)
491
+ # Returns semitone offset for a named interval.
492
+ # Delegated from {ScaleSystem.offset_of_interval}.
493
+ # @param name [Symbol] interval name (e.g., :M3, :P5)
494
+ # @return [Integer] semitone offset
495
+
446
496
  def_delegators :@scale_system, :notes_in_octave, :offset_of_interval
447
497
 
448
- attr_reader :a_frequency, :scale_system
498
+ # Reference A frequency in Hz.
499
+ # @return [Float]
500
+ attr_reader :a_frequency
501
+
502
+ # The parent scale system.
503
+ # @return [Class<ScaleSystem>] ScaleSystem subclass
504
+ attr_reader :scale_system
449
505
 
450
- def [](scale_kind_class_id)
506
+ # Retrieves a scale kind by ID.
507
+ #
508
+ # Creates and caches {ScaleKind} instances for efficient reuse.
509
+ #
510
+ # @param scale_kind_class_id [Symbol] scale kind identifier (e.g., :major, :minor)
511
+ # @return [ScaleKind] scale kind instance
512
+ # @raise [KeyError] if scale kind not found
513
+ #
514
+ # @example
515
+ # tuning[:major][60] # C major scale
516
+ # tuning[:minor][69] # A minor scale
517
+ #
518
+ # @see ScaleKind
519
+ def get(scale_kind_class_id)
451
520
  @scale_kinds[scale_kind_class_id] ||= @scale_system.scale_kind_class(scale_kind_class_id).new self
452
521
  end
453
522
 
523
+ alias_method :[], :get
524
+
525
+ # Returns the chromatic scale kind.
526
+ #
527
+ # Provides access to the chromatic scale, which contains all pitches
528
+ # in the scale system. Used as fallback for non-diatonic notes.
529
+ #
530
+ # @return [ScaleKind] chromatic scale kind instance
531
+ #
532
+ # @example
533
+ # tuning.chromatic[60] # Chromatic scale at C
534
+ #
535
+ # @see ScaleSystem.chromatic_class
454
536
  def chromatic
455
537
  @chromatic_scale_kind
456
538
  end
457
539
 
540
+ # Calculates frequency for a MIDI pitch.
541
+ #
542
+ # Delegates to the scale system's frequency calculation using
543
+ # this tuning's A frequency as reference.
544
+ #
545
+ # @param pitch [Numeric] MIDI pitch number (60 = middle C)
546
+ # @param root [Numeric] root pitch of the scale (for non-equal temperaments)
547
+ # @return [Float] frequency in Hz
548
+ #
549
+ # @example
550
+ # tuning.frequency_of_pitch(69, 60) # => 440.0 (A4)
551
+ # tuning.frequency_of_pitch(60, 60) # => ~261.63 (C4)
552
+ #
553
+ # @see ScaleSystem.frequency_of_pitch
458
554
  def frequency_of_pitch(pitch, root)
459
555
  @scale_system.frequency_of_pitch(pitch, root, @a_frequency)
460
556
  end
461
557
 
558
+ # Returns scale kinds matching the given metadata criteria.
559
+ #
560
+ # Without arguments, returns all registered scale kinds.
561
+ # With keyword arguments, filters by metadata values.
562
+ # With a block, filters using custom predicate on ScaleKind class.
563
+ #
564
+ # @param metadata_criteria [Hash] metadata key-value pairs to match
565
+ # @yield [kind_class] optional block for custom filtering
566
+ # @yieldparam kind_class [Class<ScaleKind>] the ScaleKind subclass
567
+ # @yieldreturn [Boolean] true to include this scale kind
568
+ # @return [Array<ScaleKind>] matching scale kind instances
569
+ #
570
+ # @example All scale kinds
571
+ # tuning.scale_kinds
572
+ # # => [major_kind, minor_kind, dorian_kind, ...]
573
+ #
574
+ # @example Filter by metadata
575
+ # tuning.scale_kinds(family: :diatonic)
576
+ # tuning.scale_kinds(brightness: -1..1)
577
+ # tuning.scale_kinds(character: :jazz)
578
+ #
579
+ # @example Filter with block
580
+ # tuning.scale_kinds { |klass| klass.intrinsic_metadata[:has_leading_tone] }
581
+ #
582
+ # @example Combined
583
+ # tuning.scale_kinds(family: :greek_modes) { |klass| klass.metadata[:brightness] < 0 }
584
+ #
585
+ # @see ScaleKind.metadata
586
+ # @see ScaleKind.has_metadata?
587
+ def scale_kinds(**metadata_criteria, &block)
588
+ result = @scale_system.scale_kind_classes.keys.map { |id| self[id] }
589
+
590
+ unless metadata_criteria.empty?
591
+ result = result.select do |kind|
592
+ matches_metadata?(kind.class, metadata_criteria)
593
+ end
594
+ end
595
+
596
+ if block
597
+ result = result.select { |kind| block.call(kind.class) }
598
+ end
599
+
600
+ result
601
+ end
602
+
603
+ # Searches for a chord across multiple scale types.
604
+ #
605
+ # Iterates through the specified scale kinds and pitch roots to find
606
+ # all scales that contain the given chord. Returns chords with their
607
+ # containing scale as context.
608
+ #
609
+ # @param chord [Musa::Chords::Chord] the chord to search for
610
+ # @param roots [Range, Array, nil] pitch offsets to search (default: 0...notes_in_octave)
611
+ # @param metadata_criteria [Hash] metadata filters for scale kinds
612
+ # @return [Array<Musa::Chords::Chord>] chords with their containing scales
613
+ #
614
+ # @example Search G7 in greek mode scales
615
+ # tuning = Scales.et12[440.0]
616
+ # g7 = tuning.major[60].dominant.chord :seventh
617
+ # tuning.search_chord_in_scales(g7, family: :greek_modes)
618
+ #
619
+ # @example Search with brightness filter
620
+ # tuning.search_chord_in_scales(g7, brightness: -1..1)
621
+ #
622
+ # @example Search in all scale types
623
+ # tuning.search_chord_in_scales(g7)
624
+ #
625
+ # @see ScaleKind#find_chord_in_scales
626
+ # @see Musa::Chords::Chord#search_in_scales
627
+ def search_chord_in_scales(chord, roots: nil, **metadata_criteria)
628
+ roots ||= 0...notes_in_octave
629
+ kinds = filtered_scale_kind_ids(**metadata_criteria)
630
+
631
+ kinds.flat_map do |kind_id|
632
+ self[kind_id].find_chord_in_scales(chord, roots: roots)
633
+ end
634
+ end
635
+
636
+ private
637
+
638
+ # Returns scale kind IDs filtered by metadata criteria.
639
+ #
640
+ # @param metadata_criteria [Hash] key-value pairs to match
641
+ # @return [Array<Symbol>] matching scale kind IDs
642
+ #
643
+ # @api private
644
+ def filtered_scale_kind_ids(**metadata_criteria)
645
+ kinds = @scale_system.scale_kind_classes.keys
646
+
647
+ return kinds if metadata_criteria.empty?
648
+
649
+ kinds.select do |kind_id|
650
+ kind_class = @scale_system.scale_kind_class(kind_id)
651
+ matches_metadata?(kind_class, metadata_criteria)
652
+ end
653
+ end
654
+
655
+ # Checks if a scale kind class matches the given criteria.
656
+ #
657
+ # @param kind_class [Class<ScaleKind>] ScaleKind subclass to check
658
+ # @param criteria [Hash] metadata key-value pairs to match
659
+ # @return [Boolean] true if all criteria match
660
+ #
661
+ # @api private
662
+ def matches_metadata?(kind_class, criteria)
663
+ criteria.all? do |key, value|
664
+ actual = kind_class.metadata[key]
665
+ case value
666
+ when Range
667
+ actual.is_a?(Numeric) && value.include?(actual)
668
+ when Array
669
+ value.any? { |v| actual == v || (actual.is_a?(Array) && actual.include?(v)) }
670
+ else
671
+ actual == value || (actual.is_a?(Array) && actual.include?(value))
672
+ end
673
+ end
674
+ end
675
+
676
+ public
677
+
678
+ # Checks tuning equality.
679
+ #
680
+ # Tunings are equal if they have the same scale system and A frequency.
681
+ #
682
+ # @param other [ScaleSystemTuning] the tuning to compare
683
+ # @return [Boolean] true if equal
462
684
  def ==(other)
463
685
  self.class == other.class &&
464
686
  @scale_system == other.scale_system &&
465
687
  @a_frequency == other.a_frequency
466
688
  end
467
689
 
690
+ # Returns string representation.
691
+ #
692
+ # @return [String] human-readable description
468
693
  def inspect
469
694
  "<ScaleSystemTuning: scale_system = #{@scale_system} a_frequency = #{@a_frequency}>"
470
695
  end
@@ -552,11 +777,13 @@ module Musa
552
777
  # major_kind = tuning[:major]
553
778
  # c_major = major_kind[60] # C major
554
779
  # g_major = major_kind[67] # G major
555
- def [](root_pitch)
780
+ def get(root_pitch)
556
781
  @scales[root_pitch] = Scale.new(self, root_pitch: root_pitch) unless @scales.key?(root_pitch)
557
782
  @scales[root_pitch]
558
783
  end
559
784
 
785
+ alias_method :[], :get
786
+
560
787
  # Returns scale with default root (middle C, MIDI 60).
561
788
  #
562
789
  # @return [Scale] scale rooted on middle C
@@ -577,6 +804,35 @@ module Musa
577
804
  self[0]
578
805
  end
579
806
 
807
+ # Finds all scales of this kind that contain the given chord.
808
+ #
809
+ # Searches through scales rooted on different pitches to find which ones
810
+ # contain all the notes of the given chord. Returns chords with their
811
+ # containing scale as context.
812
+ #
813
+ # @param chord [Musa::Chords::Chord] the chord to search for
814
+ # @param roots [Range, Array, nil] pitch offsets to search (default: 0...notes_in_octave)
815
+ # @return [Array<Musa::Chords::Chord>] chords with their containing scales
816
+ #
817
+ # @example Find G major triad in all major scales
818
+ # tuning = Scales.et12[440.0]
819
+ # g_triad = tuning.major[60].dominant.chord
820
+ # tuning.major.find_chord_in_scales(g_triad)
821
+ # # => [Chord in C major (V), Chord in G major (I), Chord in D major (IV)]
822
+ #
823
+ # @see Musa::Chords::Chord#as_chord_in_scale
824
+ # @see ScaleSystemTuning#search_chord_in_scales
825
+ def find_chord_in_scales(chord, roots: nil)
826
+ roots ||= 0...tuning.notes_in_octave
827
+ base_pitch = chord.root.pitch % tuning.notes_in_octave
828
+
829
+ roots.filter_map do |root_offset|
830
+ root_pitch = base_pitch + root_offset
831
+ scale = self[root_pitch]
832
+ chord.as_chord_in_scale(scale)
833
+ end
834
+ end
835
+
580
836
  # Checks scale kind equality.
581
837
  #
582
838
  # @param other [ScaleKind]
@@ -685,8 +941,200 @@ module Musa
685
941
  @grade_names_index.keys
686
942
  end
687
943
 
944
+ # Returns intrinsic metadata derived from scale structure.
945
+ #
946
+ # This metadata is automatically calculated from the scale's pitch
947
+ # structure and cannot be modified. It includes:
948
+ #
949
+ # - **:id**: Scale kind identifier
950
+ # - **:grades**: Number of diatonic degrees
951
+ # - **:pitches**: Array of pitch offsets from root
952
+ # - **:intervals**: Intervals between consecutive degrees
953
+ # - **:has_leading_tone**: Whether scale has pitch 11 (semitone below octave)
954
+ # - **:has_tritone**: Whether scale contains tritone (pitch 6)
955
+ # - **:symmetric**: Type of symmetry if any (:equal, :palindrome, :repeating)
956
+ #
957
+ # @return [Hash] intrinsic metadata derived from structure
958
+ #
959
+ # @example
960
+ # MajorScaleKind.intrinsic_metadata
961
+ # # => { id: :major, grades: 7, pitches: [0, 2, 4, 5, 7, 9, 11],
962
+ # # intervals: [2, 2, 1, 2, 2, 2], has_leading_tone: true,
963
+ # # has_tritone: true, symmetric: nil }
964
+ def self.intrinsic_metadata
965
+ result = {}
966
+ result[:id] = id if respond_to?(:id)
967
+ result[:grades] = grades if respond_to?(:grades)
968
+ if respond_to?(:pitches)
969
+ result[:pitches] = pitches.map { |p| p[:pitch] }
970
+ result[:intervals] = compute_intervals
971
+ result[:has_leading_tone] = pitches.any? { |p| p[:pitch] == 11 }
972
+ result[:has_tritone] = pitches.any? { |p| p[:pitch] == 6 }
973
+ result[:symmetric] = compute_symmetry
974
+ end
975
+ result.compact
976
+ end
977
+
978
+ # Returns base metadata defined by the musa-dsl library.
979
+ #
980
+ # This metadata is defined in each ScaleKind subclass using the
981
+ # `@base_metadata` class instance variable. It typically includes:
982
+ #
983
+ # - **:family**: Scale family (:diatonic, :greek_modes, :pentatonic, etc.)
984
+ # - **:brightness**: Relative brightness (-3 to +3, major = 0)
985
+ # - **:character**: Array of descriptive tags
986
+ # - **:parent**: Parent scale and degree for modes
987
+ #
988
+ # @return [Hash] library-defined metadata
989
+ #
990
+ # @example
991
+ # MajorScaleKind.base_metadata
992
+ # # => { family: :diatonic, brightness: 0, character: [:bright, :stable] }
993
+ def self.base_metadata
994
+ @base_metadata || {}
995
+ end
996
+
997
+ # Returns custom metadata added by users at runtime.
998
+ #
999
+ # This metadata is added via {.extend_metadata} and can be cleared
1000
+ # with {.reset_custom_metadata}. Takes precedence over base_metadata.
1001
+ #
1002
+ # @return [Hash] user-defined metadata
1003
+ #
1004
+ # @example
1005
+ # MajorScaleKind.extend_metadata(my_tag: :favorite)
1006
+ # MajorScaleKind.custom_metadata # => { my_tag: :favorite }
1007
+ def self.custom_metadata
1008
+ @custom_metadata || {}
1009
+ end
1010
+
1011
+ # Adds custom metadata to this scale kind.
1012
+ #
1013
+ # Custom metadata takes precedence over base_metadata when queried
1014
+ # via {.metadata}. Multiple calls merge metadata together.
1015
+ #
1016
+ # @param metadata [Hash] key-value pairs to add
1017
+ # @return [Hash] the updated custom_metadata hash (frozen)
1018
+ #
1019
+ # @example
1020
+ # MajorScaleKind.extend_metadata(my_mood: :happy, rating: 5)
1021
+ # MajorScaleKind.extend_metadata(suitable_for: [:pop, :classical])
1022
+ # MajorScaleKind.custom_metadata
1023
+ # # => { my_mood: :happy, rating: 5, suitable_for: [:pop, :classical] }
1024
+ def self.extend_metadata(**metadata)
1025
+ @custom_metadata ||= {}
1026
+ @custom_metadata = @custom_metadata.merge(metadata).freeze
1027
+ end
1028
+
1029
+ # Clears all custom metadata from this scale kind.
1030
+ #
1031
+ # @return [nil]
1032
+ #
1033
+ # @example
1034
+ # MajorScaleKind.extend_metadata(temp: :data)
1035
+ # MajorScaleKind.reset_custom_metadata
1036
+ # MajorScaleKind.custom_metadata # => {}
1037
+ def self.reset_custom_metadata
1038
+ @custom_metadata = nil
1039
+ end
1040
+
1041
+ # Returns combined metadata from all three layers.
1042
+ #
1043
+ # Layers are merged with later layers taking precedence:
1044
+ # intrinsic_metadata < base_metadata < custom_metadata
1045
+ #
1046
+ # @return [Hash] combined metadata from all layers
1047
+ #
1048
+ # @example
1049
+ # MajorScaleKind.metadata
1050
+ # # => { id: :major, grades: 7, pitches: [...], family: :diatonic, ... }
1051
+ def self.metadata
1052
+ intrinsic_metadata
1053
+ .merge(base_metadata)
1054
+ .merge(custom_metadata)
1055
+ end
1056
+
1057
+ # Returns a specific metadata value.
1058
+ #
1059
+ # @param key [Symbol] the metadata key
1060
+ # @return [Object, nil] the value or nil if not found
1061
+ #
1062
+ # @example
1063
+ # MajorScaleKind.metadata_value(:family) # => :diatonic
1064
+ def self.metadata_value(key)
1065
+ metadata[key]
1066
+ end
1067
+
1068
+ # Checks whether metadata contains a key or key-value match.
1069
+ #
1070
+ # When called with just a key, checks for key existence.
1071
+ # When called with key and value, checks for exact match or
1072
+ # array inclusion (if metadata value is an array).
1073
+ #
1074
+ # @param key [Symbol] the metadata key
1075
+ # @param value [Object, nil] optional value to match
1076
+ # @return [Boolean] whether the condition is satisfied
1077
+ #
1078
+ # @example Key existence
1079
+ # MajorScaleKind.has_metadata?(:family) # => true
1080
+ # MajorScaleKind.has_metadata?(:nonexistent) # => false
1081
+ #
1082
+ # @example Value matching
1083
+ # MajorScaleKind.has_metadata?(:family, :diatonic) # => true
1084
+ # MajorScaleKind.has_metadata?(:family, :pentatonic) # => false
1085
+ #
1086
+ # @example Array inclusion
1087
+ # MajorScaleKind.has_metadata?(:character, :bright) # => true
1088
+ def self.has_metadata?(key, value = nil)
1089
+ if value.nil?
1090
+ metadata.key?(key)
1091
+ else
1092
+ metadata[key] == value ||
1093
+ (metadata[key].is_a?(Array) && metadata[key].include?(value))
1094
+ end
1095
+ end
1096
+
688
1097
  private
689
1098
 
1099
+ # Computes intervals between consecutive scale degrees.
1100
+ # @return [Array<Integer>, nil] intervals or nil if not calculable
1101
+ # @api private
1102
+ def self.compute_intervals
1103
+ return nil unless respond_to?(:pitches) && pitches.size > 1
1104
+ pitch_values = pitches.map { |p| p[:pitch] }
1105
+ # Only compute within first octave
1106
+ first_octave = pitch_values.take_while { |p| p < 12 }
1107
+ first_octave.push(12) if first_octave.last != 12
1108
+ first_octave.each_cons(2).map { |a, b| b - a }
1109
+ end
1110
+
1111
+ # Computes symmetry type of the scale.
1112
+ # @return [Symbol, nil] :equal, :palindrome, :repeating, or nil
1113
+ # @api private
1114
+ def self.compute_symmetry
1115
+ return nil unless respond_to?(:pitches)
1116
+ intervals = compute_intervals
1117
+ return nil unless intervals && intervals.any?
1118
+
1119
+ # Check if intervals are all equal (e.g., whole tone: [2,2,2,2,2,2])
1120
+ return :equal if intervals.uniq.size == 1
1121
+
1122
+ # Check for palindrome pattern
1123
+ return :palindrome if intervals == intervals.reverse
1124
+
1125
+ # Check for repeating pattern
1126
+ (1..intervals.size / 2).each do |len|
1127
+ pattern = intervals.take(len)
1128
+ if intervals.each_slice(len).all? { |slice| slice == pattern || slice.size < len }
1129
+ return :repeating
1130
+ end
1131
+ end
1132
+
1133
+ nil
1134
+ end
1135
+
1136
+ public
1137
+
690
1138
  # Creates internal index mapping function names to grade indices.
691
1139
  #
692
1140
  # @return [self]
@@ -738,9 +1186,9 @@ module Musa
738
1186
  # scale[:V] # Fifth degree
739
1187
  # scale[:IV] # Fourth degree
740
1188
  #
741
- # **With accidentals** (sharp # or flat _):
1189
+ # **With accidentals** (sharp # or flat _). Use strings for #:
742
1190
  #
743
- # scale[:I#] # Raised tonic
1191
+ # scale['I#'] # Raised tonic
744
1192
  # scale[:V_] # Flatted dominant
745
1193
  # scale['II##'] # Double-raised second
746
1194
  #
@@ -767,8 +1215,8 @@ module Musa
767
1215
  # c_major.dominant.pitch # => 67 (G)
768
1216
  # c_major[:III].pitch # => 64 (E)
769
1217
  #
770
- # @example Chromatic alterations
771
- # c_major[:I#].pitch # => 61 (C#)
1218
+ # @example Chromatic alterations (use strings for #)
1219
+ # c_major['I#'].pitch # => 61 (C#)
772
1220
  # c_major[:V_].pitch # => 66 (F#/Gb)
773
1221
  #
774
1222
  # @example Building chords
@@ -803,7 +1251,16 @@ module Musa
803
1251
  freeze
804
1252
  end
805
1253
 
806
- # Delegates tuning access to kind.
1254
+ # @!method tuning
1255
+ # Returns the tuning system associated with this scale.
1256
+ #
1257
+ # Delegated from ScaleKind#tuning.
1258
+ #
1259
+ # @return [ScaleSystemTuning] the tuning system used by this scale
1260
+ #
1261
+ # @example
1262
+ # scale = Scales.et12[440.0].major[60]
1263
+ # scale.tuning # => ScaleSystemTuning for 12-TET at A=440Hz
807
1264
  def_delegators :@kind, :tuning
808
1265
 
809
1266
  # Scale kind (major, minor, etc.).
@@ -888,11 +1345,11 @@ module Musa
888
1345
  # scale[:V] # Dominant
889
1346
  # scale[:IV] # Subdominant
890
1347
  #
891
- # @example With accidentals
892
- # scale[:I#] # Raised tonic
1348
+ # @example With accidentals (use strings for #)
1349
+ # scale['I#'] # Raised tonic
893
1350
  # scale[:V_] # Flatted dominant
894
1351
  # scale['II##'] # Double-raised second
895
- def [](grade_or_symbol)
1352
+ def get(grade_or_symbol)
896
1353
 
897
1354
  raise ArgumentError, "grade_or_symbol '#{grade_or_symbol}' should be a Integer, String or Symbol" unless grade_or_symbol.is_a?(Symbol) || grade_or_symbol.is_a?(String) || grade_or_symbol.is_a?(Integer)
898
1355
 
@@ -916,6 +1373,8 @@ module Musa
916
1373
  @notes_by_grade[wide_grade].sharp(sharps)
917
1374
  end
918
1375
 
1376
+ alias_method :[], :get
1377
+
919
1378
  # Converts grade specifier to numeric grade and accidentals.
920
1379
  #
921
1380
  # @param grade_or_string_or_symbol [Integer, Symbol, String] grade specifier
@@ -1038,6 +1497,87 @@ module Musa
1038
1497
  @kind.tuning.offset_of_interval(interval_name)
1039
1498
  end
1040
1499
 
1500
+ # Checks if all chord pitches exist in this scale.
1501
+ #
1502
+ # Uses the chord's definition to verify that every pitch in the chord
1503
+ # can be found as a diatonic note in this scale.
1504
+ #
1505
+ # @param chord [Musa::Chords::Chord] the chord to check
1506
+ # @return [Boolean] true if all chord notes are in scale
1507
+ #
1508
+ # @example
1509
+ # c_major = Scales.et12[440.0].major[60]
1510
+ # g7 = c_major.dominant.chord :seventh
1511
+ # c_major.contains_chord?(g7) # => true
1512
+ #
1513
+ # cm = c_major.tonic.chord.with_quality(:minor)
1514
+ # c_major.contains_chord?(cm) # => false (Eb not in C major)
1515
+ #
1516
+ # @see #degree_of_chord
1517
+ # @see #chord_on
1518
+ def contains_chord?(chord)
1519
+ chord.chord_definition.in_scale?(self, chord_root_pitch: chord.root.pitch)
1520
+ end
1521
+
1522
+ # Returns the grade (0-based) where the chord root falls in this scale.
1523
+ #
1524
+ # @param chord [Musa::Chords::Chord] the chord to check
1525
+ # @return [Integer, nil] grade (0-based) or nil if chord not in scale
1526
+ #
1527
+ # @example
1528
+ # c_major = Scales.et12[440.0].major[60]
1529
+ # g_chord = c_major.dominant.chord
1530
+ # c_major.degree_of_chord(g_chord) # => 4 (V degree, 0-based)
1531
+ #
1532
+ # @see #contains_chord?
1533
+ def degree_of_chord(chord)
1534
+ return nil unless contains_chord?(chord)
1535
+
1536
+ note = note_of_pitch(chord.root.pitch, allow_chromatic: false)
1537
+ note&.grade
1538
+ end
1539
+
1540
+ # Creates a chord rooted on the specified scale degree.
1541
+ #
1542
+ # This is a convenience method that combines scale note access with
1543
+ # chord creation. It's equivalent to `scale[grade].chord(...)`.
1544
+ #
1545
+ # @param grade [Integer, Symbol, String] scale degree (0-based numeric, function name like :tonic, or Roman numeral like :V)
1546
+ # @param feature_values [Array<Symbol>] chord feature values (:seventh, :major, etc.)
1547
+ # @param allow_chromatic [Boolean] allow non-diatonic chord notes
1548
+ # @param move [Hash{Symbol => Integer}] initial octave moves for chord tones
1549
+ # @param duplicate [Hash{Symbol => Integer, Array}] initial duplications
1550
+ # @param features_hash [Hash] additional feature key-value pairs
1551
+ # @return [Chords::Chord] chord rooted on the specified degree
1552
+ #
1553
+ # @example Create triads
1554
+ # scale.chord_on(0) # Tonic triad (I)
1555
+ # scale.chord_on(:dominant) # Dominant triad (V)
1556
+ # scale.chord_on(:IV) # Subdominant triad
1557
+ #
1558
+ # @example Create extended chords
1559
+ # scale.chord_on(4, :seventh) # V7
1560
+ # scale.chord_on(:dominant, :ninth) # V9
1561
+ # scale.chord_on(0, :seventh, :major) # Imaj7
1562
+ #
1563
+ # @example With voicing
1564
+ # scale.chord_on(:I, :seventh, move: {root: -1})
1565
+ # scale.chord_on(0, :triad, duplicate: {root: 1})
1566
+ #
1567
+ # @see NoteInScale#chord
1568
+ # @see #get
1569
+ def chord_on(grade, *feature_values,
1570
+ allow_chromatic: nil,
1571
+ move: nil,
1572
+ duplicate: nil,
1573
+ **features_hash)
1574
+ self[grade].chord(*feature_values,
1575
+ allow_chromatic: allow_chromatic,
1576
+ move: move,
1577
+ duplicate: duplicate,
1578
+ **features_hash)
1579
+ end
1580
+
1041
1581
  # Checks scale equality.
1042
1582
  #
1043
1583
  # Scales are equal if they have same kind and root pitch.
@@ -1109,9 +1649,10 @@ module Musa
1109
1649
  #
1110
1650
  # ## Scale Navigation
1111
1651
  #
1112
- # note.scale(:minor) # Same pitch in minor scale
1113
- # note.major # Same pitch in major scale
1114
- # note.chromatic # Same pitch in chromatic scale
1652
+ # note.scale # Parent scale this note belongs to
1653
+ # note.as_root_of(:minor) # New minor scale with this pitch as root
1654
+ # note.minor # Same as note.as_root_of(:minor)
1655
+ # note.chromatic # Same as note.as_root_of(:chromatic)
1115
1656
  #
1116
1657
  # ## Chord Construction
1117
1658
  #
@@ -1175,7 +1716,7 @@ module Musa
1175
1716
 
1176
1717
  @scale.kind.tuning.scale_system.scale_kind_classes.each_key do |name|
1177
1718
  define_singleton_method name do
1178
- scale(name)
1719
+ as_root_of(name)
1179
1720
  end
1180
1721
  end
1181
1722
  end
@@ -1198,34 +1739,27 @@ module Musa
1198
1739
  @scale.kind.class.pitches[grade][:functions]
1199
1740
  end
1200
1741
 
1201
- # Transposes note or returns current octave.
1202
- #
1203
- # **Without argument**: Returns current octave relative to scale root.
1204
- #
1205
- # **With argument**: Returns note transposed by octave offset.
1742
+ # Current octave relative to scale root.
1743
+ # @return [Integer]
1744
+ attr_reader :octave
1745
+
1746
+ # Returns note transposed by octave offset.
1206
1747
  #
1207
- # @param octave [Integer, nil] octave offset (nil to query current)
1748
+ # @param offset [Integer] octave offset (positive = up, negative = down)
1208
1749
  # @param absolute [Boolean] if true, ignore current octave
1209
- # @return [Integer, NoteInScale] current octave or transposed note
1210
- # @raise [ArgumentError] if octave is not integer
1211
- #
1212
- # @example Query octave
1213
- # note.octave # => 0 (at scale root octave)
1750
+ # @return [NoteInScale] transposed note
1751
+ # @raise [ArgumentError] if offset is not integer
1214
1752
  #
1215
1753
  # @example Transpose relative
1216
- # note.octave(1).pitch # Up one octave from current
1217
- # note.octave(-1).pitch # Down one octave from current
1754
+ # note.at_octave(1).pitch # Up one octave from current
1755
+ # note.at_octave(-1).pitch # Down one octave from current
1218
1756
  #
1219
1757
  # @example Transpose absolute
1220
- # note.octave(2, absolute: true).pitch # At octave 2, regardless of current
1221
- def octave(octave = nil, absolute: false)
1222
- if octave.nil?
1223
- @octave
1224
- else
1225
- raise ArgumentError, "#{octave} is not integer" unless octave == octave.to_i
1758
+ # note.at_octave(2, absolute: true).pitch # At octave 2, regardless of current
1759
+ def at_octave(offset, absolute: false)
1760
+ raise ArgumentError, "#{offset} is not integer" unless offset == offset.to_i
1226
1761
 
1227
- @scale[@grade + ((absolute ? 0 : @octave) + octave) * @scale.kind.class.grades]
1228
- end
1762
+ @scale[@grade + ((absolute ? 0 : @octave) + offset) * @scale.kind.class.grades]
1229
1763
  end
1230
1764
 
1231
1765
  # Creates a copy with background scale context.
@@ -1320,6 +1854,13 @@ module Musa
1320
1854
  end
1321
1855
  end
1322
1856
 
1857
+ # Calculates note at given pitch offset from current note.
1858
+ #
1859
+ # @param in_scale_pitch [Numeric] base pitch
1860
+ # @param sharps [Integer] number of semitones to add (negative for flats)
1861
+ # @return [NoteInScale] resulting note
1862
+ #
1863
+ # @api private
1323
1864
  def calculate_note_of_pitch(in_scale_pitch, sharps)
1324
1865
  pitch = in_scale_pitch + sharps * @scale.kind.tuning.scale_system.part_of_tone_size
1325
1866
 
@@ -1391,33 +1932,31 @@ module Musa
1391
1932
  @scale.kind.tuning.frequency_of_pitch(@pitch, @scale.root_pitch)
1392
1933
  end
1393
1934
 
1394
- # Changes scale while keeping pitch, or returns current scale.
1395
- #
1396
- # **Without argument**: Returns current scale.
1397
- #
1398
- # **With argument**: Returns note at same pitch in different scale kind.
1935
+ # Parent scale this note belongs to.
1936
+ # @return [Scale]
1937
+ attr_reader :scale
1938
+
1939
+ # Creates a new scale with this note's pitch as the root.
1399
1940
  #
1400
- # @param kind_id_or_kind [Symbol, ScaleKind, nil] scale kind or ID
1401
- # @return [Scale, NoteInScale] current scale or note in new scale
1941
+ # @param kind_id_or_kind [Symbol, ScaleKind] scale kind or ID
1942
+ # @return [Scale] new scale rooted at this pitch
1402
1943
  #
1403
- # @example Query current scale
1404
- # note.scale # => <Scale: kind = MajorScaleKind ...>
1944
+ # @example Create minor scale from a note
1945
+ # e = c_major[64] # E in C major
1946
+ # e_minor = e.as_root_of(:minor) # E minor scale
1405
1947
  #
1406
- # @example Change to minor
1407
- # note.scale(:minor) # Same pitch in minor scale
1948
+ # @example With ScaleKind object
1949
+ # minor_kind = tuning[:minor]
1950
+ # e_minor = e.as_root_of(minor_kind)
1408
1951
  #
1409
- # @example Dynamic method
1410
- # note.minor # Same as note.scale(:minor)
1411
- # note.major # Same as note.scale(:major)
1412
- def scale(kind_id_or_kind = nil)
1413
- if kind_id_or_kind.nil?
1414
- @scale
1952
+ # @example Dynamic method (equivalent)
1953
+ # note.minor # Same as note.as_root_of(:minor)
1954
+ # note.major # Same as note.as_root_of(:major)
1955
+ def as_root_of(kind_id_or_kind)
1956
+ if kind_id_or_kind.is_a? ScaleKind
1957
+ kind_id_or_kind[@pitch]
1415
1958
  else
1416
- if kind_id_or_kind.is_a? ScaleKind
1417
- kind_id_or_kind[@pitch]
1418
- else
1419
- @scale.kind.tuning[kind_id_or_kind][@pitch]
1420
- end
1959
+ @scale.kind.tuning[kind_id_or_kind][@pitch]
1421
1960
  end
1422
1961
  end
1423
1962
 
@@ -1450,7 +1989,7 @@ module Musa
1450
1989
  # @param move [Hash{Symbol => Integer}] initial octave moves
1451
1990
  # @param duplicate [Hash{Symbol => Integer, Array<Integer>}] initial duplications
1452
1991
  # @param features_hash [Hash] feature key-value pairs
1453
- # @return [Chord] chord rooted on this note
1992
+ # @return [Chords::Chord] chord rooted on this note
1454
1993
  #
1455
1994
  # @example Default triad
1456
1995
  # note.chord # Major triad