Class: Musa::Scales::Scale

Inherits:
Object show all
Extended by:
Forwardable
Defined in:
lib/musa-dsl/music/scales.rb

Overview

Instantiated scale with specific root pitch.

Scale represents a concrete scale (major, minor, etc.) rooted on a specific pitch. It provides access to scale degrees, interval calculations, frequency generation, and chord construction.

Creation

Scales are created via ScaleKind:

tuning = Scales[:et12][440.0]
c_major = tuning.major[60]        # Via convenience method
a_minor = tuning[:minor][69]      # Via bracket notation

Accessing Notes

By numeric grade (0-based):

scale[0]    # First degree (tonic)
scale[1]    # Second degree
scale[4]    # Fifth degree

By function name (dynamic methods):

scale.tonic       # First degree
scale.dominant    # Fifth degree
scale.mediant     # Third degree

By Roman numeral:

scale[:I]     # First degree
scale[:V]     # Fifth degree
scale[:IV]    # Fourth degree

With accidentals (sharp # or flat _). Use strings for #:

scale['I#']   # Raised tonic
scale[:V_]    # Flatted dominant
scale['II##'] # Double-raised second

Note Operations

Each note is a NoteInScale instance with full capabilities:

note = scale.tonic
note.pitch              # MIDI pitch number
note.frequency          # Frequency in Hz
note.chord              # Build chord from note
note.up(:P5)            # Navigate by interval
note.sharp              # Raise by semitone

Special Methods

  • chromatic: Access chromatic scale at same root
  • octave: Transpose scale to different octave
  • note_of_pitch: Find note for specific MIDI pitch

Examples:

Basic scale access

c_major = tuning.major[60]
c_major.tonic.pitch      # => 60 (C)
c_major.dominant.pitch   # => 67 (G)
c_major[:III].pitch      # => 64 (E)

Chromatic alterations (use strings for #)

c_major['I#'].pitch      # => 61 (C#)
c_major[:V_].pitch       # => 66 (F#/Gb)

Building chords

c_major.tonic.chord              # C major triad
c_major.dominant.chord :seventh  # G dominant 7th

See Also:

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(kind, root_pitch:) ⇒ Scale

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Creates a scale instance.

Parameters:

  • kind (ScaleKind)

    the scale kind

  • root_pitch (Integer)

    MIDI root pitch



1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
# File 'lib/musa-dsl/music/scales.rb', line 1301

def initialize(kind, root_pitch:)
  @notes_by_grade = {}
  @notes_by_pitch = {}

  @kind = kind

  @root_pitch = root_pitch

  @kind.class.grades_functions.each do |name|
    define_singleton_method name do
      self[name]
    end
  end

  freeze
end

Instance Attribute Details

#kindScaleKind (readonly)

Scale kind (major, minor, etc.).

Returns:



1334
1335
1336
# File 'lib/musa-dsl/music/scales.rb', line 1334

def kind
  @kind
end

#root_pitchInteger (readonly)

Root pitch (MIDI number).

Returns:

  • (Integer)


1338
1339
1340
# File 'lib/musa-dsl/music/scales.rb', line 1338

def root_pitch
  @root_pitch
end

Instance Method Details

#==(other) ⇒ Boolean

Checks scale equality.

Scales are equal if they have same kind and root pitch.

Parameters:

Returns:

  • (Boolean)


1725
1726
1727
1728
1729
# File 'lib/musa-dsl/music/scales.rb', line 1725

def ==(other)
  self.class == other.class &&
      @kind == other.kind &&
      @root_pitch == other.root_pitch
end

#absolutScale

Returns the scale rooted at absolute pitch 0.

Examples:

c_major.absolut  # Major scale at MIDI 0

Returns:

  • (Scale)

    scale of same kind at MIDI 0



1368
1369
1370
# File 'lib/musa-dsl/music/scales.rb', line 1368

def absolut
  @kind[0]
end

#chord_on(grade, *feature_values, allow_chromatic: nil, move: nil, duplicate: nil, **features_hash) ⇒ Chords::Chord

Creates a chord rooted on the specified scale degree.

This is a convenience method that combines scale note access with chord creation. It's equivalent to scale[grade].chord(...).

Examples:

Create triads

scale.chord_on(0)           # Tonic triad (I)
scale.chord_on(:dominant)   # Dominant triad (V)
scale.chord_on(:IV)         # Subdominant triad

Create extended chords

scale.chord_on(4, :seventh)              # V7
scale.chord_on(:dominant, :ninth)        # V9
scale.chord_on(0, :seventh, :major)      # Imaj7

With voicing

scale.chord_on(:I, :seventh, move: {root: -1}).pitches   # => [48, 64, 67, 71]
scale.chord_on(0, :triad, duplicate: {root: 1}).pitches  # => [60, 64, 67, 72]

Parameters:

  • grade (Integer, Symbol, String)

    scale degree (0-based numeric, function name like :tonic, or Roman numeral like :V)

  • feature_values (Array<Symbol>)

    chord feature values (:seventh, :major, etc.)

  • allow_chromatic (Boolean) (defaults to: nil)

    allow non-diatonic chord notes

  • move (Hash{Symbol => Integer}) (defaults to: nil)

    initial octave moves for chord tones

  • duplicate (Hash{Symbol => Integer, Array}) (defaults to: nil)

    initial duplications

  • features_hash (Hash)

    additional feature key-value pairs

Returns:

See Also:



1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
# File 'lib/musa-dsl/music/scales.rb', line 1707

def chord_on(grade, *feature_values,
             allow_chromatic: nil,
             move: nil,
             duplicate: nil,
             **features_hash)
  self[grade].chord(*feature_values,
                    allow_chromatic: allow_chromatic,
                    move: move,
                    duplicate: duplicate,
                    **features_hash)
end

#chromaticScale

Returns the chromatic scale at the same root.

Examples:

c_major.chromatic  # Chromatic scale starting at C

Returns:

  • (Scale)

    chromatic scale rooted at same pitch



1358
1359
1360
# File 'lib/musa-dsl/music/scales.rb', line 1358

def chromatic
  @kind.tuning.chromatic[@root_pitch]
end

#contains_chord?(chord) ⇒ Boolean

Checks if all chord pitches exist in this scale.

Uses the chord's definition to verify that every pitch in the chord can be found as a diatonic note in this scale.

Examples:

c_major = Scales.et12[440.0].major[60]
g7 = c_major.dominant.chord :seventh
c_major.contains_chord?(g7)  # => true

cm = c_major.tonic.chord.with_quality(:minor)
c_major.contains_chord?(cm)  # => false (Eb not in C major)

Parameters:

Returns:

  • (Boolean)

    true if all chord notes are in scale

See Also:



1656
1657
1658
# File 'lib/musa-dsl/music/scales.rb', line 1656

def contains_chord?(chord)
  chord.chord_definition.in_scale?(self, chord_root_pitch: chord.root.pitch)
end

#degree_of_chord(chord) ⇒ Integer?

Returns the grade (0-based) where the chord root falls in this scale.

Examples:

c_major = Scales.et12[440.0].major[60]
g_chord = c_major.dominant.chord
c_major.degree_of_chord(g_chord)  # => 4 (V degree, 0-based)

Parameters:

Returns:

  • (Integer, nil)

    grade (0-based) or nil if chord not in scale

See Also:



1671
1672
1673
1674
1675
1676
# File 'lib/musa-dsl/music/scales.rb', line 1671

def degree_of_chord(chord)
  return nil unless contains_chord?(chord)

  note = note_of_pitch(chord.root.pitch, allow_chromatic: false)
  note&.grade
end

#get(grade_or_symbol) ⇒ NoteInScale Also known as: []

Accesses scale degree by grade, symbol, or function name.

Supports multiple access patterns:

  • Integer: Numeric grade (0-based)
  • Symbol/String: Function name or Roman numeral
  • With accidentals: Add '#' for sharp, '_' for flat

Notes are cached—repeated access returns same instance.

Examples:

Numeric access

scale[0]    # Tonic
scale[4]    # Dominant (in major/minor)

Function name access

scale[:tonic]
scale[:dominant]
scale[:mediant]

Roman numeral access

scale[:I]     # Tonic
scale[:V]     # Dominant
scale[:IV]    # Subdominant

With accidentals (use strings for #)

scale['I#']    # Raised tonic
scale[:V_]     # Flatted dominant
scale['II##']  # Double-raised second

scale['II##'].pitch  # => 64

# 64 is E, and so is the third degree -- but this is a D double sharp.
# The alteration is remembered as an alteration, not folded into the
# pitch it happens to share.

Parameters:

  • grade_or_symbol (Integer, Symbol, String)

    degree specifier

Returns:

Raises:

  • (ArgumentError)

    if grade_or_symbol is invalid type



1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
# File 'lib/musa-dsl/music/scales.rb', line 1431

def get(grade_or_symbol)

  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)

  wide_grade, sharps = grade_of(grade_or_symbol)

  unless @notes_by_grade.key?(wide_grade)

    octave = wide_grade / @kind.class.grades
    grade = wide_grade % @kind.class.grades

    pitch = @root_pitch +
        octave * @kind.tuning.notes_in_octave +
        @kind.class.pitches[grade][:pitch]

    note = NoteInScale.new self, grade, octave, pitch

    @notes_by_grade[wide_grade] = @notes_by_pitch[pitch] = note
  end


  @notes_by_grade[wide_grade].sharp(sharps)
end

#grade_of(grade_or_string_or_symbol) ⇒ Array(Integer, Integer)

Resolves any way of naming a grade into the number this scale uses for it, plus its accidentals.

This is the step #get takes before looking a note up, and it is public because a piece often has to take a grade from somewhere that is not Ruby -- a configuration file, a text score, a message from an editor -- and needs the same reading the scale itself would make. Where #parse_grade stops at the syntax, this resolves the function name through the scale kind: :dominant becomes 4 in a diatonic scale.

Examples:

Numbers, names and functions all arrive at a grade

c_major.grade_of(2)           # => [2, 0]
c_major.grade_of('I')         # => [0, 0]
c_major.grade_of(:dominant)   # => [4, 0]

Accidentals travel apart from the grade

c_major.grade_of('I#')  # => [0, 1]

Beyond the octave, the number keeps going

c_major.grade_of(7)  # => [7, 0]
# Seven grades to the octave, so 7 is the tonic an octave up.

Parameters:

  • grade_or_string_or_symbol (Integer, Symbol, String)

    grade specifier

Returns:

  • (Array(Integer, Integer))

    wide grade and accidentals count

See Also:



1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
# File 'lib/musa-dsl/music/scales.rb', line 1483

def grade_of(grade_or_string_or_symbol)
  name, wide_grade, accidentals = parse_grade(grade_or_string_or_symbol)

  grade = @kind.class.grade_of_function name if name

  octave = wide_grade / @kind.class.grades if wide_grade
  grade = wide_grade % @kind.class.grades if wide_grade

  octave ||= 0

  return octave * @kind.class.grades + grade, accidentals
end

#inspectString Also known as: to_s

Returns string representation.

Returns:



1734
1735
1736
# File 'lib/musa-dsl/music/scales.rb', line 1734

def inspect
  "<Scale: kind = #{@kind} root_pitch = #{@root_pitch}>"
end

#note_of_pitch(pitch, allow_chromatic: nil, allow_nearest: nil) ⇒ NoteInScale?

Finds note for a specific MIDI pitch.

Searches for a note in the scale matching the given pitch. Options control behavior when pitch is not in scale.

Examples:

Diatonic note

c_major.note_of_pitch(64).grade  # => 2  (E, in scale)

Not in the scale, and nothing allowed

c_major.note_of_pitch(63)  # => nil

Chromatic note: same pitch, another scale

note = c_major.note_of_pitch(63, allow_chromatic: true)
note.pitch                            # => 63
note.scale.kind.class.chromatic?      # => true
note.scale.equal?(c_major)            # => false

# The chromatic scale of the same tuning, NOT c_major: the pitch is
# kept and the scale is what gives way.

Nearest note: same scale, another pitch

note = c_major.note_of_pitch(63, allow_nearest: true)
note.pitch  # => 62 (D) -- 63 sits between D and E, and ties go down
note.grade  # => 1

Parameters:

  • pitch (Integer)

    MIDI pitch number

  • allow_chromatic (Boolean) (defaults to: nil)

    if true, return the note on the CHROMATIC scale of the same tuning when the pitch is not in this scale. The pitch is preserved; the scale of the returned note is not this one.

  • allow_nearest (Boolean) (defaults to: nil)

    if true, return the nearest note of THIS scale, which means the returned pitch may differ from the one asked for. Ties are broken downwards.

Returns:

  • (NoteInScale, nil)

    matching note, or nil when the pitch is not in the scale and neither option was given



1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
# File 'lib/musa-dsl/music/scales.rb', line 1590

def note_of_pitch(pitch, allow_chromatic: nil, allow_nearest: nil)
  allow_chromatic ||= false
  allow_nearest ||= false

  note = @notes_by_pitch[pitch]

  unless note
    pitch_offset = pitch - @root_pitch

    pitch_offset_in_octave = pitch_offset % @kind.tuning.scale_system.notes_in_octave
    pitch_offset_octave = pitch_offset / @kind.tuning.scale_system.notes_in_octave

    grade = @kind.class.pitches.find_index { |pitch_definition| pitch_definition[:pitch] == pitch_offset_in_octave }

    if grade
      wide_grade = pitch_offset_octave * @kind.class.grades + grade
      note = self[wide_grade]

    elsif allow_nearest
      sharps = 0

      until note
        note = note_of_pitch(pitch - (sharps += 1) * @kind.tuning.scale_system.part_of_tone_size)
        note ||= note_of_pitch(pitch + sharps * @kind.tuning.scale_system.part_of_tone_size)
      end

    elsif allow_chromatic
      nearest = note_of_pitch(pitch, allow_nearest: true)

      note = chromatic.note_of_pitch(pitch).with_background(scale: self, grade: nearest.grade, octave: nearest.octave, sharps: (pitch - nearest.pitch) / @kind.tuning.scale_system.part_of_tone_size)
    end
  end

  note
end

#octave(octave) ⇒ Scale

Transposes scale by octaves.

Examples:

c_major.octave(1).root.pitch   # => 72
c_major.octave(-1).root.pitch  # => 48

# An octave is an octave: the scale keeps its kind and its shape, and
# only the root moves. It used to move by GRADES -- `root_pitch +
# octave * grades` -- so C major an octave up came back as G major with
# an F sharp, because seven grades is seven semitones. It agreed with
# itself only on the chromatic scale, where the two happen to be the
# same number.

Parameters:

  • octave (Integer)

    octave offset (positive = up, negative = down)

Returns:

  • (Scale)

    transposed scale

Raises:

  • (ArgumentError)

    if octave is not integer



1388
1389
1390
1391
1392
# File 'lib/musa-dsl/music/scales.rb', line 1388

def octave(octave)
  raise ArgumentError, "#{octave} is not integer" unless octave == octave.to_i

  @kind[@root_pitch + octave * @kind.tuning.notes_in_octave]
end

#offset_of_interval(interval_name) ⇒ Integer

Returns semitone offset for a named interval.

Examples:

scale.offset_of_interval(:P5)  # => 7
scale.offset_of_interval(:M3)  # => 4

Parameters:

  • interval_name (Symbol)

    interval name (e.g., :M3, :P5)

Returns:

  • (Integer)

    semitone offset



1634
1635
1636
# File 'lib/musa-dsl/music/scales.rb', line 1634

def offset_of_interval(interval_name)
  @kind.tuning.offset_of_interval(interval_name)
end

#parse_grade(neuma_grade) ⇒ Array(Symbol, Integer, Integer)

Reads the notation of a grade, without deciding what it means.

Splits a written grade into its three parts: a function name, a numeric grade, and a count of accidentals. Exactly one of name and grade comes back, according to how it was written -- 'II' is a name, '2' is a number -- and resolving a name into a number is #grade_of's job, since that depends on the scale kind and this does not.

Accidentals are a single signed count: # adds one, _ subtracts one, and they may repeat.

Public because it is the only way to read the grade notation without a scale deciding for you, which is what a custom decoder needs.

Examples:

A written function name comes back as a name

c_major.parse_grade('I')   # => [:I, nil, 0]
c_major.parse_grade(:tonic)  # => [:tonic, nil, 0]

A written number comes back as a number

c_major.parse_grade('7')  # => [nil, 7, 0]
c_major.parse_grade(2)    # => [nil, 2, 0]

Accidentals accumulate, sharps positive and flats negative

c_major.parse_grade('I#')    # => [:I, nil, 1]
c_major.parse_grade('V_')    # => [:V, nil, -1]
c_major.parse_grade('II__')  # => [:II, nil, -2]
c_major.parse_grade('7##')   # => [nil, 7, 2]

Parameters:

  • neuma_grade (Integer, Symbol, String)

    grade to parse

Returns:

  • (Array(Symbol, Integer, Integer))

    name, wide_grade, accidentals

See Also:



1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
# File 'lib/musa-dsl/music/scales.rb', line 1528

def parse_grade(neuma_grade)
  name = wide_grade = nil
  accidentals = 0

  case neuma_grade
  when Symbol, String
    match = /\A(?<name>[^[#|_]]*)(?<accidental_sharps>#*)(?<accidental_flats>_*)\Z/.match neuma_grade.to_s

    if match
      if match[:name] == match[:name].to_i.to_s
        wide_grade = match[:name].to_i
      else
        name = match[:name].to_sym unless match[:name].empty?
      end
      accidentals = match[:accidental_sharps].length - match[:accidental_flats].length
    else
      name = neuma_grade.to_sym unless (neuma_grade.nil? || neuma_grade.empty?)
    end
  when Numeric
    wide_grade = neuma_grade.to_i

  else
    raise ArgumentError, "Cannot eval #{neuma_grade} as name or grade position."
  end

  return name, wide_grade, accidentals
end

#rootNoteInScale

Returns the root note (first degree).

Equivalent to scale or scale.tonic.

Examples:

c_major.root.pitch  # => 60

Returns:



1348
1349
1350
# File 'lib/musa-dsl/music/scales.rb', line 1348

def root
  self[0]
end

#tuningScaleSystemTuning

Returns the tuning system associated with this scale.

Delegated from ScaleKind#tuning.

Examples:

scale = Scales.et12[440.0].major[60]

scale.tuning              # => a ScaleSystemTuning
scale.tuning.a_frequency  # => 440.0

Returns:



1330
# File 'lib/musa-dsl/music/scales.rb', line 1330

def_delegators :@kind, :tuning