Skip to content

Instantly share code, notes, and snippets.

@XueshiQiao
Last active August 31, 2024 03:12
Show Gist options
  • Save XueshiQiao/a0e7188643e65747907a7cd24bfe7ffb to your computer and use it in GitHub Desktop.
Save XueshiQiao/a0e7188643e65747907a7cd24bfe7ffb to your computer and use it in GitHub Desktop.
Play streamed pcm on browser
<!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>
@XueshiQiao
Copy link
Author

XueshiQiao commented Aug 31, 2024

  1. 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..
  2. Encode each PCM slice into an MP3 slice using the MP3 encoder.
  3. Append mp3 slices to MediaSource one by one

@XueshiQiao
Copy link
Author

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