Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 66 additions & 12 deletions src/pwa-audio-recorder/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import {IndexedDBStorage} from './indexeddb-storage.mjs';
import * as visualize from './visualize.mjs';
import { audioBufferToWav } from './wav-utils.mjs';

const DELETE_BUTTON_SELECTOR = '.delete-button';
const DOWNLOAD_BUTTON_SELECTOR = '.download-button';
const RECORDING_DESCRIPTION_SELECTOR = '.recording-description';

const recordButton = document.querySelector('#record');
Expand Down Expand Up @@ -54,6 +56,7 @@ const RECORDING_CONFIGS = [
function populateRecordingConfigurations() {
const configRadioTemplate = document.querySelector(CONFIG_RADIO_TEMPLATE_SELECTOR);
const configRadios = document.querySelector(CONFIG_RADIOS_SELECTOR);
const configParam = new URLSearchParams(window.location.search).get('config');

for (const [i, {name, param}] of RECORDING_CONFIGS.entries()) {
const radio = configRadioTemplate.content.firstElementChild.cloneNode(true);
Expand All @@ -63,7 +66,16 @@ function populateRecordingConfigurations() {
label.textContent = `${name} (${JSON.stringify(param)})`;
label.setAttribute('for', input.id);
label.recordingParam = param;
if (i === 0) input.checked = true;

// Check if this config matches the query param, otherwise default to the first one
if (configParam) {
if (name === configParam) {
input.checked = true;
}
} else if (i === 0) {
input.checked = true;
}

configRadios.appendChild(radio);
}
}
Expand Down Expand Up @@ -114,6 +126,28 @@ function finalizeClip({clipContainer, blob, id, recordingDescription, storage})
clipContainer.parentNode.removeChild(clipContainer);
storage.delete(parseInt(id));
};
clipContainer.querySelector(DOWNLOAD_BUTTON_SELECTOR).onclick = async () => {
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext();
try {
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const wavView = audioBufferToWav(audioBuffer);
const wavBlob = new Blob([wavView], { type: 'audio/wav' });
const url = URL.createObjectURL(wavBlob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `${recordingDescription.replace(/[:\/\s]/g, '_')}.wav`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error('Error converting to WAV:', e);
alert('Failed to convert audio to WAV');
} finally {
await audioCtx.close();
}
};
clipContainer.querySelector('audio').src = URL.createObjectURL(blob);
clipContainer.classList.remove('clip-recording');
}
Expand Down Expand Up @@ -215,26 +249,46 @@ function visualizeRecording({stream, outlineIndicator, waveformIndicator}) {
}

async function runTest() {
// 1. Start recording
// 1. Start recording and playback
recordButton.click();

// 2. After 10 seconds, start playback
await new Promise((resolve) => setTimeout(resolve, 10000));
const playbackSource = document.querySelector('#playback-source');
playbackSource.play();

// 3. After 10 seconds, stop recording and stop playback
await new Promise((resolve) => setTimeout(resolve, 10000));
recordButton.click();
// 2. After 15 seconds, stop playback
await new Promise((resolve) => setTimeout(resolve, 15000));
playbackSource.pause();
playbackSource.currentTime = 0;

// 3. After another 25 seconds (total 40s), stop recording
await new Promise((resolve) => setTimeout(resolve, 25000));
recordButton.click();

// 4. Download the recorded audio
await new Promise((resolve) => setTimeout(resolve, 1000));
const clip = soundClips.firstElementChild;
const audio = clip.querySelector('audio');
const a = document.createElement('a');
a.href = audio.src;
a.download = 'recorded_audio.webm';
a.click();

try {
const response = await fetch(audio.src);
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const wavView = audioBufferToWav(audioBuffer);
const wavBlob = new Blob([wavView], { type: 'audio/wav' });
const url = URL.createObjectURL(wavBlob);

const a = document.createElement('a');
a.href = url;
a.download = 'recorded_audio.wav';
document.body.appendChild(a);
a.click();

// Clean up
await audioCtx.close();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (e) {
console.error('Test failed to download WAV:', e);
}
}
5 changes: 4 additions & 1 deletion src/pwa-audio-recorder/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@
</div>
<div class="mdc-card__actions recording-invisible">
<audio controls></audio>
<button class="mdc-icon-button material-icons delete-button">
<button class="mdc-icon-button material-icons download-button" title="Download as WAV">
file_download
</button>
<button class="mdc-icon-button material-icons delete-button" title="Delete">
delete
</button>
</div>
Expand Down
134 changes: 134 additions & 0 deletions src/pwa-audio-recorder/wav-utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Converts an AudioBuffer to a WAV file format DataView.
* @param {AudioBuffer} buffer The AudioBuffer to convert.
* @param {Object} [opt] Options for the conversion.
* @param {boolean} [opt.float32] Whether to use 32-bit float format (default is 16-bit PCM).
* @return {DataView} The WAV file data.
* @note Currently only supports Mono and Stereo. For more than 2 channels, only the first channel is used.
*/
export function audioBufferToWav(buffer, opt) {
opt = opt || {};
var numChannels = buffer.numberOfChannels;
var sampleRate = buffer.sampleRate;
var format = opt.float32 ? 3 : 1;
var bitDepth = format === 3 ? 32 : 16;

var result;
if (numChannels === 2) {
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
} else {
result = buffer.getChannelData(0);
}

return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
}

/**
* Encodes audio samples into WAV format.
* @param {Float32Array} samples The audio samples.
* @param {number} format The WAV format code (1 for PCM, 3 for Float).
* @param {number} sampleRate The sample rate.
* @param {number} numChannels The number of channels.
* @param {number} bitDepth The bit depth (16 or 32).
* @return {DataView} The encoded WAV data.
*/
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
var bytesPerSample = bitDepth / 8;
var blockAlign = numChannels * bytesPerSample;

var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
var view = new DataView(buffer);

/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, format, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true);
/* bits per sample */
view.setUint16(34, bitDepth, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true);

if (format === 1) { // PCM
floatTo16BitPCM(view, 44, samples);
} else {
writeFloat32(view, 44, samples);
}

return view;
}

/**
* Interleaves two channels of audio data.
* @param {Float32Array} inputL The left channel data.
* @param {Float32Array} inputR The right channel data.
* @return {Float32Array} The interleaved data.
*/
function interleave(inputL, inputR) {
var length = inputL.length + inputR.length;
var result = new Float32Array(length);

var index = 0;
var inputIndex = 0;

while (index < length) {
result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex];
inputIndex++;
}
return result;
}

/**
* Writes a string to a DataView.
* @param {DataView} view The DataView to write to.
* @param {number} offset The offset in bytes.
* @param {string} string The string to write.
*/
function writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}

/**
* Converts floating point samples to 16-bit PCM and writes them to a DataView.
* @param {DataView} output The output DataView.
* @param {number} offset The offset in bytes.
* @param {Float32Array} input The input samples.
*/
function floatTo16BitPCM(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}

/**
* Writes 32-bit floating point samples to a DataView.
* @param {DataView} output The output DataView.
* @param {number} offset The offset in bytes.
* @param {Float32Array} input The input samples.
*/
function writeFloat32(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true);
}
}
Loading