// src/audio/AudioCapture.ts
// Singleton audio-input → AnalyserNode wrapper. start() takes one of two
// sources: 'mic' (getUserMedia) or 'tab' (getDisplayMedia, then strip video).
// getFrame() polls one bass/mid/high triplet per call. The Engine consumes
// the triplet each tick via setAudio().
import type { AudioFrame } from '~/engine/types';
import { BASS_HZ, HIGH_HZ, MID_HZ, bandLevel } from './bands';
const FFT_SIZE = 1024;
const SMOOTHING = 0.8;
export type AudioSource = 'mic' | 'tab';
class AudioCapture {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private analyser: AnalyserNode | null = null;
private freqBuf: Float32Array<ArrayBuffer> | null = null;
private currentSource: AudioSource | null = null;
get active(): boolean {
return this.ctx !== null && this.analyser !== null;
}
get source(): AudioSource | null {
return this.currentSource;
}
async start(source: AudioSource = 'mic'): Promise<void> {
if (this.active) return;
const stream = source === 'tab' ? await captureTabAudio() : await captureMic();
try {
await this.attachStream(stream);
this.currentSource = source;
} catch (err) {
for (const t of stream.getTracks()) t.stop();
throw err;
}
}
private async attachStream(stream: MediaStream): Promise<void> {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) throw new Error('AudioContext unavailable');
const ctx = new Ctor();
if (ctx.state === 'suspended') await ctx.resume();
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE;
analyser.smoothingTimeConstant = SMOOTHING;
src.connect(analyser);
// Don't connect to destination — we don't want feedback through the speakers.
// If the user revokes the share (closes the tab, stops sharing), tear down.
for (const t of stream.getTracks()) {
t.addEventListener('ended', () => this.stop());
}
this.ctx = ctx;
this.stream = stream;
this.analyser = analyser;
this.freqBuf = new Float32Array(analyser.frequencyBinCount);
}
stop(): void {
if (this.stream) {
for (const t of this.stream.getTracks()) t.stop();
this.stream = null;
}
if (this.ctx) {
// close() is async but we don't need to await it for teardown.
void this.ctx.close();
this.ctx = null;
}
this.analyser = null;
this.freqBuf = null;
this.currentSource = null;
}
getFrame(): AudioFrame | null {
if (!this.analyser || !this.freqBuf || !this.ctx) return null;
this.analyser.getFloatFrequencyData(this.freqBuf);
const sr = this.ctx.sampleRate;
const fft = this.analyser.fftSize;
return {
bass: bandLevel(this.freqBuf, sr, fft, BASS_HZ),
mid: bandLevel(this.freqBuf, sr, fft, MID_HZ),
high: bandLevel(this.freqBuf, sr, fft, HIGH_HZ),
};
}
}
async function captureMic(): Promise<MediaStream> {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('mediaDevices.getUserMedia unavailable');
}
return navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
video: false,
});
}
async function captureTabAudio(): Promise<MediaStream> {
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error('mediaDevices.getDisplayMedia unavailable');
}
// Browsers require a video track on getDisplayMedia, so we accept one and
// immediately discard it — we only care about the audio track.
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
for (const t of stream.getVideoTracks()) t.stop();
if (stream.getAudioTracks().length === 0) {
for (const t of stream.getTracks()) t.stop();
throw new Error('no-audio-track: pick a tab and tick "Share tab audio" in the share dialog');
}
// Return a fresh audio-only stream so downstream code never sees the video track.
return new MediaStream(stream.getAudioTracks());
}
export const audioCapture = new AudioCapture();