musa-dsl 0.40.0 → 0.41.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/Gemfile +0 -1
  4. data/docs/subsystems/music.md +326 -15
  5. data/lib/musa-dsl/generative/darwin.rb +36 -1
  6. data/lib/musa-dsl/generative/generative-grammar.rb +28 -0
  7. data/lib/musa-dsl/generative/markov.rb +2 -0
  8. data/lib/musa-dsl/generative/rules.rb +54 -0
  9. data/lib/musa-dsl/generative/variatio.rb +69 -0
  10. data/lib/musa-dsl/midi/midi-recorder.rb +4 -0
  11. data/lib/musa-dsl/midi/midi-voices.rb +10 -0
  12. data/lib/musa-dsl/music/chords.rb +54 -9
  13. data/lib/musa-dsl/music/equally-tempered-12-tone-scale-system.rb +70 -521
  14. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_dominant_scale_kind.rb +110 -0
  15. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_major_scale_kind.rb +110 -0
  16. data/lib/musa-dsl/music/scale_kinds/bebop/bebop_minor_scale_kind.rb +110 -0
  17. data/lib/musa-dsl/music/scale_kinds/blues/blues_major_scale_kind.rb +100 -0
  18. data/lib/musa-dsl/music/scale_kinds/blues/blues_scale_kind.rb +99 -0
  19. data/lib/musa-dsl/music/scale_kinds/chromatic_scale_kind.rb +79 -0
  20. data/lib/musa-dsl/music/scale_kinds/ethnic/double_harmonic_scale_kind.rb +102 -0
  21. data/lib/musa-dsl/music/scale_kinds/ethnic/hungarian_minor_scale_kind.rb +102 -0
  22. data/lib/musa-dsl/music/scale_kinds/ethnic/neapolitan_major_scale_kind.rb +102 -0
  23. data/lib/musa-dsl/music/scale_kinds/ethnic/neapolitan_minor_scale_kind.rb +101 -0
  24. data/lib/musa-dsl/music/scale_kinds/ethnic/phrygian_dominant_scale_kind.rb +103 -0
  25. data/lib/musa-dsl/music/scale_kinds/harmonic_major/harmonic_major_scale_kind.rb +104 -0
  26. data/lib/musa-dsl/music/scale_kinds/major_scale_kind.rb +110 -0
  27. data/lib/musa-dsl/music/scale_kinds/melodic_minor/altered_scale_kind.rb +106 -0
  28. data/lib/musa-dsl/music/scale_kinds/melodic_minor/dorian_b2_scale_kind.rb +104 -0
  29. data/lib/musa-dsl/music/scale_kinds/melodic_minor/locrian_sharp2_scale_kind.rb +103 -0
  30. data/lib/musa-dsl/music/scale_kinds/melodic_minor/lydian_augmented_scale_kind.rb +103 -0
  31. data/lib/musa-dsl/music/scale_kinds/melodic_minor/lydian_dominant_scale_kind.rb +106 -0
  32. data/lib/musa-dsl/music/scale_kinds/melodic_minor/melodic_minor_scale_kind.rb +104 -0
  33. data/lib/musa-dsl/music/scale_kinds/melodic_minor/mixolydian_b6_scale_kind.rb +103 -0
  34. data/lib/musa-dsl/music/scale_kinds/minor_harmonic_scale_kind.rb +125 -0
  35. data/lib/musa-dsl/music/scale_kinds/minor_natural_scale_kind.rb +123 -0
  36. data/lib/musa-dsl/music/scale_kinds/modes/dorian_scale_kind.rb +111 -0
  37. data/lib/musa-dsl/music/scale_kinds/modes/locrian_scale_kind.rb +114 -0
  38. data/lib/musa-dsl/music/scale_kinds/modes/lydian_scale_kind.rb +111 -0
  39. data/lib/musa-dsl/music/scale_kinds/modes/mixolydian_scale_kind.rb +111 -0
  40. data/lib/musa-dsl/music/scale_kinds/modes/phrygian_scale_kind.rb +111 -0
  41. data/lib/musa-dsl/music/scale_kinds/pentatonic/pentatonic_major_scale_kind.rb +93 -0
  42. data/lib/musa-dsl/music/scale_kinds/pentatonic/pentatonic_minor_scale_kind.rb +99 -0
  43. data/lib/musa-dsl/music/scale_kinds/symmetric/diminished_hw_scale_kind.rb +110 -0
  44. data/lib/musa-dsl/music/scale_kinds/symmetric/diminished_wh_scale_kind.rb +110 -0
  45. data/lib/musa-dsl/music/scale_kinds/symmetric/whole_tone_scale_kind.rb +99 -0
  46. data/lib/musa-dsl/music/scale_systems/equally_tempered_12_tone_scale_system.rb +80 -0
  47. data/lib/musa-dsl/music/scale_systems/twelve_semitones_scale_system.rb +60 -0
  48. data/lib/musa-dsl/music/scales.rb +427 -0
  49. data/lib/musa-dsl/series/buffer-serie.rb +6 -0
  50. data/lib/musa-dsl/series/hash-or-array-serie-splitter.rb +23 -0
  51. data/lib/musa-dsl/series/quantizer-serie.rb +12 -0
  52. data/lib/musa-dsl/series/queue-serie.rb +13 -0
  53. data/lib/musa-dsl/version.rb +2 -1
  54. data/musa-dsl.gemspec +20 -15
  55. metadata +85 -22
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Musa
4
+ module Scales
5
+ # Base class for 12-semitone scale systems.
6
+ #
7
+ # TwelveSemitonesScaleSystem provides the foundation for any scale system
8
+ # using 12 semitones per octave. It defines intervals and structure but
9
+ # doesn't specify tuning (frequency calculation).
10
+ #
11
+ # Concrete subclasses must implement frequency calculation:
12
+ #
13
+ # - {EquallyTempered12ToneScaleSystem}: Equal temperament (12-TET)
14
+ # - Other temperaments could be added (e.g., meantone, just intonation)
15
+ #
16
+ # ## Intervals
17
+ #
18
+ # Defines standard interval names using semitone distances:
19
+ #
20
+ # { P0: 0, m2: 1, M2: 2, m3: 3, M3: 4, P4: 5, TT: 6,
21
+ # P5: 7, m6: 8, M6: 9, m7: 10, M7: 11, P8: 12 }
22
+ #
23
+ # @abstract Subclasses must implement {frequency_of_pitch}
24
+ # @see EquallyTempered12ToneScaleSystem Concrete equal temperament implementation
25
+ class TwelveSemitonesScaleSystem < ScaleSystem
26
+ class << self
27
+ @@intervals = { P0: 0, m2: 1, M2: 2, m3: 3, M3: 4, P4: 5, TT: 6, P5: 7, m6: 8, M6: 9, m7: 10, M7: 11, P8: 12 }
28
+
29
+ # System identifier.
30
+ # @return [Symbol] :et12
31
+ def id
32
+ :et12
33
+ end
34
+
35
+ # Number of distinct notes per octave.
36
+ # @return [Integer] 12
37
+ def notes_in_octave
38
+ 12
39
+ end
40
+
41
+ # Size of smallest pitch division.
42
+ # @return [Integer] 1 (semitone)
43
+ def part_of_tone_size
44
+ 1
45
+ end
46
+
47
+ # Interval definitions.
48
+ #
49
+ # @return [Hash{Symbol => Integer}] interval name to semitones mapping
50
+ #
51
+ # @example
52
+ # intervals[:P5] # => 7 (perfect fifth)
53
+ # intervals[:M3] # => 4 (major third)
54
+ def intervals
55
+ @@intervals
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -146,6 +146,31 @@ module Musa
146
146
  def self.default_system
