Compare commits
No commits in common. "655cb68f31d9bf9a1198c619cfe31828389e4734" and "62e6a75f4fd39d9bf20fe84257c650f3b1bbe4a7" have entirely different histories.
655cb68f31
...
62e6a75f4f
|
|
@ -5,7 +5,6 @@ OSC Sender - send chord frequencies via OSC for real-time playback.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -29,9 +28,6 @@ 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."""
|
||||||
|
|
@ -108,12 +104,6 @@ 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)."""
|
||||||
|
|
@ -124,137 +114,6 @@ class OSCSender:
|
||||||
f" [Sent kill {'soft' if soft else 'hard'} ({kill_freq}) to {self.num_voices} voices]"
|
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=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, or None to use current)
|
|
||||||
exponent: exponent for curve (1.0 = linear, 2.0 = quadratic ease, etc.)
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Handle single frequency vs list
|
|
||||||
if isinstance(frequencies, (int, float)):
|
|
||||||
frequencies = [frequencies]
|
|
||||||
|
|
||||||
# Handle single start_freq vs list (per-voice)
|
|
||||||
if isinstance(start_freq, (int, float)):
|
|
||||||
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
|
|
||||||
step_interval = 0.02 # 20ms = 50 Hz update rate
|
|
||||||
num_steps = max(1, int(duration_ms / 1000 / step_interval))
|
|
||||||
|
|
||||||
def run_ramp(voice_idx, target_freq, voice_start):
|
|
||||||
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_idx + 1}]")
|
|
||||||
break
|
|
||||||
progress = step / num_steps
|
|
||||||
current_freq = voice_start + (target_freq - voice_start) * (
|
|
||||||
progress**exponent
|
|
||||||
)
|
|
||||||
self.send_single(current_freq, voice_idx + 1)
|
|
||||||
time.sleep(step_interval)
|
|
||||||
else:
|
|
||||||
# Send final exact target frequency (only if not cancelled)
|
|
||||||
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()
|
|
||||||
|
|
||||||
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):
|
def send_current(self):
|
||||||
"""Send all voices at their current positions."""
|
"""Send all voices at their current positions."""
|
||||||
for voice_idx in range(self.num_voices):
|
for voice_idx in range(self.num_voices):
|
||||||
|
|
|
||||||
25
src/path.py
25
src/path.py
|
|
@ -77,8 +77,6 @@ class Path:
|
||||||
source_chord = path_chords[-1]
|
source_chord = path_chords[-1]
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
||||||
threshold = self.weights_config.get("rgr_voice_movement_threshold", 5)
|
|
||||||
|
|
||||||
for edge in out_edges:
|
for edge in out_edges:
|
||||||
source_node = edge[0]
|
source_node = edge[0]
|
||||||
destination_node = edge[1]
|
destination_node = edge[1]
|
||||||
|
|
@ -176,10 +174,6 @@ class Path:
|
||||||
next_cents = destination_chord.pitches[voice_idx].to_cents()
|
next_cents = destination_chord.pitches[voice_idx].to_cents()
|
||||||
if curr_cents != next_cents:
|
if curr_cents != next_cents:
|
||||||
change_after[voice_idx] += 1
|
change_after[voice_idx] += 1
|
||||||
else:
|
|
||||||
change_after[voice_idx] = sustain_after[voice_idx] / 10
|
|
||||||
if change_after[voice_idx] >= (threshold*1):
|
|
||||||
change_after[voice_idx] = sustain_after[voice_idx] / 10
|
|
||||||
|
|
||||||
step = PathStep(
|
step = PathStep(
|
||||||
source_node=source_node,
|
source_node=source_node,
|
||||||
|
|
@ -225,10 +219,7 @@ class Path:
|
||||||
total = sum(values)
|
total = sum(values)
|
||||||
if total == 0 or len(set(values)) <= 1:
|
if total == 0 or len(set(values)) <= 1:
|
||||||
return None
|
return None
|
||||||
min_value = 0.01
|
return [v / total for v in values]
|
||||||
adjusted = [max(v, min_value) for v in values]
|
|
||||||
total_adjusted = sum(adjusted)
|
|
||||||
return [v / total_adjusted for v in adjusted]
|
|
||||||
|
|
||||||
melodic_norm = sum_normalize(melodic_values)
|
melodic_norm = sum_normalize(melodic_values)
|
||||||
contrary_norm = sum_normalize(contrary_values)
|
contrary_norm = sum_normalize(contrary_values)
|
||||||
|
|
@ -344,7 +335,7 @@ class Path:
|
||||||
num_moving = num_up + num_down
|
num_moving = num_up + num_down
|
||||||
|
|
||||||
if num_moving < 2:
|
if num_moving < 2:
|
||||||
return 1.0
|
return 0.0
|
||||||
|
|
||||||
ideal_up = num_moving / 2
|
ideal_up = num_moving / 2
|
||||||
distance = abs(num_up - ideal_up)
|
distance = abs(num_up - ideal_up)
|
||||||
|
|
@ -385,12 +376,13 @@ class Path:
|
||||||
candidate_cents = [p.to_cents() for p in destination_chord.pitches]
|
candidate_cents = [p.to_cents() for p in destination_chord.pitches]
|
||||||
|
|
||||||
sum_changing = 0
|
sum_changing = 0
|
||||||
|
sum_all = sum(sustain_counts)
|
||||||
|
|
||||||
for voice_idx in range(num_voices):
|
for voice_idx in range(num_voices):
|
||||||
if current_cents[voice_idx] != candidate_cents[voice_idx]:
|
if current_cents[voice_idx] != candidate_cents[voice_idx]:
|
||||||
sum_changing += sustain_counts[voice_idx]
|
sum_changing += sustain_counts[voice_idx]
|
||||||
|
|
||||||
return sum_changing**2
|
return sum_changing
|
||||||
|
|
||||||
def _factor_rgr_voice_movement(
|
def _factor_rgr_voice_movement(
|
||||||
self,
|
self,
|
||||||
|
|
@ -414,6 +406,8 @@ class Path:
|
||||||
if num_voices == 0:
|
if num_voices == 0:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
|
threshold = config.get("rgr_voice_movement_threshold", 5)
|
||||||
|
|
||||||
current_cents = [p.to_cents() for p in source_chord.pitches]
|
current_cents = [p.to_cents() for p in source_chord.pitches]
|
||||||
candidate_cents = [p.to_cents() for p in destination_chord.pitches]
|
candidate_cents = [p.to_cents() for p in destination_chord.pitches]
|
||||||
|
|
||||||
|
|
@ -421,9 +415,12 @@ class Path:
|
||||||
|
|
||||||
for voice_idx in range(num_voices):
|
for voice_idx in range(num_voices):
|
||||||
if current_cents[voice_idx] != candidate_cents[voice_idx]:
|
if current_cents[voice_idx] != candidate_cents[voice_idx]:
|
||||||
sum_repeating += change_counts[voice_idx]
|
count = change_counts[voice_idx]
|
||||||
|
if count >= threshold:
|
||||||
|
return 0.0
|
||||||
|
sum_repeating += count
|
||||||
|
|
||||||
return sum_repeating**8
|
return sum_repeating
|
||||||
|
|
||||||
def _factor_harmonic_compactness(
|
def _factor_harmonic_compactness(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@
|
||||||
// Then click nodes in the webapp to play tones
|
// Then click nodes in the webapp to play tones
|
||||||
|
|
||||||
s.boot;
|
s.boot;
|
||||||
(
|
|
||||||
s.waitForBoot {
|
s.waitForBoot {
|
||||||
|
|
||||||
// Define synth on the server with long attack and decay
|
// Define synth on the server with long attack and decay
|
||||||
SynthDef(\sineTone, {
|
SynthDef(\sineTone, {
|
||||||
|freq = 440, amp = 0.15|
|
|freq = 440, amp = 0.15|
|
||||||
var env = EnvGen.kr(
|
var env = EnvGen.kr(
|
||||||
Env([0, 1, 0.8, 0], [1, 1.0, 3], \sin),
|
Env([0, 1, 0.8, 0], [2, 3.0, 3], \sin),
|
||||||
doneAction: Done.freeSelf
|
doneAction: Done.freeSelf
|
||||||
);
|
);
|
||||||
Out.ar(0, SinOsc.ar(freq) * env * amp);
|
Out.ar(0, SinOsc.ar(freq) * env * amp);
|
||||||
|
|
@ -34,5 +33,4 @@ s.waitForBoot {
|
||||||
|
|
||||||
// Keep alive
|
// Keep alive
|
||||||
while { true } { 10.wait; };
|
while { true } { 10.wait; };
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// osc_receiver.scd - OSC receiver for webapp
|
|
||||||
// Run this in SuperCollider: sclang supercollider/osc_receiver.scd
|
|
||||||
// Then click nodes in the webapp to play tones
|
|
||||||
|
|
||||||
s.boot;
|
|
||||||
(
|
|
||||||
s.waitForBoot {
|
|
||||||
|
|
||||||
// Store reference to current synth and frequency
|
|
||||||
var currentSynth = nil;
|
|
||||||
var currentFreq = nil;
|
|
||||||
|
|
||||||
// Define synth on the server with ASR envelope
|
|
||||||
// Note: doneAction is 0 (no auto-free) - we manage release manually
|
|
||||||
SynthDef(\sineTone, {
|
|
||||||
|freq = 440, amp = 0.25, gate = 1|
|
|
||||||
var env = EnvGen.kr(
|
|
||||||
Env.adsr(2, 3, 0.5, 3),
|
|
||||||
gate,
|
|
||||||
doneAction: 0 // Don't auto-free - we'll release manually
|
|
||||||
);
|
|
||||||
Out.ar(0, SinOsc.ar(freq) * env * amp);
|
|
||||||
}).add;
|
|
||||||
|
|
||||||
// Wait for synth to be added to server
|
|
||||||
s.sync;
|
|
||||||
|
|
||||||
// OSC handler for /freq messages - explicitly bind to port 57120
|
|
||||||
~oscHandler = OSCFunc({ |msg, time, addr, recvPort|
|
|
||||||
if (msg.size >= 3, {
|
|
||||||
var freq = msg[2].asFloat;
|
|
||||||
if (freq > 0, {
|
|
||||||
// Check if this is the same frequency - just release
|
|
||||||
if (currentSynth.notNil and: { currentFreq == freq }, {
|
|
||||||
currentSynth.set(\gate, 0);
|
|
||||||
currentSynth = nil;
|
|
||||||
currentFreq = nil;
|
|
||||||
}, {
|
|
||||||
// Different frequency - release old, create new
|
|
||||||
if (currentSynth.notNil, {
|
|
||||||
currentSynth.set(\gate, 0);
|
|
||||||
});
|
|
||||||
currentSynth = Synth(\sineTone, [\freq, freq, \amp, 0.25]);
|
|
||||||
currentFreq = freq;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, '/freq', nil, 57120);
|
|
||||||
|
|
||||||
"OSC receiver ready on port 57120".postln;
|
|
||||||
"Click nodes in webapp to play".postln;
|
|
||||||
|
|
||||||
// Keep alive
|
|
||||||
while { true } { 10.wait; };
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
@ -1,782 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Path Generator</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background: #000000;
|
|
||||||
color: #cccccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section {
|
|
||||||
background: #0a0a0a;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #666666;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #666666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="text"] {
|
|
||||||
padding: 8px;
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #888888;
|
|
||||||
border: 1px solid #222222;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide default number input spinners */
|
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
input[type="number"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #444444;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="checkbox"] {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-group {
|
|
||||||
flex-direction: row !important;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Number input with inline +/- buttons */
|
|
||||||
.number-input-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.number-input-group input {
|
|
||||||
width: 50px;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
.number-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #666666;
|
|
||||||
border: 1px solid #222222;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.number-btn:hover {
|
|
||||||
background: #151515;
|
|
||||||
color: #999999;
|
|
||||||
border-color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-group label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
text-transform: none;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons button {
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #888888;
|
|
||||||
border: 1px solid #222222;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons button:hover {
|
|
||||||
background: #151515;
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons button.primary {
|
|
||||||
background: #1a3a1a;
|
|
||||||
border-color: #2a5a2a;
|
|
||||||
color: #66cc66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons button.primary:hover {
|
|
||||||
background: #2a4a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#message {
|
|
||||||
text-align: center;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#message.success {
|
|
||||||
display: block;
|
|
||||||
background: #1a3a1a;
|
|
||||||
border: 1px solid #2a5a2a;
|
|
||||||
color: #66cc66;
|
|
||||||
}
|
|
||||||
|
|
||||||
#message.error {
|
|
||||||
display: block;
|
|
||||||
background: #3a1a1a;
|
|
||||||
border: 1px solid #5a2a2a;
|
|
||||||
color: #cc6666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link a {
|
|
||||||
color: #666666;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link a:hover {
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons-row button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #666666;
|
|
||||||
border: 1px solid #222222;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons-row button:hover {
|
|
||||||
background: #151515;
|
|
||||||
color: #888888;
|
|
||||||
border-color: #444444;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Path Generator</h1>
|
|
||||||
|
|
||||||
<div class="buttons-row">
|
|
||||||
<button type="button" id="exportSettingsBtn">Export Settings</button>
|
|
||||||
<button type="button" id="importSettingsBtn">Import Settings</button>
|
|
||||||
<button type="button" id="loadDefaultsBtn">Load Defaults</button>
|
|
||||||
<input type="file" id="importFileInput" accept=".json" style="display: none;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="message"></div>
|
|
||||||
|
|
||||||
<form id="generatorForm">
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Basic Settings</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Dims</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('dims', -1)">−</button>
|
|
||||||
<input type="number" id="dims" value="7" min="1" max="10">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('dims', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Chord Size</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('chordSize', -1)">−</button>
|
|
||||||
<input type="number" id="chordSize" value="3" min="1" max="10">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('chordSize', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Max Path</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('maxPath', -1)">−</button>
|
|
||||||
<input type="number" id="maxPath" value="50" min="1">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('maxPath', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Seed (optional)</label>
|
|
||||||
<input type="number" id="seed" placeholder="random">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Output Directory</label>
|
|
||||||
<input type="text" id="outputDir" value="output">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Fundamental (Hz)</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('fundamental', -1)">−</button>
|
|
||||||
<input type="number" id="fundamental" value="55" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('fundamental', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Transcribe Name</label>
|
|
||||||
<input type="text" id="transcribeName" value="compact_sets_transcription">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Symdiff / Melodic</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Symdiff Min</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('symdiffMin', -1)">−</button>
|
|
||||||
<input type="number" id="symdiffMin" value="2" min="0">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('symdiffMin', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Symdiff Max</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('symdiffMax', -1)">−</button>
|
|
||||||
<input type="number" id="symdiffMax" value="2" min="0">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('symdiffMax', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Melodic Min</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('melodicMin', -1)">−</button>
|
|
||||||
<input type="number" id="melodicMin" value="0" min="0">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('melodicMin', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Melodic Max</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('melodicMax', -1)">−</button>
|
|
||||||
<input type="number" id="melodicMax" value="500" min="0">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('melodicMax', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Target Register</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Target Register (octaves)</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegister', -1)">−</button>
|
|
||||||
<input type="number" id="targetRegister" value="0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegister', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Target Register Power</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterPower', -1)">−</button>
|
|
||||||
<input type="number" id="targetRegisterPower" value="1.0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterPower', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Oscillations</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterOscillations', -1)">−</button>
|
|
||||||
<input type="number" id="targetRegisterOscillations" value="0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterOscillations', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Amplitude</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterAmplitude', -1)">−</button>
|
|
||||||
<input type="number" id="targetRegisterAmplitude" value="0.25" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('targetRegisterAmplitude', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Voice Leading</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" id="allowVoiceCrossing">
|
|
||||||
<label>Allow Voice Crossing</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" id="disableDirectTuning">
|
|
||||||
<label>Disable Direct Tuning</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" id="uniformSymdiff">
|
|
||||||
<label>Uniform Symdiff</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Weights</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight Melodic</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightMelodic', -1)">−</button>
|
|
||||||
<input type="number" id="weightMelodic" value="1" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightMelodic', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight Contrary Motion</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightContraryMotion', -1)">−</button>
|
|
||||||
<input type="number" id="weightContraryMotion" value="0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightContraryMotion', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight DCA Hamiltonian</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightDcaHamiltonian', -1)">−</button>
|
|
||||||
<input type="number" id="weightDcaHamiltonian" value="1" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightDcaHamiltonian', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight DCA Voice Movement</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightDcaVoiceMovement', -1)">−</button>
|
|
||||||
<input type="number" id="weightDcaVoiceMovement" value="1" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightDcaVoiceMovement', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight RGR Voice Movement</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightRgrVoiceMovement', -1)">−</button>
|
|
||||||
<input type="number" id="weightRgrVoiceMovement" value="0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightRgrVoiceMovement', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>RGR Threshold</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rgrVoiceMovementThreshold', -1)">−</button>
|
|
||||||
<input type="number" id="rgrVoiceMovementThreshold" value="5" min="1">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rgrVoiceMovementThreshold', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight Harmonic Compactness</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightHarmonicCompactness', -1)">−</button>
|
|
||||||
<input type="number" id="weightHarmonicCompactness" value="0" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightHarmonicCompactness', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Weight Target Register</label>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightTargetRegister', -1)">−</button>
|
|
||||||
<input type="number" id="weightTargetRegister" value="1" step="any">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('weightTargetRegister', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Caching</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Cache Directory</label>
|
|
||||||
<input type="text" id="cacheDir" value="cache">
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" id="rebuildCache">
|
|
||||||
<label>Rebuild Cache</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" id="noCache">
|
|
||||||
<label>No Cache</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="buttons">
|
|
||||||
<button type="button" id="reseedGenerateBtn">Reseed & Generate</button>
|
|
||||||
<button type="button" id="generateBtn">Generate</button>
|
|
||||||
<button type="button" id="viewBtn">View</button>
|
|
||||||
<button type="button" id="transcribeBtn" class="primary">Transcribe</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="nav-link">
|
|
||||||
<a href="/">View Path Navigator</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function adjustValue(id, delta) {
|
|
||||||
const input = document.getElementById(id);
|
|
||||||
const min = parseFloat(input.min) || -Infinity;
|
|
||||||
const max = parseFloat(input.max) || Infinity;
|
|
||||||
const step = parseFloat(input.step) || 1;
|
|
||||||
let val = parseFloat(input.value) || 0;
|
|
||||||
val = Math.max(min, Math.min(max, val + (delta * step)));
|
|
||||||
input.value = step % 1 !== 0 ? parseFloat(val.toFixed(2)) : Math.round(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFormData() {
|
|
||||||
return {
|
|
||||||
dims: parseInt(document.getElementById('dims').value) || 7,
|
|
||||||
chordSize: parseInt(document.getElementById('chordSize').value) || 3,
|
|
||||||
maxPath: parseInt(document.getElementById('maxPath').value) || 50,
|
|
||||||
seed: document.getElementById('seed').value ? parseInt(document.getElementById('seed').value) : null,
|
|
||||||
outputDir: document.getElementById('outputDir').value || 'output',
|
|
||||||
fundamental: parseFloat(document.getElementById('fundamental').value) || 55,
|
|
||||||
symdiffMin: parseInt(document.getElementById('symdiffMin').value) || 2,
|
|
||||||
symdiffMax: parseInt(document.getElementById('symdiffMax').value) || 2,
|
|
||||||
melodicMin: parseInt(document.getElementById('melodicMin').value) || 0,
|
|
||||||
melodicMax: parseInt(document.getElementById('melodicMax').value) || 500,
|
|
||||||
targetRegister: parseFloat(document.getElementById('targetRegister').value) || 0,
|
|
||||||
targetRegisterPower: parseFloat(document.getElementById('targetRegisterPower').value) || 1.0,
|
|
||||||
targetRegisterOscillations: parseFloat(document.getElementById('targetRegisterOscillations').value) || 0,
|
|
||||||
targetRegisterAmplitude: parseFloat(document.getElementById('targetRegisterAmplitude').value) || 0.25,
|
|
||||||
allowVoiceCrossing: document.getElementById('allowVoiceCrossing').checked,
|
|
||||||
disableDirectTuning: document.getElementById('disableDirectTuning').checked,
|
|
||||||
uniformSymdiff: document.getElementById('uniformSymdiff').checked,
|
|
||||||
weightMelodic: (() => { const v = parseFloat(document.getElementById('weightMelodic').value); return isNaN(v) ? 1 : v; })(),
|
|
||||||
weightContraryMotion: (() => { const v = parseFloat(document.getElementById('weightContraryMotion').value); return isNaN(v) ? 0 : v; })(),
|
|
||||||
weightDcaHamiltonian: (() => { const v = parseFloat(document.getElementById('weightDcaHamiltonian').value); return isNaN(v) ? 1 : v; })(),
|
|
||||||
weightDcaVoiceMovement: (() => { const v = parseFloat(document.getElementById('weightDcaVoiceMovement').value); return isNaN(v) ? 1 : v; })(),
|
|
||||||
weightRgrVoiceMovement: (() => { const v = parseFloat(document.getElementById('weightRgrVoiceMovement').value); return isNaN(v) ? 0 : v; })(),
|
|
||||||
rgrVoiceMovementThreshold: parseInt(document.getElementById('rgrVoiceMovementThreshold').value) || 5,
|
|
||||||
weightHarmonicCompactness: (() => { const v = parseFloat(document.getElementById('weightHarmonicCompactness').value); return isNaN(v) ? 0 : v; })(),
|
|
||||||
weightTargetRegister: (() => { const v = parseFloat(document.getElementById('weightTargetRegister').value); return isNaN(v) ? 1 : v; })(),
|
|
||||||
cacheDir: document.getElementById('cacheDir').value || 'cache',
|
|
||||||
rebuildCache: document.getElementById('rebuildCache').checked,
|
|
||||||
noCache: document.getElementById('noCache').checked,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(text, isError = false) {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.textContent = text;
|
|
||||||
msg.className = isError ? 'error' : 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideMessage() {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.style.display = 'none';
|
|
||||||
msg.className = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
hideMessage();
|
|
||||||
const data = getFormData();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
showMessage('Error: ' + result.error, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update seed field with the seed used
|
|
||||||
document.getElementById('seed').value = result.seed;
|
|
||||||
showMessage('Generated ' + result.totalChords + ' chords (seed: ' + result.seed + ')');
|
|
||||||
} catch (err) {
|
|
||||||
showMessage('Error: ' + err.message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View - open Path Navigator in new tab
|
|
||||||
function viewChords() {
|
|
||||||
window.open('/', '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transcribe - generate if needed, then transcribe
|
|
||||||
async function transcribe() {
|
|
||||||
hideMessage();
|
|
||||||
const transcribeName = document.getElementById('transcribeName').value || 'compact_sets_transcription';
|
|
||||||
const data = getFormData();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First generate if chords don't exist
|
|
||||||
const response = await fetch('/api/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
showMessage('Error: ' + result.error, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update seed field
|
|
||||||
document.getElementById('seed').value = result.seed;
|
|
||||||
|
|
||||||
// Now transcribe
|
|
||||||
const transcribeResponse = await fetch('/api/transcribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ name: transcribeName })
|
|
||||||
});
|
|
||||||
|
|
||||||
const transcribeResult = await transcribeResponse.json();
|
|
||||||
|
|
||||||
if (transcribeResult.error) {
|
|
||||||
showMessage('Transcription error: ' + transcribeResult.error, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showMessage('Transcribed ' + transcribeResult.chordCount + ' chords to ' + transcribeResult.outputFile);
|
|
||||||
} catch (err) {
|
|
||||||
showMessage('Error: ' + err.message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reseedAndGenerate() {
|
|
||||||
const newSeed = Math.floor(Math.random() * 999999) + 1;
|
|
||||||
document.getElementById('seed').value = newSeed;
|
|
||||||
generate();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('reseedGenerateBtn').addEventListener('click', reseedAndGenerate);
|
|
||||||
document.getElementById('generateBtn').addEventListener('click', generate);
|
|
||||||
document.getElementById('viewBtn').addEventListener('click', viewChords);
|
|
||||||
document.getElementById('transcribeBtn').addEventListener('click', transcribe);
|
|
||||||
|
|
||||||
// Export settings to JSON file
|
|
||||||
function exportSettings() {
|
|
||||||
const data = getFormData();
|
|
||||||
data.exportedAt = new Date().toISOString();
|
|
||||||
const json = JSON.stringify(data, null, 2);
|
|
||||||
const blob = new Blob([json], {type: 'application/json'});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'generator_settings.json';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showMessage('Settings exported to generator_settings.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import settings from JSON file
|
|
||||||
function importSettings() {
|
|
||||||
document.getElementById('importFileInput').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySettings(data) {
|
|
||||||
// Map of field IDs to data keys
|
|
||||||
const fieldMap = {
|
|
||||||
dims: 'dims',
|
|
||||||
chordSize: 'chordSize',
|
|
||||||
maxPath: 'maxPath',
|
|
||||||
seed: 'seed',
|
|
||||||
outputDir: 'outputDir',
|
|
||||||
fundamental: 'fundamental',
|
|
||||||
symdiffMin: 'symdiffMin',
|
|
||||||
symdiffMax: 'symdiffMax',
|
|
||||||
melodicMin: 'melodicMin',
|
|
||||||
melodicMax: 'melodicMax',
|
|
||||||
targetRegister: 'targetRegister',
|
|
||||||
targetRegisterPower: 'targetRegisterPower',
|
|
||||||
targetRegisterOscillations: 'targetRegisterOscillations',
|
|
||||||
targetRegisterAmplitude: 'targetRegisterAmplitude',
|
|
||||||
allowVoiceCrossing: 'allowVoiceCrossing',
|
|
||||||
disableDirectTuning: 'disableDirectTuning',
|
|
||||||
uniformSymdiff: 'uniformSymdiff',
|
|
||||||
weightMelodic: 'weightMelodic',
|
|
||||||
weightContraryMotion: 'weightContraryMotion',
|
|
||||||
weightDcaHamiltonian: 'weightDcaHamiltonian',
|
|
||||||
weightDcaVoiceMovement: 'weightDcaVoiceMovement',
|
|
||||||
weightRgrVoiceMovement: 'weightRgrVoiceMovement',
|
|
||||||
rgrVoiceMovementThreshold: 'rgrVoiceMovementThreshold',
|
|
||||||
weightHarmonicCompactness: 'weightHarmonicCompactness',
|
|
||||||
weightTargetRegister: 'weightTargetRegister',
|
|
||||||
cacheDir: 'cacheDir',
|
|
||||||
rebuildCache: 'rebuildCache',
|
|
||||||
noCache: 'noCache',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [fieldId, dataKey] of Object.entries(fieldMap)) {
|
|
||||||
const input = document.getElementById(fieldId);
|
|
||||||
if (input && data[dataKey] !== undefined) {
|
|
||||||
if (input.type === 'checkbox') {
|
|
||||||
input.checked = data[dataKey];
|
|
||||||
} else {
|
|
||||||
input.value = data[dataKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('exportSettingsBtn').addEventListener('click', exportSettings);
|
|
||||||
document.getElementById('importSettingsBtn').addEventListener('click', importSettings);
|
|
||||||
|
|
||||||
// Load default settings from server
|
|
||||||
async function loadDefaults() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/generator_settings.json');
|
|
||||||
if (!response.ok) {
|
|
||||||
// No defaults file, that's fine
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
applySettings(data);
|
|
||||||
showMessage('Default settings loaded');
|
|
||||||
} catch (err) {
|
|
||||||
// File not found or error, that's fine - use hardcoded defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to load defaults on page load
|
|
||||||
loadDefaults();
|
|
||||||
|
|
||||||
document.getElementById('loadDefaultsBtn').addEventListener('click', function() {
|
|
||||||
// Reset form to hardcoded defaults
|
|
||||||
applySettings({
|
|
||||||
dims: 7,
|
|
||||||
chordSize: 3,
|
|
||||||
maxPath: 50,
|
|
||||||
seed: null,
|
|
||||||
outputDir: 'output',
|
|
||||||
fundamental: 55,
|
|
||||||
symdiffMin: 2,
|
|
||||||
symdiffMax: 2,
|
|
||||||
melodicMin: 0,
|
|
||||||
melodicMax: 500,
|
|
||||||
targetRegister: 0,
|
|
||||||
targetRegisterPower: 1.0,
|
|
||||||
targetRegisterOscillations: 0,
|
|
||||||
targetRegisterAmplitude: 0.25,
|
|
||||||
allowVoiceCrossing: false,
|
|
||||||
disableDirectTuning: false,
|
|
||||||
uniformSymdiff: false,
|
|
||||||
weightMelodic: 1,
|
|
||||||
weightContraryMotion: 0,
|
|
||||||
weightDcaHamiltonian: 1,
|
|
||||||
weightDcaVoiceMovement: 1,
|
|
||||||
weightRgrVoiceMovement: 0,
|
|
||||||
rgrVoiceMovementThreshold: 5,
|
|
||||||
weightHarmonicCompactness: 0,
|
|
||||||
weightTargetRegister: 1,
|
|
||||||
cacheDir: 'cache',
|
|
||||||
rebuildCache: false,
|
|
||||||
noCache: false,
|
|
||||||
});
|
|
||||||
showMessage('Form reset to defaults');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('importFileInput').addEventListener('change', function(e) {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.target.result);
|
|
||||||
applySettings(data);
|
|
||||||
showMessage('Settings imported from ' + file.name);
|
|
||||||
} catch (err) {
|
|
||||||
showMessage('Error reading file: ' + err.message, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
this.value = ''; // Reset for re-import
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -35,17 +35,15 @@
|
||||||
|
|
||||||
const state = new Map();
|
const state = new Map();
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
if (n.data('isLabel')) return; // Skip label nodes
|
const chordLabel = n.data('chordLabel');
|
||||||
|
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
|
||||||
const chordLabel = n.data('chordLabel');
|
state.set(n.id(), {
|
||||||
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
|
x: n.position('x'),
|
||||||
state.set(n.id(), {
|
y: n.position('y'),
|
||||||
x: n.position('x'),
|
vx: 0,
|
||||||
y: n.position('y'),
|
fx: 0,
|
||||||
vx: 0,
|
bounds: bounds,
|
||||||
fx: 0,
|
});
|
||||||
bounds: bounds,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const edgeList = edges.map(e => ({
|
const edgeList = edges.map(e => ({
|
||||||
|
|
@ -263,16 +261,6 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide default number input spinners */
|
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
input[type="number"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-select {
|
.file-select {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
|
|
@ -281,33 +269,6 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Number input with inline +/- buttons */
|
|
||||||
.number-input-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.number-input-group input {
|
|
||||||
width: 50px;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
.number-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #666666;
|
|
||||||
border: 1px solid #222222;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.number-btn:hover {
|
|
||||||
background: #151515;
|
|
||||||
color: #999999;
|
|
||||||
border-color: #444444;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -319,31 +280,9 @@
|
||||||
<input type="text" id="filepathInput" value="output/output_chords.json" style="width: 250px;">
|
<input type="text" id="filepathInput" value="output/output_chords.json" style="width: 250px;">
|
||||||
<button id="loadFileBtn">Load</button>
|
<button id="loadFileBtn">Load</button>
|
||||||
<span style="margin-left: 20px;">Fundamental (Hz):</span>
|
<span style="margin-left: 20px;">Fundamental (Hz):</span>
|
||||||
<div class="number-input-group">
|
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('fundamentalInput', -1)">−</button>
|
|
||||||
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('fundamentalInput', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
<span style="margin-left: 15px;">Octave:</span>
|
<span style="margin-left: 15px;">Octave:</span>
|
||||||
<div class="number-input-group">
|
<input type="number" id="octaveInput" value="2" style="width: 40px;">
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('octaveInput', -1)">−</button>
|
|
||||||
<input type="number" id="octaveInput" value="2" style="width: 40px;">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('octaveInput', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
<span style="margin-left: 15px;">Ramp (s):</span>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rampDuration', -1)">−</button>
|
|
||||||
<input type="number" id="rampDuration" value="3" step="0.1" style="width: 60px;">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rampDuration', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
<span style="margin-left: 10px;">Exponent:</span>
|
|
||||||
<div class="number-input-group">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rampExponent', -1)">−</button>
|
|
||||||
<input type="number" id="rampExponent" value="1" min="0.1" max="5" step="0.1" style="width: 50px;">
|
|
||||||
<button type="button" class="number-btn" onclick="adjustValue('rampExponent', 1)">+</button>
|
|
||||||
</div>
|
|
||||||
<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>
|
<button id="toggleUnitBtn" style="margin-left: 15px;">Show: Cents</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -381,16 +320,6 @@
|
||||||
let allGraphsData = null;
|
let allGraphsData = null;
|
||||||
let displayMode = 'cents';
|
let displayMode = 'cents';
|
||||||
|
|
||||||
function adjustValue(id, delta) {
|
|
||||||
const input = document.getElementById(id);
|
|
||||||
const min = parseFloat(input.min) || -Infinity;
|
|
||||||
const max = parseFloat(input.max) || Infinity;
|
|
||||||
const step = parseFloat(input.step) || 1;
|
|
||||||
let val = parseFloat(input.value) || 0;
|
|
||||||
val = Math.max(min, Math.min(max, val + (delta * step)));
|
|
||||||
input.value = step % 1 !== 0 ? parseFloat(val.toFixed(2)) : Math.round(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse fraction string like "3/2" or "1" into a number
|
// Parse fraction string like "3/2" or "1" into a number
|
||||||
function parseFraction(fracStr) {
|
function parseFraction(fracStr) {
|
||||||
if (!fracStr || fracStr === "1") return 1;
|
if (!fracStr || fracStr === "1") return 1;
|
||||||
|
|
@ -401,81 +330,6 @@
|
||||||
return parseFloat(fracStr);
|
return parseFloat(fracStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play all nodes in a chord on the siren
|
|
||||||
function playChordOnSiren(chordIdx) {
|
|
||||||
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
||||||
|
|
||||||
// Find all nodes in this chord (exclude label nodes)
|
|
||||||
const nodes = cy.nodes().filter(n =>
|
|
||||||
n.data('chordIndex') === chordIdx && !n.data('isLabel')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear old sirenActive markers
|
|
||||||
cy.nodes().forEach(n => n.data('sirenActive', ''));
|
|
||||||
|
|
||||||
// Play each node using chordIndex + localId (let server calculate frequency)
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const localId = node.data('localId');
|
|
||||||
|
|
||||||
fetch('/api/play-siren', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
chordIndex: chordIdx,
|
|
||||||
nodeIndex: localId,
|
|
||||||
ip: sirenIp
|
|
||||||
})
|
|
||||||
});
|
|
||||||
node.data('sirenActive', 'true');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ramp to a chord or single node on the siren
|
|
||||||
function rampToChord(chordIdx, nodeIdx = null) {
|
|
||||||
const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3;
|
|
||||||
const durationMs = durationSeconds * 1000;
|
|
||||||
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)
|
|
||||||
const nodes = cy.nodes().filter(n =>
|
|
||||||
n.data('chordIndex') === chordIdx && !n.data('isLabel')
|
|
||||||
);
|
|
||||||
|
|
||||||
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all
|
|
||||||
if (nodeIdx !== null) {
|
|
||||||
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
|
|
||||||
if (targetNode) {
|
|
||||||
const nodeColor = targetNode.data('color');
|
|
||||||
cy.nodes().forEach(n => {
|
|
||||||
if (n.data('color') === nodeColor && n.data('sirenActive')) {
|
|
||||||
n.data('sirenActive', '');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
targetNode.data('sirenActive', 'true');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cy.nodes().forEach(n => n.data('sirenActive', ''));
|
|
||||||
nodes.forEach(node => node.data('sirenActive', 'true'));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/api/ramp-to-chord', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
chordIndex: chordIdx,
|
|
||||||
nodeIndex: nodeIdx,
|
|
||||||
duration: durationMs,
|
|
||||||
exponent: exponent,
|
|
||||||
ip: sirenIp
|
|
||||||
})
|
|
||||||
}).then(r => r.json()).then(data => {
|
|
||||||
console.log('Ramping:', data);
|
|
||||||
}).catch(err => {
|
|
||||||
console.log('Error ramping:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle between cents and frequency display
|
// Toggle between cents and frequency display
|
||||||
function toggleDisplayUnit() {
|
function toggleDisplayUnit() {
|
||||||
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
|
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
|
||||||
|
|
@ -532,7 +386,7 @@
|
||||||
container: document.getElementById('graph-container'),
|
container: document.getElementById('graph-container'),
|
||||||
style: [
|
style: [
|
||||||
{
|
{
|
||||||
selector: 'node[color]',
|
selector: 'node',
|
||||||
style: {
|
style: {
|
||||||
'background-color': 'data(color)',
|
'background-color': 'data(color)',
|
||||||
'width': 32,
|
'width': 32,
|
||||||
|
|
@ -548,22 +402,6 @@
|
||||||
'border-width': 0,
|
'border-width': 0,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
selector: 'node[isLabel = "true"]',
|
|
||||||
style: {
|
|
||||||
'background-color': '#888888',
|
|
||||||
'width': 40,
|
|
||||||
'height': 40,
|
|
||||||
'label': 'data(chordIndex)',
|
|
||||||
'color': '#ffffff',
|
|
||||||
'font-size': '18px',
|
|
||||||
'font-weight': 'bold',
|
|
||||||
'text-valign': 'center',
|
|
||||||
'text-halign': 'center',
|
|
||||||
'text-outline-width': 0,
|
|
||||||
'border-width': 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
selector: 'node[sirenActive = "true"]',
|
selector: 'node[sirenActive = "true"]',
|
||||||
style: {
|
style: {
|
||||||
|
|
@ -663,34 +501,13 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check modifiers
|
|
||||||
const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
|
|
||||||
const isRamp = evt.originalEvent && evt.originalEvent.ctrlKey;
|
|
||||||
|
|
||||||
// Handle label node clicks
|
|
||||||
if (node.data('isLabel')) {
|
|
||||||
const chordIdx = node.data('chordIndex');
|
|
||||||
if (isRamp) {
|
|
||||||
rampToChord(chordIdx);
|
|
||||||
} else {
|
|
||||||
playChordOnSiren(chordIdx);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chordIndex = node.data('chordIndex');
|
const chordIndex = node.data('chordIndex');
|
||||||
const localId = node.data('localId');
|
const localId = node.data('localId');
|
||||||
|
|
||||||
// Handle ramp modifier
|
|
||||||
if (isRamp) {
|
|
||||||
rampToChord(chordIndex, localId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Shift key is held - send to siren, otherwise send to SuperCollider
|
// Check if Shift key is held - send to siren, otherwise send to SuperCollider
|
||||||
|
const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
|
||||||
const endpoint = isShift ? '/api/play-siren' : '/api/play-freq';
|
const endpoint = isShift ? '/api/play-siren' : '/api/play-freq';
|
||||||
const destination = isShift ? 'siren' : 'SuperCollider';
|
const destination = isShift ? 'siren' : 'SuperCollider';
|
||||||
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
||||||
|
|
||||||
console.log('Sending play request to', destination, ':', chordIndex, localId);
|
console.log('Sending play request to', destination, ':', chordIndex, localId);
|
||||||
|
|
||||||
|
|
@ -700,8 +517,7 @@
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chordIndex: chordIndex,
|
chordIndex: chordIndex,
|
||||||
nodeIndex: localId,
|
nodeIndex: localId,
|
||||||
octave: parseInt(document.getElementById('octaveInput').value) || 0,
|
octave: parseInt(document.getElementById('octaveInput').value) || 0
|
||||||
ip: sirenIp
|
|
||||||
})
|
})
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on voice', data.voice);
|
console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on voice', data.voice);
|
||||||
|
|
@ -782,11 +598,10 @@
|
||||||
allGraphsData.graphs.forEach((graph, chordIdx) => {
|
allGraphsData.graphs.forEach((graph, chordIdx) => {
|
||||||
if (!graph || !graph.nodes) return;
|
if (!graph || !graph.nodes) return;
|
||||||
|
|
||||||
const nodes = graph.nodes;
|
const nodes = graph.nodes;
|
||||||
const edges = graph.edges || [];
|
const edges = graph.edges || [];
|
||||||
const gap = 50;
|
const xBase = 100 + chordIdx * chordSpacing;
|
||||||
const xBase = 100 + chordIdx * (chordSpacing + gap);
|
const idMap = {};
|
||||||
const idMap = {};
|
|
||||||
|
|
||||||
// Create unique IDs per chord to avoid collisions
|
// Create unique IDs per chord to avoid collisions
|
||||||
const chordPrefix = `c${chordIdx}_`;
|
const chordPrefix = `c${chordIdx}_`;
|
||||||
|
|
@ -826,18 +641,6 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add label node for this chord (locked so layout doesn't move it)
|
|
||||||
elements.push({
|
|
||||||
group: 'nodes',
|
|
||||||
data: {
|
|
||||||
id: `label_${chordIdx}`,
|
|
||||||
chordIndex: chordIdx,
|
|
||||||
isLabel: "true",
|
|
||||||
},
|
|
||||||
position: { x: xBase + (chordSpacing * 0.15), y: 30 },
|
|
||||||
locked: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elements.length === 0) return;
|
if (elements.length === 0) return;
|
||||||
|
|
@ -1244,11 +1047,10 @@
|
||||||
}
|
}
|
||||||
} else if (e.key === "k") {
|
} else if (e.key === "k") {
|
||||||
// Soft kill - send 20 Hz to stop voices gently
|
// Soft kill - send 20 Hz to stop voices gently
|
||||||
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
||||||
fetch('/api/kill-siren', {
|
fetch('/api/kill-siren', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ soft: true, ip: sirenIp })
|
body: JSON.stringify({ soft: true })
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
console.log('Soft kill sent (20 Hz)');
|
console.log('Soft kill sent (20 Hz)');
|
||||||
// Clear all siren circles
|
// Clear all siren circles
|
||||||
|
|
@ -1258,11 +1060,10 @@
|
||||||
});
|
});
|
||||||
} else if (e.key === "K") {
|
} else if (e.key === "K") {
|
||||||
// Hard kill - send 0 Hz to stop voices immediately
|
// Hard kill - send 0 Hz to stop voices immediately
|
||||||
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
||||||
fetch('/api/kill-siren', {
|
fetch('/api/kill-siren', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ soft: false, ip: sirenIp })
|
body: JSON.stringify({ soft: false })
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
console.log('Hard kill sent (0 Hz)');
|
console.log('Hard kill sent (0 Hz)');
|
||||||
// Clear all siren circles
|
// Clear all siren circles
|
||||||
|
|
|
||||||
290
webapp/server.py
290
webapp/server.py
|
|
@ -27,17 +27,6 @@ DEFAULT_FILE = "output_chords.json" # default file
|
||||||
current_index = 0
|
current_index = 0
|
||||||
chords = []
|
chords = []
|
||||||
|
|
||||||
# Cache persistent OSCSenders per IP for the siren
|
|
||||||
_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
|
||||||
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
|
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
|
||||||
|
|
@ -140,13 +129,8 @@ def play_freq():
|
||||||
def play_siren():
|
def play_siren():
|
||||||
"""Play a single frequency for a node on the siren."""
|
"""Play a single frequency for a node on the siren."""
|
||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
chord_index = data.get("chordIndex")
|
chord_index = data.get("chordIndex")
|
||||||
node_index = data.get("nodeIndex")
|
node_index = data.get("nodeIndex")
|
||||||
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
|
|
||||||
|
|
||||||
if chord_index is None or node_index is None:
|
|
||||||
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
|
|
||||||
|
|
||||||
if chord_index < 0 or chord_index >= len(chords):
|
if chord_index < 0 or chord_index >= len(chords):
|
||||||
return jsonify({"error": "Invalid chord index"}), 400
|
return jsonify({"error": "Invalid chord index"}), 400
|
||||||
|
|
@ -160,8 +144,8 @@ 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 cached sender
|
# Send to siren (192.168.4.200:54001) using current fundamental
|
||||||
siren_sender = get_siren_sender(siren_ip)
|
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -169,8 +153,9 @@ def play_siren():
|
||||||
{
|
{
|
||||||
"frequency": frequency,
|
"frequency": frequency,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
|
"fundamental": fundamental,
|
||||||
|
"fraction": pitch.get("fraction", "1"),
|
||||||
"destination": "siren",
|
"destination": "siren",
|
||||||
"ip": siren_ip,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -180,17 +165,12 @@ def kill_siren():
|
||||||
"""Send kill message to siren (soft or hard)."""
|
"""Send kill message to siren (soft or hard)."""
|
||||||
data = request.json
|
data = request.json
|
||||||
soft = data.get("soft", True) # default to soft kill
|
soft = data.get("soft", True) # default to soft kill
|
||||||
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
|
|
||||||
|
|
||||||
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 (for this IP)
|
# Send kill to all voices on siren using send_kill
|
||||||
siren_sender = get_siren_sender(siren_ip)
|
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
||||||
for event in siren_sender._ramp_events.values():
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
# Send kill to all voices using cached sender
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -199,63 +179,6 @@ def kill_siren():
|
||||||
"kill_freq": kill_freq,
|
"kill_freq": kill_freq,
|
||||||
"kill_type": kill_type,
|
"kill_type": kill_type,
|
||||||
"voices": list(range(1, siren_sender.num_voices + 1)),
|
"voices": list(range(1, siren_sender.num_voices + 1)),
|
||||||
"ip": siren_ip,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ramp-to-chord", methods=["POST"])
|
|
||||||
def ramp_to_chord():
|
|
||||||
"""Ramp to a chord or single voice on the siren."""
|
|
||||||
data = request.json
|
|
||||||
|
|
||||||
chord_index = data.get("chordIndex")
|
|
||||||
node_index = data.get("nodeIndex") # Optional - if present, ramp single voice
|
|
||||||
duration_ms = data.get("duration", 3000)
|
|
||||||
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:
|
|
||||||
return jsonify({"error": "Missing chordIndex"}), 400
|
|
||||||
|
|
||||||
if chord_index < 0 or chord_index >= len(chords):
|
|
||||||
return jsonify({"error": "Invalid chord index"}), 400
|
|
||||||
|
|
||||||
chord = chords[chord_index]
|
|
||||||
|
|
||||||
# Use cached sender
|
|
||||||
siren_sender = get_siren_sender(siren_ip)
|
|
||||||
siren_sender.set_chords(chords)
|
|
||||||
|
|
||||||
if node_index is not None:
|
|
||||||
# Ramp single voice
|
|
||||||
if node_index < 0 or node_index >= len(chord):
|
|
||||||
return jsonify({"error": "Invalid node index"}), 400
|
|
||||||
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_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, exponent=exponent
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "ramping",
|
|
||||||
"chordIndex": chord_index,
|
|
||||||
"voiceIndex": node_index,
|
|
||||||
"duration": duration_ms,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -320,207 +243,6 @@ def batch_calculate_cents_api():
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route("/generate")
|
|
||||||
def generate_page():
|
|
||||||
"""Render the generator page."""
|
|
||||||
return send_from_directory(app.root_path, "generate.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/generator_settings.json")
|
|
||||||
def default_settings():
|
|
||||||
"""Serve default generator settings from output directory."""
|
|
||||||
settings_path = DATA_DIR / "generator_settings.json"
|
|
||||||
if settings_path.exists():
|
|
||||||
return send_from_directory(DATA_DIR, "generator_settings.json")
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "No default settings file"}), 404
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/transcribe", methods=["POST"])
|
|
||||||
def run_transcribe():
|
|
||||||
"""Run the transcriber to create LilyPond output."""
|
|
||||||
data = request.json
|
|
||||||
transcribe_name = data.get("name", "compact_sets_transcription")
|
|
||||||
fundamental = data.get("fundamental", 55)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
|
|
||||||
input_file = DATA_DIR / "output_chords.json"
|
|
||||||
|
|
||||||
if not input_file.exists():
|
|
||||||
return jsonify({"error": "No chords to transcribe. Generate first."}), 400
|
|
||||||
|
|
||||||
# Load chords to get count
|
|
||||||
with open(input_file) as f:
|
|
||||||
chords_data = json.load(f)
|
|
||||||
chord_count = len(chords_data.get("chords", chords_data))
|
|
||||||
|
|
||||||
# Create lilypond directory if needed
|
|
||||||
lilypond_dir = DATA_DIR.parent / "lilypond"
|
|
||||||
os.makedirs(lilypond_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Run the transcriber
|
|
||||||
args = [
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"src.io",
|
|
||||||
"--transcribe",
|
|
||||||
str(input_file),
|
|
||||||
transcribe_name,
|
|
||||||
"--fundamental",
|
|
||||||
str(fundamental),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
args,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return jsonify({"error": result.stderr}), 400
|
|
||||||
|
|
||||||
output_file = lilypond_dir / f"{transcribe_name}.pdf"
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"chordCount": chord_count,
|
|
||||||
"outputFile": str(output_file),
|
|
||||||
"output": result.stdout,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({"error": str(e)}), 400
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/generate", methods=["POST"])
|
|
||||||
def run_generator():
|
|
||||||
"""Run the path generator with provided options."""
|
|
||||||
data = request.json
|
|
||||||
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Build args list
|
|
||||||
args = [
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"src.io",
|
|
||||||
"--dims",
|
|
||||||
str(data.get("dims", 7)),
|
|
||||||
"--chord-size",
|
|
||||||
str(data.get("chordSize", 3)),
|
|
||||||
"--max-path",
|
|
||||||
str(data.get("maxPath", 50)),
|
|
||||||
"--symdiff-min",
|
|
||||||
str(data.get("symdiffMin", 2)),
|
|
||||||
"--symdiff-max",
|
|
||||||
str(data.get("symdiffMax", 2)),
|
|
||||||
"--melodic-min",
|
|
||||||
str(data.get("melodicMin", 0)),
|
|
||||||
"--melodic-max",
|
|
||||||
str(data.get("melodicMax", 500)),
|
|
||||||
"--target-register",
|
|
||||||
str(data.get("targetRegister", 0)),
|
|
||||||
"--target-register-power",
|
|
||||||
str(data.get("targetRegisterPower", 1.0)),
|
|
||||||
"--target-register-oscillations",
|
|
||||||
str(data.get("targetRegisterOscillations", 0)),
|
|
||||||
"--target-register-amplitude",
|
|
||||||
str(data.get("targetRegisterAmplitude", 0.25)),
|
|
||||||
"--weight-melodic",
|
|
||||||
str(data.get("weightMelodic", 1)),
|
|
||||||
"--weight-contrary-motion",
|
|
||||||
str(data.get("weightContraryMotion", 0)),
|
|
||||||
"--weight-dca-hamiltonian",
|
|
||||||
str(data.get("weightDcaHamiltonian", 1)),
|
|
||||||
"--weight-dca-voice-movement",
|
|
||||||
str(data.get("weightDcaVoiceMovement", 1)),
|
|
||||||
"--weight-rgr-voice-movement",
|
|
||||||
str(data.get("weightRgrVoiceMovement", 0)),
|
|
||||||
"--rgr-voice-movement-threshold",
|
|
||||||
str(data.get("rgrVoiceMovementThreshold", 5)),
|
|
||||||
"--weight-harmonic-compactness",
|
|
||||||
str(data.get("weightHarmonicCompactness", 0)),
|
|
||||||
"--weight-target-register",
|
|
||||||
str(data.get("weightTargetRegister", 1)),
|
|
||||||
"--output-dir",
|
|
||||||
data.get("outputDir", "output"),
|
|
||||||
"--fundamental",
|
|
||||||
str(data.get("fundamental", 55)),
|
|
||||||
"--cache-dir",
|
|
||||||
data.get("cacheDir", "cache"),
|
|
||||||
]
|
|
||||||
|
|
||||||
seed = data.get("seed")
|
|
||||||
# If no seed provided, generate a random one
|
|
||||||
if seed is None:
|
|
||||||
import random
|
|
||||||
|
|
||||||
seed = random.randint(1, 999999)
|
|
||||||
args.extend(["--seed", str(seed)])
|
|
||||||
if data.get("allowVoiceCrossing", False):
|
|
||||||
args.append("--allow-voice-crossing")
|
|
||||||
if data.get("disableDirectTuning", False):
|
|
||||||
args.append("--disable-direct-tuning")
|
|
||||||
if data.get("uniformSymdiff", False):
|
|
||||||
args.append("--uniform-symdiff")
|
|
||||||
if data.get("rebuildCache", False):
|
|
||||||
args.append("--rebuild-cache")
|
|
||||||
if data.get("noCache", False):
|
|
||||||
args.append("--no-cache")
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
os.makedirs(data.get("outputDir", "output"), exist_ok=True)
|
|
||||||
|
|
||||||
# Run the generator
|
|
||||||
result = subprocess.run(
|
|
||||||
args,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return jsonify({"error": result.stderr}), 400
|
|
||||||
|
|
||||||
# Try to count generated chords from output
|
|
||||||
total_chords = 0
|
|
||||||
for line in result.stdout.split("\n"):
|
|
||||||
if "Path length:" in line:
|
|
||||||
try:
|
|
||||||
total_chords = int(line.split(":")[-1].strip())
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"totalChords": total_chords,
|
|
||||||
"status": "success",
|
|
||||||
"seed": seed,
|
|
||||||
"output": result.stdout,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({"error": str(e)}), 400
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Starting Path Navigator server...")
|
print("Starting Path Navigator server...")
|
||||||
filepath = DATA_DIR / DEFAULT_FILE
|
filepath = DATA_DIR / DEFAULT_FILE
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue