fix: Transport lifecycle, scope zoom, clear button, and freq routing

- Fix pianoroll/sequencer Transport not resetting on stop/restart (notes
  were scheduled in the past and never fired)
- Stop and cancel Transport in stopAudio() to prevent stale events
- Add zoom +/- buttons to scope widget (6 levels, 64–2048 samples)
- Increase scope analyser buffer from 256 to 2048 for wider time view
- Add vertical grid lines to scope display
- Add "Limpiar" clear canvas button to PuzzleView
- Skip audio-graph connection for keyboard/seq/pianoroll freq→osc freq
  (direct frequency setting prevents inaudible ultrasonic values)
- Auto-trigger envelopes without gate connections for noise/ambient levels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 04:28:36 +01:00
parent 58d567c671
commit 36eb31a652
5 changed files with 173 additions and 24 deletions

View File

@@ -235,12 +235,23 @@ export default function PianoRollWidget({ moduleId }) {
// Playback // Playback
useEffect(() => { useEffect(() => {
if (!state.isRunning) { 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); setPlayPos(-1);
return; 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) // Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
// This lets the Transport BPM control actual playback speed // 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 // Note-off: convert duration beats to musical time for proper BPM-relative timing
const durSixteenths = Math.round(ev.dur * 4); const durSixteenths = Math.round(ev.dur * 4);
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9; const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
Tone.getTransport().scheduleOnce(() => { transport.scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(ev.note), false); setSequencerSignals(moduleId, midiToFreq(ev.note), false);
}, noteOffTime); }, noteOffTime);
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }])); }, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
@@ -271,16 +282,15 @@ export default function PianoRollWidget({ moduleId }) {
part.loop = loop; part.loop = loop;
part.loopEnd = `${bars}m`; part.loopEnd = `${bars}m`;
part.start(0); part.start(0);
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
partRef.current = part; partRef.current = part;
// Start Transport fresh from position 0
transport.start();
// Track playhead position // Track playhead position
const posInterval = setInterval(() => { const posInterval = setInterval(() => {
if (Tone.getTransport().state === 'started') { if (transport.state === 'started') {
const pos = Tone.getTransport().seconds; const pos = transport.seconds;
const beatDuration = 60 / bpm; const beatDuration = 60 / bpm;
const currentBeat = (pos / beatDuration) % totalBeats; const currentBeat = (pos / beatDuration) % totalBeats;
setPlayPos(currentBeat); setPlayPos(currentBeat);
@@ -289,7 +299,11 @@ export default function PianoRollWidget({ moduleId }) {
return () => { return () => {
clearInterval(posInterval); 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]); }, [state.isRunning, moduleId, bpm, bars, loop]);

View File

@@ -1,9 +1,19 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { getAnalyserData } from '../engine/audioEngine.js'; 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 }) { export default function ScopeDisplay({ moduleId }) {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const rafRef = 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(() => { useEffect(() => {
const canvas = canvasRef.current; 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 / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4); ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 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(); ctx.stroke();
const data = getAnalyserData(moduleId); const data = getAnalyserData(moduleId);
if (data && data.length > 0) { 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.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.beginPath();
const step = w / data.length; const count = end - offset;
for (let i = 0; i < data.length; i++) { const step = w / count;
const y = h / 2 + data[i] * h / 2 * -1; for (let i = 0; i < count; i++) {
const y = h / 2 + data[offset + i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y); if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y); else ctx.lineTo(i * step, y);
} }
@@ -46,5 +65,43 @@ export default function ScopeDisplay({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]); }, [moduleId]);
return <canvas ref={canvasRef} className="scope-canvas" />; const canZoomIn = zoomIdx > 0;
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
return (
<div style={{ position: 'relative' }}>
<canvas ref={canvasRef} className="scope-canvas" />
<div style={{
position: 'absolute', bottom: 2, right: 2,
display: 'flex', gap: 2,
}}>
<button
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
disabled={!canZoomOut}
title="Zoom out (más tiempo)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomOut ? '#00e5ff' : '#333',
cursor: canZoomOut ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
></button>
<button
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
disabled={!canZoomIn}
title="Zoom in (más detalle)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomIn ? '#00e5ff' : '#333',
cursor: canZoomIn ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
>+</button>
</div>
</div>
);
} }

View File

@@ -39,12 +39,23 @@ export default function SequencerWidget({ moduleId }) {
// Start/stop sequencer when audio engine runs // Start/stop sequencer when audio engine runs
useEffect(() => { useEffect(() => {
if (!state.isRunning) { 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); setCurrentStep(-1);
return; 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 seq = new Tone.Sequence((time, stepIdx) => {
const s = stepsRef.current[stepIdx]; const s = stepsRef.current[stepIdx];
@@ -53,7 +64,7 @@ export default function SequencerWidget({ moduleId }) {
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
Tone.getTransport().scheduleOnce(() => { transport.scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, time + Tone.Time('16n').toSeconds() * 0.8); }, time + Tone.Time('16n').toSeconds() * 0.8);
} else { } else {
@@ -62,13 +73,15 @@ export default function SequencerWidget({ moduleId }) {
}, Array.from({ length: numSteps }, (_, i) => i), '16n'); }, Array.from({ length: numSteps }, (_, i) => i), '16n');
seq.start(0); seq.start(0);
if (Tone.getTransport().state !== 'started') { transport.start();
Tone.getTransport().start();
}
seqRef.current = seq; seqRef.current = seq;
return () => { 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]); }, [state.isRunning, moduleId, numSteps]);

View File

@@ -136,7 +136,7 @@ function createNode(mod) {
}; };
} }
case 'scope': { case 'scope': {
const analyser = new Tone.Analyser('waveform', 256); const analyser = new Tone.Analyser('waveform', 2048);
return { return {
node: analyser, node: analyser,
inputs: { in: analyser }, inputs: { in: analyser },
@@ -245,6 +245,17 @@ export function connectWire(conn) {
const toEntry = ensureNode(conn.to.moduleId); const toEntry = ensureNode(conn.to.moduleId);
if (!fromEntry || !toEntry) return; 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 output = fromEntry.outputs[conn.from.port];
const input = toEntry.inputs[conn.to.port]; const input = toEntry.inputs[conn.to.port];
if (!output || input === undefined || input === null) return; 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._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; 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 // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of state.connections) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { 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._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; 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 // Also trigger any connected envelopes
for (const conn of state.connections) { for (const conn of state.connections) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
@@ -395,6 +428,13 @@ export async function startAudio() {
} }
export function stopAudio() { 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 // Destroy all nodes
for (const id of Object.keys(audioNodes)) { for (const id of Object.keys(audioNodes)) {
destroyNode(parseInt(id)); destroyNode(parseInt(id));
@@ -417,6 +457,21 @@ export function rebuildGraph() {
for (const conn of state.connections) { for (const conn of state.connections) {
connectWire(conn); 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) { export function getAnalyserData(moduleId) {

View File

@@ -6,7 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx'; import WireLayer from '../components/WireLayer.jsx';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx'; 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 { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
import { SOLUTIONS } from './autoSolver.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) // Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
const handleRevealHint = () => { const handleRevealHint = () => {
setHintUsed(true); setHintUsed(true);
@@ -326,6 +333,9 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
> >
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'} {state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
</button> </button>
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button>
<button className="gm-btn check" onClick={handleCheck}> <button className="gm-btn check" onClick={handleCheck}>
Comprobar Comprobar
</button> </button>