Zum Inhalt

src/audio/AudioCapture.ts

// 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();