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:
@@ -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
223
src/engine/uiSounds.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user