Add per-voice ramp cancellation and frequency tracking in osc_sender
This commit is contained in:
parent
f3a5dda4bc
commit
f6a98bf8a7
|
|
@ -16,15 +16,6 @@ except ImportError:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
# Module-level flag to cancel any running ramp
|
|
||||||
_ramp_cancelled = threading.Event()
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_ramp():
|
|
||||||
"""Cancel any currently running ramp."""
|
|
||||||
_ramp_cancelled.set()
|
|
||||||
|
|
||||||
|
|
||||||
class OSCSender:
|
class OSCSender:
|
||||||
"""Send chord frequencies via OSC."""
|
"""Send chord frequencies via OSC."""
|
||||||
|
|
||||||
|
|
@ -38,6 +29,9 @@ class OSCSender:
|
||||||
self.preview_mode = True
|
self.preview_mode = True
|
||||||
self.num_voices = 4 # Default to 4 voices
|
self.num_voices = 4 # Default to 4 voices
|
||||||
self.voice_indices = [] # Track position for each voice independently
|
self.voice_indices = [] # Track position for each voice independently
|
||||||
|
self._ramp_events = {} # voice_idx -> threading.Event
|
||||||
|
self._ramp_threads = {} # voice_idx -> thread
|
||||||
|
self._current_frequencies = {} # voice_idx -> current frequency
|
||||||
|
|
||||||
def load_chords(self, chords_file):
|
def load_chords(self, chords_file):
|
||||||
"""Load chords from output_chords.json."""
|
"""Load chords from output_chords.json."""
|
||||||
|
|
@ -114,6 +108,12 @@ class OSCSender:
|
||||||
msg.add_arg(voice) # Voice number (1-indexed)
|
msg.add_arg(voice) # Voice number (1-indexed)
|
||||||
msg.add_arg(frequency)
|
msg.add_arg(frequency)
|
||||||
self.client.send(msg.build())
|
self.client.send(msg.build())
|
||||||
|
# Track current frequency for this voice
|
||||||
|
self._current_frequencies[voice - 1] = frequency
|
||||||
|
|
||||||
|
def get_current_frequency(self, voice_idx):
|
||||||
|
"""Get current frequency for a voice (0-indexed)."""
|
||||||
|
return self._current_frequencies.get(voice_idx, 20)
|
||||||
|
|
||||||
def send_kill(self, soft=True):
|
def send_kill(self, soft=True):
|
||||||
"""Send kill to all voices (soft=20Hz, hard=0Hz)."""
|
"""Send kill to all voices (soft=20Hz, hard=0Hz)."""
|
||||||
|
|
@ -132,7 +132,6 @@ class OSCSender:
|
||||||
duration_ms: duration in milliseconds
|
duration_ms: duration in milliseconds
|
||||||
start_freq: starting frequency (single or per-voice list)
|
start_freq: starting frequency (single or per-voice list)
|
||||||
"""
|
"""
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Handle single frequency vs list
|
# Handle single frequency vs list
|
||||||
|
|
@ -142,31 +141,54 @@ class OSCSender:
|
||||||
# Handle single start_freq vs list (per-voice)
|
# Handle single start_freq vs list (per-voice)
|
||||||
if isinstance(start_freq, (int, float)):
|
if isinstance(start_freq, (int, float)):
|
||||||
start_freq = [start_freq] * len(frequencies)
|
start_freq = [start_freq] * len(frequencies)
|
||||||
|
elif start_freq is None:
|
||||||
|
# Default: use current frequency from sender
|
||||||
|
start_freq = [
|
||||||
|
self.get_current_frequency(i) for i in range(len(frequencies))
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cancel any existing ramp for these voices before starting new ones
|
||||||
|
for voice_idx in range(len(frequencies)):
|
||||||
|
if voice_idx in self._ramp_events:
|
||||||
|
self._ramp_events[voice_idx].set()
|
||||||
|
if voice_idx in self._ramp_threads:
|
||||||
|
old_thread = self._ramp_threads[voice_idx]
|
||||||
|
if old_thread.is_alive():
|
||||||
|
print(f" [Cancelling existing ramp for voice {voice_idx + 1}]")
|
||||||
|
|
||||||
|
# Create new cancel events for each voice
|
||||||
|
for voice_idx in range(len(frequencies)):
|
||||||
|
self._ramp_events[voice_idx] = threading.Event()
|
||||||
|
|
||||||
# Fixed interval time for smooth ramp regardless of duration
|
# Fixed interval time for smooth ramp regardless of duration
|
||||||
step_interval = 0.02 # 20ms = 50 Hz update rate
|
step_interval = 0.02 # 20ms = 50 Hz update rate
|
||||||
num_steps = max(1, int(duration_ms / 1000 / step_interval))
|
num_steps = max(1, int(duration_ms / 1000 / step_interval))
|
||||||
|
|
||||||
def run_ramp():
|
def run_ramp(voice_idx, target_freq, voice_start):
|
||||||
_ramp_cancelled.clear() # Reset cancellation flag
|
cancel_event = self._ramp_events[voice_idx]
|
||||||
|
cancel_event.clear() # Reset cancellation flag
|
||||||
for step in range(num_steps + 1):
|
for step in range(num_steps + 1):
|
||||||
if _ramp_cancelled.is_set():
|
if cancel_event.is_set():
|
||||||
print(" [Ramp cancelled]")
|
print(f" [Ramp cancelled for voice {voice_idx + 1}]")
|
||||||
break
|
break
|
||||||
progress = step / num_steps
|
progress = step / num_steps
|
||||||
for voice_idx, target_freq in enumerate(frequencies):
|
current_freq = voice_start + (target_freq - voice_start) * progress
|
||||||
voice_start = start_freq[voice_idx]
|
self.send_single(current_freq, voice_idx + 1)
|
||||||
current_freq = voice_start + (target_freq - voice_start) * progress
|
|
||||||
self.send_single(current_freq, voice_idx + 1)
|
|
||||||
time.sleep(step_interval)
|
time.sleep(step_interval)
|
||||||
else:
|
else:
|
||||||
# Send final exact target frequencies (only if not cancelled)
|
# Send final exact target frequency (only if not cancelled)
|
||||||
for voice_idx, target_freq in enumerate(frequencies):
|
self.send_single(target_freq, voice_idx + 1)
|
||||||
self.send_single(target_freq, voice_idx + 1)
|
|
||||||
|
# Start a thread for each voice
|
||||||
|
for voice_idx, target_freq in enumerate(frequencies):
|
||||||
|
voice_start = start_freq[voice_idx]
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=run_ramp, args=(voice_idx, target_freq, voice_start)
|
||||||
|
)
|
||||||
|
thread.daemon = True
|
||||||
|
self._ramp_threads[voice_idx] = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
thread = threading.Thread(target=run_ramp)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]")
|
print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]")
|
||||||
|
|
||||||
def send_current(self):
|
def send_current(self):
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import sys
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from src.pitch import Pitch
|
from src.pitch import Pitch
|
||||||
from src.osc_sender import OSCSender, cancel_ramp
|
from src.osc_sender import OSCSender
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
@ -27,8 +27,16 @@ DEFAULT_FILE = "output_chords.json" # default file
|
||||||
current_index = 0
|
current_index = 0
|
||||||
chords = []
|
chords = []
|
||||||
|
|
||||||
# Track last frequency per voice per siren IP (in-memory)
|
# Cache persistent OSCSenders per IP for the siren
|
||||||
last_frequencies = {} # {ip: {voice_index: frequency}}
|
_sender_cache = {} # ip -> OSCSender
|
||||||
|
|
||||||
|
|
||||||
|
def get_siren_sender(ip):
|
||||||
|
"""Get or create a persistent OSCSender for the given siren IP."""
|
||||||
|
if ip not in _sender_cache:
|
||||||
|
_sender_cache[ip] = OSCSender(ip=ip, port=54001, fundamental=fundamental)
|
||||||
|
return _sender_cache[ip]
|
||||||
|
|
||||||
|
|
||||||
# OSC settings
|
# OSC settings
|
||||||
fundamental = 110.0
|
fundamental = 110.0
|
||||||
|
|
@ -152,14 +160,11 @@ def play_siren():
|
||||||
frequency = fundamental * float(fraction)
|
frequency = fundamental * float(fraction)
|
||||||
voice = node_index + 1 # 1-indexed
|
voice = node_index + 1 # 1-indexed
|
||||||
|
|
||||||
# Send to siren using provided or default IP
|
# Send to siren using cached sender
|
||||||
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
siren_sender = get_siren_sender(siren_ip)
|
||||||
siren_sender.set_chords(chords) # Set chords to ensure proper voice count
|
siren_sender.set_chords(chords) # Set chords to ensure proper voice count
|
||||||
siren_sender.send_single(frequency, voice)
|
siren_sender.send_single(frequency, voice)
|
||||||
|
|
||||||
# Track frequency per voice (0-indexed)
|
|
||||||
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"frequency": frequency,
|
"frequency": frequency,
|
||||||
|
|
@ -180,19 +185,15 @@ def kill_siren():
|
||||||
kill_freq = 20.0 if soft else 0.0
|
kill_freq = 20.0 if soft else 0.0
|
||||||
kill_type = "soft" if soft else "hard"
|
kill_type = "soft" if soft else "hard"
|
||||||
|
|
||||||
# Cancel any running ramp
|
# Cancel any running ramp (for this IP)
|
||||||
cancel_ramp()
|
siren_sender = get_siren_sender(siren_ip)
|
||||||
|
for event in siren_sender._ramp_events.values():
|
||||||
|
event.set()
|
||||||
|
|
||||||
# Send kill to all voices on siren using send_kill
|
# Send kill to all voices using cached sender
|
||||||
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
|
||||||
siren_sender.set_chords(chords) # Set chords to get correct num_voices
|
siren_sender.set_chords(chords) # Set chords to get correct num_voices
|
||||||
siren_sender.send_kill(soft=soft)
|
siren_sender.send_kill(soft=soft)
|
||||||
|
|
||||||
# Update tracking: set all voices to kill frequency (20 or 0)
|
|
||||||
if siren_ip in last_frequencies:
|
|
||||||
for voice_idx in last_frequencies[siren_ip]:
|
|
||||||
last_frequencies[siren_ip][voice_idx] = kill_freq
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"kill_freq": kill_freq,
|
"kill_freq": kill_freq,
|
||||||
|
|
@ -222,8 +223,8 @@ def ramp_to_chord():
|
||||||
|
|
||||||
chord = chords[chord_index]
|
chord = chords[chord_index]
|
||||||
|
|
||||||
# Create OSC sender with provided or default IP
|
# Use cached sender
|
||||||
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
siren_sender = get_siren_sender(siren_ip)
|
||||||
siren_sender.set_chords(chords)
|
siren_sender.set_chords(chords)
|
||||||
|
|
||||||
if node_index is not None:
|
if node_index is not None:
|
||||||
|
|
@ -234,29 +235,15 @@ def ramp_to_chord():
|
||||||
fraction = Fraction(pitch.get("fraction", "1"))
|
fraction = Fraction(pitch.get("fraction", "1"))
|
||||||
frequency = fundamental * float(fraction)
|
frequency = fundamental * float(fraction)
|
||||||
|
|
||||||
# Get current frequency for this voice (or default to 20)
|
# Ramp single voice - let sender get start frequency from current position
|
||||||
current_freqs = last_frequencies.get(siren_ip, {})
|
siren_sender.ramp_to_chord(frequency, duration_ms, start_freq=None)
|
||||||
voice_start_freq = current_freqs.get(node_index, 20)
|
|
||||||
|
|
||||||
siren_sender.ramp_to_chord(frequency, duration_ms, voice_start_freq)
|
|
||||||
|
|
||||||
# Update tracking with target (will be reached after ramp completes)
|
|
||||||
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
|
|
||||||
else:
|
else:
|
||||||
# Ramp entire chord
|
# Ramp entire chord - let sender get start frequencies from current positions
|
||||||
frequencies = [
|
frequencies = [
|
||||||
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
|
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
|
||||||
]
|
]
|
||||||
|
|
||||||
# Get current frequencies for each voice (or default to 20)
|
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freq=None)
|
||||||
current_freqs = last_frequencies.get(siren_ip, {})
|
|
||||||
start_freqs = [current_freqs.get(i, 20) for i in range(len(frequencies))]
|
|
||||||
|
|
||||||
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freqs)
|
|
||||||
|
|
||||||
# Update tracking with targets (will be reached after ramp completes)
|
|
||||||
for i, freq in enumerate(frequencies):
|
|
||||||
last_frequencies.setdefault(siren_ip, {})[i] = freq
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue