Add ramp exponent UI and split ramp_to_pitch for single voice

This commit is contained in:
Michael Winter 2026-04-07 08:48:06 +02:00
parent f6a98bf8a7
commit 30675756c9
3 changed files with 80 additions and 9 deletions

View file

@ -124,13 +124,16 @@ class OSCSender:
f" [Sent kill {'soft' if soft else 'hard'} ({kill_freq}) to {self.num_voices} voices]"
)
def ramp_to_chord(self, frequencies, duration_ms=3000, start_freq=20):
"""Ramp each voice from start_freq to target frequency(s) linearly.
def ramp_to_chord(
self, frequencies, duration_ms=3000, start_freq=None, exponent=1.0
):
"""Ramp each voice from start_freq to target frequency(s).
Args:
frequencies: single frequency (int/float) or list of frequencies
duration_ms: duration in milliseconds
start_freq: starting frequency (single or per-voice list)
start_freq: starting frequency (single or per-voice list, or None to use current)
exponent: exponent for curve (1.0 = linear, 2.0 = quadratic ease, etc.)
"""
import time
@ -172,7 +175,9 @@ class OSCSender:
print(f" [Ramp cancelled for voice {voice_idx + 1}]")
break
progress = step / num_steps
current_freq = voice_start + (target_freq - voice_start) * progress
current_freq = voice_start + (target_freq - voice_start) * (
progress**exponent
)
self.send_single(current_freq, voice_idx + 1)
time.sleep(step_interval)
else:
@ -191,6 +196,65 @@ class OSCSender:
print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]")
def ramp_to_pitch(
self, frequency, voice, duration_ms=3000, start_freq=None, exponent=1.0
):
"""Ramp a single voice from start_freq to target frequency.
Args:
frequency: target frequency (float)
voice: voice number (1-indexed, e.g., 1-4)
duration_ms: duration in milliseconds
start_freq: starting frequency (or None to use current)
exponent: exponent for curve (1.0 = linear, 2.0 = quadratic ease, etc.)
"""
import time
voice_idx = voice - 1 # Convert to 0-indexed
# Get start frequency from sender if not provided
if start_freq is None:
start_freq = self.get_current_frequency(voice_idx)
# Cancel any existing ramp for this voice
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}]")
# Create new cancel event
self._ramp_events[voice_idx] = threading.Event()
# Fixed interval time for smooth ramp regardless of duration
step_interval = 0.02 # 20ms = 50 Hz update rate
num_steps = max(1, int(duration_ms / 1000 / step_interval))
def run_ramp():
cancel_event = self._ramp_events[voice_idx]
cancel_event.clear() # Reset cancellation flag
for step in range(num_steps + 1):
if cancel_event.is_set():
print(f" [Ramp cancelled for voice {voice}]")
break
progress = step / num_steps
current_freq = start_freq + (frequency - start_freq) * (
progress**exponent
)
self.send_single(current_freq, voice)
time.sleep(step_interval)
else:
# Send final exact target frequency (only if not cancelled)
self.send_single(frequency, voice)
thread = threading.Thread(target=run_ramp)
thread.daemon = True
self._ramp_threads[voice_idx] = thread
thread.start()
print(f" [Started ramp for voice {voice} over {duration_ms}ms]")
def send_current(self):
"""Send all voices at their current positions."""
for voice_idx in range(self.num_voices):

View file

@ -287,6 +287,8 @@
<input type="number" id="octaveInput" value="2" style="width: 40px;">
<span style="margin-left: 15px;">Ramp (s):</span>
<input type="number" id="rampDuration" value="3" step="0.1" style="width: 60px;">
<span style="margin-left: 10px;">Exponent:</span>
<input type="number" id="rampExponent" value="1" min="0.1" max="5" step="0.1" style="width: 50px;">
<span style="margin-left: 15px;">Siren IP:</span>
<input type="text" id="sirenIp" value="192.168.4.200" style="width: 100px;">
<button id="toggleUnitBtn" style="margin-left: 15px;">Show: Cents</button>
@ -369,7 +371,7 @@
function rampToChord(chordIdx, nodeIdx = null) {
const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3;
const durationMs = durationSeconds * 1000;
const startFreq = 20; // Default start frequency
const exponent = parseFloat(document.getElementById('rampExponent').value) || 1.0;
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
// Find nodes in this chord (exclude label nodes)
@ -401,7 +403,7 @@
chordIndex: chordIdx,
nodeIndex: nodeIdx,
duration: durationMs,
startFreq: startFreq,
exponent: exponent,
ip: sirenIp
})
}).then(r => r.json()).then(data => {

View file

@ -212,7 +212,7 @@ def ramp_to_chord():
chord_index = data.get("chordIndex")
node_index = data.get("nodeIndex") # Optional - if present, ramp single voice
duration_ms = data.get("duration", 3000)
start_freq = data.get("startFreq", 20)
exponent = data.get("exponent", 1.0)
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
if chord_index is None:
@ -234,16 +234,21 @@ def ramp_to_chord():
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed
# Ramp single voice - let sender get start frequency from current position
siren_sender.ramp_to_chord(frequency, duration_ms, start_freq=None)
siren_sender.ramp_to_pitch(
frequency, voice, duration_ms, start_freq=None, exponent=exponent
)
else:
# Ramp entire chord - let sender get start frequencies from current positions
frequencies = [
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
]
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freq=None)
siren_sender.ramp_to_chord(
frequencies, duration_ms, start_freq=None, exponent=exponent
)
return jsonify(
{