gingo 1.0.0__cp312-cp312-macosx_11_0_arm64.whl
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.
- gingo/__init__.py +106 -0
- gingo/__init__.pyi +476 -0
- gingo/__main__.py +1219 -0
- gingo/_gingo.cpython-312-darwin.so +0 -0
- gingo/audio.py +507 -0
- gingo/py.typed +0 -0
- gingo-1.0.0.dist-info/METADATA +1098 -0
- gingo-1.0.0.dist-info/RECORD +11 -0
- gingo-1.0.0.dist-info/WHEEL +5 -0
- gingo-1.0.0.dist-info/entry_points.txt +3 -0
- gingo-1.0.0.dist-info/licenses/LICENSE +21 -0
gingo/__main__.py
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Gingo CLI — explore music theory from the terminal.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m gingo note C#
|
|
6
|
+
python -m gingo interval 5J
|
|
7
|
+
python -m gingo chord Cmaj7
|
|
8
|
+
python -m gingo scale "C major"
|
|
9
|
+
python -m gingo field "C major"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
import textwrap
|
|
15
|
+
|
|
16
|
+
from gingo import (
|
|
17
|
+
Note,
|
|
18
|
+
Interval,
|
|
19
|
+
Chord,
|
|
20
|
+
Scale,
|
|
21
|
+
Field,
|
|
22
|
+
FieldComparison,
|
|
23
|
+
HarmonicFunction,
|
|
24
|
+
Duration,
|
|
25
|
+
Tempo,
|
|
26
|
+
TimeSignature,
|
|
27
|
+
__version__,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _join(items, sep=" "):
|
|
36
|
+
return sep.join(str(x) for x in items)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _header(text):
|
|
40
|
+
print(f"\n {text}")
|
|
41
|
+
print(f" {'—' * len(text)}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _row(label, value, indent=4):
|
|
45
|
+
print(f"{' ' * indent}{label:.<20s} {value}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _add_audio_args(parser):
|
|
49
|
+
"""Add --play, --wav, --waveform, and --strum arguments to a subparser."""
|
|
50
|
+
parser.add_argument("--play", action="store_true",
|
|
51
|
+
help="play through audio output")
|
|
52
|
+
parser.add_argument("--wav", metavar="FILE",
|
|
53
|
+
help="export to WAV file")
|
|
54
|
+
parser.add_argument("--waveform",
|
|
55
|
+
choices=["sine", "square", "sawtooth", "triangle"],
|
|
56
|
+
default="sine",
|
|
57
|
+
help="waveform for --play / --wav (default: sine)")
|
|
58
|
+
parser.add_argument("--strum", type=float, default=0.03, metavar="SEC",
|
|
59
|
+
help="delay between chord notes in seconds (default: 0.03)")
|
|
60
|
+
parser.add_argument("--gap", type=float, default=0.05, metavar="SEC",
|
|
61
|
+
help="silence between consecutive notes/chords (default: 0.05)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _handle_audio(obj, args):
|
|
65
|
+
"""Handle --play and --wav flags if present."""
|
|
66
|
+
has_play = getattr(args, "play", False)
|
|
67
|
+
wav_path = getattr(args, "wav", None)
|
|
68
|
+
if not has_play and not wav_path:
|
|
69
|
+
return
|
|
70
|
+
from gingo.audio import play as audio_play, to_wav
|
|
71
|
+
kw = {"waveform": args.waveform, "strum": args.strum, "gap": args.gap}
|
|
72
|
+
if hasattr(args, "octave") and args.octave is not None:
|
|
73
|
+
kw["octave"] = args.octave
|
|
74
|
+
if wav_path:
|
|
75
|
+
to_wav(obj, wav_path, **kw)
|
|
76
|
+
print(f" Saved: {wav_path}")
|
|
77
|
+
if has_play:
|
|
78
|
+
audio_play(obj, **kw)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Subcommands
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def cmd_note(args):
|
|
86
|
+
"""Show note properties."""
|
|
87
|
+
n = Note(args.name)
|
|
88
|
+
|
|
89
|
+
_header(f"Note: {n.name()}")
|
|
90
|
+
_row("Semitone index", str(n.semitone()))
|
|
91
|
+
tuning = args.tuning or 440.0
|
|
92
|
+
octave = args.octave if args.octave is not None else 4
|
|
93
|
+
freq = n.frequency(octave, tuning)
|
|
94
|
+
label = f"Frequency ({n.name()}{octave}"
|
|
95
|
+
if tuning != 440.0:
|
|
96
|
+
label += f", A4={tuning:.0f}"
|
|
97
|
+
label += ")"
|
|
98
|
+
_row(label, f"{freq:.2f} Hz")
|
|
99
|
+
_row("Natural name", n.natural())
|
|
100
|
+
_row("Sound", n.sound())
|
|
101
|
+
|
|
102
|
+
if args.transpose:
|
|
103
|
+
t = n.transpose(args.transpose)
|
|
104
|
+
_row(f"Transpose ({args.transpose:+d} st)", str(t))
|
|
105
|
+
|
|
106
|
+
if args.enharmonic:
|
|
107
|
+
other = Note(args.enharmonic)
|
|
108
|
+
eq = n.is_enharmonic(other)
|
|
109
|
+
_row(f"Enharmonic to {args.enharmonic}", "yes" if eq else "no")
|
|
110
|
+
|
|
111
|
+
if args.distance:
|
|
112
|
+
other = Note(args.distance)
|
|
113
|
+
d = n.distance(other)
|
|
114
|
+
_row(f"Distance to {args.distance}", f"{d} fifth{'s' if d != 1 else ''}")
|
|
115
|
+
|
|
116
|
+
if args.fifths:
|
|
117
|
+
print()
|
|
118
|
+
_header("Circle of fifths")
|
|
119
|
+
fifths = Note.fifths()
|
|
120
|
+
# Find current note's position on the circle
|
|
121
|
+
pos = (n.semitone() * 7) % 12
|
|
122
|
+
items = []
|
|
123
|
+
for i, name in enumerate(fifths):
|
|
124
|
+
marker = f"[{name}]" if i == pos else f" {name} "
|
|
125
|
+
items.append(marker)
|
|
126
|
+
# Print in a circular layout: top row, then two lines
|
|
127
|
+
print(f" {_join(items, ' ')}")
|
|
128
|
+
|
|
129
|
+
_handle_audio(n, args)
|
|
130
|
+
print()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_interval(args):
|
|
134
|
+
"""Show interval properties."""
|
|
135
|
+
try:
|
|
136
|
+
iv = Interval(args.label)
|
|
137
|
+
except (ValueError, RuntimeError):
|
|
138
|
+
try:
|
|
139
|
+
iv = Interval(int(args.label))
|
|
140
|
+
except (ValueError, RuntimeError):
|
|
141
|
+
print(f" Error: unknown interval '{args.label}'")
|
|
142
|
+
print(f" Try a label (5J, 3M, 7m) or semitone count (0-23)")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
_header(f"Interval: {iv.label()}")
|
|
146
|
+
_row("Semitones", str(iv.semitones()))
|
|
147
|
+
_row("Anglo-saxon", iv.anglo_saxon())
|
|
148
|
+
_row("Degree", str(iv.degree()))
|
|
149
|
+
_row("Octave", str(iv.octave()))
|
|
150
|
+
|
|
151
|
+
if args.all:
|
|
152
|
+
print()
|
|
153
|
+
_header("All 24 intervals (2 octaves)")
|
|
154
|
+
print(f" {'ST':>3s} {'Label':<6s} {'Anglo-saxon':<10s} {'Deg':>3s} {'Oct':>3s}")
|
|
155
|
+
print(f" {'---':>3s} {'------':<6s} {'----------':<10s} {'---':>3s} {'---':>3s}")
|
|
156
|
+
for i in range(24):
|
|
157
|
+
x = Interval(i)
|
|
158
|
+
print(f" {x.semitones():>3d} {x.label():<6s} {x.anglo_saxon():<10s} {x.degree():>3d} {x.octave():>3d}")
|
|
159
|
+
|
|
160
|
+
print()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_chord(args):
|
|
164
|
+
"""Show chord properties."""
|
|
165
|
+
if args.identify:
|
|
166
|
+
notes = [n.strip() for n in args.name.split(",")]
|
|
167
|
+
c = Chord.identify(notes)
|
|
168
|
+
print(f"\n Identified: {c.name()}")
|
|
169
|
+
print(f" From notes: {', '.join(notes)}\n")
|
|
170
|
+
else:
|
|
171
|
+
c = Chord(args.name)
|
|
172
|
+
|
|
173
|
+
_header(f"Chord: {c.name()}")
|
|
174
|
+
_row("Root", str(c.root()))
|
|
175
|
+
_row("Type", c.type())
|
|
176
|
+
_row("Notes", _join(c.notes(), ", "))
|
|
177
|
+
_row("Formal notes", _join(c.formal_notes(), ", "))
|
|
178
|
+
_row("Intervals", _join(c.intervals(), ", "))
|
|
179
|
+
_row("Size", str(c.size()))
|
|
180
|
+
_handle_audio(c, args)
|
|
181
|
+
print()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_scale(args):
|
|
185
|
+
"""Show scale properties, modes, and pentatonic."""
|
|
186
|
+
if args.identify:
|
|
187
|
+
notes = [n.strip() for n in args.name.split(",")]
|
|
188
|
+
s = Scale.identify(notes)
|
|
189
|
+
print(f"\n Identified: {s}")
|
|
190
|
+
print(f" From notes: {', '.join(notes)}")
|
|
191
|
+
else:
|
|
192
|
+
parts = args.name.strip().split(maxsplit=1)
|
|
193
|
+
if len(parts) < 2:
|
|
194
|
+
print(f" Error: provide tonic and type, e.g. 'C major' or 'A natural minor'")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
tonic, stype = parts[0], parts[1]
|
|
198
|
+
|
|
199
|
+
# Handle --pentatonic flag: append "pentatonic" suffix if not already present
|
|
200
|
+
if args.pentatonic and "pentatonic" not in stype.lower():
|
|
201
|
+
s = Scale(tonic, stype + " pentatonic")
|
|
202
|
+
else:
|
|
203
|
+
s = Scale(tonic, stype)
|
|
204
|
+
|
|
205
|
+
_header(f"Scale: {s}")
|
|
206
|
+
_row("Tonic", str(s.tonic()))
|
|
207
|
+
_row("Parent", str(s.parent()).replace("ScaleType.", ""))
|
|
208
|
+
_row("Mode", f"{s.mode_name()} (mode {s.mode_number()})")
|
|
209
|
+
_row("Quality", s.quality())
|
|
210
|
+
brightness = s.brightness()
|
|
211
|
+
if brightness > 0:
|
|
212
|
+
_row("Brightness", str(brightness))
|
|
213
|
+
if s.is_pentatonic():
|
|
214
|
+
_row("Pentatonic", "yes")
|
|
215
|
+
sig = s.signature()
|
|
216
|
+
if sig != 0 or s.parent().name in ("Major", "NaturalMinor"):
|
|
217
|
+
if sig > 0:
|
|
218
|
+
_row("Key signature", f"{sig} sharp{'s' if sig != 1 else ''}")
|
|
219
|
+
elif sig < 0:
|
|
220
|
+
_row("Key signature", f"{-sig} flat{'s' if sig != -1 else ''}")
|
|
221
|
+
else:
|
|
222
|
+
_row("Key signature", "none")
|
|
223
|
+
_row("Size", str(s.size()))
|
|
224
|
+
_row("Notes", _join(s.notes(), " "))
|
|
225
|
+
_row("Formal notes", _join(s.formal_notes(), " "))
|
|
226
|
+
_row("Binary mask", "".join(str(x) for x in s.mask()))
|
|
227
|
+
|
|
228
|
+
if args.colors:
|
|
229
|
+
cols = s.colors(args.colors)
|
|
230
|
+
if cols:
|
|
231
|
+
_row("Colors vs " + args.colors, _join(cols, " "))
|
|
232
|
+
else:
|
|
233
|
+
_row("Colors vs " + args.colors, "(none)")
|
|
234
|
+
|
|
235
|
+
if args.modes:
|
|
236
|
+
print()
|
|
237
|
+
_header("Modes")
|
|
238
|
+
base = Scale(tonic, stype) if s.is_pentatonic() else s
|
|
239
|
+
# Build the base parent scale (mode 1) for mode listing
|
|
240
|
+
parent_tonic = base.tonic()
|
|
241
|
+
if base.mode_number() != 1:
|
|
242
|
+
# Reconstruct parent scale at mode 1 to list all modes
|
|
243
|
+
parent_name = str(base.parent()).replace("ScaleType.", "").lower()
|
|
244
|
+
base = Scale(str(parent_tonic), parent_name)
|
|
245
|
+
for i in range(1, base.size() + 1):
|
|
246
|
+
m = base.mode(i)
|
|
247
|
+
notes_str = _join(m.notes(), " ")
|
|
248
|
+
print(f" {i}. {m.mode_name():<22s} {str(m.tonic()):<4s} {notes_str}")
|
|
249
|
+
|
|
250
|
+
if args.degrees:
|
|
251
|
+
print()
|
|
252
|
+
_header("Degrees")
|
|
253
|
+
for i in range(1, s.size() + 1):
|
|
254
|
+
d = s.degree(i)
|
|
255
|
+
print(f" {i}: {d}")
|
|
256
|
+
|
|
257
|
+
if args.degree:
|
|
258
|
+
result = s.degree(*args.degree)
|
|
259
|
+
degs = " → ".join(str(d) for d in args.degree)
|
|
260
|
+
chain = " of ".join(
|
|
261
|
+
["I", "II", "III", "IV", "V", "VI", "VII"][d - 1]
|
|
262
|
+
if 1 <= d <= 7 else str(d)
|
|
263
|
+
for d in reversed(args.degree)
|
|
264
|
+
)
|
|
265
|
+
print()
|
|
266
|
+
_header(f"Chained Degree: {chain}")
|
|
267
|
+
_row("Input", degs)
|
|
268
|
+
_row("Result", str(result))
|
|
269
|
+
_row("Natural", result.natural())
|
|
270
|
+
|
|
271
|
+
if args.walk:
|
|
272
|
+
start = args.walk[0]
|
|
273
|
+
steps = args.walk[1:]
|
|
274
|
+
if not steps:
|
|
275
|
+
print(" Error: --walk requires start + at least one step")
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
result = s.walk(start, *steps)
|
|
278
|
+
print()
|
|
279
|
+
_header(f"Walk: start={start}, steps={', '.join(str(x) for x in steps)}")
|
|
280
|
+
_row("Result", str(result))
|
|
281
|
+
_row("Natural", result.natural())
|
|
282
|
+
|
|
283
|
+
if args.relative:
|
|
284
|
+
try:
|
|
285
|
+
rel = s.relative()
|
|
286
|
+
print()
|
|
287
|
+
_header("Relative key")
|
|
288
|
+
_row("Scale", str(rel))
|
|
289
|
+
_row("Notes", _join(rel.notes(), " "))
|
|
290
|
+
except ValueError as e:
|
|
291
|
+
print(f"\n {e}")
|
|
292
|
+
|
|
293
|
+
if args.parallel:
|
|
294
|
+
try:
|
|
295
|
+
par = s.parallel()
|
|
296
|
+
print()
|
|
297
|
+
_header("Parallel key")
|
|
298
|
+
_row("Scale", str(par))
|
|
299
|
+
_row("Notes", _join(par.notes(), " "))
|
|
300
|
+
except ValueError as e:
|
|
301
|
+
print(f"\n {e}")
|
|
302
|
+
|
|
303
|
+
if args.neighbors:
|
|
304
|
+
sub, dom = s.neighbors()
|
|
305
|
+
print()
|
|
306
|
+
_header("Neighbors (circle of fifths)")
|
|
307
|
+
_row("Subdominant", f"{sub} {_join(sub.notes(), ' ')}")
|
|
308
|
+
_row("Dominant", f"{dom} {_join(dom.notes(), ' ')}")
|
|
309
|
+
|
|
310
|
+
_handle_audio(s, args)
|
|
311
|
+
print()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_applied_target(target_str):
|
|
315
|
+
"""Parse the target part of an applied expression.
|
|
316
|
+
|
|
317
|
+
Returns an int if it's a number, or a string for Roman numerals.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
return int(target_str)
|
|
321
|
+
except ValueError:
|
|
322
|
+
return target_str
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_field(args):
|
|
326
|
+
"""Show harmonic field (triads and sevenths)."""
|
|
327
|
+
if args.deduce:
|
|
328
|
+
items = [i.strip() for i in args.name.split(",")]
|
|
329
|
+
limit = args.limit
|
|
330
|
+
results = Field.deduce(items, limit=limit)
|
|
331
|
+
|
|
332
|
+
_header(f"Deduce from: {', '.join(items)}")
|
|
333
|
+
if not results:
|
|
334
|
+
print(" (no matches found)")
|
|
335
|
+
print()
|
|
336
|
+
return
|
|
337
|
+
print()
|
|
338
|
+
print(f" {'#':>3} {'Field':<25s} {'Score':>6s} {'Match':>5s} Roles")
|
|
339
|
+
print(f" {'---':>3} {'-----':<25s} {'-----':>6s} {'-----':>5s} -----")
|
|
340
|
+
for i, r in enumerate(results, 1):
|
|
341
|
+
roles_str = ", ".join(x for x in r.roles if x) if r.roles else ""
|
|
342
|
+
print(f" {i:>3} {str(r.field):<25s} {r.score:>6.2f} {r.matched}/{r.total:<3} {roles_str}")
|
|
343
|
+
print()
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if args.identify:
|
|
347
|
+
items = [i.strip() for i in args.name.split(",")]
|
|
348
|
+
f = Field.identify(items)
|
|
349
|
+
print(f"\n Identified: {f}")
|
|
350
|
+
print(f" From: {', '.join(items)}")
|
|
351
|
+
else:
|
|
352
|
+
parts = args.name.strip().split(maxsplit=1)
|
|
353
|
+
if len(parts) < 2:
|
|
354
|
+
print(f" Error: provide tonic and type, e.g. 'C major'")
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
tonic, stype = parts[0], parts[1]
|
|
358
|
+
f = Field(tonic, stype)
|
|
359
|
+
|
|
360
|
+
scale = f.scale()
|
|
361
|
+
size = scale.size()
|
|
362
|
+
stype = str(scale.type()).replace("ScaleType.", "").lower()
|
|
363
|
+
if scale.mode_name() in ("Aeolian", "NaturalMinor"):
|
|
364
|
+
stype = "natural minor"
|
|
365
|
+
|
|
366
|
+
if args.applied:
|
|
367
|
+
# Parse X/Y notation
|
|
368
|
+
if "/" not in args.applied:
|
|
369
|
+
print(" Error: use X/Y notation, e.g. V7/II or 5/2")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
func_str, target_str = args.applied.rsplit("/", 1)
|
|
373
|
+
target = _parse_applied_target(target_str)
|
|
374
|
+
|
|
375
|
+
# Detect numeric function
|
|
376
|
+
try:
|
|
377
|
+
func = int(func_str)
|
|
378
|
+
except ValueError:
|
|
379
|
+
func = func_str
|
|
380
|
+
|
|
381
|
+
c = f.applied(func, target)
|
|
382
|
+
|
|
383
|
+
_header(f"Applied Chord: {func_str}/{target_str} in {f.tonic()} {stype}")
|
|
384
|
+
_row("Chord", str(c))
|
|
385
|
+
_row("Root", str(c.root()))
|
|
386
|
+
_row("Type", c.type())
|
|
387
|
+
_row("Notes", _join(c.notes(), ", "))
|
|
388
|
+
_row("Formal notes", _join(c.formal_notes(), ", "))
|
|
389
|
+
_row("Intervals", _join(c.intervals(), ", "))
|
|
390
|
+
print()
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
sig = f.signature()
|
|
394
|
+
sig_str = (f"{sig}#" if sig > 0 else f"{abs(sig)}b" if sig < 0 else "0")
|
|
395
|
+
_header(f"Harmonic Field: {f.tonic()} {stype} (sig: {sig_str})")
|
|
396
|
+
|
|
397
|
+
roman = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"]
|
|
398
|
+
|
|
399
|
+
if args.functions:
|
|
400
|
+
print()
|
|
401
|
+
print(f" {'':3s} {'Triad':<12s} {'Seventh':<12s} {'Function':<14s} {'Role'}")
|
|
402
|
+
print(f" {'':3s} {'-----':<12s} {'-------':<12s} {'--------':<14s} {'----'}")
|
|
403
|
+
|
|
404
|
+
triads = f.chords()
|
|
405
|
+
sevenths = f.sevenths()
|
|
406
|
+
for i in range(size):
|
|
407
|
+
deg = roman[i] if i < len(roman) else str(i + 1)
|
|
408
|
+
t = triads[i]
|
|
409
|
+
s = sevenths[i]
|
|
410
|
+
hf = f.function(i + 1)
|
|
411
|
+
rl = f.role(i + 1)
|
|
412
|
+
print(f" {deg:>3s} {str(t):<12s} {str(s):<12s} {hf.name:<14s} {rl}")
|
|
413
|
+
else:
|
|
414
|
+
print()
|
|
415
|
+
print(f" {'':3s} {'Triad':<12s} {'Seventh':<12s} {'Notes (triad)':<20s} {'Notes (7th)'}")
|
|
416
|
+
print(f" {'':3s} {'-----':<12s} {'-------':<12s} {'-------------':<20s} {'-----------'}")
|
|
417
|
+
|
|
418
|
+
triads = f.chords()
|
|
419
|
+
sevenths = f.sevenths()
|
|
420
|
+
for i in range(size):
|
|
421
|
+
deg = roman[i] if i < len(roman) else str(i + 1)
|
|
422
|
+
t = triads[i]
|
|
423
|
+
s = sevenths[i]
|
|
424
|
+
t_notes = _join(t.notes(), " ")
|
|
425
|
+
s_notes = _join(s.notes(), " ")
|
|
426
|
+
print(f" {deg:>3s} {str(t):<12s} {str(s):<12s} {t_notes:<20s} {s_notes}")
|
|
427
|
+
|
|
428
|
+
if args.relative:
|
|
429
|
+
try:
|
|
430
|
+
rel = f.relative()
|
|
431
|
+
print()
|
|
432
|
+
_header("Relative field")
|
|
433
|
+
_row("Field", repr(rel))
|
|
434
|
+
_row("Triads", _join(rel.chords(), " "))
|
|
435
|
+
_row("Sevenths", _join(rel.sevenths(), " "))
|
|
436
|
+
except ValueError as e:
|
|
437
|
+
print(f"\n {e}")
|
|
438
|
+
|
|
439
|
+
if args.parallel:
|
|
440
|
+
try:
|
|
441
|
+
par = f.parallel()
|
|
442
|
+
print()
|
|
443
|
+
_header("Parallel field")
|
|
444
|
+
_row("Field", repr(par))
|
|
445
|
+
_row("Triads", _join(par.chords(), " "))
|
|
446
|
+
_row("Sevenths", _join(par.sevenths(), " "))
|
|
447
|
+
except ValueError as e:
|
|
448
|
+
print(f"\n {e}")
|
|
449
|
+
|
|
450
|
+
if args.neighbors:
|
|
451
|
+
sub, dom = f.neighbors()
|
|
452
|
+
print()
|
|
453
|
+
_header("Neighbors (circle of fifths)")
|
|
454
|
+
_row("Subdominant", f"{repr(sub)}")
|
|
455
|
+
_row(" Triads", _join(sub.chords(), " "))
|
|
456
|
+
_row("Dominant", f"{repr(dom)}")
|
|
457
|
+
_row(" Triads", _join(dom.chords(), " "))
|
|
458
|
+
|
|
459
|
+
_handle_audio(f, args)
|
|
460
|
+
print()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def cmd_compare(args):
|
|
464
|
+
"""Compare two chords (absolute or within a harmonic field)."""
|
|
465
|
+
a = Chord(args.chord_a)
|
|
466
|
+
b = Chord(args.chord_b)
|
|
467
|
+
|
|
468
|
+
# Absolute comparison (always shown)
|
|
469
|
+
r = a.compare(b)
|
|
470
|
+
|
|
471
|
+
_header(f"Compare: {a.name()} vs {b.name()}")
|
|
472
|
+
_row("Common notes", _join(r.common_notes, ", ") if r.common_notes else "(none)")
|
|
473
|
+
if r.exclusive_a:
|
|
474
|
+
_row("Exclusive A", _join(r.exclusive_a, ", "))
|
|
475
|
+
if r.exclusive_b:
|
|
476
|
+
_row("Exclusive B", _join(r.exclusive_b, ", "))
|
|
477
|
+
_row("Root distance", str(r.root_distance))
|
|
478
|
+
_row("Root direction", f"{r.root_direction:+d}")
|
|
479
|
+
_row("Same quality", "yes" if r.same_quality else "no")
|
|
480
|
+
_row("Same size", "yes" if r.same_size else "no")
|
|
481
|
+
if r.common_intervals:
|
|
482
|
+
_row("Common intervals", _join(r.common_intervals, ", "))
|
|
483
|
+
_row("Enharmonic", "yes" if r.enharmonic else "no")
|
|
484
|
+
if r.subset:
|
|
485
|
+
_row("Subset", r.subset)
|
|
486
|
+
if r.voice_leading >= 0:
|
|
487
|
+
_row("Voice leading", f"{r.voice_leading} st")
|
|
488
|
+
else:
|
|
489
|
+
_row("Voice leading", "N/A (different sizes)")
|
|
490
|
+
if r.transformation:
|
|
491
|
+
names = {
|
|
492
|
+
"P": "Parallel", "L": "Leading-tone", "R": "Relative",
|
|
493
|
+
"RP": "Relative + Parallel", "LP": "Leading-tone + Parallel",
|
|
494
|
+
"PL": "Parallel + Leading-tone", "PR": "Parallel + Relative",
|
|
495
|
+
"LR": "Leading-tone + Relative", "RL": "Relative + Leading-tone",
|
|
496
|
+
}
|
|
497
|
+
label = f"{r.transformation} ({names.get(r.transformation, r.transformation)})"
|
|
498
|
+
_row("Transformation", label)
|
|
499
|
+
if r.inversion:
|
|
500
|
+
_row("Inversion", "yes")
|
|
501
|
+
|
|
502
|
+
_row("Interval vector A", str(r.interval_vector_a))
|
|
503
|
+
_row("Interval vector B", str(r.interval_vector_b))
|
|
504
|
+
if r.same_interval_vector:
|
|
505
|
+
_row("Same interval vector", "yes")
|
|
506
|
+
|
|
507
|
+
if r.transposition >= 0:
|
|
508
|
+
_row("Transposition", f"T{r.transposition}")
|
|
509
|
+
|
|
510
|
+
_row("Dissonance A", f"{r.dissonance_a:.4f}")
|
|
511
|
+
_row("Dissonance B", f"{r.dissonance_b:.4f}")
|
|
512
|
+
|
|
513
|
+
# Contextual comparison (if --field is given)
|
|
514
|
+
if args.field:
|
|
515
|
+
parts = args.field.strip().split(maxsplit=1)
|
|
516
|
+
if len(parts) < 2:
|
|
517
|
+
print(f" Error: --field requires 'tonic type', e.g. 'C major'")
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
|
|
520
|
+
tonic, stype = parts[0], parts[1]
|
|
521
|
+
f = Field(tonic, stype)
|
|
522
|
+
fc = f.compare(a, b)
|
|
523
|
+
|
|
524
|
+
print()
|
|
525
|
+
_header(f"Context: {f.tonic()} {stype}")
|
|
526
|
+
|
|
527
|
+
def _deg_label(deg):
|
|
528
|
+
roman = ["I", "II", "III", "IV", "V", "VI", "VII"]
|
|
529
|
+
return roman[deg - 1] if deg and 1 <= deg <= 7 else str(deg)
|
|
530
|
+
|
|
531
|
+
if fc.degree_a is not None:
|
|
532
|
+
_row("Degree A", _deg_label(fc.degree_a))
|
|
533
|
+
else:
|
|
534
|
+
_row("Degree A", "non-diatonic")
|
|
535
|
+
if fc.degree_b is not None:
|
|
536
|
+
_row("Degree B", _deg_label(fc.degree_b))
|
|
537
|
+
else:
|
|
538
|
+
_row("Degree B", "non-diatonic")
|
|
539
|
+
|
|
540
|
+
if fc.function_a is not None:
|
|
541
|
+
_row("Function A", fc.function_a.name)
|
|
542
|
+
if fc.function_b is not None:
|
|
543
|
+
_row("Function B", fc.function_b.name)
|
|
544
|
+
if fc.same_function is not None:
|
|
545
|
+
_row("Same function", "yes" if fc.same_function else "no")
|
|
546
|
+
|
|
547
|
+
_row("Diatonic A", "yes" if fc.diatonic_a else "no")
|
|
548
|
+
_row("Diatonic B", "yes" if fc.diatonic_b else "no")
|
|
549
|
+
|
|
550
|
+
if fc.degree_distance is not None:
|
|
551
|
+
_row("Degree distance", str(fc.degree_distance))
|
|
552
|
+
if fc.relative:
|
|
553
|
+
_row("Relative pair", "yes")
|
|
554
|
+
if fc.root_motion:
|
|
555
|
+
_row("Root motion", fc.root_motion)
|
|
556
|
+
if fc.secondary_dominant:
|
|
557
|
+
_row("Secondary dominant", fc.secondary_dominant)
|
|
558
|
+
if fc.applied_diminished:
|
|
559
|
+
_row("Applied diminished", fc.applied_diminished)
|
|
560
|
+
if fc.tritone_sub:
|
|
561
|
+
_row("Tritone sub", "yes")
|
|
562
|
+
if fc.chromatic_mediant:
|
|
563
|
+
_row("Chromatic mediant", fc.chromatic_mediant)
|
|
564
|
+
|
|
565
|
+
if fc.borrowed_a is not None:
|
|
566
|
+
_row("Borrowed A", f"deg {fc.borrowed_a.degree} of {fc.borrowed_a.scale_type}")
|
|
567
|
+
if fc.borrowed_b is not None:
|
|
568
|
+
_row("Borrowed B", f"deg {fc.borrowed_b.degree} of {fc.borrowed_b.scale_type}")
|
|
569
|
+
|
|
570
|
+
if fc.foreign_a:
|
|
571
|
+
_row("Foreign notes A", _join(fc.foreign_a, ", "))
|
|
572
|
+
if fc.foreign_b:
|
|
573
|
+
_row("Foreign notes B", _join(fc.foreign_b, ", "))
|
|
574
|
+
|
|
575
|
+
if fc.pivot:
|
|
576
|
+
print()
|
|
577
|
+
_header("Pivot fields")
|
|
578
|
+
for p in fc.pivot:
|
|
579
|
+
print(f" {p.tonic} {p.scale_type}: "
|
|
580
|
+
f"{_deg_label(p.degree_a)} / {_deg_label(p.degree_b)}")
|
|
581
|
+
|
|
582
|
+
print()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def cmd_duration(args):
|
|
586
|
+
"""Show duration properties."""
|
|
587
|
+
if args.name_or_num:
|
|
588
|
+
# Try rational construction (e.g. "1 4" → Duration(1, 4))
|
|
589
|
+
if args.denominator is not None:
|
|
590
|
+
d = Duration(int(args.name_or_num), args.denominator)
|
|
591
|
+
else:
|
|
592
|
+
try:
|
|
593
|
+
d = Duration(args.name_or_num, dots=args.dots, tuplet=args.tuplet)
|
|
594
|
+
except (ValueError, RuntimeError):
|
|
595
|
+
print(f" Error: unknown duration '{args.name_or_num}'")
|
|
596
|
+
print(f" Valid names: {', '.join(Duration.standard_names())}")
|
|
597
|
+
sys.exit(1)
|
|
598
|
+
|
|
599
|
+
_header(f"Duration: {d.name()}")
|
|
600
|
+
_row("Beats", f"{d.beats():.4g}")
|
|
601
|
+
num, den = d.rational()
|
|
602
|
+
_row("Rational", f"{num}/{den}")
|
|
603
|
+
if args.dots:
|
|
604
|
+
_row("Dots", str(args.dots))
|
|
605
|
+
if args.tuplet:
|
|
606
|
+
_row("Tuplet", str(args.tuplet))
|
|
607
|
+
|
|
608
|
+
if args.tempo:
|
|
609
|
+
t = Tempo(args.tempo)
|
|
610
|
+
_row(f"Seconds ({int(t.bpm())} BPM)", f"{t.seconds(d):.4g} s")
|
|
611
|
+
_row("Milliseconds", f"{t.seconds(d) * 1000:.1f} ms")
|
|
612
|
+
elif not args.all:
|
|
613
|
+
print(" Error: provide a duration name (e.g. quarter), or use --all")
|
|
614
|
+
sys.exit(1)
|
|
615
|
+
|
|
616
|
+
if args.all:
|
|
617
|
+
print()
|
|
618
|
+
_header("Standard durations")
|
|
619
|
+
print(f" {'Name':<16s} {'Beats':>6s} {'Rational':>10s}")
|
|
620
|
+
print(f" {'----':<16s} {'-----':>6s} {'--------':>10s}")
|
|
621
|
+
for name in Duration.standard_names():
|
|
622
|
+
dd = Duration(name)
|
|
623
|
+
num, den = dd.rational()
|
|
624
|
+
print(f" {name:<16s} {dd.beats():>6.4g} {num:>3d}/{den:<3d}")
|
|
625
|
+
|
|
626
|
+
print()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cmd_tempo(args):
|
|
630
|
+
"""Show tempo properties."""
|
|
631
|
+
try:
|
|
632
|
+
t = Tempo(args.value)
|
|
633
|
+
except (ValueError, RuntimeError):
|
|
634
|
+
try:
|
|
635
|
+
t = Tempo(float(args.value))
|
|
636
|
+
except (ValueError, RuntimeError):
|
|
637
|
+
print(f" Error: unknown tempo '{args.value}'")
|
|
638
|
+
print(f" Use a BPM number (60-240) or marking (Allegro, Adagio, ...)")
|
|
639
|
+
sys.exit(1)
|
|
640
|
+
|
|
641
|
+
_header(f"Tempo: {t.bpm():.0f} BPM")
|
|
642
|
+
_row("BPM", f"{t.bpm():.1f}")
|
|
643
|
+
_row("Marking", t.marking())
|
|
644
|
+
_row("ms per beat", f"{t.ms_per_beat():.1f} ms")
|
|
645
|
+
_row("Quarter note", f"{t.seconds(Duration('quarter')):.4g} s")
|
|
646
|
+
_row("Half note", f"{t.seconds(Duration('half')):.4g} s")
|
|
647
|
+
_row("Whole note", f"{t.seconds(Duration('whole')):.4g} s")
|
|
648
|
+
_row("Eighth note", f"{t.seconds(Duration('eighth')):.4g} s")
|
|
649
|
+
|
|
650
|
+
if args.all:
|
|
651
|
+
print()
|
|
652
|
+
_header("Standard tempo markings")
|
|
653
|
+
markings = [
|
|
654
|
+
("Grave", 35), ("Largo", 50), ("Adagio", 60),
|
|
655
|
+
("Andante", 80), ("Moderato", 108), ("Allegretto", 120),
|
|
656
|
+
("Allegro", 140), ("Vivace", 168), ("Presto", 188),
|
|
657
|
+
("Prestissimo", 210),
|
|
658
|
+
]
|
|
659
|
+
print(f" {'Marking':<16s} {'BPM':>6s} {'ms/beat':>8s} {'Quarter (s)':>11s}")
|
|
660
|
+
print(f" {'-------':<16s} {'---':>6s} {'-------':>8s} {'-----------':>11s}")
|
|
661
|
+
for name, bpm in markings:
|
|
662
|
+
tt = Tempo(bpm)
|
|
663
|
+
print(f" {name:<16s} {bpm:>6d} {tt.ms_per_beat():>8.1f} {tt.seconds(Duration('quarter')):>11.4g}")
|
|
664
|
+
|
|
665
|
+
print()
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def cmd_timesig(args):
|
|
669
|
+
"""Show time signature properties."""
|
|
670
|
+
if args.beats is not None and args.unit is not None:
|
|
671
|
+
ts = TimeSignature(args.beats, args.unit)
|
|
672
|
+
|
|
673
|
+
_header(f"Time Signature: {ts}")
|
|
674
|
+
_row("Beats per bar", str(ts.beats_per_bar()))
|
|
675
|
+
_row("Beat unit", str(ts.beat_unit()))
|
|
676
|
+
_row("Classification", ts.classification())
|
|
677
|
+
common = ts.common_name()
|
|
678
|
+
if common:
|
|
679
|
+
_row("Common name", common)
|
|
680
|
+
bar = ts.bar_duration()
|
|
681
|
+
_row("Bar duration", f"{bar.beats():.4g} beats")
|
|
682
|
+
|
|
683
|
+
if args.tempo:
|
|
684
|
+
t = Tempo(args.tempo)
|
|
685
|
+
bar_sec = t.seconds(bar)
|
|
686
|
+
_row(f"Bar at {int(t.bpm())} BPM", f"{bar_sec:.4g} s")
|
|
687
|
+
elif not args.all:
|
|
688
|
+
print(" Error: provide beats and unit (e.g. 4 4), or use --all")
|
|
689
|
+
sys.exit(1)
|
|
690
|
+
|
|
691
|
+
if args.all:
|
|
692
|
+
print()
|
|
693
|
+
_header("Common time signatures")
|
|
694
|
+
common_sigs = [
|
|
695
|
+
(4, 4), (3, 4), (2, 4), (2, 2),
|
|
696
|
+
(6, 8), (9, 8), (12, 8),
|
|
697
|
+
(5, 4), (7, 8),
|
|
698
|
+
]
|
|
699
|
+
print(f" {'Sig':>5s} {'Class':<10s} {'Name':<14s} {'Bar (beats)':>11s}")
|
|
700
|
+
print(f" {'---':>5s} {'-----':<10s} {'----':<14s} {'-----------':>11s}")
|
|
701
|
+
for b, u in common_sigs:
|
|
702
|
+
tt = TimeSignature(b, u)
|
|
703
|
+
cn = tt.common_name() or ""
|
|
704
|
+
print(f" {str(tt):>5s} {tt.classification():<10s} {cn:<14s} {tt.bar_duration().beats():>11.4g}")
|
|
705
|
+
|
|
706
|
+
print()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
# Argument parser
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
def build_parser():
|
|
714
|
+
parser = argparse.ArgumentParser(
|
|
715
|
+
prog="gingo",
|
|
716
|
+
description=textwrap.dedent("""\
|
|
717
|
+
Gingo — music theory from the terminal.
|
|
718
|
+
|
|
719
|
+
Explore notes, intervals, chords, scales, harmonic fields,
|
|
720
|
+
and rhythm (durations, tempo, time signatures).
|
|
721
|
+
|
|
722
|
+
Pitch concepts build on each other:
|
|
723
|
+
Note → Interval → Chord → Scale → Field
|
|
724
|
+
|
|
725
|
+
Rhythm concepts:
|
|
726
|
+
Duration → Tempo → Time Signature
|
|
727
|
+
|
|
728
|
+
The chromatic universe has 12 pitch classes.
|
|
729
|
+
Everything else is selection, rotation, and relationship.
|
|
730
|
+
"""),
|
|
731
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
732
|
+
epilog=textwrap.dedent(f"""\
|
|
733
|
+
examples:
|
|
734
|
+
%(prog)s note C#
|
|
735
|
+
%(prog)s note Bb --transpose 7
|
|
736
|
+
%(prog)s note C --fifths
|
|
737
|
+
%(prog)s note C --distance G
|
|
738
|
+
%(prog)s interval 5J
|
|
739
|
+
%(prog)s interval 7 --all
|
|
740
|
+
%(prog)s chord Cmaj7
|
|
741
|
+
%(prog)s chord "C,E,G,B" --identify
|
|
742
|
+
%(prog)s scale "C major"
|
|
743
|
+
%(prog)s scale "C major" --modes
|
|
744
|
+
%(prog)s scale "D dorian"
|
|
745
|
+
%(prog)s scale "D dorian" --colors ionian
|
|
746
|
+
%(prog)s scale "C whole tone"
|
|
747
|
+
%(prog)s scale "C major pentatonic"
|
|
748
|
+
%(prog)s scale "C major" --pentatonic
|
|
749
|
+
%(prog)s scale "C major" --degree 5 5
|
|
750
|
+
%(prog)s scale "C major" --walk 1 4
|
|
751
|
+
%(prog)s scale "C major" --relative
|
|
752
|
+
%(prog)s scale "C major" --neighbors
|
|
753
|
+
%(prog)s scale "C,D,E,F,G,A,B" --identify
|
|
754
|
+
%(prog)s field "C major"
|
|
755
|
+
%(prog)s field "C major" --applied V7/II
|
|
756
|
+
%(prog)s field "C major" --relative
|
|
757
|
+
%(prog)s field "C major" --neighbors
|
|
758
|
+
%(prog)s field "CM,FM,G7" --identify
|
|
759
|
+
%(prog)s field "CM,FM" --deduce
|
|
760
|
+
%(prog)s compare CM Am
|
|
761
|
+
%(prog)s compare CM GM --field "C major"
|
|
762
|
+
%(prog)s duration quarter
|
|
763
|
+
%(prog)s duration eighth --dots 1
|
|
764
|
+
%(prog)s duration --all
|
|
765
|
+
%(prog)s tempo 120
|
|
766
|
+
%(prog)s tempo Allegro --all
|
|
767
|
+
%(prog)s timesig 4 4
|
|
768
|
+
%(prog)s timesig 6 8 --tempo 120
|
|
769
|
+
%(prog)s timesig --all
|
|
770
|
+
|
|
771
|
+
scale types (parent families):
|
|
772
|
+
major, natural minor, harmonic minor,
|
|
773
|
+
melodic minor, diminished, harmonic major,
|
|
774
|
+
whole tone, augmented, blues, chromatic
|
|
775
|
+
|
|
776
|
+
mode names (selected):
|
|
777
|
+
ionian, dorian, phrygian, lydian, mixolydian,
|
|
778
|
+
aeolian, locrian, altered, phrygian dominant,
|
|
779
|
+
lydian dominant, lydian augmented, ...
|
|
780
|
+
|
|
781
|
+
interval labels (24 total, 2 octaves):
|
|
782
|
+
P1 2m 2M 3m 3M 4J d5 5J
|
|
783
|
+
#5 M6 7m 7M 8J b9 9 #9
|
|
784
|
+
b11 11 #11 5 b13 13 #13 bI
|
|
785
|
+
|
|
786
|
+
version: {__version__}
|
|
787
|
+
"""),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
sub = parser.add_subparsers(
|
|
791
|
+
dest="command",
|
|
792
|
+
title="commands",
|
|
793
|
+
description="choose what to explore",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
# --- note ---
|
|
797
|
+
p_note = sub.add_parser(
|
|
798
|
+
"note", help="explore a note (pitch class)",
|
|
799
|
+
description=textwrap.dedent("""\
|
|
800
|
+
Show properties of a note: semitone index, frequency,
|
|
801
|
+
enharmonic resolution, and transposition.
|
|
802
|
+
|
|
803
|
+
The 12 chromatic pitch classes:
|
|
804
|
+
C C# D D# E F F# G G# A A# B
|
|
805
|
+
|
|
806
|
+
Flat notation is also accepted:
|
|
807
|
+
Db Eb Gb Ab Bb
|
|
808
|
+
|
|
809
|
+
Double accidentals and unicode work too:
|
|
810
|
+
Ebb F## B♭ F♯
|
|
811
|
+
"""),
|
|
812
|
+
epilog=textwrap.dedent("""\
|
|
813
|
+
examples:
|
|
814
|
+
gingo note C#
|
|
815
|
+
gingo note Bb
|
|
816
|
+
gingo note Bb --transpose 7
|
|
817
|
+
gingo note Db --enharmonic C#
|
|
818
|
+
gingo note C --distance F#
|
|
819
|
+
gingo note G --fifths
|
|
820
|
+
"""),
|
|
821
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
822
|
+
)
|
|
823
|
+
p_note.add_argument("name", help="note name (C, C#, Db, Bb, ...)")
|
|
824
|
+
p_note.add_argument("--transpose", type=int, metavar="ST",
|
|
825
|
+
help="transpose by N semitones")
|
|
826
|
+
p_note.add_argument("--enharmonic", metavar="NOTE",
|
|
827
|
+
help="check if enharmonic to another note")
|
|
828
|
+
p_note.add_argument("--distance", metavar="NOTE",
|
|
829
|
+
help="distance to another note on the circle of fifths (0-6)")
|
|
830
|
+
p_note.add_argument("--octave", type=int, metavar="N",
|
|
831
|
+
help="octave for frequency calculation (default: 4)")
|
|
832
|
+
p_note.add_argument("--tuning", type=float, metavar="HZ",
|
|
833
|
+
help="A4 reference frequency in Hz (default: 440)")
|
|
834
|
+
p_note.add_argument("--fifths", action="store_true",
|
|
835
|
+
help="show position on the circle of fifths")
|
|
836
|
+
_add_audio_args(p_note)
|
|
837
|
+
p_note.set_defaults(func=cmd_note)
|
|
838
|
+
|
|
839
|
+
# --- interval ---
|
|
840
|
+
p_iv = sub.add_parser(
|
|
841
|
+
"interval", help="explore an interval",
|
|
842
|
+
description=textwrap.dedent("""\
|
|
843
|
+
Show properties of an interval by label or semitone count.
|
|
844
|
+
|
|
845
|
+
An interval is the distance between two notes.
|
|
846
|
+
Gingo covers 24 intervals (2 octaves, 0-23 semitones).
|
|
847
|
+
|
|
848
|
+
Common intervals:
|
|
849
|
+
P1 = unison (0 st) 3m = minor 3rd (3 st)
|
|
850
|
+
3M = major 3rd (4 st) 4J = perfect 4th (5 st)
|
|
851
|
+
5J = perfect 5th (7 st) 7m = minor 7th (10 st)
|
|
852
|
+
7M = major 7th (11 st) 8J = octave (12 st)
|
|
853
|
+
|
|
854
|
+
Use --all to see the full table of 24 intervals.
|
|
855
|
+
"""),
|
|
856
|
+
epilog=textwrap.dedent("""\
|
|
857
|
+
examples:
|
|
858
|
+
gingo interval 5J
|
|
859
|
+
gingo interval 3M
|
|
860
|
+
gingo interval 7 (by semitone count)
|
|
861
|
+
gingo interval 7 --all (show all 24)
|
|
862
|
+
"""),
|
|
863
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
864
|
+
)
|
|
865
|
+
p_iv.add_argument("label", help="interval label (5J, 3M, 7m) or semitone count (0-23)")
|
|
866
|
+
p_iv.add_argument("--all", action="store_true",
|
|
867
|
+
help="show all 24 intervals (2 octaves)")
|
|
868
|
+
p_iv.set_defaults(func=cmd_interval)
|
|
869
|
+
|
|
870
|
+
# --- chord ---
|
|
871
|
+
p_ch = sub.add_parser(
|
|
872
|
+
"chord", help="explore a chord or identify notes",
|
|
873
|
+
description=textwrap.dedent("""\
|
|
874
|
+
Show chord properties or identify a chord from notes.
|
|
875
|
+
|
|
876
|
+
A chord = root note + type suffix. 42 chord types are supported.
|
|
877
|
+
|
|
878
|
+
Triads: M m dim aug sus4 sus2 5
|
|
879
|
+
Sevenths: 7 7M m7 m7M dim7 m7(b5)
|
|
880
|
+
Sixths: 6 m6
|
|
881
|
+
Extended: 9 7M(9) m9 6(9) m6(9)
|
|
882
|
+
Altered: 7(b5) aug7 7(#9) 7(b9) 7(#5)
|
|
883
|
+
7(b5b9) 7(#5#9) m7(9) 7M(#5)
|
|
884
|
+
Suspended: 7sus4 7sus2
|
|
885
|
+
Add: add9 madd9 add11 add13
|
|
886
|
+
Other: dim7M m7(11) 7(13) 7M(13) ...
|
|
887
|
+
|
|
888
|
+
With --identify, give comma-separated notes (first = root).
|
|
889
|
+
"""),
|
|
890
|
+
epilog=textwrap.dedent("""\
|
|
891
|
+
examples:
|
|
892
|
+
gingo chord CM
|
|
893
|
+
gingo chord Am7
|
|
894
|
+
gingo chord Bbdim7
|
|
895
|
+
gingo chord "F#7M(#5)"
|
|
896
|
+
gingo chord "C,E,G,B" --identify
|
|
897
|
+
gingo chord "D,F,A" --identify
|
|
898
|
+
"""),
|
|
899
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
900
|
+
)
|
|
901
|
+
p_ch.add_argument("name", help="chord name (Cmaj7, Am, G7) or comma-separated notes")
|
|
902
|
+
p_ch.add_argument("--identify", action="store_true",
|
|
903
|
+
help="identify chord from comma-separated notes")
|
|
904
|
+
_add_audio_args(p_ch)
|
|
905
|
+
p_ch.set_defaults(func=cmd_chord)
|
|
906
|
+
|
|
907
|
+
# --- scale ---
|
|
908
|
+
p_sc = sub.add_parser(
|
|
909
|
+
"scale", help="explore a scale, its modes and degrees",
|
|
910
|
+
description=textwrap.dedent("""\
|
|
911
|
+
Show scale notes, binary mask, modes, and degrees.
|
|
912
|
+
|
|
913
|
+
A scale is a selection of notes from the 12 chromatic pitch classes,
|
|
914
|
+
organized around a tonic (root note). Format: "TONIC TYPE".
|
|
915
|
+
|
|
916
|
+
Parent scale types:
|
|
917
|
+
major ........... Major / Ionian
|
|
918
|
+
natural minor ... Natural Minor / Aeolian
|
|
919
|
+
harmonic minor .. Harmonic Minor
|
|
920
|
+
melodic minor ... Melodic Minor
|
|
921
|
+
harmonic major .. Harmonic Major
|
|
922
|
+
diminished ...... Diminished / Whole-Half
|
|
923
|
+
whole tone ...... Whole Tone (6 notes)
|
|
924
|
+
augmented ....... Augmented (6 notes)
|
|
925
|
+
blues ........... Blues (6 notes)
|
|
926
|
+
chromatic ....... Chromatic (12 notes)
|
|
927
|
+
|
|
928
|
+
Mode names (also accepted as type):
|
|
929
|
+
dorian, phrygian, lydian, mixolydian, locrian,
|
|
930
|
+
altered, phrygian dominant, lydian dominant,
|
|
931
|
+
lydian augmented, dorian b2, mixolydian b6, ...
|
|
932
|
+
|
|
933
|
+
Pentatonic variants:
|
|
934
|
+
"C major pentatonic", "D dorian pentatonic", ...
|
|
935
|
+
|
|
936
|
+
Tonic can be any note: C, C#, Db, D, Eb, E, F, F#, Gb, G, Ab, A, Bb, B
|
|
937
|
+
"""),
|
|
938
|
+
epilog=textwrap.dedent("""\
|
|
939
|
+
examples:
|
|
940
|
+
gingo scale "C major"
|
|
941
|
+
gingo scale "C major" --modes
|
|
942
|
+
gingo scale "D dorian"
|
|
943
|
+
gingo scale "D dorian" --colors ionian
|
|
944
|
+
gingo scale "A natural minor" --modes
|
|
945
|
+
gingo scale "C harmonic minor" --modes
|
|
946
|
+
gingo scale "C melodic minor" --modes
|
|
947
|
+
gingo scale "C diminished"
|
|
948
|
+
gingo scale "C whole tone"
|
|
949
|
+
gingo scale "A blues"
|
|
950
|
+
gingo scale "C major pentatonic"
|
|
951
|
+
gingo scale "Bb major" --pentatonic
|
|
952
|
+
gingo scale "F# major" --degrees
|
|
953
|
+
gingo scale "C major" --degree 5 (V = G)
|
|
954
|
+
gingo scale "C major" --degree 5 5 (V of V = D)
|
|
955
|
+
gingo scale "C major" --degree 5 5 3 (III of V of V = F)
|
|
956
|
+
gingo scale "C major" --walk 1 4 (from I, walk 4 = F)
|
|
957
|
+
gingo scale "C major" --walk 5 5 (from V, walk 5 = D)
|
|
958
|
+
gingo scale "C major" --relative
|
|
959
|
+
gingo scale "A natural minor" --parallel
|
|
960
|
+
gingo scale "G major" --neighbors
|
|
961
|
+
gingo scale "C,D,E,F,G,A,B" --identify
|
|
962
|
+
gingo scale "A,B,C,D,E,F,G" --identify
|
|
963
|
+
"""),
|
|
964
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
965
|
+
)
|
|
966
|
+
p_sc.add_argument("name", help='"tonic type" — e.g. "C major", "A natural minor", "Bb dim"')
|
|
967
|
+
p_sc.add_argument("--modes", action="store_true",
|
|
968
|
+
help="show all modes of the scale with traditional names")
|
|
969
|
+
p_sc.add_argument("--degrees", action="store_true",
|
|
970
|
+
help="show each degree note")
|
|
971
|
+
p_sc.add_argument("--pentatonic", action="store_true",
|
|
972
|
+
help="use pentatonic modality (5 notes)")
|
|
973
|
+
p_sc.add_argument("--colors", metavar="REF",
|
|
974
|
+
help="show color notes vs a reference mode (e.g. ionian)")
|
|
975
|
+
p_sc.add_argument("--degree", nargs="+", type=int, metavar="N",
|
|
976
|
+
help="chained degree: --degree 5 5 = V of V")
|
|
977
|
+
p_sc.add_argument("--walk", nargs="+", type=int, metavar="N",
|
|
978
|
+
help="walk: start + steps, e.g. --walk 1 4 = from I, walk 4")
|
|
979
|
+
p_sc.add_argument("--relative", action="store_true",
|
|
980
|
+
help="show the relative major/minor key")
|
|
981
|
+
p_sc.add_argument("--parallel", action="store_true",
|
|
982
|
+
help="show the parallel major/minor key")
|
|
983
|
+
p_sc.add_argument("--neighbors", action="store_true",
|
|
984
|
+
help="show neighboring keys on the circle of fifths")
|
|
985
|
+
p_sc.add_argument("--identify", action="store_true",
|
|
986
|
+
help="identify scale from comma-separated notes")
|
|
987
|
+
_add_audio_args(p_sc)
|
|
988
|
+
p_sc.set_defaults(func=cmd_scale)
|
|
989
|
+
|
|
990
|
+
# --- field ---
|
|
991
|
+
p_fi = sub.add_parser(
|
|
992
|
+
"field", help="show the harmonic field (triads and sevenths)",
|
|
993
|
+
description=textwrap.dedent("""\
|
|
994
|
+
Build triads and seventh chords on each degree of a scale.
|
|
995
|
+
|
|
996
|
+
A harmonic field shows which chords naturally belong to a scale.
|
|
997
|
+
For each degree (I through VII), it builds a triad (3 notes) and
|
|
998
|
+
a seventh chord (4 notes) using only notes from the scale.
|
|
999
|
+
|
|
1000
|
+
Format: "TONIC TYPE"
|
|
1001
|
+
|
|
1002
|
+
Scale types (accepted names):
|
|
1003
|
+
major, natural minor, harmonic minor,
|
|
1004
|
+
melodic minor, diminished, harmonic major,
|
|
1005
|
+
whole tone, augmented, blues, chromatic
|
|
1006
|
+
|
|
1007
|
+
Tonic can be any note: C, C#, Db, D, Eb, E, F, F#, Gb, G, Ab, A, Bb, B
|
|
1008
|
+
|
|
1009
|
+
Output columns:
|
|
1010
|
+
Degree | Triad | Seventh | Notes (triad) | Notes (7th)
|
|
1011
|
+
"""),
|
|
1012
|
+
epilog=textwrap.dedent("""\
|
|
1013
|
+
examples:
|
|
1014
|
+
gingo field "C major"
|
|
1015
|
+
gingo field "A natural minor"
|
|
1016
|
+
gingo field "C harmonic minor"
|
|
1017
|
+
gingo field "D melodic minor"
|
|
1018
|
+
gingo field "Bb major"
|
|
1019
|
+
gingo field "F# diminished"
|
|
1020
|
+
gingo field "C major" --applied V7/II
|
|
1021
|
+
gingo field "C major" --applied "IIm7(b5)/V"
|
|
1022
|
+
gingo field "C major" --applied 5/2
|
|
1023
|
+
gingo field "C major" --functions
|
|
1024
|
+
gingo field "C major" --relative
|
|
1025
|
+
gingo field "A natural minor" --parallel
|
|
1026
|
+
gingo field "G major" --neighbors
|
|
1027
|
+
gingo field "CM,Dm,Em,FM,GM,Am" --identify
|
|
1028
|
+
gingo field "CM,FM,G7" --identify
|
|
1029
|
+
gingo field "C,D,E,F,G,A,B" --identify
|
|
1030
|
+
gingo field "CM,FM" --deduce
|
|
1031
|
+
gingo field "Am,Dm,E7" --deduce
|
|
1032
|
+
gingo field "C,D,E" --deduce
|
|
1033
|
+
gingo field "CM,FM" --deduce --limit 5
|
|
1034
|
+
"""),
|
|
1035
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1036
|
+
)
|
|
1037
|
+
p_fi.add_argument("name", help='"tonic type" — e.g. "C major", "A natural minor", "Eb dim"')
|
|
1038
|
+
p_fi.add_argument("--applied", metavar="X/Y",
|
|
1039
|
+
help="applied chord in X/Y notation (e.g. V7/II, 5/2, IIm7(b5)/V)")
|
|
1040
|
+
p_fi.add_argument("--functions", action="store_true",
|
|
1041
|
+
help="show harmonic function (T/S/D) and role for each degree")
|
|
1042
|
+
p_fi.add_argument("--relative", action="store_true",
|
|
1043
|
+
help="show the relative major/minor harmonic field")
|
|
1044
|
+
p_fi.add_argument("--parallel", action="store_true",
|
|
1045
|
+
help="show the parallel major/minor harmonic field")
|
|
1046
|
+
p_fi.add_argument("--neighbors", action="store_true",
|
|
1047
|
+
help="show neighboring harmonic fields on the circle of fifths")
|
|
1048
|
+
p_fi.add_argument("--identify", action="store_true",
|
|
1049
|
+
help="identify field from comma-separated notes or chords")
|
|
1050
|
+
p_fi.add_argument("--deduce", action="store_true",
|
|
1051
|
+
help="deduce likely fields from partial input (ranked)")
|
|
1052
|
+
p_fi.add_argument("--limit", type=int, default=10, metavar="N",
|
|
1053
|
+
help="max results for --deduce (default: 10, 0=all)")
|
|
1054
|
+
_add_audio_args(p_fi)
|
|
1055
|
+
p_fi.set_defaults(func=cmd_field)
|
|
1056
|
+
|
|
1057
|
+
# --- compare ---
|
|
1058
|
+
p_cmp = sub.add_parser(
|
|
1059
|
+
"compare", help="compare two chords (absolute or contextual)",
|
|
1060
|
+
description=textwrap.dedent("""\
|
|
1061
|
+
Compare two chords and see their relationship.
|
|
1062
|
+
|
|
1063
|
+
Without --field, shows absolute (context-free) comparison:
|
|
1064
|
+
common notes, root distance, voice leading, neo-Riemannian
|
|
1065
|
+
transformation (P/L/R), subset, enharmonic equivalence.
|
|
1066
|
+
|
|
1067
|
+
With --field, adds contextual comparison within a harmonic field:
|
|
1068
|
+
degrees, functions, secondary dominants, tritone substitution,
|
|
1069
|
+
modal borrowing, pivot fields, chromatic mediants.
|
|
1070
|
+
|
|
1071
|
+
Neo-Riemannian transformations (triads only):
|
|
1072
|
+
P (Parallel) CM <-> Cm same root, change quality
|
|
1073
|
+
L (Leading-tone) CM <-> Em move root by semitone
|
|
1074
|
+
R (Relative) CM <-> Am relative major/minor
|
|
1075
|
+
"""),
|
|
1076
|
+
epilog=textwrap.dedent("""\
|
|
1077
|
+
examples:
|
|
1078
|
+
gingo compare CM Am
|
|
1079
|
+
gingo compare CM Cm
|
|
1080
|
+
gingo compare CM Em
|
|
1081
|
+
gingo compare CM GM --field "C major"
|
|
1082
|
+
gingo compare D7 GM --field "C major"
|
|
1083
|
+
gingo compare CM Fm --field "C major"
|
|
1084
|
+
gingo compare G7 C#7 --field "C major"
|
|
1085
|
+
"""),
|
|
1086
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1087
|
+
)
|
|
1088
|
+
p_cmp.add_argument("chord_a", help="first chord name (CM, Am7, Bbdim, ...)")
|
|
1089
|
+
p_cmp.add_argument("chord_b", help="second chord name")
|
|
1090
|
+
p_cmp.add_argument("--field", metavar='"T type"',
|
|
1091
|
+
help='harmonic field context, e.g. "C major"')
|
|
1092
|
+
p_cmp.set_defaults(func=cmd_compare)
|
|
1093
|
+
|
|
1094
|
+
# --- duration ---
|
|
1095
|
+
p_dur = sub.add_parser(
|
|
1096
|
+
"duration", help="explore rhythmic durations",
|
|
1097
|
+
description=textwrap.dedent("""\
|
|
1098
|
+
Show properties of a rhythmic duration.
|
|
1099
|
+
|
|
1100
|
+
A duration represents a note length in beats (quarter = 1 beat).
|
|
1101
|
+
Standard durations: whole, half, quarter, eighth, sixteenth,
|
|
1102
|
+
thirty_second, sixty_fourth.
|
|
1103
|
+
|
|
1104
|
+
Modifiers:
|
|
1105
|
+
--dots N dotted duration (1 dot = 1.5x, 2 dots = 1.75x)
|
|
1106
|
+
--tuplet N tuplet division (3 = triplet = 2/3 of normal)
|
|
1107
|
+
|
|
1108
|
+
Rational form: numerator/denominator (quarter = 1/4).
|
|
1109
|
+
"""),
|
|
1110
|
+
epilog=textwrap.dedent("""\
|
|
1111
|
+
examples:
|
|
1112
|
+
gingo duration quarter
|
|
1113
|
+
gingo duration half
|
|
1114
|
+
gingo duration eighth --dots 1
|
|
1115
|
+
gingo duration quarter --dots 2
|
|
1116
|
+
gingo duration eighth --tuplet 3
|
|
1117
|
+
gingo duration quarter --tempo 120
|
|
1118
|
+
gingo duration --all
|
|
1119
|
+
"""),
|
|
1120
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1121
|
+
)
|
|
1122
|
+
p_dur.add_argument("name_or_num", nargs="?", default=None,
|
|
1123
|
+
help="duration name (quarter, half, ...) or numerator")
|
|
1124
|
+
p_dur.add_argument("denominator", nargs="?", type=int, default=None,
|
|
1125
|
+
help="denominator for rational form (e.g. 1 4 = quarter)")
|
|
1126
|
+
p_dur.add_argument("--dots", type=int, default=0, metavar="N",
|
|
1127
|
+
help="number of dots (1 = dotted, 2 = double-dotted)")
|
|
1128
|
+
p_dur.add_argument("--tuplet", type=int, default=0, metavar="N",
|
|
1129
|
+
help="tuplet division (3 = triplet)")
|
|
1130
|
+
p_dur.add_argument("--tempo", type=float, metavar="BPM",
|
|
1131
|
+
help="show real-time duration at given tempo")
|
|
1132
|
+
p_dur.add_argument("--all", action="store_true",
|
|
1133
|
+
help="show all standard durations")
|
|
1134
|
+
p_dur.set_defaults(func=cmd_duration)
|
|
1135
|
+
|
|
1136
|
+
# --- tempo ---
|
|
1137
|
+
p_tmp = sub.add_parser(
|
|
1138
|
+
"tempo", help="explore tempo markings and conversions",
|
|
1139
|
+
description=textwrap.dedent("""\
|
|
1140
|
+
Show tempo properties: BPM, marking, and note durations.
|
|
1141
|
+
|
|
1142
|
+
A tempo can be given as a BPM number or an Italian marking.
|
|
1143
|
+
Standard markings: Grave, Largo, Adagio, Andante, Moderato,
|
|
1144
|
+
Allegretto, Allegro, Vivace, Presto, Prestissimo.
|
|
1145
|
+
"""),
|
|
1146
|
+
epilog=textwrap.dedent("""\
|
|
1147
|
+
examples:
|
|
1148
|
+
gingo tempo 120
|
|
1149
|
+
gingo tempo 60
|
|
1150
|
+
gingo tempo Allegro
|
|
1151
|
+
gingo tempo Adagio
|
|
1152
|
+
gingo tempo 140 --all
|
|
1153
|
+
"""),
|
|
1154
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1155
|
+
)
|
|
1156
|
+
p_tmp.add_argument("value", help="BPM number or marking name (Allegro, Adagio, ...)")
|
|
1157
|
+
p_tmp.add_argument("--all", action="store_true",
|
|
1158
|
+
help="show all standard tempo markings")
|
|
1159
|
+
p_tmp.set_defaults(func=cmd_tempo)
|
|
1160
|
+
|
|
1161
|
+
# --- timesig ---
|
|
1162
|
+
p_ts = sub.add_parser(
|
|
1163
|
+
"timesig", help="explore time signatures",
|
|
1164
|
+
description=textwrap.dedent("""\
|
|
1165
|
+
Show time signature properties: classification, bar duration.
|
|
1166
|
+
|
|
1167
|
+
A time signature defines the meter: beats per bar and beat unit.
|
|
1168
|
+
Common examples: 4/4 (common time), 3/4 (waltz), 6/8 (compound).
|
|
1169
|
+
|
|
1170
|
+
Classification:
|
|
1171
|
+
simple ...... beat divides into 2 (4/4, 3/4, 2/4)
|
|
1172
|
+
compound .... beat divides into 3 (6/8, 9/8, 12/8)
|
|
1173
|
+
"""),
|
|
1174
|
+
epilog=textwrap.dedent("""\
|
|
1175
|
+
examples:
|
|
1176
|
+
gingo timesig 4 4
|
|
1177
|
+
gingo timesig 3 4
|
|
1178
|
+
gingo timesig 6 8
|
|
1179
|
+
gingo timesig 2 2
|
|
1180
|
+
gingo timesig 7 8
|
|
1181
|
+
gingo timesig 4 4 --tempo 120
|
|
1182
|
+
gingo timesig --all
|
|
1183
|
+
"""),
|
|
1184
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1185
|
+
)
|
|
1186
|
+
p_ts.add_argument("beats", nargs="?", type=int, default=None,
|
|
1187
|
+
help="beats per bar")
|
|
1188
|
+
p_ts.add_argument("unit", nargs="?", type=int, default=None,
|
|
1189
|
+
help="beat unit (denominator)")
|
|
1190
|
+
p_ts.add_argument("--tempo", type=float, metavar="BPM",
|
|
1191
|
+
help="show bar duration in seconds at given tempo")
|
|
1192
|
+
p_ts.add_argument("--all", action="store_true",
|
|
1193
|
+
help="show common time signatures")
|
|
1194
|
+
p_ts.set_defaults(func=cmd_timesig)
|
|
1195
|
+
|
|
1196
|
+
return parser
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# ---------------------------------------------------------------------------
|
|
1200
|
+
# Main
|
|
1201
|
+
# ---------------------------------------------------------------------------
|
|
1202
|
+
|
|
1203
|
+
def main():
|
|
1204
|
+
parser = build_parser()
|
|
1205
|
+
args = parser.parse_args()
|
|
1206
|
+
|
|
1207
|
+
if not args.command:
|
|
1208
|
+
parser.print_help()
|
|
1209
|
+
sys.exit(0)
|
|
1210
|
+
|
|
1211
|
+
try:
|
|
1212
|
+
args.func(args)
|
|
1213
|
+
except (ValueError, RuntimeError) as e:
|
|
1214
|
+
print(f"\n Error: {e}\n", file=sys.stderr)
|
|
1215
|
+
sys.exit(1)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
if __name__ == "__main__":
|
|
1219
|
+
main()
|