Files
2026-07-13 13:30:30 +08:00

992 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PCM Debugging Suite</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
/* Lucide Icons SVG styling */
.lucide {
width: 1.25rem;
height: 1.25rem;
}
/* Simple loading spinner */
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 24px;
height: 24px;
border-radius: 50%;
border-left-color: #4f46e5;
/* Indigo */
animation: spin 1s ease infinite;
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
textarea {
word-break: break-all;
}
/* Tab active state */
.tab-active {
border-bottom: 2px solid #4f46e5;
color: #4f46e5;
}
.tab-inactive {
border-bottom: 2px solid transparent;
color: #6b7280;
}
.tab-inactive:hover {
color: #374151;
border-bottom: 2px solid #d1d5db;
}
</style>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
},
},
};
</script>
</head>
<body
class="bg-gray-100 font-sans min-h-screen p-4 flex flex-col items-center"
>
<div class="bg-white rounded-lg shadow-lg w-full max-w-4xl overflow-hidden">
<!-- Header -->
<div class="bg-gray-50 border-b border-gray-200 px-8 py-6">
<h1 class="text-3xl font-bold text-gray-800 text-center">
PCM Debugging Suite
</h1>
<p class="text-center text-gray-500 mt-2">
Generate and Playback Base64 PCM Audio
</p>
</div>
<!-- Tabs -->
<div class="flex border-b border-gray-200">
<button
id="tab-generator"
class="flex-1 py-4 px-6 text-center font-medium text-lg focus:outline-none transition-colors duration-200 tab-active flex items-center justify-center"
onclick="switchTab('generator')"
>
<i data-lucide="mic" class="mr-2"></i> PCM Generator
</button>
<button
id="tab-player"
class="flex-1 py-4 px-6 text-center font-medium text-lg focus:outline-none transition-colors duration-200 tab-inactive flex items-center justify-center"
onclick="switchTab('player')"
>
<i data-lucide="play" class="mr-2"></i> Packet Player
</button>
</div>
<!-- Content Area -->
<div class="p-8">
<!-- GENERATOR SECTION -->
<div id="view-generator" class="block">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
Audio Recorder to Base64 PCM
</h2>
<div class="mb-4">
<label
for="gen_audioDeviceSelect"
class="block text-sm font-medium text-gray-700 mb-1"
>Select Microphone:</label
>
<select
id="gen_audioDeviceSelect"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white disabled:opacity-50 disabled:bg-gray-200"
>
<option value="">Waiting for permissions...</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div>
<label
for="gen_sampleRate"
class="block text-sm font-medium text-gray-700 mb-1"
>Target Sample Rate (Hz):</label
>
<select
id="gen_sampleRate"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white disabled:opacity-50 disabled:bg-gray-200"
>
<option value="8000">8000</option>
<option value="16000" selected>16000</option>
<option value="22050">22050</option>
<option value="24000">24000</option>
<option value="32000">32000</option>
<option value="44100">44100</option>
<option value="48000">48000</option>
</select>
<p
id="gen_actualRateInfo"
class="text-xs text-gray-500 mt-1"
></p>
</div>
<div>
<label
for="gen_channels"
class="block text-sm font-medium text-gray-700 mb-1"
>Target Channels:</label
>
<select
id="gen_channels"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white disabled:opacity-50 disabled:bg-gray-200"
>
<option value="1" selected>1 (Mono)</option>
<option value="2">2 (Stereo)</option>
</select>
<p
id="gen_actualChannelInfo"
class="text-xs text-gray-500 mt-1"
></p>
</div>
<div>
<label
for="gen_bitDepth"
class="block text-sm font-medium text-gray-700 mb-1"
>Target Bit Depth / Format:</label
>
<select
id="gen_bitDepth"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white disabled:opacity-50 disabled:bg-gray-200"
>
<option value="8u">8-bit Unsigned Int</option>
<option value="16s" selected>16-bit Signed Int (LE)</option>
<option value="32f">32-bit Float (LE)</option>
</select>
</div>
</div>
<div class="text-center mb-4 space-x-4">
<button
id="gen_recordButton"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded-md shadow-md transition duration-150 ease-in-out inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<i data-lucide="mic" class="mr-2"></i> Record
</button>
<button
id="gen_stopButton"
class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-6 rounded-md shadow-md transition duration-150 ease-in-out inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
disabled
>
<i data-lucide="square" class="mr-2"></i> Stop
</button>
</div>
<div
id="gen_statusArea"
class="mt-4 p-3 text-center text-sm rounded-md min-h-[40px]"
role="alert"
>
Select microphone and click Record.
</div>
<div class="mt-6">
<label
for="gen_base64Output"
class="block text-sm font-medium text-gray-700 mb-1"
>Base64 PCM Output:</label
>
<textarea
id="gen_base64Output"
rows="6"
class="w-full p-3 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-gray-50 readonly"
readonly
placeholder="Base64 encoded audio will appear here..."
></textarea>
<div class="flex items-center mt-2">
<button
id="gen_copyButton"
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-1 px-4 rounded-md shadow-md transition duration-150 ease-in-out inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
disabled
>
<i data-lucide="copy" class="mr-2"></i> Copy to Clipboard
</button>
<span
id="gen_copyStatus"
class="ml-2 text-sm text-green-600"
></span>
</div>
</div>
</div>
</div>
<!-- PLAYER SECTION -->
<div id="view-player" class="hidden">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
Multi-Packet Base64 PCM Decoder
</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2"
>Paste Sequential Base64 Audio Packets:</label
>
<div id="play_packetContainer" class="space-y-3">
<div class="packet-entry flex items-center space-x-2">
<textarea
rows="3"
class="packet-input w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm"
placeholder="Paste Base64 packet 1 here..."
></textarea>
</div>
</div>
<button
id="play_addPacketButton"
type="button"
class="mt-3 text-sm text-indigo-600 hover:text-indigo-800 font-medium inline-flex items-center px-3 py-1 border border-indigo-200 rounded-md hover:bg-indigo-50 transition duration-150"
>
<i data-lucide="plus" class="mr-1"></i> Add Packet
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div>
<label
for="play_sampleRate"
class="block text-sm font-medium text-gray-700 mb-1"
>Sample Rate (Hz):</label
>
<input
type="number"
id="play_sampleRate"
value="24000"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm"
placeholder="e.g., 44100"
/>
</div>
<div>
<label
for="play_channels"
class="block text-sm font-medium text-gray-700 mb-1"
>Channels:</label
>
<select
id="play_channels"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white"
>
<option value="1" selected>1 (Mono)</option>
<option value="2">2 (Stereo)</option>
</select>
</div>
<div>
<label
for="play_bitDepth"
class="block text-sm font-medium text-gray-700 mb-1"
>Bit Depth / Format:</label
>
<select
id="play_bitDepth"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm bg-white"
>
<option value="8u">8-bit Unsigned Int</option>
<option value="16s" selected>16-bit Signed Int (LE)</option>
<option value="32f">32-bit Float (LE)</option>
</select>
</div>
</div>
<div class="text-center mb-4">
<button
id="play_playButton"
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-6 rounded-md shadow-md transition duration-150 ease-in-out inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<i data-lucide="play" class="mr-2"></i> Decode & Play All
Packets
</button>
</div>
<div
id="play_statusArea"
class="mt-4 p-3 text-center text-sm rounded-md min-h-[40px]"
role="alert"
></div>
</div>
</div>
</div>
</div>
<script>
// --- Tab Switching Logic ---
function switchTab(tabName) {
const genView = document.getElementById("view-generator");
const playView = document.getElementById("view-player");
const genTab = document.getElementById("tab-generator");
const playTab = document.getElementById("tab-player");
if (tabName === "generator") {
genView.classList.remove("hidden");
genView.classList.add("block");
playView.classList.remove("block");
playView.classList.add("hidden");
genTab.classList.add("tab-active");
genTab.classList.remove("tab-inactive");
playTab.classList.add("tab-inactive");
playTab.classList.remove("tab-active");
} else {
genView.classList.remove("block");
genView.classList.add("hidden");
playView.classList.remove("hidden");
playView.classList.add("block");
genTab.classList.add("tab-inactive");
genTab.classList.remove("tab-active");
playTab.classList.add("tab-active");
playTab.classList.remove("tab-inactive");
}
}
// ==========================================
// GENERATOR LOGIC
// ==========================================
(function () {
const audioDeviceSelect = document.getElementById(
"gen_audioDeviceSelect"
);
const targetSampleRateSelect =
document.getElementById("gen_sampleRate");
const targetChannelsSelect = document.getElementById("gen_channels");
const targetBitDepthSelect = document.getElementById("gen_bitDepth");
const recordButton = document.getElementById("gen_recordButton");
const stopButton = document.getElementById("gen_stopButton");
const statusArea = document.getElementById("gen_statusArea");
const base64Output = document.getElementById("gen_base64Output");
const copyButton = document.getElementById("gen_copyButton");
const copyStatus = document.getElementById("gen_copyStatus");
const actualRateInfo = document.getElementById("gen_actualRateInfo");
const actualChannelInfo = document.getElementById(
"gen_actualChannelInfo"
);
let audioContext = null;
let mediaStream = null;
let mediaStreamSource = null;
let scriptProcessorNode = null;
let recordedChunks = [];
let isRecording = false;
const bufferSize = 4096;
let actualSampleRate = 0;
let actualNumChannels = 0;
async function init() {
recordButton.disabled = true;
stopButton.disabled = true;
copyButton.disabled = true;
setInputsDisabled(true);
try {
await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
console.log("Generator: Microphone permission granted.");
await listAudioDevices();
recordButton.disabled = false;
setInputsDisabled(false);
displayMessage(
"info",
"Ready. Select device and format, then press Record."
);
} catch (err) {
console.error("Generator: Error getting permissions:", err);
displayMessage(
"error",
`Could not get microphone permissions: ${err.message}`
);
audioDeviceSelect.innerHTML =
'<option value="">Permission Denied</option>';
}
}
async function listAudioDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputDevices = devices.filter(
(d) => d.kind === "audioinput"
);
audioDeviceSelect.innerHTML = "";
if (audioInputDevices.length === 0) {
audioDeviceSelect.innerHTML =
'<option value="">No microphone devices found</option>';
recordButton.disabled = true;
displayMessage("error", "No microphone devices found.");
return;
}
audioInputDevices.forEach((device, index) => {
const option = document.createElement("option");
option.value = device.deviceId;
option.textContent = device.label || `Microphone ${index + 1}`;
audioDeviceSelect.appendChild(option);
});
} catch (err) {
console.error("Generator: Error listing devices:", err);
displayMessage("error", `Error listing devices: ${err.message}`);
}
}
async function startRecording() {
if (isRecording) return;
const selectedDeviceId = audioDeviceSelect.value;
if (!selectedDeviceId) {
displayMessage("error", "Please select a microphone device.");
return;
}
base64Output.value = "";
copyButton.disabled = true;
copyStatus.textContent = "";
actualRateInfo.textContent = "";
actualChannelInfo.textContent = "";
const targetSampleRate = parseInt(targetSampleRateSelect.value, 10);
isRecording = true;
recordButton.disabled = true;
stopButton.disabled = false;
setInputsDisabled(true);
recordedChunks = [];
displayMessage("info", "Starting recording...");
try {
window.AudioContext =
window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext({ sampleRate: targetSampleRate });
actualSampleRate = audioContext.sampleRate;
actualRateInfo.textContent = `(Recording at ${actualSampleRate} Hz)`;
const constraints = {
audio: {
deviceId: { exact: selectedDeviceId },
},
video: false,
};
mediaStream = await navigator.mediaDevices.getUserMedia(
constraints
);
mediaStreamSource =
audioContext.createMediaStreamSource(mediaStream);
const audioTrack = mediaStream.getAudioTracks()[0];
const trackSettings = audioTrack.getSettings();
actualNumChannels =
trackSettings.channelCount || mediaStreamSource.channelCount || 1;
actualChannelInfo.textContent = `(${actualNumChannels} channel${
actualNumChannels > 1 ? "s" : ""
})`;
scriptProcessorNode = audioContext.createScriptProcessor(
bufferSize,
actualNumChannels,
actualNumChannels
);
scriptProcessorNode.onaudioprocess = (event) => {
if (!isRecording) return;
const inputBuffer = event.inputBuffer;
const bufferChannels = [];
for (let i = 0; i < actualNumChannels; i++) {
bufferChannels.push(
new Float32Array(inputBuffer.getChannelData(i))
);
}
recordedChunks.push(bufferChannels);
};
mediaStreamSource.connect(scriptProcessorNode);
scriptProcessorNode.connect(audioContext.destination);
displayMessage(
"success",
`Recording started at ${actualSampleRate} Hz, ${actualNumChannels} channels...`
);
} catch (err) {
console.error("Generator: Error starting recording:", err);
displayMessage("error", `Error starting recording: ${err.message}`);
stopRecording(true);
}
}
function stopRecording(errorOccurred = false) {
if (!isRecording && !errorOccurred) return;
isRecording = false;
recordButton.disabled = false;
stopButton.disabled = true;
setInputsDisabled(false);
if (scriptProcessorNode) {
scriptProcessorNode.disconnect();
scriptProcessorNode.onaudioprocess = null;
scriptProcessorNode = null;
}
if (mediaStreamSource) {
mediaStreamSource.disconnect();
mediaStreamSource = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
if (audioContext) {
audioContext
.close()
.then(() => {
audioContext = null;
if (!errorOccurred && recordedChunks.length > 0) {
processAndEncode();
} else if (errorOccurred) {
displayMessage("error", "Recording stopped due to an error.");
} else {
displayMessage(
"info",
"Recording stopped. No audio data captured."
);
}
})
.catch((err) => {
console.error("Generator: Error closing context:", err);
if (!errorOccurred && recordedChunks.length > 0)
processAndEncode();
});
} else if (!errorOccurred && recordedChunks.length > 0) {
processAndEncode();
}
}
function processAndEncode() {
if (recordedChunks.length === 0) return;
displayMessage("loading", "Processing recorded audio...");
setTimeout(() => {
try {
const numberOfChannels = recordedChunks[0].length;
const totalLength = recordedChunks.reduce(
(sum, chunks) => sum + chunks[0].length,
0
);
const combinedChannels = [];
for (let i = 0; i < numberOfChannels; i++) {
const channelData = new Float32Array(totalLength);
let offset = 0;
recordedChunks.forEach((buffer) => {
channelData.set(buffer[i], offset);
offset += buffer[i].length;
});
combinedChannels.push(channelData);
}
recordedChunks = [];
const targetSampleRate = parseInt(
targetSampleRateSelect.value,
10
);
const targetNumChannels = parseInt(
targetChannelsSelect.value,
10
);
const targetFormat = targetBitDepthSelect.value;
if (actualSampleRate !== targetSampleRate) {
displayMessage(
"warning",
`Processing... (Note: Output sample rate is ${actualSampleRate} Hz, not ${targetSampleRate} Hz as resampling is not implemented)`
);
}
let finalChannelData = [];
if (numberOfChannels === 1 && targetNumChannels === 2) {
finalChannelData.push(combinedChannels[0]);
finalChannelData.push(new Float32Array(combinedChannels[0]));
} else if (numberOfChannels === 2 && targetNumChannels === 1) {
const monoData = new Float32Array(totalLength);
for (let i = 0; i < totalLength; i++) {
monoData[i] =
(combinedChannels[0][i] + combinedChannels[1][i]) / 2;
}
finalChannelData.push(monoData);
} else {
finalChannelData = combinedChannels;
}
const outputNumChannels = finalChannelData.length;
let interleavedData;
if (outputNumChannels === 1) {
interleavedData = finalChannelData[0];
} else {
interleavedData = new Float32Array(totalLength * 2);
for (let i = 0; i < totalLength; i++) {
interleavedData[i * 2] = finalChannelData[0][i];
interleavedData[i * 2 + 1] = finalChannelData[1][i];
}
}
let outputBuffer;
const numSamples = interleavedData.length;
if (targetFormat === "8u") {
outputBuffer = new Uint8Array(numSamples);
for (let i = 0; i < numSamples; i++) {
const sample = Math.max(-1, Math.min(1, interleavedData[i]));
outputBuffer[i] = Math.round((sample + 1) * 127.5);
}
} else if (targetFormat === "16s") {
outputBuffer = new Int16Array(numSamples);
for (let i = 0; i < numSamples; i++) {
const sample = Math.max(-1, Math.min(1, interleavedData[i]));
outputBuffer[i] = Math.round(sample * 32767);
}
} else {
outputBuffer = new Float32Array(interleavedData);
}
const base64String = arrayBufferToBase64(outputBuffer.buffer);
base64Output.value = base64String;
copyButton.disabled = false;
displayMessage(
"success",
`Processing complete. Base64 generated for ${actualSampleRate} Hz, ${outputNumChannels}-channel, ${targetFormat} audio.`
);
} catch (err) {
console.error("Generator: Error processing:", err);
displayMessage("error", `Error processing audio: ${err.message}`);
}
}, 50);
}
function arrayBufferToBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function displayMessage(type, message) {
statusArea.innerHTML = "";
statusArea.className =
"mt-4 p-3 text-center text-sm rounded-md min-h-[40px]";
if (type === "error")
statusArea.classList.add("bg-red-100", "text-red-700");
else if (type === "success")
statusArea.classList.add("bg-green-100", "text-green-700");
else if (type === "info")
statusArea.classList.add("bg-blue-100", "text-blue-700");
else if (type === "warning")
statusArea.classList.add("bg-yellow-100", "text-yellow-700");
else if (type === "loading") {
statusArea.classList.add("bg-yellow-100", "text-yellow-700");
statusArea.innerHTML = `<span class="spinner"></span> ${message}`;
}
if (type !== "loading") statusArea.textContent = message;
}
function setInputsDisabled(disabled) {
audioDeviceSelect.disabled = disabled;
targetSampleRateSelect.disabled = disabled;
targetChannelsSelect.disabled = disabled;
targetBitDepthSelect.disabled = disabled;
}
async function copyToClipboard() {
if (!base64Output.value) return;
try {
await navigator.clipboard.writeText(base64Output.value);
copyStatus.textContent = "Copied!";
setTimeout(() => {
copyStatus.textContent = "";
}, 2000);
} catch (err) {
copyStatus.textContent = "Copy failed!";
setTimeout(() => {
copyStatus.textContent = "";
}, 2000);
}
}
recordButton.addEventListener("click", startRecording);
stopButton.addEventListener("click", () => stopRecording(false));
copyButton.addEventListener("click", copyToClipboard);
navigator.mediaDevices.addEventListener(
"devicechange",
listAudioDevices
);
window.addEventListener("load", init);
})();
// ==========================================
// PLAYER LOGIC
// ==========================================
(function () {
const packetContainer = document.getElementById("play_packetContainer");
const addPacketButton = document.getElementById("play_addPacketButton");
const sampleRateInput = document.getElementById("play_sampleRate");
const channelsInput = document.getElementById("play_channels");
const bitDepthInput = document.getElementById("play_bitDepth");
const playButton = document.getElementById("play_playButton");
const statusArea = document.getElementById("play_statusArea");
let audioContext;
let currentSource = null;
function initAudioContext() {
if (!audioContext) {
try {
window.AudioContext =
window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext();
} catch (e) {
displayMessage("error", "Web Audio API is not supported.");
playButton.disabled = true;
addPacketButton.disabled = true;
}
}
if (audioContext && audioContext.state === "suspended") {
audioContext.resume().catch(console.error);
}
}
function displayMessage(type, message) {
statusArea.innerHTML = "";
statusArea.className =
"mt-4 p-3 text-center text-sm rounded-md min-h-[40px]";
if (type === "error")
statusArea.classList.add("bg-red-100", "text-red-700");
else if (type === "success")
statusArea.classList.add("bg-green-100", "text-green-700");
else if (type === "info")
statusArea.classList.add("bg-blue-100", "text-blue-700");
else if (type === "loading") {
statusArea.classList.add("bg-yellow-100", "text-yellow-700");
statusArea.innerHTML = `<span class="spinner"></span> ${message}`;
}
if (type !== "loading") statusArea.textContent = message;
}
function addPacketInput() {
const packetCount =
packetContainer.querySelectorAll(".packet-entry").length + 1;
const entryDiv = document.createElement("div");
entryDiv.className = "packet-entry flex items-center space-x-2";
const textarea = document.createElement("textarea");
textarea.rows = 3;
textarea.className =
"packet-input w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 shadow-sm";
textarea.placeholder = `Paste Base64 packet ${packetCount} here...`;
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className =
"remove-packet-btn text-red-500 hover:text-red-700 p-1 rounded-md hover:bg-red-100 transition duration-150 flex-shrink-0";
removeButton.innerHTML = '<i data-lucide="trash-2"></i>';
removeButton.onclick = () => {
entryDiv.remove();
updatePlaceholders();
};
entryDiv.appendChild(textarea);
entryDiv.appendChild(removeButton);
packetContainer.appendChild(entryDiv);
lucide.createIcons(); // Render the new icon
}
function updatePlaceholders() {
const textareas = packetContainer.querySelectorAll(".packet-input");
textareas.forEach((ta, index) => {
ta.placeholder = `Paste Base64 packet ${index + 1} here...`;
});
}
function decodeAndPrepareAudio() {
initAudioContext();
if (!audioContext) return;
const sampleRate = parseInt(sampleRateInput.value, 10);
const numChannels = parseInt(channelsInput.value, 10);
const format = bitDepthInput.value;
if (isNaN(sampleRate) || sampleRate <= 0) {
displayMessage("error", "Invalid Sample Rate.");
return;
}
const packetTextareas =
packetContainer.querySelectorAll(".packet-input");
const decodedByteArrays = [];
let totalByteLength = 0;
let hasValidPackets = false;
displayMessage("loading", "Decoding Base64 packets...");
playButton.disabled = true;
setTimeout(() => {
try {
for (let i = 0; i < packetTextareas.length; i++) {
const base64Data = packetTextareas[i].value.trim();
if (!base64Data) continue;
try {
const binaryString = atob(base64Data);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let j = 0; j < len; j++)
bytes[j] = binaryString.charCodeAt(j);
decodedByteArrays.push(bytes);
totalByteLength += bytes.length;
hasValidPackets = true;
} catch (e) {
throw new Error(`Invalid Base64 in packet ${i + 1}`);
}
}
if (!hasValidPackets)
throw new Error("No valid Base64 data found.");
const combinedBytes = new Uint8Array(totalByteLength);
let offset = 0;
for (const byteArray of decodedByteArrays) {
combinedBytes.set(byteArray, offset);
offset += byteArray.length;
}
const audioDataBuffer = combinedBytes.buffer;
let bytesPerSample = 0;
if (format === "8u") bytesPerSample = 1;
else if (format === "16s") bytesPerSample = 2;
else if (format === "32f") bytesPerSample = 4;
const totalSamples = totalByteLength / bytesPerSample;
const frameCount = totalSamples / numChannels;
const audioBuffer = audioContext.createBuffer(
numChannels,
Math.floor(frameCount),
sampleRate
);
const dataView = new DataView(audioDataBuffer);
const littleEndian = true;
for (let channel = 0; channel < numChannels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
let currentReadOffset;
for (let i = 0; i < audioBuffer.length; i++) {
let sampleValue = 0;
currentReadOffset =
(i * numChannels + channel) * bytesPerSample;
if (
currentReadOffset + bytesPerSample <=
audioDataBuffer.byteLength
) {
if (format === "8u") {
sampleValue =
(dataView.getUint8(currentReadOffset) - 128) / 128.0;
} else if (format === "16s") {
sampleValue =
dataView.getInt16(currentReadOffset, littleEndian) /
32768.0;
} else if (format === "32f") {
sampleValue = dataView.getFloat32(
currentReadOffset,
littleEndian
);
}
}
channelData[i] = Math.max(-1.0, Math.min(1.0, sampleValue));
}
}
playAudioBuffer(audioBuffer);
} catch (error) {
console.error("Player: Error decoding:", error);
displayMessage("error", `Error: ${error.message}`);
playButton.disabled = false;
}
}, 50);
}
function playAudioBuffer(audioBuffer) {
if (!audioContext || !audioBuffer) return;
if (currentSource) {
try {
currentSource.stop();
currentSource.disconnect();
} catch (e) {}
currentSource = null;
}
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.onended = () => {
displayMessage(
"success",
`Playback finished. Duration: ${audioBuffer.duration.toFixed(2)}s`
);
playButton.disabled = false;
currentSource = null;
};
try {
source.start(0);
currentSource = source;
displayMessage(
"info",
`Playing... (${audioBuffer.duration.toFixed(2)}s)`
);
} catch (error) {
displayMessage(
"error",
`Error starting playback: ${error.message}`
);
playButton.disabled = false;
currentSource = null;
}
}
addPacketButton.addEventListener("click", addPacketInput);
playButton.addEventListener("click", decodeAndPrepareAudio);
window.addEventListener("load", () => {
initAudioContext();
lucide.createIcons();
});
})();
</script>
</body>
</html>