2026-03-17 16:47:30 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
"""
|
|
|
|
|
OSC Sender - send chord frequencies via OSC for real-time playback.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from pythonosc import osc_message_builder
|
|
|
|
|
from pythonosc.udp_client import SimpleUDPClient
|
|
|
|
|
except ImportError:
|
|
|
|
|
print("Error: python-osc not installed. Run: pip install python-osc")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OSCSender:
|
|
|
|
|
"""Send chord frequencies via OSC."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, ip="192.168.4.200", port=54001, fundamental=100):
|
|
|
|
|
self.ip = ip
|
|
|
|
|
self.port = port
|
|
|
|
|
self.fundamental = fundamental
|
|
|
|
|
self.client = SimpleUDPClient(ip, port)
|
|
|
|
|
self.chords = []
|
|
|
|
|
self.current_index = 0
|
2026-03-19 15:57:17 +01:00
|
|
|
self.preview_mode = True
|
2026-03-28 17:13:51 +01:00
|
|
|
self.num_voices = 0
|
|
|
|
|
self.voice_indices = [] # Track position for each voice independently
|
2026-03-17 16:47:30 +01:00
|
|
|
|
|
|
|
|
def load_chords(self, chords_file):
|
|
|
|
|
"""Load chords from output_chords.json."""
|
|
|
|
|
with open(chords_file) as f:
|
2026-03-28 15:12:14 +01:00
|
|
|
chords_data = json.load(f)
|
|
|
|
|
|
|
|
|
|
# Handle both old format (list) and new format (dict with dims + chords)
|
|
|
|
|
if isinstance(chords_data, dict) and "chords" in chords_data:
|
|
|
|
|
self.chords = chords_data["chords"]
|
|
|
|
|
else:
|
|
|
|
|
self.chords = chords_data
|
|
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
# Initialize voice indices to 0 for all voices
|
|
|
|
|
if self.chords:
|
|
|
|
|
self.num_voices = len(self.chords[0])
|
|
|
|
|
self.voice_indices = [0] * self.num_voices
|
|
|
|
|
else:
|
|
|
|
|
self.num_voices = 0
|
|
|
|
|
self.voice_indices = []
|
|
|
|
|
|
|
|
|
|
print(f"Loaded {len(self.chords)} chords, {self.num_voices} voices")
|
2026-03-17 16:47:30 +01:00
|
|
|
|
|
|
|
|
def send_chord(self, index):
|
|
|
|
|
"""Send frequencies for a specific chord index."""
|
|
|
|
|
if index < 0 or index >= len(self.chords):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
chord = self.chords[index]
|
|
|
|
|
|
|
|
|
|
for voice_idx, pitch in enumerate(chord):
|
|
|
|
|
from fractions import Fraction
|
|
|
|
|
|
|
|
|
|
frac = Fraction(pitch["fraction"])
|
|
|
|
|
freq = self.fundamental * float(frac)
|
2026-03-19 15:57:17 +01:00
|
|
|
addr = "/freq"
|
2026-03-17 17:10:37 +01:00
|
|
|
msg = osc_message_builder.OscMessageBuilder(address=addr)
|
2026-03-17 16:47:30 +01:00
|
|
|
msg.add_arg(voice_idx + 1) # Voice number (1-indexed)
|
|
|
|
|
msg.add_arg(freq) # Frequency
|
|
|
|
|
self.client.send(msg.build())
|
2026-03-19 15:57:17 +01:00
|
|
|
print(f" [Sent chord {index + 1}/{len(self.chords)}]")
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
def send_voice(self, voice_idx):
|
|
|
|
|
"""Send frequency for a single voice at its current position."""
|
|
|
|
|
if voice_idx < 0 or voice_idx >= len(self.voice_indices):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
chord_idx = self.voice_indices[voice_idx]
|
|
|
|
|
if chord_idx < 0 or chord_idx >= len(self.chords):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
chord = self.chords[chord_idx]
|
|
|
|
|
pitch = chord[voice_idx]
|
|
|
|
|
|
|
|
|
|
from fractions import Fraction
|
|
|
|
|
|
|
|
|
|
frac = Fraction(pitch["fraction"])
|
|
|
|
|
freq = self.fundamental * float(frac)
|
|
|
|
|
|
|
|
|
|
msg = osc_message_builder.OscMessageBuilder(address="/freq")
|
|
|
|
|
msg.add_arg(voice_idx + 1) # Voice number (1-indexed)
|
|
|
|
|
msg.add_arg(freq)
|
|
|
|
|
self.client.send(msg.build())
|
|
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
def send_current(self):
|
2026-03-28 17:13:51 +01:00
|
|
|
"""Send all voices at their current positions."""
|
|
|
|
|
for voice_idx in range(self.num_voices):
|
|
|
|
|
self.send_voice(voice_idx)
|
|
|
|
|
print(f" [Sent all {self.num_voices} voices at their positions]")
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
def _get_chord_preview(self, index):
|
|
|
|
|
"""Get a one-line preview of a chord."""
|
|
|
|
|
if index < 0 or index >= len(self.chords):
|
|
|
|
|
return None
|
2026-03-17 16:47:30 +01:00
|
|
|
chord = self.chords[index]
|
2026-03-19 15:57:17 +01:00
|
|
|
from fractions import Fraction
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
freqs = [
|
|
|
|
|
f"{self.fundamental * float(Fraction(p['fraction'])):.2f}" for p in chord
|
|
|
|
|
]
|
|
|
|
|
return f"Chord {index + 1}: {', '.join(freqs)} Hz"
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
def _get_chord_full(self, index):
|
|
|
|
|
"""Get full details of a chord."""
|
|
|
|
|
if index < 0 or index >= len(self.chords):
|
|
|
|
|
return []
|
|
|
|
|
return self.chords[index]
|
|
|
|
|
|
|
|
|
|
def display_chords(self):
|
2026-03-28 17:13:51 +01:00
|
|
|
"""Display previous, current, and next chords with per-voice positions."""
|
2026-03-19 15:57:17 +01:00
|
|
|
total = len(self.chords)
|
2026-03-28 17:13:51 +01:00
|
|
|
from fractions import Fraction
|
2026-03-19 15:57:17 +01:00
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
# Build prev/next info for each voice independently
|
|
|
|
|
prev_freqs = []
|
|
|
|
|
prev_positions = []
|
|
|
|
|
next_freqs = []
|
|
|
|
|
next_positions = []
|
|
|
|
|
current_freqs = []
|
2026-03-19 15:57:17 +01:00
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
for voice_idx in range(self.num_voices):
|
|
|
|
|
curr_idx = self.voice_indices[voice_idx]
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
# Previous for this voice
|
|
|
|
|
prev_idx = max(0, curr_idx - 1)
|
|
|
|
|
chord = self.chords[prev_idx]
|
|
|
|
|
pitch = chord[voice_idx]
|
2026-03-17 16:47:30 +01:00
|
|
|
frac = Fraction(pitch["fraction"])
|
|
|
|
|
freq = self.fundamental * float(frac)
|
2026-03-28 17:13:51 +01:00
|
|
|
prev_freqs.append(f"{freq:.2f}")
|
|
|
|
|
prev_positions.append(prev_idx + 1)
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-28 17:13:51 +01:00
|
|
|
# Current for this voice
|
|
|
|
|
chord = self.chords[curr_idx]
|
|
|
|
|
pitch = chord[voice_idx]
|
|
|
|
|
frac = Fraction(pitch["fraction"])
|
|
|
|
|
freq = self.fundamental * float(frac)
|
|
|
|
|
current_freqs.append((freq, pitch["hs_array"]))
|
|
|
|
|
|
|
|
|
|
# Next for this voice
|
|
|
|
|
next_idx = min(len(self.chords) - 1, curr_idx + 1)
|
|
|
|
|
chord = self.chords[next_idx]
|
|
|
|
|
pitch = chord[voice_idx]
|
|
|
|
|
frac = Fraction(pitch["fraction"])
|
|
|
|
|
freq = self.fundamental * float(frac)
|
|
|
|
|
next_freqs.append(f"{freq:.2f}")
|
|
|
|
|
next_positions.append(next_idx + 1)
|
|
|
|
|
|
|
|
|
|
# Get current positions for display
|
|
|
|
|
curr_positions = [v + 1 for v in self.voice_indices]
|
|
|
|
|
|
|
|
|
|
print("\n" + "=" * 60)
|
|
|
|
|
|
|
|
|
|
# PREVIOUS line
|
|
|
|
|
print(f"PREVIOUS ({prev_positions}/{total})")
|
|
|
|
|
print(f"{', '.join(prev_freqs)} Hz")
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
print("-" * 60)
|
2026-03-28 17:13:51 +01:00
|
|
|
|
|
|
|
|
# CURRENT line with positions
|
|
|
|
|
print(f"CURRENT ({curr_positions}/{total})")
|
|
|
|
|
for voice_idx in range(self.num_voices):
|
|
|
|
|
freq, hs = current_freqs[voice_idx]
|
|
|
|
|
print(f" Voice {voice_idx + 1}: {freq:.2f} Hz {hs}")
|
|
|
|
|
|
|
|
|
|
print("-" * 60)
|
|
|
|
|
|
|
|
|
|
# NEXT line
|
|
|
|
|
print(f"Next ({next_positions}/{total})")
|
|
|
|
|
print(f"{', '.join(next_freqs)} Hz")
|
|
|
|
|
|
|
|
|
|
print("-" * 60)
|
|
|
|
|
|
|
|
|
|
mode_str = "[PREVIEW]" if self.preview_mode else "[SEND]"
|
2026-03-19 15:57:17 +01:00
|
|
|
print(
|
2026-03-28 17:13:51 +01:00
|
|
|
f"Fundamental: {self.fundamental} Hz | Destination: {self.ip}:{self.port} {mode_str}"
|
2026-03-19 15:57:17 +01:00
|
|
|
)
|
|
|
|
|
print(
|
2026-03-28 17:13:51 +01:00
|
|
|
"Controls: <-|-> all | [n]<-/->=voice n | p=preview | k/K=kill | Enter=send all | Esc=quit"
|
2026-03-19 15:57:17 +01:00
|
|
|
)
|
2026-03-17 16:47:30 +01:00
|
|
|
|
|
|
|
|
def play(self):
|
|
|
|
|
"""Interactive playback with keyboard control."""
|
|
|
|
|
if not self.chords:
|
|
|
|
|
print("No chords loaded!")
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-17 17:19:52 +01:00
|
|
|
try:
|
2026-03-17 17:41:09 +01:00
|
|
|
import tty
|
|
|
|
|
import termios
|
|
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
self.preview_mode = True
|
|
|
|
|
self.current_index = 0
|
|
|
|
|
self.display_chords()
|
2026-03-17 17:41:09 +01:00
|
|
|
|
|
|
|
|
def get_key():
|
2026-03-19 15:57:17 +01:00
|
|
|
"""Get a keypress. Returns full escape sequences for arrow keys."""
|
2026-03-17 17:41:09 +01:00
|
|
|
fd = sys.stdin.fileno()
|
|
|
|
|
old_settings = termios.tcgetattr(fd)
|
|
|
|
|
try:
|
|
|
|
|
tty.setraw(sys.stdin.fileno())
|
|
|
|
|
ch = sys.stdin.read(1)
|
2026-03-19 15:57:17 +01:00
|
|
|
if ch == "\x1b":
|
|
|
|
|
next1 = sys.stdin.read(1)
|
|
|
|
|
if next1 == "[":
|
|
|
|
|
next2 = sys.stdin.read(1)
|
|
|
|
|
return "\x1b[" + next2
|
|
|
|
|
else:
|
|
|
|
|
return ch
|
2026-03-17 17:41:09 +01:00
|
|
|
finally:
|
|
|
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
|
|
|
return ch
|
|
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
num_buffer = ""
|
2026-03-17 17:41:09 +01:00
|
|
|
|
2026-03-17 16:47:30 +01:00
|
|
|
while True:
|
|
|
|
|
key = get_key()
|
2026-03-17 17:41:09 +01:00
|
|
|
|
|
|
|
|
if key == "\x1b":
|
|
|
|
|
break
|
2026-03-17 16:47:30 +01:00
|
|
|
|
2026-03-19 15:57:17 +01:00
|
|
|
elif key == "\x1b[D":
|
2026-03-28 17:13:51 +01:00
|
|
|
# Back arrow
|
|
|
|
|
if num_buffer:
|
|
|
|
|
# Individual voice: [n]<- move voice n back
|
|
|
|
|
try:
|
|
|
|
|
voice_num = int(num_buffer) - 1
|
|
|
|
|
if 0 <= voice_num < self.num_voices:
|
|
|
|
|
if self.voice_indices[voice_num] > 0:
|
|
|
|
|
self.voice_indices[voice_num] -= 1
|
|
|
|
|
self.display_chords()
|
|
|
|
|
if not self.preview_mode:
|
|
|
|
|
self.send_voice(voice_num)
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n Voice {voice_num + 1} at start")
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n Invalid voice: {num_buffer}")
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
num_buffer = ""
|
|
|
|
|
else:
|
|
|
|
|
# All voices: move all voice_indices back
|
|
|
|
|
can_move = any(vi > 0 for vi in self.voice_indices)
|
|
|
|
|
if can_move:
|
|
|
|
|
for voice_idx in range(self.num_voices):
|
|
|
|
|
if self.voice_indices[voice_idx] > 0:
|
|
|
|
|
self.voice_indices[voice_idx] -= 1
|
|
|
|
|
self.display_chords()
|
|
|
|
|
if not self.preview_mode:
|
|
|
|
|
self.send_current()
|
2026-03-19 15:57:17 +01:00
|
|
|
|
|
|
|
|
elif key == "\x1b[C":
|
2026-03-28 17:13:51 +01:00
|
|
|
# Forward arrow
|
|
|
|
|
if num_buffer:
|
|
|
|
|
# Individual voice: [n]-> move voice n forward
|
|
|
|
|
try:
|
|
|
|
|
voice_num = int(num_buffer) - 1
|
|
|
|
|
if 0 <= voice_num < self.num_voices:
|
|
|
|
|
if self.voice_indices[voice_num] < len(self.chords) - 1:
|
|
|
|
|
self.voice_indices[voice_num] += 1
|
|
|
|
|
self.display_chords()
|
|
|
|
|
if not self.preview_mode:
|
|
|
|
|
self.send_voice(voice_num)
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n Voice {voice_num + 1} at end")
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n Invalid voice: {num_buffer}")
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
num_buffer = ""
|
|
|
|
|
else:
|
|
|
|
|
# All voices: move all voice_indices forward
|
|
|
|
|
can_move = any(
|
|
|
|
|
vi < len(self.chords) - 1 for vi in self.voice_indices
|
|
|
|
|
)
|
|
|
|
|
if can_move:
|
|
|
|
|
for voice_idx in range(self.num_voices):
|
|
|
|
|
if self.voice_indices[voice_idx] < len(self.chords) - 1:
|
|
|
|
|
self.voice_indices[voice_idx] += 1
|
|
|
|
|
self.display_chords()
|
|
|
|
|
if not self.preview_mode:
|
|
|
|
|
self.send_current()
|
2026-03-19 15:57:17 +01:00
|
|
|
|
|
|
|
|
elif key == "p":
|
|
|
|
|
self.preview_mode = not self.preview_mode
|
|
|
|
|
self.display_chords()
|
|
|
|
|
|
|
|
|
|
elif key == "k":
|
2026-03-28 17:13:51 +01:00
|
|
|
for voice_idx in range(self.num_voices):
|
2026-03-19 15:57:17 +01:00
|
|
|
msg = osc_message_builder.OscMessageBuilder(address="/freq")
|
|
|
|
|
msg.add_arg(voice_idx + 1)
|
|
|
|
|
msg.add_arg(15.0)
|
|
|
|
|
self.client.send(msg.build())
|
2026-03-28 17:13:51 +01:00
|
|
|
print(f" [Sent kill soft (15.0) to {self.num_voices} voices]")
|
2026-03-19 15:57:17 +01:00
|
|
|
|
|
|
|
|
elif key == "K":
|
2026-03-28 17:13:51 +01:00
|
|
|
for voice_idx in range(self.num_voices):
|
2026-03-19 15:57:17 +01:00
|
|
|
msg = osc_message_builder.OscMessageBuilder(address="/freq")
|
|
|
|
|
msg.add_arg(voice_idx + 1)
|
|
|
|
|
msg.add_arg(0.0)
|
|
|
|
|
self.client.send(msg.build())
|
2026-03-28 17:13:51 +01:00
|
|
|
print(f" [Sent kill hard (0.0) to {self.num_voices} voices]")
|
2026-03-19 15:57:17 +01:00
|
|
|
|
|
|
|
|
elif key.isdigit():
|
|
|
|
|
num_buffer += key
|
2026-03-28 17:13:51 +01:00
|
|
|
print(f"\rVoice: {num_buffer}", end="", flush=True)
|
2026-03-19 15:57:17 +01:00
|
|
|
|
|
|
|
|
elif key == "\r":
|
|
|
|
|
if num_buffer:
|
|
|
|
|
try:
|
|
|
|
|
idx = int(num_buffer) - 1
|
|
|
|
|
if 0 <= idx < len(self.chords):
|
2026-03-28 17:13:51 +01:00
|
|
|
# Set all voices to this chord index
|
|
|
|
|
self.voice_indices = [idx] * self.num_voices
|
2026-03-19 15:57:17 +01:00
|
|
|
self.display_chords()
|
2026-03-28 17:13:51 +01:00
|
|
|
if not self.preview_mode:
|
|
|
|
|
self.send_current()
|
2026-03-19 15:57:17 +01:00
|
|
|
else:
|
|
|
|
|
print(f"\nIndex must be 1-{len(self.chords)}")
|
|
|
|
|
except ValueError:
|
|
|
|
|
print(f"\nInvalid number: {num_buffer}")
|
|
|
|
|
num_buffer = ""
|
|
|
|
|
elif not self.preview_mode:
|
|
|
|
|
self.send_current()
|
|
|
|
|
|
|
|
|
|
elif key == "\x7f" or key == "\x08":
|
|
|
|
|
if num_buffer:
|
|
|
|
|
num_buffer = num_buffer[:-1]
|
|
|
|
|
if num_buffer:
|
2026-03-28 17:13:51 +01:00
|
|
|
print(f"\rVoice: {num_buffer}", end="", flush=True)
|
2026-03-19 15:57:17 +01:00
|
|
|
else:
|
2026-03-28 17:13:51 +01:00
|
|
|
print("\r ", end="", flush=True)
|
2026-03-19 15:57:17 +01:00
|
|
|
print("\r", end="", flush=True)
|
2026-03-17 16:47:30 +01:00
|
|
|
|
|
|
|
|
except KeyboardInterrupt:
|
2026-03-19 15:57:17 +01:00
|
|
|
pass
|
2026-03-17 17:41:09 +01:00
|
|
|
|
|
|
|
|
print("\n\nPlayback stopped.")
|
2026-03-17 16:47:30 +01:00
|
|
|
|
|
|
|
|
def send_all(self):
|
|
|
|
|
"""Send all chords in sequence (for testing)."""
|
|
|
|
|
for i in range(len(self.chords)):
|
|
|
|
|
self.send_chord(i)
|
|
|
|
|
print("All chords sent!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_chords_from_output(output_dir="output"):
|
|
|
|
|
"""Load chords from the standard output directory."""
|
|
|
|
|
chords_file = Path(output_dir) / "output_chords.json"
|
|
|
|
|
if not chords_file.exists():
|
|
|
|
|
raise FileNotFoundError(f"Chords file not found: {chords_file}")
|
|
|
|
|
|
|
|
|
|
with open(chords_file) as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="OSC Sender for Compact Sets")
|
|
|
|
|
parser.add_argument("--ip", default="192.168.4.200", help="Destination IP")
|
|
|
|
|
parser.add_argument("--port", type=int, default=54001, help="Destination port")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--fundamental", type=float, default=100, help="Fundamental frequency in Hz"
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--output-dir", default="output", help="Output directory")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--send-all", action="store_true", help="Send all chords in sequence"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
# Load chords
|
|
|
|
|
chords_file = Path(args.output_dir) / "output_chords.json"
|
|
|
|
|
if not chords_file.exists():
|
|
|
|
|
print(f"Error: {chords_file} not found!")
|
|
|
|
|
print("Run compact_sets.py first to generate chords.")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Create sender
|
|
|
|
|
sender = OSCSender(ip=args.ip, port=args.port, fundamental=args.fundamental)
|
|
|
|
|
sender.load_chords(chords_file)
|
|
|
|
|
|
|
|
|
|
if args.send_all:
|
|
|
|
|
sender.send_all()
|
|
|
|
|
else:
|
|
|
|
|
sender.play()
|