UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

334 lines (318 loc) • 10.4 kB
import { bytesToBase64, raceWithAbortSignal, } from "@assistant-ui/core/internal"; export const encodePcm16 = (samples: Float32Array): string => { const bytes = new Uint8Array(samples.length * 2); const view = new DataView(bytes.buffer); for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i]!)); view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } return bytesToBase64(bytes); }; export const decodePcm16 = (base64: string): Float32Array<ArrayBuffer> => { const binary = atob(base64); const samples = new Float32Array(new ArrayBuffer((binary.length >> 1) * 4)); const view = new DataView(new ArrayBuffer(samples.length * 2)); for (let i = 0; i < samples.length * 2; i++) view.setUint8(i, binary.charCodeAt(i)); for (let i = 0; i < samples.length; i++) samples[i] = view.getInt16(i * 2, true) / 0x8000; return samples; }; export const readLevel = (analyser: AnalyserNode): number => { const data = new Uint8Array(analyser.fftSize); analyser.getByteTimeDomainData(data); let sum = 0; for (const v of data) { const centered = (v - 128) / 128; sum += centered * centered; } return Math.min(1, Math.sqrt(sum / data.length) * 2); }; export type MicrophoneCapture = { setMuted: (muted: boolean) => void; level: () => number; close: () => Promise<void>; }; const createLowPass = (ratio: number) => { const omega = (Math.PI * 0.9) / ratio; const cosine = Math.cos(omega); const sine = Math.sin(omega); const sections = [0.5176380902, 0.7071067812, 1.9318516526].map((q) => { const alpha = sine / (2 * q); const a0 = 1 + alpha; const b = (1 - cosine) / (2 * a0); return { b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 }; }); return (samples: Float32Array) => { for (let i = 0; i < samples.length; i++) { let x = samples[i]!; for (const s of sections) { const y = s.b * x + s.z1; s.z1 = 2 * s.b * x - s.a1 * y + s.z2; s.z2 = s.b * x - s.a2 * y; x = y; } samples[i] = x; } }; }; export const WORKLET_SOURCE = ` class PcmCapture extends AudioWorkletProcessor { constructor(options) { super(); this.ratio = sampleRate / options.processorOptions.targetRate; this.chunk = Math.round(options.processorOptions.targetRate / 50); this.position = 0; this.last = 0; this.pending = []; this.sections = []; if (this.ratio > 1) { const omega = (Math.PI * 0.9) / this.ratio; const cosine = Math.cos(omega); const sine = Math.sin(omega); for (const q of [0.5176380902, 0.7071067812, 1.9318516526]) { const alpha = sine / (2 * q); const a0 = 1 + alpha; const b = (1 - cosine) / (2 * a0); this.sections.push({ b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 }); } } } process(inputs) { const channels = inputs[0]; if (!channels || channels.length === 0 || channels[0].length === 0) return true; const input = channels[0].slice(); for (let c = 1; c < channels.length; c++) for (let i = 0; i < input.length; i++) input[i] += channels[c][i]; if (channels.length > 1) for (let i = 0; i < input.length; i++) input[i] /= channels.length; for (let i = 0; i < input.length; i++) { let x = input[i]; for (const s of this.sections) { const y = s.b * x + s.z1; s.z1 = 2 * s.b * x - s.a1 * y + s.z2; s.z2 = s.b * x - s.a2 * y; x = y; } input[i] = x; } while (this.position < input.length - 1) { const index = Math.floor(this.position); const prev = index < 0 ? this.last : input[index]; const next = input[index + 1]; this.pending.push(prev + (next - prev) * (this.position - index)); this.position += this.ratio; } this.position -= input.length; this.last = input[input.length - 1]; if (this.pending.length >= this.chunk) { this.port.postMessage(Float32Array.from(this.pending)); this.pending = []; } return true; } } registerProcessor("pcm-capture", PcmCapture); `; export const createResampler = (ratio: number) => { let position = 0; let last = 0; const lowPass = ratio > 1 ? createLowPass(ratio) : undefined; return (input: Float32Array): Float32Array => { if (input.length === 0) return new Float32Array(0); const samples = lowPass ? input.slice() : input; lowPass?.(samples); const out: number[] = []; while (position < samples.length - 1) { const index = Math.floor(position); const prev = index < 0 ? last : samples[index]!; const next = samples[index + 1]!; out.push(prev + (next - prev) * (position - index)); position += ratio; } position -= samples.length; last = samples[samples.length - 1]!; return Float32Array.from(out); }; }; export const openMicrophone = async (options: { sampleRate: number; onChunk: (base64: string) => void; signal?: AbortSignal; }): Promise<MicrophoneCapture> => { let stream: MediaStream | undefined; const stopTracks = () => { stream?.getTracks().forEach((track) => track.stop()); stream = undefined; }; const throwIfAborted = () => { if (options.signal?.aborted) throw options.signal.reason; }; let context: AudioContext | undefined; try { const openedStream = await raceWithAbortSignal(options.signal, async () => { const acquired = await navigator.mediaDevices.getUserMedia({ audio: true, }); stream = acquired; if (options.signal?.aborted) { stopTracks(); throw options.signal.reason; } return acquired; }); throwIfAborted(); context = new AudioContext(); if (context.state === "suspended") await raceWithAbortSignal(options.signal, () => context!.resume()); throwIfAborted(); const source = context.createMediaStreamSource(openedStream); const analyser = context.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); let muted = false; let closed = false; let closing: Promise<void> | undefined; const push = (samples: Float32Array) => { if (!muted && !closed && samples.length) options.onChunk(encodePcm16(samples)); }; let node: AudioNode | undefined; let detach = () => {}; if (context.audioWorklet) { const url = URL.createObjectURL( new Blob([WORKLET_SOURCE], { type: "text/javascript" }), ); try { await raceWithAbortSignal(options.signal, () => context!.audioWorklet.addModule(url), ); throwIfAborted(); const worklet = new AudioWorkletNode(context, "pcm-capture", { processorOptions: { targetRate: options.sampleRate }, }); worklet.port.onmessage = (event) => push(event.data as Float32Array); detach = () => { worklet.port.onmessage = null; }; node = worklet; } catch { // A CSP can block blob worklets even when AudioWorklet is available. throwIfAborted(); } finally { URL.revokeObjectURL(url); } } if (!node) { const resample = createResampler(context.sampleRate / options.sampleRate); const processor = context.createScriptProcessor(1024, 1, 1); processor.onaudioprocess = (event) => push(resample(event.inputBuffer.getChannelData(0))); detach = () => { processor.onaudioprocess = null; }; node = processor; } analyser.connect(node); node.connect(context.destination); const opened = context; return { setMuted: (value) => { muted = value; openedStream.getAudioTracks().forEach((track) => { track.enabled = !value; }); }, level: () => readLevel(analyser), close: () => { if (closing) return closing; closed = true; detach(); node.disconnect(); source.disconnect(); stopTracks(); return (closing = opened.close()); }, }; } catch (error) { stopTracks(); await context?.close().catch(() => {}); throw error; } }; export type PcmPlayer = { enqueue: (base64: string) => void; clear: () => void; isPlaying: () => boolean; level: () => number; close: () => Promise<void>; }; const IDLE_GRACE_MS = 100; export const createPcmPlayer = (options: { sampleRate: number; onIdle?: () => void; }): PcmPlayer => { const context = new AudioContext({ sampleRate: options.sampleRate }); const analyser = context.createAnalyser(); analyser.fftSize = 256; analyser.connect(context.destination); const sources = new Set<AudioBufferSourceNode>(); let cursor = 0; let closing: Promise<void> | undefined; let idleTimer: ReturnType<typeof setTimeout> | undefined; const scheduleIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (sources.size === 0) options.onIdle?.(); }, IDLE_GRACE_MS); }; return { enqueue: (base64) => { const samples = decodePcm16(base64); if (samples.length === 0) return; const buffer = context.createBuffer( 1, samples.length, options.sampleRate, ); buffer.copyToChannel(samples, 0); const source = context.createBufferSource(); source.buffer = buffer; source.connect(analyser); const start = Math.max(context.currentTime, cursor); source.start(start); cursor = start + buffer.duration; sources.add(source); source.onended = () => { sources.delete(source); if (sources.size === 0) scheduleIdle(); }; clearTimeout(idleTimer); if (context.state === "suspended") void context.resume().catch(() => {}); }, clear: () => { clearTimeout(idleTimer); for (const source of sources) { source.onended = null; source.stop(); } sources.clear(); cursor = 0; }, isPlaying: () => sources.size > 0, level: () => readLevel(analyser), close: () => { if (closing) return closing; clearTimeout(idleTimer); for (const source of sources) { source.onended = null; source.stop(); } sources.clear(); return (closing = context.close()); }, }; };