147
147
  @default_scale_system
148
148
  end
149
+
150
+ # Convenience method to extend metadata for a scale kind by ID.
151
+ #
152
+ # Finds the ScaleKind class by its ID symbol and adds custom metadata to it.
153
+ # This is a shortcut for accessing the class directly and calling extend_metadata.
154
+ #
155
+ # @param scale_kind_id [Symbol] the scale kind identifier (e.g., :major, :dorian)
156
+ # @param metadata [Hash] key-value pairs to add as custom metadata
157
+ # @return [Hash] the updated custom_metadata hash
158
+ # @raise [KeyError] if scale kind not found
159
+ #
160
+ # @example
161
+ # Scales.extend_metadata(:major, my_tag: :favorite)
162
+ # Scales.extend_metadata(:dorian, mood: :nostalgic, suitable_for: [:jazz])
163
+ #
164
+ # @see ScaleKind.extend_metadata
165
+ def self.extend_metadata(scale_kind_id, **metadata)
166
+ system = default_system
167
+ raise KeyError, "No default scale system registered" unless system
168
+
169
+ klass = system.scale_kind_class(scale_kind_id)
170
+ raise KeyError, "Scale kind :#{scale_kind_id} not found" unless klass
171
+
172
+ klass.extend_metadata(**metadata)
173
+ end
149
174
  end
