Spaces:
Runtime error
Runtime error
| import debug from './debug'; | |
| type AddAudioToBufferFunction = ( | |
| samples: Array<number>, | |
| sampleRate: number, | |
| ) => void; | |
| export type BufferedSpeechPlayer = { | |
| addAudioToBuffer: AddAudioToBufferFunction; | |
| setGain: (gain: number) => void; | |
| start: () => void; | |
| stop: () => void; | |
| }; | |
| type Options = { | |
| onEnded?: () => void; | |
| onStarted?: () => void; | |
| }; | |
| export default function createBufferedSpeechPlayer({ | |
| onStarted, | |
| onEnded, | |
| }: Options): BufferedSpeechPlayer { | |
| const audioContext = new AudioContext(); | |
| const gainNode = audioContext.createGain(); | |
| gainNode.connect(audioContext.destination); | |
| let unplayedAudioBuffers: Array<AudioBuffer> = []; | |
| let currentPlayingBufferSource: AudioBufferSourceNode | null = null; | |
| let isPlaying = false; | |
| // This means that the player starts in the 'stopped' state, and you need to call player.start() for it to start playing | |
| let shouldPlayWhenAudioAvailable = false; | |
| const setGain = (gain: number) => { | |
| gainNode.gain.setValueAtTime(gain, audioContext.currentTime); | |
| }; | |
| const start = () => { | |
| shouldPlayWhenAudioAvailable = true; | |
| debug()?.start(); | |
| playNextBufferIfNotAlreadyPlaying(); | |
| }; | |
| // Stop will stop the audio and clear the buffers | |
| const stop = () => { | |
| shouldPlayWhenAudioAvailable = false; | |
| // Stop the current buffers | |
| currentPlayingBufferSource?.stop(); | |
| currentPlayingBufferSource = null; | |
| unplayedAudioBuffers = []; | |
| onEnded != null && onEnded(); | |
| isPlaying = false; | |
| return; | |
| }; | |
| const playNextBufferIfNotAlreadyPlaying = () => { | |
| if (!isPlaying) { | |
| playNextBuffer(); | |
| } | |
| }; | |
| const playNextBuffer = () => { | |
| if (shouldPlayWhenAudioAvailable === false) { | |
| console.debug( | |
| '[BufferedSpeechPlayer][playNextBuffer] Not playing any more audio because shouldPlayWhenAudioAvailable is false.', | |
| ); | |
| // NOTE: we do not need to set isPlaying = false or call onEnded because that will be handled in the stop() function | |
| return; | |
| } | |
| if (unplayedAudioBuffers.length === 0) { | |
| console.debug( | |
| '[BufferedSpeechPlayer][playNextBuffer] No buffers to play.', | |
| ); | |
| if (isPlaying) { | |
| isPlaying = false; | |
| onEnded != null && onEnded(); | |
| } | |
| return; | |
| } | |
| // If isPlaying is false, then we are starting playback fresh rather than continuing it, and should call onStarted | |
| if (isPlaying === false) { | |
| isPlaying = true; | |
| onStarted != null && onStarted(); | |
| } | |
| const source = audioContext.createBufferSource(); | |
| // Get the first unplayed buffer from the array, and remove it from the array | |
| const buffer = unplayedAudioBuffers.shift() ?? null; | |
| source.buffer = buffer; | |
| console.debug( | |
| `[BufferedSpeechPlayer] Playing buffer with ${source.buffer?.length} samples`, | |
| ); | |
| source.connect(gainNode); | |
| const startTime = new Date().getTime(); | |
| source.start(); | |
| currentPlayingBufferSource = source; | |
| // This is probably not necessary, but it doesn't hurt | |
| isPlaying = true; | |
| // TODO: consider changing this to a while loop to avoid deep recursion | |
| const onThisBufferPlaybackEnded = () => { | |
| console.debug( | |
| `[BufferedSpeechPlayer] Buffer with ${source.buffer?.length} samples ended.`, | |
| ); | |
| source.removeEventListener('ended', onThisBufferPlaybackEnded); | |
| const endTime = new Date().getTime(); | |
| debug()?.playedAudio(startTime, endTime, buffer); | |
| currentPlayingBufferSource = null; | |
| // We don't set isPlaying = false here because we are attempting to continue playing. It will get set to false if there are no more buffers to play | |
| playNextBuffer(); | |
| }; | |
| source.addEventListener('ended', onThisBufferPlaybackEnded); | |
| }; | |
| const addAudioToBuffer: AddAudioToBufferFunction = (samples, sampleRate) => { | |
| const incomingArrayBufferChunk = audioContext.createBuffer( | |
| // 1 channel | |
| 1, | |
| samples.length, | |
| sampleRate, | |
| ); | |
| incomingArrayBufferChunk.copyToChannel( | |
| new Float32Array(samples), | |
| // first channel | |
| 0, | |
| ); | |
| console.debug( | |
| `[addAudioToBufferAndPlay] Adding buffer with ${incomingArrayBufferChunk.length} samples to queue.`, | |
| ); | |
| unplayedAudioBuffers.push(incomingArrayBufferChunk); | |
| debug()?.receivedAudio( | |
| incomingArrayBufferChunk.length / incomingArrayBufferChunk.sampleRate, | |
| ); | |
| const audioBuffersTableInfo = unplayedAudioBuffers.map((buffer, i) => { | |
| return { | |
| index: i, | |
| duration: buffer.length / buffer.sampleRate, | |
| samples: buffer.length, | |
| }; | |
| }); | |
| const totalUnplayedDuration = unplayedAudioBuffers.reduce((acc, buffer) => { | |
| return acc + buffer.length / buffer.sampleRate; | |
| }, 0); | |
| console.debug( | |
| `[addAudioToBufferAndPlay] Current state of incoming audio buffers (${totalUnplayedDuration.toFixed( | |
| 1, | |
| )}s unplayed):`, | |
| ); | |
| console.table(audioBuffersTableInfo); | |
| if (shouldPlayWhenAudioAvailable) { | |
| playNextBufferIfNotAlreadyPlaying(); | |
| } | |
| }; | |
| return {addAudioToBuffer, setGain, stop, start}; | |
| } | |