Sync from GitHub 7eaee086
Browse files
apps/web/lib/jambuddy/recorder.ts
CHANGED
|
@@ -238,6 +238,70 @@ export async function grantMicPermission(): Promise<MediaStream> {
|
|
| 238 |
return navigator.mediaDevices.getUserMedia({ audio: true });
|
| 239 |
}
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
/**
|
| 242 |
* Create an audio recorder. Requests microphone access (user gesture). Throws
|
| 243 |
* if getUserMedia/MediaRecorder is unsupported or the mic is denied.
|
|
@@ -289,17 +353,24 @@ export async function createAudioRecorder(
|
|
| 289 |
},
|
| 290 |
stop() {
|
| 291 |
return new Promise((resolve) => {
|
| 292 |
-
const onStop = () => {
|
| 293 |
active = false;
|
| 294 |
stream.getTracks().forEach((t) => t.stop());
|
| 295 |
const type = rec.mimeType || "audio/webm";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
const file = new File(
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
.
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
{ type },
|
| 303 |
);
|
| 304 |
resolve({
|
| 305 |
file,
|
|
|
|
| 238 |
return navigator.mediaDevices.getUserMedia({ audio: true });
|
| 239 |
}
|
| 240 |
|
| 241 |
+
/**
|
| 242 |
+
* Decode an audio Blob (any browser-playable format, e.g. WebM/Opus from
|
| 243 |
+
* MediaRecorder) into a 16-bit PCM WAV Blob. The Python pipeline reads the
|
| 244 |
+
* take with `soundfile`, which does NOT understand WebM — it needs a real WAV.
|
| 245 |
+
* Returns null if decode fails (caller can fall back to the raw blob).
|
| 246 |
+
*/
|
| 247 |
+
export async function blobToWav(blob: Blob): Promise<Blob | null> {
|
| 248 |
+
try {
|
| 249 |
+
const buf = await blob.arrayBuffer();
|
| 250 |
+
const Ctx =
|
| 251 |
+
window.AudioContext ??
|
| 252 |
+
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
| 253 |
+
.webkitAudioContext;
|
| 254 |
+
if (!Ctx) return null;
|
| 255 |
+
const ctx = new Ctx();
|
| 256 |
+
try {
|
| 257 |
+
const audioBuf = await ctx.decodeAudioData(buf);
|
| 258 |
+
const numChannels = audioBuf.numberOfChannels ?? 1;
|
| 259 |
+
const sr = audioBuf.sampleRate ?? 44100;
|
| 260 |
+
const samples = audioBuf.getChannelData(0);
|
| 261 |
+
const numFrames = samples.length;
|
| 262 |
+
const interleaved = new Float32Array(numFrames * numChannels);
|
| 263 |
+
for (let ch = 0; ch < numChannels; ch++) {
|
| 264 |
+
const chan = audioBuf.getChannelData(ch);
|
| 265 |
+
for (let i = 0; i < numFrames; i++) {
|
| 266 |
+
const v = chan[i];
|
| 267 |
+
if (v !== undefined) interleaved[i * numChannels + ch] = v;
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
// 16-bit PCM WAV.
|
| 271 |
+
const buffer = new ArrayBuffer(44 + interleaved.length * 2);
|
| 272 |
+
const view = new DataView(buffer);
|
| 273 |
+
const writeStr = (off: number, s: string) => {
|
| 274 |
+
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
|
| 275 |
+
};
|
| 276 |
+
writeStr(0, "RIFF");
|
| 277 |
+
view.setUint32(4, 36 + interleaved.length * 2, true);
|
| 278 |
+
writeStr(8, "WAVE");
|
| 279 |
+
writeStr(12, "fmt ");
|
| 280 |
+
view.setUint32(16, 16, true);
|
| 281 |
+
view.setUint16(20, 1, true); // PCM
|
| 282 |
+
view.setUint16(22, numChannels, true);
|
| 283 |
+
view.setUint32(24, sr, true);
|
| 284 |
+
view.setUint32(28, sr * numChannels * 2, true);
|
| 285 |
+
view.setUint16(32, numChannels * 2, true);
|
| 286 |
+
view.setUint16(34, 16, true);
|
| 287 |
+
writeStr(36, "data");
|
| 288 |
+
view.setUint32(40, interleaved.length * 2, true);
|
| 289 |
+
let off = 44;
|
| 290 |
+
for (let i = 0; i < interleaved.length; i++) {
|
| 291 |
+
const raw = interleaved[i] ?? 0;
|
| 292 |
+
const s = Math.max(-1, Math.min(1, raw));
|
| 293 |
+
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
| 294 |
+
off += 2;
|
| 295 |
+
}
|
| 296 |
+
return new Blob([buffer], { type: "audio/wav" });
|
| 297 |
+
} finally {
|
| 298 |
+
void ctx.close();
|
| 299 |
+
}
|
| 300 |
+
} catch {
|
| 301 |
+
return null;
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
/**
|
| 306 |
* Create an audio recorder. Requests microphone access (user gesture). Throws
|
| 307 |
* if getUserMedia/MediaRecorder is unsupported or the mic is denied.
|
|
|
|
| 353 |
},
|
| 354 |
stop() {
|
| 355 |
return new Promise((resolve) => {
|
| 356 |
+
const onStop = async () => {
|
| 357 |
active = false;
|
| 358 |
stream.getTracks().forEach((t) => t.stop());
|
| 359 |
const type = rec.mimeType || "audio/webm";
|
| 360 |
+
const raw = new Blob(chunks, { type });
|
| 361 |
+
// Convert WebM -> PCM WAV so the Python pipeline (soundfile) can read
|
| 362 |
+
// it. If decode fails, fall back to the raw blob.
|
| 363 |
+
const wav = await blobToWav(raw);
|
| 364 |
+
const ts = new Date()
|
| 365 |
+
.toISOString()
|
| 366 |
+
.replace(/[-:]/g, "")
|
| 367 |
+
.replace(/\.\d+Z$/, "Z");
|
| 368 |
const file = new File(
|
| 369 |
+
[wav ?? raw],
|
| 370 |
+
wav
|
| 371 |
+
? `jambuddy-live-audio-${ts}.wav`
|
| 372 |
+
: `jambuddy-live-audio-${ts}.webm`,
|
| 373 |
+
{ type: wav ? "audio/wav" : type },
|
|
|
|
| 374 |
);
|
| 375 |
resolve({
|
| 376 |
file,
|