Last active
August 31, 2024 03:12
-
-
Save XueshiQiao/a0e7188643e65747907a7cd24bfe7ffb to your computer and use it in GitHub Desktop.
Play streamed pcm on browser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>PCM to MP3 Player (with Queue and Delay)</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/lamejs/1.2.0/lame.min.js"></script> | |
</head> | |
<body> | |
<input type="file" id="fileInput" accept=".pcm" /> | |
<button id="playButton" disabled>Play</button> | |
<audio id="audioPlayer" controls></audio> | |
<div id="log"></div> | |
<script> | |
const fileInput = document.getElementById("fileInput"); | |
const playButton = document.getElementById("playButton"); | |
const audioPlayer = document.getElementById("audioPlayer"); | |
const logElement = document.getElementById("log"); | |
let mediaSource; | |
let sourceBuffer; | |
let pcmData; | |
let appendQueue = []; | |
let isAppending = false; | |
let appendCount = 0; | |
function log(message) { | |
console.log(message); | |
logElement.innerHTML += message + "<br>"; | |
logElement.scrollTop = logElement.scrollHeight; | |
} | |
fileInput.addEventListener("change", handleFileSelect); | |
playButton.addEventListener("click", startPlayback); | |
function handleFileSelect(event) { | |
const file = event.target.files[0]; | |
const reader = new FileReader(); | |
reader.onload = function (e) { | |
pcmData = new Int16Array(e.target.result); | |
playButton.disabled = false; | |
log( | |
`File loaded: ${file.name}, size: ${pcmData.length} samples`, | |
); | |
}; | |
reader.onerror = function (e) { | |
log(`Error reading file: ${e.target.error}`); | |
}; | |
reader.readAsArrayBuffer(file); | |
} | |
function startPlayback() { | |
log("Starting playback..."); | |
mediaSource = new MediaSource(); | |
audioPlayer.src = URL.createObjectURL(mediaSource); | |
mediaSource.addEventListener("sourceopen", sourceOpen); | |
mediaSource.addEventListener("error", (e) => | |
log(`MediaSource error: ${e}`), | |
); | |
audioPlayer.addEventListener("error", (e) => | |
log(`Audio player error: ${e.target.error.message}`), | |
); | |
} | |
function sourceOpen() { | |
log("MediaSource opened"); | |
try { | |
sourceBuffer = mediaSource.addSourceBuffer("audio/mpeg"); | |
sourceBuffer.addEventListener("error", (e) => | |
log(`SourceBuffer error: ${e}`), | |
); | |
sourceBuffer.addEventListener( | |
"updateend", | |
sourceBufferUpdateEnd, | |
); | |
processAudio(); | |
} catch (e) { | |
log(`Error in sourceOpen: ${e.message}`); | |
} | |
} | |
function processAudio() { | |
log("Processing audio..."); | |
const chunkSize = 1152; // MP3 frame size | |
const sampleRate = 24000; | |
const channels = 1; | |
const bitRate = 128; | |
const mp3encoder = new lamejs.Mp3Encoder( | |
channels, | |
sampleRate, | |
bitRate, | |
); | |
for (let i = 0; i < pcmData.length; i += chunkSize) { | |
const chunk = pcmData.slice(i, i + chunkSize); | |
const mp3Data = mp3encoder.encodeBuffer(chunk); | |
if (mp3Data.length > 0) { | |
appendToSourceBuffer(mp3Data); | |
} | |
} | |
const mp3End = mp3encoder.flush(); | |
if (mp3End.length > 0) { | |
appendToSourceBuffer(mp3End); | |
} | |
appendQueue.push(null); // Signal end of stream | |
log("Audio processing completed"); | |
} | |
function appendToSourceBuffer(data) { | |
appendQueue.push(data); | |
if (!isAppending) { | |
appendNextChunk(); | |
} | |
} | |
function appendNextChunk() { | |
if (appendQueue.length === 0 || isAppending) return; | |
isAppending = true; | |
const chunk = appendQueue.shift(); | |
if (chunk === null) { | |
// End of stream | |
mediaSource.endOfStream(); | |
log("MediaSource stream ended"); | |
return; | |
} | |
try { | |
sourceBuffer.appendBuffer(new Uint8Array(chunk)); | |
appendCount++; | |
log( | |
`Appending chunk ${appendCount}, size: ${chunk.length} bytes`, | |
); | |
} catch (e) { | |
log(`Error appending to SourceBuffer: ${e.message}`); | |
isAppending = false; | |
appendNextChunk(); | |
} | |
} | |
function sourceBufferUpdateEnd() { | |
log(`Chunk ${appendCount} appended successfully`); | |
// Introduce a small delay before processing the next chunk | |
setTimeout(() => { | |
isAppending = false; | |
appendNextChunk(); | |
}, 15); // 10ms delay | |
} | |
</script> | |
</body> | |
</html> |
Author
XueshiQiao
commented
Aug 31, 2024
•
- To overcome WebAPI player limitations with streamed PCM data from external sources like WebSocket or gRPC, here we use a local pcm file instead for simplicity (sint16, 24000 samplerate, mono channel), we implemented a MediaSource workaround..
- Encode each PCM slice into an MP3 slice using the MP3 encoder.
- Append mp3 slices to MediaSource one by one
Must be run on a local server, such as with the Python command python3 -m http.server 8989
.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment