gingo 1.0.0__cp313-cp313-win_amd64.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/__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()