Skip to content

Chord.Voice(octave, inversion) puts the root back in the bass from the 4th inversion of extended chords: C9 inversion 4 gives C5 D5 E5 G5 A#5 #356

Open

Description

@matt-edmondson

What's wrong

Chord.Voice(int octave, int inversion) (Chord.cs#L529-L563) builds inversion k by adding 12 to the first k chord tones and re-sorting. The doc says inversion k voices the chord with the k-th chord tone lowest. That only holds while every tone above the raised ones is within an octave of the root.

ChordTones() returns tensions as 13–21 semitones. Once the root is raised to 12, it sits below the 9th (14), 11th (17) and 13th (21). So when the inversion reaches a tension, the raised root drops back to the bottom.

Reproduction (against ce31509)

Call Result Expected lowest note
Chord.Parse("C9").Voice(4, 3) A#4 C5 D5 E5 G5 A# ✓
Chord.Parse("C9").Voice(4, 4) C5 D5 E5 G5 A#5 D (the 9th)
Chord.Parse("C13").Voice(4, 4) C5 D5 E5 F5 G5 A5 A#5 D

Inversions 5 and 6 of C13 also put C in the bass. The inversion is silently ignored, and the result is root position an octave up.

Trace for C9: the tones [0,4,7,10,14] become [12,16,19,22,14] after four raises, which sorts to [12,14,…].

There is a related smaller problem. The root is built with Pitch.Parse(Root.Name + octave), so a root above MIDI 127 throws FormatException("Invalid note name 'B9'") (Chord.Parse("B").Voice(9)). An upper tone out of range throws ArgumentOutOfRangeException for midi (Chord.Parse("G").Voice(9)). The same kind of bad input gives two different exceptions.

Suggested fix

Take the target bass as tones[rotation], then raise every tone below it by whole octaves until it sits above that bass, instead of raising only the first rotation tones by one octave. Compute the root from MIDI arithmetic rather than by parsing a string. Throw a single ArgumentOutOfRangeException(nameof(octave)) when the voicing leaves 0..127.

Acceptance criteria

  • For every chord in the round-trip corpus and every inversion in 0..ChordTones().Count-1, the lowest pitch of Voice(4, inversion) (ignoring a slash bass) has pitch class Root + ChordTones()[inversion].
  • Voice with an octave that pushes any tone outside MIDI 0..127 throws ArgumentOutOfRangeException.

The existing InversionTransposeTests cover only triads.

Activity

  1. matt-edmondson commented on Oct 5, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: Medium. Voice silently returns root position for inversions of 4 and above on extended chords, and out-of-range octaves throw two different exception types.
    • Area: Semantics.Music/Chord.cs (Voice)
    • Duplicates / in progress: No duplicate or open PR. Redesign chord symbols: one grammar for Chord.Parse and Chord.ToString (fixes #300 #322 #336 #340 #341 #344 #345 #346 #358) #360 (merged) explicitly listed it as out of scope.
    • Next step: Pick the bass as tones[inversion], then raise every lower tone by whole octaves above it. Build the root from MIDI arithmetic and throw a single ArgumentOutOfRangeException(nameof(octave)). Extend InversionTransposeTests beyond triads over the round-trip corpus.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions