feat: UI sounds, live LFO visualization, wire fix, worlds 7-12, bug fixes

- Add procedural UI sound effects (connect/disconnect, engine start/stop,
  level complete/fail, star earned, hint, navigation) via Tone.js
- Live LFO modulation visualization: knobs animate in real-time showing
  modulated value, ghost dot shows base value, number glows cyan
- Fix wire recalculation on zoom/pan/level re-entry (post-layout refresh)
- Fix retry button to keep current patch instead of reloading level
- Fix default param detection: newly added modules now populate all
  default params so level checkers work without manual param changes
- Add worlds 7-12: Secuencias y Ritmos, Texturas de Ruido, Síntesis
  Sustractiva, Espacio y Stereo, Técnicas Avanzadas, Gran Final
  (48 new levels, 144 new possible stars, 288 total stars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 03:03:29 +01:00
parent f0100eb64f
commit a1be6df355
17 changed files with 3317 additions and 24 deletions

View File

@@ -2,6 +2,8 @@
* state.js — Centralized reactive state for the modular synth
* Uses a simple pub/sub pattern for React integration
*/
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js';
let _listeners = new Set();
let _nextModuleId = 1;
@@ -40,9 +42,15 @@ export function emit() {
export function addModule(type, x, y) {
const id = _nextModuleId++;
state.modules.push({ id, type, x, y, params: {}, collapsed: false });
// Populate ALL default params so level checkers can read them immediately
const def = getModuleDef(type);
const defaults = def
? Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default]))
: {};
state.modules.push({ id, type, x, y, params: defaults, collapsed: false });
state.selectedModuleId = id;
emit();
playModuleAdd();
return id;
}
@@ -53,6 +61,7 @@ export function removeModule(id) {
);
if (state.selectedModuleId === id) state.selectedModuleId = null;
emit();
playModuleDelete();
}
export function updateModulePosition(id, x, y) {
@@ -78,19 +87,21 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
c.to.moduleId === toModuleId && c.to.port === toPort
);
if (inputTaken) {
// Remove old connection to this input
removeConnection(inputTaken.id);
// Remove old connection to this input (silent — connect sound will play)
removeConnection(inputTaken.id, true);
}
const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
emit();
playConnect();
return id;
}
export function removeConnection(id) {
export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id);
emit();
if (!_silent) playDisconnect();
}
export function getModule(id) {

223
src/engine/uiSounds.js Normal file
View File

@@ -0,0 +1,223 @@
/**
* uiSounds.js — Procedural UI sound effects using Tone.js
* All sounds are synthesized on-the-fly — no audio files needed.
* Sounds are short, subtle, and "synth-themed" to match the app.
*/
import * as Tone from 'tone';
let _enabled = true;
let _volume = -18; // dB, subtle
let _initialized = false;
let _masterGain = null;
// Lazy init — only create audio nodes after user interaction (Tone.start)
function ensureInit() {
if (_initialized) return true;
if (Tone.context.state !== 'running') return false;
_masterGain = new Tone.Gain(Tone.dbToGain(_volume)).toDestination();
_initialized = true;
return true;
}
export function setUISoundsEnabled(enabled) { _enabled = enabled; }
export function isUISoundsEnabled() { return _enabled; }
export function setUIVolume(db) {
_volume = db;
if (_masterGain) _masterGain.gain.value = Tone.dbToGain(db);
}
// ==================== Sound definitions ====================
/** Cable connected — short bright "click" with rising pitch */
export function playConnect() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.05 },
}).connect(_masterGain);
synth.triggerAttackRelease('C6', 0.06);
setTimeout(() => {
const synth2 = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.04 },
}).connect(_masterGain);
synth2.triggerAttackRelease('E6', 0.05);
setTimeout(() => synth2.dispose(), 200);
}, 40);
setTimeout(() => synth.dispose(), 300);
}
/** Cable disconnected — short descending blip */
export function playDisconnect() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 },
}).connect(_masterGain);
synth.triggerAttackRelease('E5', 0.06);
setTimeout(() => {
const synth2 = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.04 },
}).connect(_masterGain);
synth2.triggerAttackRelease('C5', 0.05);
setTimeout(() => synth2.dispose(), 200);
}, 50);
setTimeout(() => synth.dispose(), 300);
}
/** Module added — soft metallic "pop" */
export function playModuleAdd() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.MembraneSynth({
pitchDecay: 0.01,
octaves: 4,
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth.triggerAttackRelease('C4', 0.08);
setTimeout(() => synth.dispose(), 400);
}
/** Module deleted — reverse "zap" */
export function playModuleDelete() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.05 },
}).connect(_masterGain);
synth.triggerAttackRelease('A3', 0.08);
setTimeout(() => synth.dispose(), 300);
}
/** Button click — tiny tick */
export function playClick() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 },
}).connect(_masterGain);
synth.triggerAttackRelease('A5', 0.02);
setTimeout(() => synth.dispose(), 150);
}
/** Star earned — bright ascending arpeggio */
export function playStar(starNumber = 1) {
if (!_enabled || !ensureInit()) return;
const notes = ['C5', 'E5', 'G5'];
const note = notes[Math.min(starNumber - 1, 2)];
const delay = (starNumber - 1) * 300;
setTimeout(() => {
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.005, decay: 0.3, sustain: 0.1, release: 0.3 },
}).connect(_masterGain);
synth.triggerAttackRelease(note, 0.25);
// Shimmer harmonic
const shimmer = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
volume: -6,
}).connect(_masterGain);
shimmer.triggerAttackRelease(
Tone.Frequency(note).transpose(12).toNote(), 0.15
);
setTimeout(() => { synth.dispose(); shimmer.dispose(); }, 800);
}, delay);
}
/** Level complete — triumphant chord */
export function playLevelComplete() {
if (!_enabled || !ensureInit()) return;
const chord = ['C4', 'E4', 'G4', 'C5'];
chord.forEach((note, i) => {
setTimeout(() => {
const synth = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: { attack: 0.01, decay: 0.5, sustain: 0.2, release: 0.5 },
}).connect(_masterGain);
synth.triggerAttackRelease(note, 0.4);
setTimeout(() => synth.dispose(), 1200);
}, i * 60);
});
}
/** Level failed / check failed — low "bonk" */
export function playFail() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'square' },
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth.triggerAttackRelease('D#3', 0.1);
setTimeout(() => {
const synth2 = new Tone.Synth({
oscillator: { type: 'square' },
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth2.triggerAttackRelease('C3', 0.12);
setTimeout(() => synth2.dispose(), 400);
}, 100);
setTimeout(() => synth.dispose(), 400);
}
/** Hint revealed — mysterious "whoosh" */
export function playHint() {
if (!_enabled || !ensureInit()) return;
const noise = new Tone.Noise('pink');
const filter = new Tone.Filter({ type: 'bandpass', frequency: 2000, Q: 2 });
const env = new Tone.AmplitudeEnvelope({ attack: 0.05, decay: 0.2, sustain: 0, release: 0.1 });
noise.connect(filter).connect(env).connect(_masterGain);
noise.start();
env.triggerAttack();
setTimeout(() => { env.triggerRelease(); }, 150);
setTimeout(() => { noise.stop(); noise.dispose(); filter.dispose(); env.dispose(); }, 600);
}
/** Audio engine start — power-on sweep */
export function playEngineStart() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.1, decay: 0.2, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth.triggerAttackRelease('C4', 0.15);
setTimeout(() => {
const synth2 = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth2.triggerAttackRelease('G4', 0.12);
setTimeout(() => synth2.dispose(), 400);
}, 100);
setTimeout(() => synth.dispose(), 400);
}
/** Audio engine stop — power-down */
export function playEngineStop() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 },
}).connect(_masterGain);
synth.triggerAttackRelease('G4', 0.1);
setTimeout(() => {
const synth2 = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.15 },
}).connect(_masterGain);
synth2.triggerAttackRelease('C4', 0.15);
setTimeout(() => synth2.dispose(), 500);
}, 80);
setTimeout(() => synth.dispose(), 500);
}
/** Navigation click (map, back buttons) — soft "tick" */
export function playNav() {
if (!_enabled || !ensureInit()) return;
const synth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.03 },
}).connect(_masterGain);
synth.triggerAttackRelease('E5', 0.03);
setTimeout(() => synth.dispose(), 150);
}