Last active
February 21, 2024 11:46
-
-
Save lynn/516d5a19cf9a98d6ba28013f43fb79b7 to your computer and use it in GitHub Desktop.
play just-intonation chord progressions in your terminal
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# | |
# python3.8 ji.py --help | |
# python3.8 ji.py CEGBb CE-GBbL | aplay -r44 -f S16_LE | |
# python3.8 ji.py CEGBb CE-GBbL | ffmpeg -f s16le -ar 44.1k -ac 1 -i - ji.mp3 | |
""" | |
Play chord progressions in just intonation. | |
Raw PCM audio is written to stdout (signed, 16-bit, LE, mono). | |
____________________________________________________________________ | |
The input is given in an ASCII version of "Helmholtz-Ellis notation". | |
Notes are in an octave-reduced circle of perfect fifths, centered on C: | |
... Ab Eb Bb F [C] G D A E B F# C# G# ... | |
A chord is a string of notes followed by accidentals, like: CE-GBbLC. | |
Accidentals pitch notes down or up by other ratios: | |
, . octave down/up 2:1 | |
b # 3-limit sharp/flat 2187:2048 * | |
- + 5-limit syntonic comma 81:80 | |
L 7 7-limit septimal comma 64:63 | |
v ^ 11-limit quarter-tone 33:32 | |
(* This is (3:2)^7, octave-reduced: 3^7 / 2^11.) | |
Example: CE-GBbLC. is a 4:5:6:7:8 chord on C. | |
____________________________________________________________________ | |
""" | |
import argparse | |
import struct | |
import sys | |
from fractions import Fraction | |
from typing import List | |
from math import sin, pi | |
def tty_warning(): | |
return ( | |
'\x1b[36mIt looks like stdout is a terminal.\x1b[0m\n' | |
'\x1b[36mYou should probably pipe the program output somewhere, like:\x1b[0m\n' | |
'\n' | |
f'\tpython {sys.argv[0]} CEG | aplay -r44 -f S16_LE\n' | |
f'\tpython {sys.argv[0]} CEG | ffmpeg -f s16le -ar 44.1k -ac 1 -i - ji.mp3\n' | |
'\n' | |
'\x1b[36mOr run with --force if you want to force PCM output.\x1b[0m\n' | |
) | |
def octave_reduce(x): | |
while x >= 2: | |
x /= 2 | |
while x < 1: | |
x *= 2 | |
return x | |
octave = Fraction(2, 1) | |
fifth = Fraction(3, 2) | |
syntonic = Fraction(81, 80) | |
septimal = Fraction(64, 63) | |
undecimal = Fraction(33, 32) | |
sharp = octave_reduce(fifth ** 7) | |
notes = {x: octave_reduce(fifth ** i) for (i, x) in enumerate('FCGDAEB', -1)} | |
accidentals = { | |
".": octave, ",": octave ** -1, "'": octave, | |
"#": sharp, "b": sharp ** -1, "x": sharp ** 2, | |
"+": syntonic, "-": syntonic ** -1, | |
"7": septimal, "L": septimal ** -1, | |
"^": undecimal, "v": undecimal ** -1, | |
} | |
def parse_chord(chord: str) -> List[float]: | |
fractions = [] | |
for glyph in chord: | |
if (f := notes.get(glyph)): | |
fractions.append(f) | |
elif (f := accidentals.get(glyph)): | |
fractions[-1] *= f | |
else: | |
raise ValueError(f'Parse error: {glyph} in {chord}') | |
return fractions | |
def organ(t: float) -> float: | |
return 0.1*sin(2*pi*t) + 0.01*sin(4*pi*t) + 0.001*sin(6*pi*t) | |
def s16_le(amplitude: float) -> bytes: | |
x = int(max(-1, min(1, amplitude)) * 32767) | |
return bytes((x & 0xFF, x >> 8 & 0xFF)) | |
def play(args): | |
for chord in args.chords: | |
fractions = parse_chord(chord) | |
fs = [float(f) for f in fractions] | |
n = int(args.sample_rate * args.duration) | |
for i in range(n): | |
t = i / args.sample_rate | |
a = args.volume * min(1, 40*t, 40*(args.duration-t)) * max(0.75, 1-t) | |
sample = s16_le(a * sum(organ(args.base*f*t) for f in fs)) | |
sys.stdout.buffer.write(sample) | |
sys.stderr.write(f'{chord:>16} = {" ".join(str(f) for f in fractions).replace("/", ":")}\n') | |
sys.stderr.flush() | |
if __name__ == '__main__': | |
class MyFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): | |
pass | |
parser = argparse.ArgumentParser(description=__doc__, formatter_class=MyFormatter) | |
parser.add_argument('chords', metavar='CHORD', type=str, nargs='+', help='a string of notes and accidentals') | |
parser.add_argument('-r', '--sample-rate', metavar='RATE', type=float, default=44100.0, help='output PCM samples per second') | |
parser.add_argument('-b', '--base', type=float, default=261.63, help='frequency for C (1:1) note in Hz') | |
parser.add_argument('-d', '--duration', metavar='DUR', type=float, default=1.0, help='duration of each chord in seconds') | |
parser.add_argument('-v', '--volume', type=float, default=1.0, help='volume multiplier') | |
parser.add_argument('-f', '--force', action='store_true', help='force PCM output even to a TTY') | |
args = parser.parse_args() | |
if sys.stdout.isatty() and not args.force: | |
sys.exit(tty_warning()) | |
play(args) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment