diff --git a/src/osc_sender.py b/src/osc_sender.py
index 2f09fc5..d079c3c 100644
--- a/src/osc_sender.py
+++ b/src/osc_sender.py
@@ -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):
diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index 1ee4e45..d63dd9a 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -287,6 +287,8 @@
Ramp (s):
+ Exponent:
+
Siren IP:
@@ -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 => {
diff --git a/webapp/server.py b/webapp/server.py
index a23f087..c008157 100644
--- a/webapp/server.py
+++ b/webapp/server.py
@@ -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(
{