150
175
 
151
176
  # Abstract base class for musical scale systems.
@@ -459,6 +484,113 @@ module Musa
459
484
  @scale_system.frequency_of_pitch(pitch, root, @a_frequency)
460
485
  end
461
486
 
487
+ # Returns scale kinds matching the given metadata criteria.
488
+ #
489
+ # Without arguments, returns all registered scale kinds.
490
+ # With keyword arguments, filters by metadata values.
491
+ # With a block, filters using custom predicate on ScaleKind class.
492
+ #
493
+ # @param metadata_criteria [Hash] metadata key-value pairs to match
494
+ # @yield [kind_class] optional block for custom filtering
495
+ # @yieldparam kind_class [Class] the ScaleKind subclass
496
+ # @yieldreturn [Boolean] true to include this scale kind
497
+ # @return [Array<ScaleKind>] matching scale kind instances
498
+ #
499
+ # @example All scale kinds
500
+ # tuning.scale_kinds
501
+ # # => [major_kind, minor_kind, dorian_kind, ...]
502
+ #
503
+ # @example Filter by metadata
504
+ # tuning.scale_kinds(family: :diatonic)
505
+ # tuning.scale_kinds(brightness: -1..1)
506
+ # tuning.scale_kinds(character: :jazz)
507
+ #
508
+ # @example Filter with block
509
+ # tuning.scale_kinds { |klass| klass.intrinsic_metadata[:has_leading_tone] }
510
+ #
511
+ # @example Combined
512
+ # tuning.scale_kinds(family: :greek_modes) { |klass| klass.metadata[:brightness] < 0 }
513
+ #
514
+ # @see ScaleKind.metadata
515
+ # @see ScaleKind.has_metadata?
516
+ def scale_kinds(**metadata_criteria, &block)
517
+ result = @scale_system.scale_kind_classes.keys.map { |id| self[id] }
518
+
519
+ unless metadata_criteria.empty?
520
+ result = result.select do |kind|
521
+ matches_metadata?(kind.class, metadata_criteria)
522
+ end
523
+ end
524
+
525
+ if block
526
+ result = result.select { |kind| block.call(kind.class) }
527
+ end
528
+
529
+ result
530
+ end
531
+
532
+ # Searches for a chord across multiple scale types.
533
+ #
534
+ # Iterates through the specified scale kinds and pitch roots to find
535
+ # all scales that contain the given chord. Returns chords with their
536
+ # containing scale as context.
537
+ #
538
+ # @param chord [Musa::Chords::Chord] the chord to search for
539
+ # @param roots [Range, Array, nil] pitch offsets to search (default: 0...notes_in_octave)
540
+ # @param metadata_criteria [Hash] metadata filters for scale kinds
541
+ # @return [Array<Musa::Chords::Chord>] chords with their containing scales
542
+ #
543
+ # @example Search G7 in greek mode scales
544
+ # tuning = Scales.et12[440.0]
545
+ # g7 = tuning.major[60].dominant.chord :seventh
546
+ # tuning.chords_of(g7, family: :greek_modes)
547
+ #
548
+ # @example Search with brightness filter
549
+ # tuning.chords_of(g7, brightness: -1..1)
550
+ #
551
+ # @example Search in all scale types
552
+ # tuning.chords_of(g7)
553
+ #
554
+ # @see ScaleKind#scales_containing
555
+ # @see Musa::Chords::Chord#in_scales
556
+ def chords_of(chord, roots: nil, **metadata_criteria)
557
+ roots ||= 0...notes_in_octave
558
+ kinds = filtered_scale_kind_ids(**metadata_criteria)
559
+
560
+ kinds.flat_map do |kind_id|
561
+ self[kind_id].scales_containing(chord, roots: roots)
562
+ end
563
+ end
564
+
565
+ private
566
+
567
+ def filtered_scale_kind_ids(**metadata_criteria)
568
+ kinds = @scale_system.scale_kind_classes.keys
569
+
570
+ return kinds if metadata_criteria.empty?
571
+
572
+ kinds.select do |kind_id|
573
+ kind_class = @scale_system.scale_kind_class(kind_id)
574
+ matches_metadata?(kind_class, metadata_criteria)
575
+ end
576
+ end
577
+
578
+ def matches_metadata?(kind_class, criteria)
579
+ criteria.all? do |key, value|
580
+ actual = kind_class.metadata[key]
581
+ case value
582
+ when Range
583
+ actual.is_a?(Numeric) && value.include?(actual)
584
+ when Array
585
+ value.any? { |v| actual == v || (actual.is_a?(Array) && actual.include?(v)) }
586
+ else
587
+ actual == value || (actual.is_a?(Array) && actual.include?(value))
588
+ end
589
+ end
590
+ end
591
+
592
+ public
593
+
462
594
  def ==(other)
