'use client' /** * Plays an Arcturus Trax composition by scheduling each sample on the * Web Audio API timeline. * * Format recap (see `@/lib/services/soundtracks`): * channelN:sample_id,ticks;sample_id,ticks;…:channelM:… * Each tick = 2 seconds. Channels play in parallel. * * We fetch each unique `sound_machine_sample_{id}.mp3`, decode it once, * then schedule AudioBufferSourceNode instances at `tickOffset * 2` * seconds relative to the master start time. Samples that end before * their tick slot simply fall silent until the next event — the slot * acts as an upper bound, not a forced loop. */ import { Loader2, Play, Square } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { TRAX_TICK_SECONDS, type TraxChannel } from '@/lib/trax-format' const TICK_SECONDS = TRAX_TICK_SECONDS // Module-level registry so only one TraxPlayer plays at a time. // When a new player starts, the previous one's stop() is invoked. // `activeOwner` identifies the player via a stable object ref so each // instance can tell "am I the current one?" without closing over callbacks. let activeOwner: object | null = null let activeStop: (() => void) | null = null interface TraxPlayerProps { channels: TraxChannel[] /** URL prefix to prepend to `sound_machine_sample_{id}.mp3`. */ baseUrl: string /** Expected total length in seconds (used for the progress bar). */ expectedLength: number } type Status = 'idle' | 'loading' | 'ready' | 'playing' | 'error' export function TraxPlayer({ channels, baseUrl, expectedLength }: TraxPlayerProps) { const [status, setStatus] = useState('idle') const [loaded, setLoaded] = useState(0) const [totalSamples, setTotalSamples] = useState(0) const [progress, setProgress] = useState(0) const ctxRef = useRef(null) const buffersRef = useRef>(new Map()) const sourcesRef = useRef([]) const startTimeRef = useRef(0) const rafRef = useRef(null) // Unique sample IDs referenced by the track (skip silence). const uniqueIds = Array.from( new Set(channels.flatMap((c) => c.events.map((e) => e.sampleId))), ).filter((id) => id > 0) // Stable self-marker used to identify this instance in the module registry. const selfRef = useRef({}) const stop = useCallback(() => { for (const src of sourcesRef.current) { try { src.stop() } catch { /* already stopped */ } } sourcesRef.current = [] if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } setStatus((prev) => (prev === 'playing' ? 'ready' : prev)) setProgress(0) if (activeOwner === selfRef.current) { activeOwner = null activeStop = null } }, []) useEffect(() => { return () => { stop() ctxRef.current?.close() ctxRef.current = null } }, [stop]) async function loadBuffers(): Promise { setStatus('loading') setTotalSamples(uniqueIds.length) setLoaded(0) const AudioCtor = window.AudioContext if (!AudioCtor) { toast.error('Web Audio API non supportata da questo browser.') setStatus('error') return } const ctx = ctxRef.current ?? new AudioCtor() ctxRef.current = ctx const buffers = buffersRef.current let done = 0 let failed = 0 await Promise.all( uniqueIds.map(async (id) => { if (buffers.has(id)) { done++ setLoaded(done) return } try { const url = `${baseUrl}sound_machine_sample_${id}.mp3` const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) const arr = await res.arrayBuffer() const buf = await ctx.decodeAudioData(arr) buffers.set(id, buf) } catch (err) { failed++ console.warn(`[trax] failed to load sample ${id}:`, err) } finally { done++ setLoaded(done) } }), ) if (failed > 0) { toast.warning( `${failed}/${uniqueIds.length} sample non caricati — la riproduzione potrebbe avere buchi.`, ) } if (failed === uniqueIds.length) { setStatus('error') return } setStatus('ready') } async function play() { // Stop any other TraxPlayer currently playing. if (activeOwner && activeOwner !== selfRef.current) { activeStop?.() } if (status !== 'ready') { await loadBuffers() } const ctx = ctxRef.current if (!ctx) return if (ctx.state === 'suspended') await ctx.resume() stop() // just in case const buffers = buffersRef.current const start = ctx.currentTime + 0.05 // tiny head-start startTimeRef.current = start const scheduled: AudioBufferSourceNode[] = [] for (const ch of channels) { let t = 0 // ticks from channel start for (const ev of ch.events) { if (ev.sampleId > 0) { const buf = buffers.get(ev.sampleId) if (buf) { const src = ctx.createBufferSource() src.buffer = buf src.connect(ctx.destination) const when = start + t * TICK_SECONDS const maxDur = ev.ticks * TICK_SECONDS src.start(when, 0, Math.min(buf.duration, maxDur)) scheduled.push(src) } } t += ev.ticks } } sourcesRef.current = scheduled setStatus('playing') activeOwner = selfRef.current activeStop = stop // Progress tick. const totalSec = Math.max( expectedLength, ...channels.map((c) => c.events.reduce((s, e) => s + e.ticks, 0) * TICK_SECONDS), ) const tick = () => { const elapsed = ctxRef.current ? ctxRef.current.currentTime - startTimeRef.current : 0 const pct = totalSec > 0 ? Math.min(100, (elapsed / totalSec) * 100) : 0 setProgress(pct) if (elapsed >= totalSec) { stop() return } rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) } const isBusy = status === 'loading' const isPlaying = status === 'playing' return (
{isPlaying ? ( ) : ( )} {isPlaying && (
)}
) }