From 8326fc1d11c6853d14a3daca7a6af63ba13df8e7 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Thu, 19 Mar 2026 15:57:17 +0100 Subject: [PATCH] Add interactive OSC sender with preview mode and chord navigation - Add preview mode (p key) to toggle between send/preview behavior - Arrow keys for navigation with optional send - Enter sends current chord (only when not in preview mode) - Jump to chord by number + Enter - k/K keys for kill soft (15.0) and kill hard (0.0) commands - Display prev/current/next chords with frequencies to 2 decimals - Add OSC test receiver for debugging - Use arrow key escape sequences for left/right navigation --- src/osc_sender.py | 156 ++++++++++++++++++++++++++++++++++--------- src/test_receiver.py | 17 +++++ 2 files changed, 143 insertions(+), 30 deletions(-) create mode 100644 src/test_receiver.py diff --git a/src/osc_sender.py b/src/osc_sender.py index a6e74cc..a87c898 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -25,6 +25,7 @@ class OSCSender: self.client = SimpleUDPClient(ip, port) self.chords = [] self.current_index = 0 + self.preview_mode = True def load_chords(self, chords_file): """Load chords from output_chords.json.""" @@ -44,39 +45,79 @@ class OSCSender: frac = Fraction(pitch["fraction"]) freq = self.fundamental * float(frac) - addr = f"/freq" + addr = "/freq" msg = osc_message_builder.OscMessageBuilder(address=addr) msg.add_arg(voice_idx + 1) # Voice number (1-indexed) msg.add_arg(freq) # Frequency self.client.send(msg.build()) + print(f" [Sent chord {index + 1}/{len(self.chords)}]") - def display_chord(self, index): - """Display current chord info.""" + def send_current(self): + """Send the current chord.""" + self.send_chord(self.current_index) + + def _get_chord_preview(self, index): + """Get a one-line preview of a chord.""" if index < 0 or index >= len(self.chords): - return - + return None chord = self.chords[index] + from fractions import Fraction - print(f"\r{'=' * 50}") - print(f"Chord {index + 1}/{len(self.chords)}") - print(f"{'=' * 50}") + freqs = [ + f"{self.fundamental * float(Fraction(p['fraction'])):.2f}" for p in chord + ] + return f"Chord {index + 1}: {', '.join(freqs)} Hz" + 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): + """Display previous, current, and next chords.""" + total = len(self.chords) + prev_idx = self.current_index - 1 + next_idx = self.current_index + 1 + + print("\n" + "=" * 60) + print( + f"PREVIOUS: Chord {prev_idx + 1}/{total}" + if prev_idx >= 0 + else "PREVIOUS: None" + ) + if prev_idx >= 0: + print(f" {self._get_chord_preview(prev_idx)}") + print("-" * 60) + mode_str = "[PREVIEW]" if self.preview_mode else "[SEND]" + print(f">>> CURRENT: Chord {self.current_index + 1}/{total} {mode_str} <<<") + + chord = self._get_chord_full(self.current_index) print("Frequencies (Hz):") from fractions import Fraction for voice_idx, pitch in enumerate(chord): frac = Fraction(pitch["fraction"]) freq = self.fundamental * float(frac) - print(f" Voice {voice_idx + 1}: {freq:.1f}") + print(f" Voice {voice_idx + 1}: {freq:.2f}") print("\nHS Arrays:") for voice_idx, pitch in enumerate(chord): print(f" Voice {voice_idx + 1}: {pitch['hs_array']}") - print(f"\nFundamental: {self.fundamental} Hz") - print(f"Destination: {self.ip}:{self.port}") - print(f"{'=' * 50}") - print("← Previous | Next → | Escape to quit") + print("-" * 60) + print( + f"NEXT: Chord {next_idx + 1}/{total}" if next_idx < total else "NEXT: None" + ) + if next_idx < total: + print(f" {self._get_chord_preview(next_idx)}") + print("=" * 60) + print( + f"Fundamental: {self.fundamental} Hz | Destination: {self.ip}:{self.port}" + ) + print( + "Controls: <-|-> navigate | p=preview | k/K=kill | Enter=send | [n]=jump | Esc=quit" + ) def play(self): """Interactive playback with keyboard control.""" @@ -85,49 +126,105 @@ class OSCSender: return try: - import threading import tty import termios - import os - # Send and display first chord - self.send_chord(self.current_index) - self.display_chord(self.current_index) + self.preview_mode = True + self.current_index = 0 + self.display_chords() def get_key(): - """Get a single keypress.""" + """Get a keypress. Returns full escape sequences for arrow keys.""" fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) + if ch == "\x1b": + next1 = sys.stdin.read(1) + if next1 == "[": + next2 = sys.stdin.read(1) + return "\x1b[" + next2 + else: + return ch finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch - print("\nUse arrow keys to navigate. Press Escape to quit.") + num_buffer = "" while True: key = get_key() - # Escape key - quit if key == "\x1b": break - # Alternative: use < and > keys - elif key == "," or key == "<": + elif key == "\x1b[D": if self.current_index > 0: self.current_index -= 1 - self.send_chord(self.current_index) - self.display_chord(self.current_index) - elif key == "." or key == ">": + self.display_chords() + if not self.preview_mode: + self.send_current() + + elif key == "\x1b[C": if self.current_index < len(self.chords) - 1: self.current_index += 1 - self.send_chord(self.current_index) - self.display_chord(self.current_index) + self.display_chords() + if not self.preview_mode: + self.send_current() + + elif key == "p": + self.preview_mode = not self.preview_mode + self.display_chords() + + elif key == "k": + num_voices = len(self.chords[self.current_index]) + for voice_idx in range(num_voices): + msg = osc_message_builder.OscMessageBuilder(address="/freq") + msg.add_arg(voice_idx + 1) + msg.add_arg(15.0) + self.client.send(msg.build()) + print(f" [Sent kill soft (15.0) to {num_voices} voices]") + + elif key == "K": + num_voices = len(self.chords[self.current_index]) + for voice_idx in range(num_voices): + msg = osc_message_builder.OscMessageBuilder(address="/freq") + msg.add_arg(voice_idx + 1) + msg.add_arg(0.0) + self.client.send(msg.build()) + print(f" [Sent kill hard (0.0) to {num_voices} voices]") + + elif key.isdigit(): + num_buffer += key + print(f"\rJump to: {num_buffer}", end="", flush=True) + + elif key == "\r": + if num_buffer: + try: + idx = int(num_buffer) - 1 + if 0 <= idx < len(self.chords): + self.current_index = idx + self.display_chords() + 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: + print(f"\rJump to: {num_buffer}", end="", flush=True) + else: + print("\r ", end="", flush=True) + print("\r", end="", flush=True) except KeyboardInterrupt: - pass # Clean exit + pass print("\n\nPlayback stopped.") @@ -135,7 +232,6 @@ class OSCSender: """Send all chords in sequence (for testing).""" for i in range(len(self.chords)): self.send_chord(i) - print(f"Sent chord {i + 1}/{len(self.chords)}") print("All chords sent!") diff --git a/src/test_receiver.py b/src/test_receiver.py new file mode 100644 index 0000000..87790bd --- /dev/null +++ b/src/test_receiver.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +"""Simple OSC receiver for testing.""" + +import socket +from pythonosc.osc_message import OscMessage + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.bind(("0.0.0.0", 54001)) +print("Listening on port 54001...") + +while True: + data, addr = sock.recvfrom(4096) + msg = OscMessage(dgram=data) + print(f"From {addr}: {msg.address}", end="") + for p in msg.params: + print(f" {p}", end="") + print()