Spaces:
Runtime error
Runtime error
import { useCallback, useEffect, useRef, useState } from 'react'; | |
// Helper Types | |
type UseAudioResponse = { | |
playback: (base64Data?: string, isLastTrackOfPlaylist?: boolean) => Promise<boolean>; | |
progress: number; | |
isLoaded: boolean; | |
isPlaying: boolean; | |
isSwitchingTracks: boolean; // when audio is temporary cut (but it's not a real pause) | |
togglePause: () => void; | |
}; | |
export function useAudio(): UseAudioResponse { | |
const audioContextRef = useRef<AudioContext | null>(null); | |
const sourceNodeRef = useRef<AudioBufferSourceNode | null>(null); | |
const [progress, setProgress] = useState(0.0); | |
const [isPlaying, setIsPlaying] = useState(false); | |
const [isLoaded, setIsLoaded] = useState(false); | |
const [isSwitchingTracks, setSwitchingTracks] = useState(false); | |
const startTimeRef = useRef(0); | |
const pauseTimeRef = useRef(0); | |
const stopAudio = useCallback(() => { | |
try { | |
audioContextRef.current?.close(); | |
} catch (err) { | |
// already closed probably | |
} | |
setSwitchingTracks(false); | |
sourceNodeRef.current = null; | |
sourceNodeRef.current = null; | |
// setProgress(0); // Reset progress | |
}, []); | |
// Helper function to handle conversion from Base64 to an ArrayBuffer | |
async function base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> { | |
const response = await fetch(base64); | |
return response.arrayBuffer(); | |
} | |
const playback = useCallback( | |
async (base64Data?: string, isLastTrackOfPlaylist?: boolean): Promise<boolean> => { | |
stopAudio(); // Stop any playing audio first | |
// If no base64 data provided, we don't attempt to play any audio | |
if (!base64Data) { | |
return false; | |
} | |
// Initialize AudioContext | |
const audioContext = new AudioContext(); | |
audioContextRef.current = audioContext; | |
// Format Base64 string if necessary and get ArrayBuffer | |
const formattedBase64 = | |
base64Data.startsWith('data:audio/wav') || base64Data.startsWith('data:audio/wav;base64,') | |
? base64Data | |
: `data:audio/wav;base64,${base64Data}`; | |
console.log(`formattedBase64: ${formattedBase64.slice(0, 50)} (len: ${formattedBase64.length})`); | |
const arrayBuffer = await base64ToArrayBuffer(formattedBase64); | |
return new Promise((resolve, reject) => { | |
// Decode the audio data and play | |
audioContext.decodeAudioData(arrayBuffer, (audioBuffer) => { | |
// Create a source node and gain node | |
const source = audioContext.createBufferSource(); | |
const gainNode = audioContext.createGain(); | |
// Set buffer and gain | |
source.buffer = audioBuffer; | |
gainNode.gain.value = 1.0; | |
// Connect nodes | |
source.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
// Assign source node to ref for progress tracking | |
sourceNodeRef.current = source; | |
source.start(0, pauseTimeRef.current % audioBuffer.duration); // Start at the correct offset if paused previously | |
startTimeRef.current = audioContextRef.current!.currentTime - pauseTimeRef.current; | |
setSwitchingTracks(false); | |
setProgress(0); | |
setIsLoaded(true); | |
setIsPlaying(true); | |
// Set up progress interval | |
const totalDuration = audioBuffer.duration; | |
const updateProgressInterval = setInterval(() => { | |
if (sourceNodeRef.current && audioContextRef.current) { | |
const currentTime = audioContextRef.current.currentTime; | |
const currentProgress = currentTime / totalDuration; | |
setProgress(currentProgress); | |
if (currentProgress >= 1.0) { | |
clearInterval(updateProgressInterval); | |
} | |
} | |
}, 50); // Update every 50ms | |
if (source) { | |
source.onended = () => { | |
// used to indicate a temporary stop, while we switch tracks | |
if (!isLastTrackOfPlaylist) { | |
setSwitchingTracks(true); | |
} | |
setIsPlaying(false); | |
clearInterval(updateProgressInterval); | |
stopAudio(); | |
resolve(true); | |
}; | |
} | |
}, (error) => { | |
console.error('Error decoding audio data:', error); | |
reject(error); | |
}); | |
}) | |
}, | |
[stopAudio] | |
); | |
const togglePause = useCallback(() => { | |
if (!audioContextRef.current || !sourceNodeRef.current) { | |
return; // Do nothing if audio is not initialized | |
} | |
if (isPlaying) { | |
// Pause the audio | |
pauseTimeRef.current += audioContextRef.current.currentTime - startTimeRef.current; | |
sourceNodeRef.current.stop(); // This effectively "pauses" the audio, but it also means the sourceNode will be unusable | |
sourceNodeRef.current = null; // As the node is now unusable, we nullify it | |
setIsPlaying(false); | |
} else { | |
// Resume playing | |
audioContextRef.current.resume().then(() => { | |
playback(); // This will pick up where we left off due to pauseTimeRef | |
}); | |
} | |
}, [audioContextRef, sourceNodeRef, isPlaying, playback]); | |
// Effect to handle cleanup on component unmount | |
useEffect(() => { | |
return () => { | |
stopAudio(); | |
}; | |
}, [stopAudio]); | |
return { playback, isPlaying, isSwitchingTracks, isLoaded, progress, togglePause }; | |
} |