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'}
+