A small, self-contained CLI that transposes musical note names and chords up or down by a number of half steps.
$ ./transpose.py A7 D7/E 'C (add9)' F#m7b5 C7/9/13 2
B7 E7/F# D (add9) G#m7b5 D7/9/13
transpose.py [-b|--flats] CHORD [CHORD ...] SEMITONES
- One or more chords (a plain note like
Cis just a chord with an empty suffix), followed by a single integer half-step count. - Output defaults to sharps; pass
-b/--flatsfor flat spellings. The flag is-bbecausebis the flat glyph itself (Bb,Eb). - Quote chords containing spaces or parentheses so the shell keeps them
together, e.g.
'C (add9)'.
Examples:
| Input | Output |
|---|---|
A7 1 |
A#7 |
-b A7 1 |
Bb7 |
D7/E -2 |
C7/D |
C7/9/13 1 |
C#7/9/13 |
C7b9 1 |
C#7b9 |
Opaque suffix. Only the chord root and a true /bass note are transposed;
the entire suffix (quality, extensions, alterations, add9, sus, parens) is
preserved verbatim. A / is treated as a bass separator only when the trailing
segment is a valid note name (D7/E); otherwise every / is an extension
separator (C7/9/13). These conventions match the sibling
greene project, though this tool does not import it.
Self-contained uv script (no dependencies to install):
./transpose.py C E G 2 # run directly via the uv shebang
./test_transpose.py # run the tests directly
uv run --with pytest pytest test_transpose.py -q # ... or via pytest
It is also a package, so other tools can import it rather than shell out:
uv pip install git+https://github.com/bwagner/transpose.git
That installs the same single transpose.py module plus a transpose console
script, so ./transpose.py above keeps working unchanged. Consumers depend on
it with transpose @ git+https://github.com/bwagner/transpose.git.
transpose_chord expects one chord. A label in a chord track usually holds
more: a section marker, a lyric cue, a fingering, a performance note, often
alongside the chord. transpose_label_text transposes the chords in such a
string and returns everything else byte-identical:
>>> from transpose import transpose_label_text as T
>>> T("Chorus1: Dj7", 2) # section marker stays, chord moves
'Chorus1: Ej7'
>>> T("A: I let him slip away", 2) # chord moves, lyric cue stays
'B: I let him slip away'
>>> T("F, Gm, Bb/C", 2) # every chord moves, not just the first
'G, Am, C/D'
>>> T("Guitar Solo", 2) is None # nothing here was a chord
TrueIt returns None when the text held no chord at all, so a caller can report
what it left alone rather than silently doing nothing.
Why a strict pattern. parse_chord takes a root plus an opaque suffix, so
it reads Guitar Solo as G + uitar Solo and would cheerfully return
G#uitar Solo. transpose_label_text therefore requires a token's suffix to be
built only from chord vocabulary (QUALITY_WORDS, SUFFIX_NUMBERS,
SUFFIX_SYMBOLS), and rejects a digits-only suffix that is not a real extension
so B1 and B2 stay section markers. Tokens split on whitespace, commas and
colons - never on /, which is meaningful inside a chord (D7/E, 6/9), and
never on -, which is a minor marker in some charts (C#-7).
Validated against a real corpus of ~530 distinct chord-track label texts: 365 transposed, 166 passed through, no silent damage; transposing by an octave is a byte-exact no-op.