fix: LFO→cutoff modulation, visual knob feedback, persistent hints

- Fix LFO→Filter cutoff: add scaling Gain nodes so LFO (-1..1) maps to
  meaningful Hz modulation (±cutoff value). Same fix for LFO→Osc freq.
  Mod scale updates dynamically when user changes the base param value.
- Visual modulation indicator: knobs receiving LFO/modulation show a
  pulsing dashed ring animation (spin + pulse) around the knob arc
- Persist hint usage per level: using a hint permanently caps that level
  at 2 stars — survives reload/restart. No more cheating by restarting!
- Hint state stored in separate localStorage key (synthquest-hints)
- Admin reset also clears hint history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 02:44:28 +01:00
parent c4a2cb3cef
commit f0100eb64f
7 changed files with 120 additions and 15 deletions

View File

@@ -24,11 +24,16 @@ function createNode(mod) {
case 'oscillator': {
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
osc.start();
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
// Scale = half the current frequency so modulation is musically meaningful
const freqMod = new Tone.Gain(p.frequency * 0.5);
freqMod.connect(osc.frequency);
return {
node: osc,
inputs: { freq: osc.frequency, detune: osc.detune },
_freqMod: freqMod,
inputs: { freq: freqMod, detune: osc.detune },
outputs: { out: osc },
dispose: () => { osc.stop(); osc.dispose(); },
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
};
}
case 'lfo': {
@@ -53,11 +58,16 @@ function createNode(mod) {
}
case 'filter': {
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
const cutoffMod = new Tone.Gain(p.frequency);
cutoffMod.connect(filter.frequency);
return {
node: filter,
inputs: { in: filter, cutoff: filter.frequency },
_cutoffMod: cutoffMod,
inputs: { in: filter, cutoff: cutoffMod },
outputs: { out: filter },
dispose: () => filter.dispose(),
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
};
}
case 'envelope': {
@@ -277,7 +287,11 @@ export function updateParam(moduleId, paramName, value) {
switch (mod.type) {
case 'oscillator':
if (paramName === 'waveform') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value;
else if (paramName === 'frequency') {
entry.node.frequency.value = value;
// Update mod scaler proportionally
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
}
else if (paramName === 'detune') entry.node.detune.value = value;
break;
case 'lfo':
@@ -290,7 +304,11 @@ export function updateParam(moduleId, paramName, value) {
break;
case 'filter':
if (paramName === 'type') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value;
else if (paramName === 'frequency') {
entry.node.frequency.value = value;
// Update mod scaler proportionally
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
}
else if (paramName === 'Q') entry.node.Q.value = value;
break;
case 'envelope':