463
595
  self.class == other.class &&
464
596
  @scale_system == other.scale_system &&
@@ -577,6 +709,35 @@ module Musa
577
709
  self[0]
578
710
  end
579
711
 
712
+ # Finds all scales of this kind that contain the given chord.
713
+ #
714
+ # Searches through scales rooted on different pitches to find which ones
715
+ # contain all the notes of the given chord. Returns chords with their
716
+ # containing scale as context.
717
+ #
718
+ # @param chord [Musa::Chords::Chord] the chord to search for
719
+ # @param roots [Range, Array, nil] pitch offsets to search (default: 0...notes_in_octave)
720
+ # @return [Array<Musa::Chords::Chord>] chords with their containing scales
721
+ #
722
+ # @example Find G major triad in all major scales
723
+ # tuning = Scales.et12[440.0]
724
+ # g_triad = tuning.major[60].dominant.chord
725
+ # tuning.major.scales_containing(g_triad)
726
+ # # => [Chord in C major (V), Chord in G major (I), Chord in D major (IV)]
727
+ #
728
+ # @see Scale#chord_on
729
+ # @see ScaleSystemTuning#chords_in_scales
730
+ def scales_containing(chord, roots: nil)
731
+ roots ||= 0...tuning.notes_in_octave
732
+ base_pitch = chord.root.pitch % tuning.notes_in_octave
733
+
734
+ roots.filter_map do |root_offset|
735
+ root_pitch = base_pitch + root_offset
736
+ scale = self[root_pitch]
737
+ scale.chord_on(chord)
738
+ end
739
+ end
740
+
580
741
  # Checks scale kind equality.
581
742
  #
582
743
  # @param other [ScaleKind]
@@ -685,8 +846,200 @@ module Musa
685
846
  @grade_names_index.keys
686
847
  end
687
848
 
