Files
GMW/services/frontend/src/lib/audio/wav.ts
T
asepharyana 75ff9274b3 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
2026-08-30 12:59:46 +07:00

127 lines
4.3 KiB
TypeScript

/**
* 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);
}