diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 8cf5747..8927561 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -235,12 +235,23 @@ export default function PianoRollWidget({ moduleId }) { // Playback useEffect(() => { if (!state.isRunning) { - if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; } + if (partRef.current) { + try { partRef.current.stop(); } catch {} + try { partRef.current.dispose(); } catch {} + partRef.current = null; + } setPlayPos(-1); return; } - Tone.getTransport().bpm.value = bpm; + const transport = Tone.getTransport(); + transport.bpm.value = bpm; + + // Ensure Transport is at position 0 before scheduling + if (transport.state === 'started') { + transport.stop(); + } + transport.position = 0; // Build Tone.Part from notes using musical time (bars:quarters:sixteenths) // This lets the Transport BPM control actual playback speed @@ -263,7 +274,7 @@ export default function PianoRollWidget({ moduleId }) { // Note-off: convert duration beats to musical time for proper BPM-relative timing const durSixteenths = Math.round(ev.dur * 4); const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9; - Tone.getTransport().scheduleOnce(() => { + transport.scheduleOnce(() => { setSequencerSignals(moduleId, midiToFreq(ev.note), false); }, noteOffTime); }, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }])); @@ -271,16 +282,15 @@ export default function PianoRollWidget({ moduleId }) { part.loop = loop; part.loopEnd = `${bars}m`; part.start(0); - - if (Tone.getTransport().state !== 'started') { - Tone.getTransport().start(); - } partRef.current = part; + // Start Transport fresh from position 0 + transport.start(); + // Track playhead position const posInterval = setInterval(() => { - if (Tone.getTransport().state === 'started') { - const pos = Tone.getTransport().seconds; + if (transport.state === 'started') { + const pos = transport.seconds; const beatDuration = 60 / bpm; const currentBeat = (pos / beatDuration) % totalBeats; setPlayPos(currentBeat); @@ -289,7 +299,11 @@ export default function PianoRollWidget({ moduleId }) { return () => { clearInterval(posInterval); - if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; } + if (partRef.current) { + try { partRef.current.stop(); } catch {} + try { partRef.current.dispose(); } catch {} + partRef.current = null; + } }; }, [state.isRunning, moduleId, bpm, bars, loop]); diff --git a/src/components/ScopeDisplay.jsx b/src/components/ScopeDisplay.jsx index 737d488..7ac45cf 100644 --- a/src/components/ScopeDisplay.jsx +++ b/src/components/ScopeDisplay.jsx @@ -1,9 +1,19 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import { getAnalyserData } from '../engine/audioEngine.js'; +// Zoom levels: how many samples to display (from a 2048-sample buffer) +// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible) +const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048]; +const DEFAULT_ZOOM = 2; // index → 256 samples + export default function ScopeDisplay({ moduleId }) { const canvasRef = useRef(null); const rafRef = useRef(null); + const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM); + const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]); + + // Keep ref in sync so the draw loop picks it up without re-creating the effect + useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]); useEffect(() => { const canvas = canvasRef.current; @@ -23,16 +33,25 @@ export default function ScopeDisplay({ moduleId }) { ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4); ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4); + for (let x = w / 4; x < w; x += w / 4) { + ctx.moveTo(x, 0); ctx.lineTo(x, h); + } ctx.stroke(); const data = getAnalyserData(moduleId); if (data && data.length > 0) { + const samplesToShow = zoomRef.current; + // Center the window in the buffer + const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2)); + const end = Math.min(data.length, offset + samplesToShow); + ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 1.5; ctx.beginPath(); - const step = w / data.length; - for (let i = 0; i < data.length; i++) { - const y = h / 2 + data[i] * h / 2 * -1; + const count = end - offset; + const step = w / count; + for (let i = 0; i < count; i++) { + const y = h / 2 + data[offset + i] * h / 2 * -1; if (i === 0) ctx.moveTo(0, y); else ctx.lineTo(i * step, y); } @@ -46,5 +65,43 @@ export default function ScopeDisplay({ moduleId }) { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [moduleId]); - return ; + const canZoomIn = zoomIdx > 0; + const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1; + + return ( +
+ +
+ + +
+
+ ); } diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 61b3750..e87dc1d 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -39,12 +39,23 @@ export default function SequencerWidget({ moduleId }) { // Start/stop sequencer when audio engine runs useEffect(() => { if (!state.isRunning) { - if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } + if (seqRef.current) { + try { seqRef.current.stop(); } catch {} + try { seqRef.current.dispose(); } catch {} + seqRef.current = null; + } setCurrentStep(-1); return; } - Tone.getTransport().bpm.value = bpm; + const transport = Tone.getTransport(); + transport.bpm.value = bpm; + + // Ensure Transport is at position 0 + if (transport.state === 'started') { + transport.stop(); + } + transport.position = 0; const seq = new Tone.Sequence((time, stepIdx) => { const s = stepsRef.current[stepIdx]; @@ -53,7 +64,7 @@ export default function SequencerWidget({ moduleId }) { if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - Tone.getTransport().scheduleOnce(() => { + transport.scheduleOnce(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); }, time + Tone.Time('16n').toSeconds() * 0.8); } else { @@ -62,13 +73,15 @@ export default function SequencerWidget({ moduleId }) { }, Array.from({ length: numSteps }, (_, i) => i), '16n'); seq.start(0); - if (Tone.getTransport().state !== 'started') { - Tone.getTransport().start(); - } + transport.start(); seqRef.current = seq; return () => { - if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } + if (seqRef.current) { + try { seqRef.current.stop(); } catch {} + try { seqRef.current.dispose(); } catch {} + seqRef.current = null; + } }; }, [state.isRunning, moduleId, numSteps]); diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index ffd5eec..a1af47e 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -136,7 +136,7 @@ function createNode(mod) { }; } case 'scope': { - const analyser = new Tone.Analyser('waveform', 256); + const analyser = new Tone.Analyser('waveform', 2048); return { node: analyser, inputs: { in: analyser }, @@ -245,6 +245,17 @@ export function connectWire(conn) { const toEntry = ensureNode(conn.to.moduleId); if (!fromEntry || !toEntry) return; + // Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq. + // These signals carry absolute Hz values that would be mangled by the oscillator's + // frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals + // set the oscillator frequency directly when notes are played. + const fromMod = state.modules.find(m => m.id === conn.from.moduleId); + const toMod = state.modules.find(m => m.id === conn.to.moduleId); + if (fromMod && ['keyboard', 'sequencer', 'pianoroll'].includes(fromMod.type) && + conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') { + return; // handled imperatively in triggerKeyboard / setSequencerSignals + } + const output = fromEntry.outputs[conn.from.port]; const input = toEntry.inputs[conn.to.port]; if (!output || input === undefined || input === null) return; @@ -356,6 +367,17 @@ export function setSequencerSignals(moduleId, freq, gate) { if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; + // Directly set connected oscillator frequencies (bypasses the modulation Gain) + for (const conn of state.connections) { + if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { + const oscEntry = audioNodes[conn.to.moduleId]; + const oscMod = state.modules.find(m => m.id === conn.to.moduleId); + if (oscEntry?.node && oscMod?.type === 'oscillator') { + oscEntry.node.frequency.value = freq; + } + } + } + // Trigger connected envelopes for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { @@ -374,6 +396,17 @@ export function triggerKeyboard(moduleId, freq, gate) { if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; + // Directly set connected oscillator frequencies (bypasses the modulation Gain) + for (const conn of state.connections) { + if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { + const oscEntry = audioNodes[conn.to.moduleId]; + const oscMod = state.modules.find(m => m.id === conn.to.moduleId); + if (oscEntry?.node && oscMod?.type === 'oscillator') { + oscEntry.node.frequency.value = freq; + } + } + } + // Also trigger any connected envelopes for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { @@ -395,6 +428,13 @@ export async function startAudio() { } export function stopAudio() { + // Stop and reset Transport so pianoroll/sequencer Parts don't get stranded + try { + Tone.getTransport().stop(); + Tone.getTransport().cancel(); // Remove all scheduled events + Tone.getTransport().position = 0; + } catch (e) { /* ignore if Transport not started */ } + // Destroy all nodes for (const id of Object.keys(audioNodes)) { destroyNode(parseInt(id)); @@ -417,6 +457,21 @@ export function rebuildGraph() { for (const conn of state.connections) { connectWire(conn); } + + // Auto-trigger envelopes that have no gate connection (free-running mode). + // This allows noise/ambient patches to work without a keyboard/sequencer. + for (const mod of state.modules) { + if (mod.type !== 'envelope') continue; + const hasGateInput = state.connections.some( + c => c.to.moduleId === mod.id && c.to.port === 'gate' + ); + if (!hasGateInput) { + const entry = audioNodes[mod.id]; + if (entry && entry.node && typeof entry.node.triggerAttack === 'function') { + entry.node.triggerAttack(); + } + } + } } export function getAnalyserData(moduleId) { diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index d1f3a29..612bcd1 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -6,7 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import LevelComplete from './LevelComplete.jsx'; -import { completeLevel, saveLevelPatch, getLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; +import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js'; import { SOLUTIONS } from './autoSolver.js'; @@ -251,6 +251,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN } }; + // Clear canvas — remove all user-added modules and reset to preplaced only + const handleClearCanvas = () => { + if (state.isRunning) stopAudio(); + clearLevelPatch(level.id); + loadLevel(true); + }; + // Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload) const handleRevealHint = () => { setHintUsed(true); @@ -326,6 +333,9 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN > {state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'} +