849
+ # Returns intrinsic metadata derived from scale structure.
850
+ #
851
+ # This metadata is automatically calculated from the scale's pitch
852
+ # structure and cannot be modified. It includes:
853
+ #
854
+ # - **:id**: Scale kind identifier
855
+ # - **:grades**: Number of diatonic degrees
856
+ # - **:pitches**: Array of pitch offsets from root
857
+ # - **:intervals**: Intervals between consecutive degrees
858
+ # - **:has_leading_tone**: Whether scale has pitch 11 (semitone below octave)
859
+ # - **:has_tritone**: Whether scale contains tritone (pitch 6)
860
+ # - **:symmetric**: Type of symmetry if any (:equal, :palindrome, :repeating)
861
+ #
862
+ # @return [Hash] intrinsic metadata derived from structure
863
+ #
864
+ # @example
865
+ # MajorScaleKind.intrinsic_metadata
866
+ # # => { id: :major, grades: 7, pitches: [0, 2, 4, 5, 7, 9, 11],
867
+ # # intervals: [2, 2, 1, 2, 2, 2], has_leading_tone: true,
868
+ # # has_tritone: true, symmetric: nil }
869
+ def self.intrinsic_metadata
870
+ result = {}
871
+ result[:id] = id if respond_to?(:id)
872
+ result[:grades] = grades if respond_to?(:grades)
873
+ if respond_to?(:pitches)
874
+ result[:pitches] = pitches.map { |p| p[:pitch] }
875
+ result[:intervals] = compute_intervals
876
+ result[:has_leading_tone] = pitches.any? { |p| p[:pitch] == 11 }
877
+ result[:has_tritone] = pitches.any? { |p| p[:pitch] == 6 }
878
+ result[:symmetric] = compute_symmetry
879
+ end
880
+ result.compact
881
+ end
882
+
883
+ # Returns base metadata defined by the musa-dsl library.
884
+ #
885
+ # This metadata is defined in each ScaleKind subclass using the
886
+ # `@base_metadata` class instance variable. It typically includes:
887
+ #
888
+ # - **:family**: Scale family (:diatonic, :greek_modes, :pentatonic, etc.)
889
+ # - **:brightness**: Relative brightness (-3 to +3, major = 0)
890
+ # - **:character**: Array of descriptive tags
891
+ # - **:parent**: Parent scale and degree for modes
892
+ #
893
+ # @return [Hash] library-defined metadata
894
+ #
895
+ # @example
896
+ # MajorScaleKind.base_metadata
897
+ # # => { family: :diatonic, brightness: 0, character: [:bright, :stable] }
898
+ def self.base_metadata
899
+ @base_metadata || {}
900
+ end
901
+
902
+ # Returns custom metadata added by users at runtime.
903
+ #
904
+ # This metadata is added via {.extend_metadata} and can be cleared
905
+ # with {.reset_custom_metadata}. Takes precedence over base_metadata.
906
+ #
907
+ # @return [Hash] user-defined metadata
908
+ #
909
+ # @example
910
+ # MajorScaleKind.extend_metadata(my_tag: :favorite)
911
+ # MajorScaleKind.custom_metadata # => { my_tag: :favorite }
912
+ def self.custom_metadata
913
+ @custom_metadata || {}
914
+ end
915
+
916
+ # Adds custom metadata to this scale kind.
917
+ #
918
+ # Custom metadata takes precedence over base_metadata when queried
919
+ # via {.metadata}. Multiple calls merge metadata together.
920
+ #
921
+ # @param metadata [Hash] key-value pairs to add
922
+ # @return [Hash] the updated custom_metadata hash (frozen)
923
+ #
924
+ # @example
925
+ # MajorScaleKind.extend_metadata(my_mood: :happy, rating: 5)
926
+ # MajorScaleKind.extend_metadata(suitable_for: [:pop, :classical])
927
+ # MajorScaleKind.custom_metadata
928
+ # # => { my_mood: :happy, rating: 5, suitable_for: [:pop, :classical] }
929
+ def self.extend_metadata(**metadata)
930
+ @custom_metadata ||= {}
931
+ @custom_metadata = @custom_metadata.merge(metadata).freeze
932
+ end
933
+
934
+ # Clears all custom metadata from this scale kind.
935
+ #
936
+ # @return [nil]
937
+ #
938
+ # @example
939
+ # MajorScaleKind.extend_metadata(temp: :data)
940
+ # MajorScaleKind.reset_custom_metadata
941
+ # MajorScaleKind.custom_metadata # => {}
942
+ def self.reset_custom_metadata
943
+ @custom_metadata = nil
944
+ end
945
+
946
+ # Returns combined metadata from all three layers.
947
+ #
948
+ # Layers are merged with later layers taking precedence:
949
+ # intrinsic_metadata < base_metadata < custom_metadata
950
+ #
951
+ # @return [Hash] combined metadata from all layers
952
+ #
953
+ # @example
954
+ # MajorScaleKind.metadata
955
+ # # => { id: :major, grades: 7, pitches: [...], family: :diatonic, ... }
956
+ def self.metadata
957
+ intrinsic_metadata
958
+ .merge(base_metadata)
959
+ .merge(custom_metadata)
960
+ end
961
+
962
+ # Returns a specific metadata value.
963
+ #
964
+ # @param key [Symbol] the metadata key
965
+ # @return [Object, nil] the value or nil if not found
966
+ #
967
+ # @example
968
+ # MajorScaleKind.metadata_value(:family) # => :diatonic
969
+ def self.metadata_value(key)
970
+ metadata[key]
971
+ end
972
+
973
+ # Checks whether metadata contains a key or key-value match.
974
+ #
975
+ # When called with just a key, checks for key existence.
976
+ # When called with key and value, checks for exact match or
977
+ # array inclusion (if metadata value is an array).
978
+ #
979
+ # @param key [Symbol] the metadata key
980
+ # @param value [Object, nil] optional value to match
981
+ # @return [Boolean] whether the condition is satisfied
982
+ #
983
+ # @example Key existence
984
+ # MajorScaleKind.has_metadata?(:family) # => true
985
+ # MajorScaleKind.has_metadata?(:nonexistent) # => false
986
+ #
987
+ # @example Value matching
988
+ # MajorScaleKind.has_metadata?(:family, :diatonic) # => true
989
+ # MajorScaleKind.has_metadata?(:family, :pentatonic) # => false
990
+ #
991
+ # @example Array inclusion
992
+ # MajorScaleKind.has_metadata?(:character, :bright) # => true
993
+ def self.has_metadata?(key, value = nil)
994
+ if value.nil?
995
+ metadata.key?(key)
996
+ else
997
+ metadata[key] == value ||
998
+ (metadata[key].is_a?(Array) && metadata[key].include?(value))
999
+ end
1000
+ end
1001
+
688
1002
  private
