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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'}
|
||||
</button>
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<button className="gm-btn check" onClick={handleCheck}>
|
||||
✓ Comprobar
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user