feat(frontend): filter recordings per-speaker + export WAV for Audacity
- recordings page: speaker filter dropdown (server-side userId filter via existing recordings.list proc) + clear-filter + pagination reset on change - WAV export per card: decode download_url via Web Audio, re-encode 16-bit PCM WAV (Audacity-ready) client-side, no server/ffmpeg needed - EXPORT WAV (N) header button: concatenate visible (filtered) recordings into a single mono WAV for mixdown/analysis - hooks: per-filter SWR cache key (recordings/<userId|all>); delete & live WS-sync invalidate every filter cache via key matcher - new lib/audio/wav.ts: decodeAudio / audioBufferToWav / concatAudioBuffers / downloadBlob
This commit is contained in:
1 parent
e3016a858a
commit
75ff9274b3
4 files changed
+421
-35
No files matched your search
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Client-side audio export helpers (no server/ffmpeg needed).
|
||||
*
|
||||
* Fetches a remote MP3/OGG, decodes it with the browser's codec stack
|
||||
* (Web Audio API), and re-encodes as 16-bit PCM WAV — the format Audacity
|
||||
* opens cleanest for editing. Also supports concatenating several decoded
|
||||
* clips into a single WAV (e.g. every recording from one filtered user).
|
||||
*/
|
||||
|
||||
/** Get a shared AudioContext (created lazily; kept suspended for decode). */
|
||||
function getCtx(): AudioContext {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) throw new Error("Web Audio API is not available in this browser");
|
||||
return new Ctor();
|
||||
}
|
||||
|
||||
/** Fetch a remote audio URL and decode it to an AudioBuffer. */
|
||||
export async function decodeAudio(url: string): Promise<AudioBuffer> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch audio (${res.status})`);
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const ctx = getCtx();
|
||||
try {
|
||||
return await ctx.decodeAudioData(arrayBuffer);
|
||||
} finally {
|
||||
// free the context resources; decodeAudioData is standalone
|
||||
void ctx.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an AudioBuffer to a 16-bit PCM WAV Blob (RIFF).
|
||||
* Multi-channel buffers are preserved (interleaved). Audacity imports this
|
||||
* as one track per channel.
|
||||
*/
|
||||
export function audioBufferToWav(buf: AudioBuffer): Blob {
|
||||
const numChannels = Math.max(1, buf.numberOfChannels);
|
||||
const sampleRate = buf.sampleRate;
|
||||
const frames = buf.length;
|
||||
const bytesPerSample = 2; // 16-bit
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const dataSize = frames * blockAlign;
|
||||
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset: number, str: string): void => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // fmt chunk size
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// Interleave channel data into the sample block. Clamp to [-1, 1].
|
||||
let offset = 44;
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const data = buf.getChannelData(ch);
|
||||
const s = Math.max(-1, Math.min(1, data[frame]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate several decoded clips into one mono AudioBuffer (channel 0).
|
||||
* Uses the highest sample rate among inputs. Returns null if `buffers` is empty.
|
||||
*/
|
||||
export function concatAudioBuffers(buffers: AudioBuffer[]): AudioBuffer | null {
|
||||
if (buffers.length === 0) return null;
|
||||
const rate = Math.max(...buffers.map((b) => b.sampleRate));
|
||||
const totalFrames = buffers.reduce((acc, b) => acc + b.length, 0);
|
||||
const ctx = getCtx();
|
||||
const out = ctx.createBuffer(1, totalFrames, rate);
|
||||
const target = out.getChannelData(0);
|
||||
|
||||
let write = 0;
|
||||
for (const b of buffers) {
|
||||
const src = b.getChannelData(0);
|
||||
// Resample if the source rate differs from the target rate.
|
||||
if (b.sampleRate === rate) {
|
||||
target.set(src, write);
|
||||
write += src.length;
|
||||
} else {
|
||||
const step = b.sampleRate / rate;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
target[Math.floor(write + i * step)] = src[i];
|
||||
}
|
||||
write += Math.floor(src.length / step);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trigger a browser download for a Blob. */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on next tick so the download has a chance to start
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
Reference in new issue
Block a user