689
1003
 
1004
+ # Computes intervals between consecutive scale degrees.
1005
+ # @return [Array<Integer>, nil] intervals or nil if not calculable
1006
+ # @api private
1007
+ def self.compute_intervals
1008
+ return nil unless respond_to?(:pitches) && pitches.size > 1
1009
+ pitch_values = pitches.map { |p| p[:pitch] }
1010
+ # Only compute within first octave
1011
+ first_octave = pitch_values.take_while { |p| p < 12 }
1012
+ first_octave.push(12) if first_octave.last != 12
1013
+ first_octave.each_cons(2).map { |a, b| b - a }
1014
+ end
1015
+
1016
+ # Computes symmetry type of the scale.
1017
+ # @return [Symbol, nil] :equal, :palindrome, :repeating, or nil
1018
+ # @api private
1019
+ def self.compute_symmetry
1020
+ return nil unless respond_to?(:pitches)
1021
+ intervals = compute_intervals
1022
+ return nil unless intervals && intervals.any?
1023
+
1024
+ # Check if intervals are all equal (e.g., whole tone: [2,2,2,2,2,2])
1025
+ return :equal if intervals.uniq.size == 1
1026
+
1027
+ # Check for palindrome pattern
1028
+ return :palindrome if intervals == intervals.reverse
1029
+
1030
+ # Check for repeating pattern
1031
+ (1..intervals.size / 2).each do |len|
1032
+ pattern = intervals.take(len)
1033
+ if intervals.each_slice(len).all? { |slice| slice == pattern || slice.size < len }
1034
+ return :repeating
1035
+ end
1036
+ end
1037
+
1038
+ nil
1039
+ end
1040
+
1041
+ public
1042
+
690
1043
  # Creates internal index mapping function names to grade indices.
691
1044
  #
692
1045
  # @return [self]
@@ -1038,6 +1391,80 @@ module Musa
1038
1391
  @kind.tuning.offset_of_interval(interval_name)
1039
1392
  end
1040
1393
 
