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



1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
# File 'lib/musa-dsl/music/scales.rb', line 1235

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:



1266
1267
1268
# File 'lib/musa-dsl/music/scales.rb', line 1266

def kind
  @kind
end

#root_pitchInteger (readonly)

Root pitch (MIDI number).

Returns:

  • (Integer)


1270
1271
1272
# File 'lib/musa-dsl/music/scales.rb', line 1270

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)


1640
1641
1642
1643
1644
# File 'lib/musa-dsl/music/scales.rb', line 1640

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



1300
1301
1302
# File 'lib/musa-dsl/music/scales.rb', line 1300

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})
scale.chord_on(0, :triad, duplicate: {root: 1})

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:



1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
# File 'lib/musa-dsl/music/scales.rb', line 1622

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



1290
1291
1292
# File 'lib/musa-dsl/music/scales.rb', line 1290

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:



1571
1572
1573
# File 'lib/musa-dsl/music/scales.rb', line 1571

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:



1586
1587
1588
1589
1590
1591
# File 'lib/musa-dsl/music/scales.rb', line 1586

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

Parameters:

  • grade_or_symbol (Integer, Symbol, String)

    degree specifier

Returns:

Raises:

  • (ArgumentError)

    if grade_or_symbol is invalid type



1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
# File 'lib/musa-dsl/music/scales.rb', line 1350

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:



1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
# File 'lib/musa-dsl/music/scales.rb', line 1402

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:



1649
1650
1651
# File 'lib/musa-dsl/music/scales.rb', line 1649

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  # => the chromatic scale, NOT c_major

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



1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
# File 'lib/musa-dsl/music/scales.rb', line 1505

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)   # C major one octave higher
c_major.octave(-1)  # C major one octave lower

Parameters:

  • octave (Integer)

    octave offset (positive = up, negative = down)

Returns:

  • (Scale)

    transposed scale

Raises:

  • (ArgumentError)

    if octave is not integer



1313
1314
1315
1316
1317
# File 'lib/musa-dsl/music/scales.rb', line 1313

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

  @kind[@root_pitch + octave * @kind.class.grades]
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



1549
1550
1551
# File 'lib/musa-dsl/music/scales.rb', line 1549

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:



1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
# File 'lib/musa-dsl/music/scales.rb', line 1447

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:



1280
1281
1282
# File 'lib/musa-dsl/music/scales.rb', line 1280

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  # => ScaleSystemTuning for 12-TET at A=440Hz

Returns:



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

def_delegators :@kind, :tuning