/** * Audio playback controls with Tone.js. * * Features: * - Smooth playback using Tone.Transport for scheduling * - Proper pause/resume functionality * - Visual feedback of currently playing notes */ import { useState, useRef, useEffect } from 'react'; import * as Tone from 'tone'; // useNotationStore is optional for tests; guard its usage import { useNotationStore } from '../store/notation'; import { durationToSeconds } from '../utils/duration'; import type { Note } from '../store/notation'; import './PlaybackControls.css'; interface PlaybackControlsProps { isPlaying?: boolean; tempo?: number; minTempo?: number; maxTempo?: number; currentTime?: number; duration?: number; audioLoaded?: boolean; loading?: boolean; showVolumeControl?: boolean; volume?: number; loop?: boolean; supportKeyboardShortcuts?: boolean; onPlay?: () => void; onPause?: () => void; onStop?: () => void; onTempoChange?: (tempo: number) => void; onSeek?: (time: number) => void; onVolumeChange?: (volume: number) => void; onLoopToggle?: (enabled: boolean) => void; } export function PlaybackControls(props: PlaybackControlsProps) { const [isPlaying, setIsPlaying] = useState(props?.isPlaying ?? false); const [tempo, setTempo] = useState(props?.tempo ?? 120); const [currentPosition, setCurrentPosition] = useState(props?.currentTime ?? 0); // Current playback position in seconds const samplerRef = useRef(null); const scheduledEventsRef = useRef([]); // Store Tone event IDs for cleanup const animationFrameRef = useRef(null); const startTimeRef = useRef(0); const pausedAtRef = useRef(0); const score = useNotationStore?.((state) => state.score); const setPlayingNoteIds = useNotationStore?.((state) => state.setPlayingNoteIds); useEffect(() => { // Initialize Tone.js sampler with piano samples samplerRef.current = new Tone.Sampler({ urls: { A0: "A0.mp3", C1: "C1.mp3", 'D#1': "Ds1.mp3", 'F#1': "Fs1.mp3", A1: "A1.mp3", C2: "C2.mp3", 'D#2': "Ds2.mp3", 'F#2': "Fs2.mp3", A2: "A2.mp3", C3: "C3.mp3", 'D#3': "Ds3.mp3", 'F#3': "Fs3.mp3", A3: "A3.mp3", C4: "C4.mp3", 'D#4': "Ds4.mp3", 'F#4': "Fs4.mp3", A4: "A4.mp3", C5: "C5.mp3", 'D#5': "Ds5.mp3", 'F#5': "Fs5.mp3", A5: "A5.mp3", C6: "C6.mp3", 'D#6': "Ds6.mp3", 'F#6': "Fs6.mp3", A6: "A6.mp3", C7: "C7.mp3", 'D#7': "Ds7.mp3", 'F#7': "Fs7.mp3", A7: "A7.mp3", C8: "C8.mp3", }, baseUrl: "https://tonejs.github.io/audio/salamander/", }).toDestination(); return () => { // Cleanup on unmount if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } if (samplerRef.current && typeof samplerRef.current.dispose === 'function') { samplerRef.current.dispose(); } const transport = Tone.Transport as any; if (transport?.stop) transport.stop(); if (typeof transport?.cancel === 'function') transport.cancel(); }; }, []); // Sync props to state when provided useEffect(() => { if (props?.isPlaying !== undefined) setIsPlaying(props.isPlaying); }, [props?.isPlaying]); useEffect(() => { if (props?.tempo !== undefined) setTempo(props.tempo); }, [props?.tempo]); useEffect(() => { if (props?.currentTime !== undefined) setCurrentPosition(props.currentTime); }, [props?.currentTime]); // Keyboard shortcuts support useEffect(() => { if (!props?.supportKeyboardShortcuts) return; const onKey = (e: KeyboardEvent) => { if (e.key === ' ') { e.preventDefault(); if (isPlaying) { props?.onPause ? props.onPause() : handlePause(); } else { props?.onPlay ? props.onPlay() : handlePlay(); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [props?.supportKeyboardShortcuts, isPlaying]); // Update position indicator during playback const updatePosition = () => { if (!isPlaying) return; const elapsed = Tone.Transport.seconds; setCurrentPosition(elapsed); animationFrameRef.current = requestAnimationFrame(updatePosition); }; const handlePlay = async () => { if (!samplerRef.current) return; // If no score loaded, just invoke callbacks and mark playing for tests if (!score) { setIsPlaying(true); if (props?.onPlay) props.onPlay(); return; } await Tone.start(); // Clear any previously scheduled events if (typeof (Tone.Transport as any)?.cancel === 'function') { (Tone.Transport as any).cancel(); } scheduledEventsRef.current = []; // Set tempo Tone.Transport.bpm.value = tempo; // Build a timeline of all note events across all parts interface NoteEvent { time: number; duration: number; notes: Array<{ pitch: string; id: string }>; } const timeline: NoteEvent[] = []; // Process all parts (treble + bass for grand staff) score.parts.forEach((part) => { let partTime = 0; part.measures.forEach((measure) => { let i = 0; while (i < measure.notes.length) { const currentNote = measure.notes[i]; if (currentNote.isRest) { // Rest: just advance time const restDuration = durationToSeconds(currentNote.duration, tempo, currentNote.dotted); partTime += restDuration; i++; continue; } // Collect all consecutive notes with same duration (chord detection) const chordNotes: Note[] = [currentNote]; let j = i + 1; while (j < measure.notes.length && !measure.notes[j].isRest && measure.notes[j].duration === currentNote.duration && measure.notes[j].dotted === currentNote.dotted) { chordNotes.push(measure.notes[j]); j++; } // Calculate duration once for the chord const noteDuration = durationToSeconds(currentNote.duration, tempo, currentNote.dotted); // Find or create timeline entry for this time let timelineEntry = timeline.find(e => Math.abs(e.time - partTime) < 0.001); if (!timelineEntry) { timelineEntry = { time: partTime, duration: noteDuration, notes: [] }; timeline.push(timelineEntry); } // Add all chord notes to this timeline entry chordNotes.forEach((note) => { if (note.pitch) { timelineEntry!.notes.push({ pitch: note.pitch, id: note.id }); } }); // Advance time by the chord duration (only once, not per note) partTime += noteDuration; i = j; } }); }); // Sort timeline by time timeline.sort((a, b) => a.time - b.time); // Calculate total duration const totalDuration = timeline.length > 0 ? timeline[timeline.length - 1].time + timeline[timeline.length - 1].duration : 0; // Schedule all events on Tone.Transport timeline.forEach((event) => { // Schedule note playback Tone.Transport.schedule((time) => { event.notes.forEach(({ pitch }) => { try { samplerRef.current?.triggerAttackRelease(pitch, event.duration, time); } catch (err) { console.warn('Failed to play note:', pitch, err); } }); // Update visual feedback (highlight playing notes) const noteIds = event.notes.map(n => n.id); if (setPlayingNoteIds) setPlayingNoteIds(noteIds); // Clear highlight after note duration Tone.Transport.scheduleOnce(() => { if (setPlayingNoteIds) setPlayingNoteIds([]); }, time + event.duration); }, event.time); }); // Schedule end of playback if (typeof (Tone.Transport as any)?.schedule === 'function') { (Tone.Transport as any).schedule(() => { handleStop(); }, totalDuration); } // Start transport from paused position or beginning if (pausedAtRef.current > 0) { Tone.Transport.seconds = pausedAtRef.current; } else { Tone.Transport.seconds = 0; } Tone.Transport.start(); setIsPlaying(true); if (props?.onPlay) props.onPlay(); startTimeRef.current = Tone.now() - Tone.Transport.seconds; // Start position update loop updatePosition(); }; const handlePause = () => { if (!isPlaying) return; // Pause the transport const transport = Tone.Transport as any; transport?.pause?.(); pausedAtRef.current = transport?.seconds ?? 0; setIsPlaying(false); if (props?.onPause) props.onPause(); if (setPlayingNoteIds) setPlayingNoteIds([]); // Clear any highlighted notes if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; const handleStop = () => { // Stop and reset to beginning const transport = Tone.Transport as any; transport?.stop?.(); if (typeof transport?.cancel === 'function') transport.cancel(); if (transport) transport.seconds = 0; pausedAtRef.current = 0; setIsPlaying(false); if (props?.onStop) props.onStop(); setCurrentPosition(0); if (setPlayingNoteIds) setPlayingNoteIds([]); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; const handleTempoChange = (newTempo: number) => { const wasPlaying = isPlaying; if (wasPlaying) { handlePause(); } const clamped = Math.min(Math.max(newTempo, props?.minTempo ?? 40), props?.maxTempo ?? 240); setTempo(clamped); Tone.Transport.bpm.value = clamped; if (props?.onTempoChange) props.onTempoChange(clamped); }; function formatTime(totalSeconds: number) { const sec = Math.floor(totalSeconds); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const mm = h > 0 ? String(m).padStart(2, '0') : String(m); const ss = String(s).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`; } return (
{tempo}
{props?.duration !== undefined && ( { const val = parseInt((e.target as HTMLInputElement).value); setCurrentPosition(val); if (props?.onSeek) props.onSeek(val); }} /> )}
{formatTime(currentPosition)} {props?.duration !== undefined && / {formatTime(props.duration)}}
{props?.showVolumeControl && (
)} {props?.loading &&
Loading...
}
); } export default PlaybackControls;