1394
+ # Checks if all chord pitches exist in this scale.
1395
+ #
1396
+ # Uses the chord's definition to verify that every pitch in the chord
1397
+ # can be found as a diatonic note in this scale.
1398
+ #
1399
+ # @param chord [Musa::Chords::Chord] the chord to check
1400
+ # @return [Boolean] true if all chord notes are in scale
1401
+ #
1402
+ # @example
1403
+ # c_major = Scales.et12[440.0].major[60]
1404
+ # g7 = c_major.dominant.chord :seventh
1405
+ # c_major.contains_chord?(g7) # => true
1406
+ #
1407
+ # cm = c_major.tonic.chord.with_quality(:minor)
1408
+ # c_major.contains_chord?(cm) # => false (Eb not in C major)
1409
+ #
1410
+ # @see #degree_of_chord
1411
+ # @see #chord_on
1412
+ def contains_chord?(chord)
1413
+ chord.chord_definition.in_scale?(self, chord_root_pitch: chord.root.pitch)
1414
+ end
1415
+
1416
+ # Returns the grade (0-based) where the chord root falls in this scale.
1417
+ #
1418
+ # @param chord [Musa::Chords::Chord] the chord to check
1419
+ # @return [Integer, nil] grade (0-based) or nil if chord not in scale
1420
+ #
1421
+ # @example
1422
+ # c_major = Scales.et12[440.0].major[60]
1423
+ # g_chord = c_major.dominant.chord
1424
+ # c_major.degree_of_chord(g_chord) # => 4 (V degree, 0-based)
1425
+ #
1426
+ # @see #contains_chord?
1427
+ def degree_of_chord(chord)
1428
+ return nil unless contains_chord?(chord)
1429
+
1430
+ note = note_of_pitch(chord.root.pitch, allow_chromatic: false)
1431
+ note&.grade
1432
+ end
1433
+
1434
+ # Creates an equivalent chord with this scale as its context.
1435
+ #
1436
+ # Returns a new Chord object that represents the same chord but with
1437
+ # this scale as its harmonic context. The chord's voicing (move and
1438
+ # duplicate settings) is preserved.
1439
+ #
1440
+ # @param chord [Musa::Chords::Chord] the source chord
1441
+ # @return [Musa::Chords::Chord, nil] new chord with this scale, or nil if not contained
1442
+ #
1443
+ # @example
1444
+ # c_major = Scales.et12[440.0].major[60]
1445
+ # g7 = c_major.dominant.chord :seventh
1446
+ #
1447
+ # g_mixolydian = Scales.et12[440.0].mixolydian[67]
1448
+ # g7_in_mixolydian = g_mixolydian.chord_on(g7)
1449
+ # g7_in_mixolydian.scale # => G Mixolydian scale
1450
+ #
1451
+ # @see #contains_chord?
1452
+ # @see #degree_of_chord
1453
+ def chord_on(chord)
1454
+ return nil unless contains_chord?(chord)
1455
+
1456
+ root_note = note_of_pitch(chord.root.pitch, allow_chromatic: false)
1457
+ return nil unless root_note
1458
+
1459
+ Musa::Chords::Chord.with_root(
1460
+ root_note,
1461
+ scale: self,
1462
+ name: chord.chord_definition.name,
1463
+ move: chord.move.empty? ? nil : chord.move,
1464
+ duplicate: chord.duplicate.empty? ? nil : chord.duplicate
1465
+ )
1466
+ end
1467
+
1041
1468
  # Checks scale equality.
1042
1469
  #
1043
1470
  # Scales are equal if they have same kind and root pitch.
@@ -76,6 +76,9 @@ module Musa
76
76
  @source.next_value.tap { |value| @history << value unless value.nil? && !@history.empty? && @history.last.nil? }
77
77
  end
78
78
 
79
+ # Creates buffer reader for this buffered serie.
80
+ #
81
+ # @return [Buffer] buffer serie reader
79
82
  def buffer
80
83
  @buffer ||= Buffer.new(@history)
81
84
  @buffer.send(state).tap { |_| @buffers << _ }
@@ -156,6 +159,9 @@ module Musa
156
159
  init
157
160
  end
158
161
 
162
+ # Creates buffer reader for this buffered serie.
163
+ #
164
+ # @return [Buffer] buffer serie reader
159
165
  def buffer
160
166
  Buffer.new(self)
161
167
  end
@@ -40,6 +40,7 @@ module Musa
40
40
  # first = pairs[0]
41
41
  # second = pairs[1]
42
42
  #
43
+ # @return [Splitter] hash/array splitter
43
44
  # @api public
44
45
  def split
45
46
  Splitter.new(self)
@@ -68,6 +69,11 @@ module Musa
68
69
  @proxy = SplitterProxy.new(@source)
69
70
  end
