P1c: microphone recording through the material pipeline
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import workletUrl from './recorder-worklet.js?url';
|
||||
|
||||
export const MAX_RECORD_SECONDS = 30;
|
||||
|
||||
interface ActiveRecorder {
|
||||
context: AudioContext;
|
||||
stream: MediaStream;
|
||||
node: AudioWorkletNode;
|
||||
mute: GainNode;
|
||||
chunks: Float32Array[];
|
||||
started: number;
|
||||
timer: number;
|
||||
}
|
||||
|
||||
let active: ActiveRecorder | null = null;
|
||||
|
||||
export async function startRecording(tick: (seconds: number) => void): Promise<void> {
|
||||
if (active) return;
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
}
|
||||
});
|
||||
const context = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
try {
|
||||
await context.audioWorklet.addModule(workletUrl);
|
||||
} catch (error) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
await context.close();
|
||||
throw error;
|
||||
}
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const node = new AudioWorkletNode(context, 'zhila-recorder', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1]
|
||||
});
|
||||
const chunks: Float32Array[] = [];
|
||||
node.port.onmessage = (event: MessageEvent<Float32Array>) => {
|
||||
if (event.data instanceof Float32Array) chunks.push(event.data);
|
||||
};
|
||||
// The processor needs a path to the destination to run; the mute keeps the
|
||||
// microphone out of the speakers.
|
||||
const mute = context.createGain();
|
||||
mute.gain.value = 0;
|
||||
source.connect(node);
|
||||
node.connect(mute);
|
||||
mute.connect(context.destination);
|
||||
if (context.state === 'suspended') await context.resume();
|
||||
const started = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
tick(Math.min(MAX_RECORD_SECONDS, (performance.now() - started) / 1000));
|
||||
}, 100);
|
||||
active = { context, stream, node, mute, chunks, started, timer };
|
||||
}
|
||||
|
||||
export async function stopRecording(): Promise<{
|
||||
blob: Blob;
|
||||
seconds: number;
|
||||
sampleRate: number;
|
||||
} | null> {
|
||||
if (!active) return null;
|
||||
const recorder = active;
|
||||
active = null;
|
||||
window.clearInterval(recorder.timer);
|
||||
recorder.node.port.onmessage = null;
|
||||
recorder.node.disconnect();
|
||||
recorder.mute.disconnect();
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
const seconds = (performance.now() - recorder.started) / 1000;
|
||||
const sampleRate = recorder.context.sampleRate;
|
||||
await recorder.context.close();
|
||||
|
||||
const length = recorder.chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||
if (length === 0) return null;
|
||||
const samples = new Float32Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of recorder.chunks) {
|
||||
samples.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return { blob: encodeWav(samples, sampleRate), seconds, sampleRate };
|
||||
}
|
||||
|
||||
export function isRecording(): boolean {
|
||||
return active !== null;
|
||||
}
|
||||
|
||||
function encodeWav(samples: Float32Array, sampleRate: number): Blob {
|
||||
const channels = 1;
|
||||
const bytesPerSample = 2;
|
||||
const dataBytes = samples.length * bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + dataBytes);
|
||||
const view = new DataView(buffer);
|
||||
const text = (offset: number, value: string) => {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
};
|
||||
text(0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataBytes, true);
|
||||
text(8, 'WAVE');
|
||||
text(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * channels * bytesPerSample, true);
|
||||
view.setUint16(32, channels * bytesPerSample, true);
|
||||
view.setUint16(34, bytesPerSample * 8, true);
|
||||
text(36, 'data');
|
||||
view.setUint32(40, dataBytes, true);
|
||||
let offset = 44;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[index]));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Runs on the audio thread. Copies each render quantum; the main thread owns
|
||||
// the take buffer and the WAV encoding.
|
||||
class ZhilaRecorderProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const channel = inputs[0] && inputs[0][0];
|
||||
if (channel && channel.length) this.port.postMessage(channel.slice(0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('zhila-recorder', ZhilaRecorderProcessor);
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
|
||||
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
|
||||
export type Status = 'connecting' | 'live' | 'offline';
|
||||
export type Seal = 'found' | 'taken' | 'gift' | 'loan' | 'unknown';
|
||||
export type Seal = 'found' | 'recorded' | 'taken' | 'gift' | 'loan' | 'unknown';
|
||||
|
||||
export interface FaceSummary {
|
||||
index: number;
|
||||
|
||||
Reference in New Issue
Block a user