Skip to content

Instantly share code, notes, and snippets.

@lynn
Last active February 21, 2024 11:46
Show Gist options
  • Save lynn/516d5a19cf9a98d6ba28013f43fb79b7 to your computer and use it in GitHub Desktop.
Save lynn/516d5a19cf9a98d6ba28013f43fb79b7 to your computer and use it in GitHub Desktop.
play just-intonation chord progressions in your terminal
#!/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