70
71
 
72
+ # Accesses component serie by key or index.
73
+ #
74
+ # @param key_or_index [Symbol, Integer] hash key or array index
75
+ #
76
+ # @return [Split] component serie
71
77
  def [](key_or_index)
72
78
  raise "Can't get a component because Splitter is a prototype. To get a component you need a Splitter instance." unless instance?
73
79
 
@@ -78,6 +84,13 @@ module Musa
78
84
  end
79
85
  end
80
86
 
87
+ # Iterates over component series.
88
+ #
89
+ # @yield [key, split] for hash mode, [split] for array mode
90
+ # @yieldparam key [Symbol] hash key (hash mode only)
91
+ # @yieldparam split [Split] component serie
92
+ #
93
+ # @return [Enumerator, void] enumerator if no block given
81
94
  def each
82
95
  raise "Can't iterate because Splitter is in state '#{state}'. To iterate you need a Splitter in state 'instance'." unless instance?
83
96
 
@@ -104,6 +117,11 @@ module Musa
104
117
  end
105
118
  end
106
119
 
120
+ # Converts to hash of component series.
121
+ #
122
+ # @return [Hash{Symbol => Split}] hash of component series
123
+ #
124
+ # @raise [RuntimeError] if not in hash mode
107
125
  def to_hash
108
126
  if @proxy.hash_mode?
109
127
  @proxy.components.collect { |key| [key, self[key]] }.to_h
@@ -112,6 +130,11 @@ module Musa
112
130
  end
113
131
  end
114
132
 
133
+ # Converts to array of component series.
134
+ #
135
+ # @return [Array<Split>] array of component series
136
+ #
137
+ # @raise [RuntimeError] if not in array mode
115
138
  def to_ary
116
139
  if @proxy.array_mode?
117
140
  [].tap { |_| @proxy.components.each { |i| _[i] = self[i] } }
@@ -63,6 +63,18 @@ module Musa
63
63
  end
64
64
 
65
65
  module Series::Constructors
66
+ # Quantizes time-value serie to discrete steps.
67
+ #
68
+ # @param time_value_serie [Serie] source timed serie
69
+ # @param reference [Numeric, nil] quantization reference
70
+ # @param step [Numeric, nil] step size
71
+ # @param value_attribute [Symbol, nil] attribute to quantize
72
+ # @param stops [Boolean, nil] include stop points
73
+ # @param predictive [Boolean, nil] use predictive mode
74
+ # @param left_open [Boolean, nil] left boundary open
75
+ # @param right_open [Boolean, nil] right boundary open
76
+ #
77
+ # @return [RawQuantizer, PredictiveQuantizer] quantized serie
66
78
  def QUANTIZE(time_value_serie,
67
79
  reference: nil, step: nil,
68
80
  value_attribute: nil,
@@ -79,6 +79,13 @@ module Musa
79
79
  init
80
80
  end
81
81
 
82
+ # Adds serie to queue.
83
+ #
84
+ # @param serie [Serie] instance serie to add
85
+ #
86
+ # @return [self] for chaining
87
+ #
88
+ # @raise [ArgumentError] if serie is not an instance
82
89
  def <<(serie)
83
90
  # when queue is a prototype it is also frozen so no serie can be added (it would raise an Exception if tried).
84
91
  # when queue is an instance the added serie should also be an instance (raise an Exception otherwise)
@@ -91,6 +98,9 @@ module Musa
91
98
  self
92
99
  end
93
100
 
101
+ # Clears all series from queue.
102
+ #
103
+ # @return [self] for chaining
94
104
  def clear
95
105
  @sources.clear
96
106
  init
@@ -148,6 +158,9 @@ module Musa
148
158
  end
149
159
 
150
160
  module Series::Operations
161
+ # Wraps this serie in a queue.
162
+ #
163
+ # @return [QueueSerie] queue containing this serie
151
164
  def queued
152
165
  Series::Constructors.QUEUE(self)
153
166
  end
@@ -1,3 +1,4 @@
1
1
  module Musa
2
- VERSION = '0.40.0'.freeze
2
+ VERSION = '0.41.0'.freeze
3
+ VERSION_DATE = '2026-01-15'.freeze
3
4
  end