Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,111 @@ One possibility is to install with pip from GitHub:

pip install git+https://github.com/endolith/just_intonation.git

## MIDI playback (`midi_play.py`)

`midi_play.py` is a small **demo / scratchpad**, not part of the installable
`just_intonation` package. It sends notes to a **MIDI output device** using
[pygame](https://www.pygame.org/)’s PortMidi wrapper. It does **not** produce
audio by itself: you need a **second program** (a DAW, standalone synth, or
similar) that listens on the **other end** of a **virtual MIDI cable**.

Rough signal path:

```text
midi_play.py --MIDI--> virtual cable --MIDI--> softsynth / DAW --> audio
```

### Why setup is fiddly

* The script used to assume **device id `3`** and a name containing **`LoopBe`**. That breaks as soon as you plug in a different USB interface or reorder drivers. It now **scans** output devices by default (see environment variables below).
* **Pitch bend** is per **MIDI channel**. The script cycles channels so chords stay bent independently; channel **10** (GM drums) is skipped.
* You must hear the **same synth** that receives the cable. If nothing is routed to the cable’s **input** side, you get silence even though the script runs.

### Dependencies

```bash
pip install pygame numpy
```

(`numpy` is only used for mode arrays at import time.)

### Windows — LoopBe1 or loopMIDI

1. Install a virtual MIDI driver, for example **[LoopBe1](https://www.tobias-erichsen.de/software/loopbe1.html)** (free for non‑commercial use) or **loopMIDI** from the same author.
2. After installation you should see an **output** port in Windows whose name contains `LoopBe` (e.g. **“LoopBe Internal MIDI”**). `midi_play.py` looks for that substring by default.
3. Open your **synthesizer** (Reaper, Ableton, standalone VST host, etc.) and set its **MIDI input** to that **same** LoopBe / loopMIDI port (the “other end” of the cable).
4. Run Python from the repo root so `just_intonation` imports correctly, e.g.:

```bash
python -i midi_play.py
```

Then call `just_major()`, `play_chord(...)`, etc. in the REPL.

### macOS — IAC Driver

1. Open **Audio MIDI Setup** → **Window** → **Show MIDI Studio** → double‑click **IAC Driver** → enable **“Device is online”** and at least one **port** (e.g. **Bus 1**).
2. Point your synth’s **MIDI input** at that IAC bus.
3. Set a match substring for this script, for example:

```bash
export JUST_INTONATION_MIDI_OUT_NAME=IAC
python -i midi_play.py
```

### Linux — ALSA virtual port + `aconnect`

Typical pattern: create a writable **MIDI Through** or **virmidi** port, run
**FluidSynth** / **TiMidity++** / etc., then connect with **`aconnect`**:

```bash
# Example: FluidSynth with ALSA; names vary by distro / args.
fluidsynth -a alsa -m alsa_seq /path/to/soundfont.sf2 &
python -i midi_play.py &
# List clients, then connect pygame’s output client to FluidSynth’s input:
aconnect -l
aconnect <pygame_sender> <fluidsynth_receiver>
```

Exact client numbers change every run; use `aconnect -l` after starting both
ends. If pygame lists **“Midi Through Port-0”** as an output, you can try:

```bash
export JUST_INTONATION_MIDI_OUT_NAME="Midi Through"
```

…and wire the other program accordingly (some setups route **Through**
between apps; others need **virmidi** — see your distro’s MIDI how‑tos).

### Choosing the device from Python

If auto‑detection picks the wrong port:

1. Run once without fixing anything; the **`RuntimeError`** lists **all** MIDI
**output** devices pygame sees, with numeric ids.
2. Pin the id:

```bash
export JUST_INTONATION_MIDI_DEVICE_ID=5
python -i midi_play.py
```

3. Or match a unique substring of the port name (case‑insensitive):

```bash
export JUST_INTONATION_MIDI_OUT_NAME=loopmidi
```

PortMidi also supports **`PM_RECOMMENDED_OUTPUT_DEVICE`** (integer id or
registry / env hints); see [pygame’s midi docs](https://www.pygame.org/docs/ref/midi.html).

### Pitch‑bend detail

The script rounds each frequency to the nearest **MIDI note number**, then
applies a **pitch wheel** offset for the remaining fraction of a semitone.
Your synth should use the **default** bend range (**±2 semitones**); if it is
configured differently, bends will sound wrong.

## Examples

* [Everything is a power chord in just intonation](https://soundcloud.com/endolith/everything-is-a-power-chord-in-just-intonation) - Dyads made from the harmonic series, first in piano, then undistorted guitar, then distorted guitar.
Expand Down
133 changes: 105 additions & 28 deletions midi_play.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,127 @@
# -*- coding: utf-8 -*-
"""
Created on Tue Aug 26 22:27:10 2014
Play just-intonation material over MIDI using pitch bend.

So to play MIDI chords, put each note on its
own channel, pitch bend them by less than one semitone to correct the pitch
and then play them all at once
Each note uses its own MIDI channel so polyphony can stay in tune: the
script picks the nearest MIDI note number and applies a small pitch-bend
offset for the remaining cents.

This module expects a *virtual MIDI cable* (or similar) as the PortMidi /
pygame output device. You send MIDI from this script into the cable, and a
separate synthesizer program listens on the other end of the cable.

See the "MIDI playback" section in README.md for setup (LoopBe, loopMIDI,
Linux ALSA routing, environment variables).
"""

from __future__ import division, print_function

import os
import time
from fractions import Fraction
from threading import Timer # :D
import random
import math
from threading import Timer

# modded for pitch bend: https://github.com/endolith/pygame/blob/master/lib/midi.py
from pygame import midi

import random
import math
from numpy import arange
from numpy.random import randint
from just_intonation import (Interval, Pitch, Chord, P1, m2, M2, m3, M3,
P4, P5, m6, M6, m7, M7, P8)

from just_intonation import Interval, Pitch, Chord, m3, M3, P4, P5, P8


def _midi_device_name(device_info):
"""Return a Unicode device name; pygame may give bytes on some platforms."""
name = device_info[1]
if isinstance(name, bytes):
return name.decode('utf-8', errors='replace')
return str(name)


def _list_midi_output_devices():
"""Yield (device_id, name) for each PortMidi output device."""
for device_id in range(midi.get_count()):
info = midi.get_device_info(device_id)
if info is None:
continue
_interf, _name, is_input, is_output, _opened = info
if is_output:
yield device_id, _midi_device_name(info)


def resolve_midi_output_device_id():
"""
Pick the pygame / PortMidi output device used by this script.

Resolution order:

1. If the environment variable ``JUST_INTONATION_MIDI_DEVICE_ID`` is set
to an integer, use that device id (must be an output device).
2. Otherwise scan outputs whose name contains the substring from
``JUST_INTONATION_MIDI_OUT_NAME`` (default: ``loopbe``, case-insensitive).
This matches Windows *LoopBe1* ports ("LoopBe Internal MIDI", etc.).
"""
if 'JUST_INTONATION_MIDI_DEVICE_ID' in os.environ:
device_id = int(os.environ['JUST_INTONATION_MIDI_DEVICE_ID'])
info = midi.get_device_info(device_id)
if info is None:
raise RuntimeError(
'JUST_INTONATION_MIDI_DEVICE_ID=%s is out of range '
'(midi.get_count() == %s).' % (device_id, midi.get_count()))
if not info[3]:
raise RuntimeError(
'JUST_INTONATION_MIDI_DEVICE_ID=%s is not a MIDI output.' % (
device_id,))
return device_id

needle = os.environ.get(
'JUST_INTONATION_MIDI_OUT_NAME', 'loopbe').lower()
outputs = list(_list_midi_output_devices())
for device_id, name in outputs:
if needle in name.lower():
return device_id

lines = ['No MIDI output device name contains %r.' % needle,
'Install a virtual MIDI cable (see README.md), then either',
' set JUST_INTONATION_MIDI_OUT_NAME to a unique substring of',
' its port name, or set JUST_INTONATION_MIDI_DEVICE_ID to a',
' number from the list below.',
'',
'Available MIDI *output* devices:']
if not outputs:
lines.append(' (none — PortMidi sees no writable outputs; on Linux'
' check ALSA `seq` / `snd_seq` and that pygame was built'
' with MIDI support.)')
else:
for device_id, name in outputs:
lines.append(' %s: %s' % (device_id, name))
raise RuntimeError('\n'.join(lines))


rest = 0.5 # seconds


def log2(x):
return math.log(x, 2)


def freq_to_MIDI(freq):
A = 440
return 12 * log2(freq / A) + 69


midi.init()

if 'LoopBe' in midi.get_device_info(3)[1]:
try:
synth = midi.Output(3)
except Exception as e:
if 'Invalid device ID' in str(e):
synth.close()
synth = midi.Output(3)
else:
raise
else:
raise Exception('Loopback MIDI device not found')
_output_id = resolve_midi_output_device_id()
try:
synth = midi.Output(_output_id)
except Exception:
midi.quit()
raise

channels = 16
program = 0


def play_freq(freq, duration=None, sustain=15):
"""
Use MIDI pitch bends to play arbitrary frequencies. Bends apply to the
Expand Down Expand Up @@ -83,7 +157,8 @@ def play_freq(freq, duration=None, sustain=15):
# Prevents synth from choking on all the tails of notes
# Randomize by 10% so they don't all shut off at once
rand = random.uniform(0.9, 1.1)
Timer(sustain*rand, synth.note_off, (MIDI_note, 0, channel)).start()
Timer(sustain * rand, synth.note_off, (MIDI_note, 0, channel)).start()


play_freq.channel = 0

Expand Down Expand Up @@ -158,21 +233,22 @@ def play_seq(pitch, intervals, rest=rest):
play_freq(root + x)
time.sleep(rest)


pitch = Pitch(110)

# Lydian mode
lydian = sorted(arange(7)*P5 % P8) + [P8]
lydian = sorted(arange(7) * P5 % P8) + [P8]

# Locrian mode
locrian = sorted(arange(7)*P4 % P8) + [P8]
locrian = sorted(arange(7) * P4 % P8) + [P8]

# play_seq(110, random.sample(locrian, 8))


# Equal:
def equal_major():
synth.set_instrument(program, channel=0)
synth.pitch_bend(channel=0)
synth.pitch_bend(0, channel=0)
for note in [57, 61, 64]:
synth.note_on(note, 127, 0)
Timer(5, synth.note_off, (note, 0, 0)).start()
Expand All @@ -183,6 +259,7 @@ def equal_major():
# synth.note_on(note, 127, 0)
# Timer(5, synth.note_off, (note, 0, 0)).start()


def just_major():
play_freq(Pitch(220), sustain=5)
time.sleep(1)
Expand All @@ -195,7 +272,7 @@ def just_major():

def equal_minor():
synth.set_instrument(program, channel=0)
synth.pitch_bend(channel=0)
synth.pitch_bend(0, channel=0)
for note in [57, 60, 64]:
synth.note_on(note, 127, 0)
Timer(5, synth.note_off, (note, 0, 0)).start()
Expand Down
Loading