diff --git a/src/pwa-audio-recorder/app.mjs b/src/pwa-audio-recorder/app.mjs index b3b8134..35f0106 100644 --- a/src/pwa-audio-recorder/app.mjs +++ b/src/pwa-audio-recorder/app.mjs @@ -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'); @@ -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); @@ -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); } } @@ -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'); } @@ -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); + } } diff --git a/src/pwa-audio-recorder/index.html b/src/pwa-audio-recorder/index.html index bde8427..3cb6f0c 100644 --- a/src/pwa-audio-recorder/index.html +++ b/src/pwa-audio-recorder/index.html @@ -88,7 +88,10 @@
- +
diff --git a/src/pwa-audio-recorder/wav-utils.mjs b/src/pwa-audio-recorder/wav-utils.mjs new file mode 100644 index 0000000..1670aeb --- /dev/null +++ b/src/pwa-audio-recorder/wav-utils.mjs @@ -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); + } +}