Zum Inhalt

src/engine/audio/synth.ts

// Web Audio synthetic sound layer.
import type { ScreensaverSettings } from '../data/defaults';

export class AudioLayer {
  ctx: AudioContext | null = null;
  master: GainNode | null = null;
  humOsc: OscillatorNode | null = null;
  humGain: GainNode | null = null;
  whineOsc: OscillatorNode | null = null;
  whineGain: GainNode | null = null;

  // Carpenter soundscape — minimalist 80s synth score.
  private synthMaster: GainNode | null = null;
  private bassFilter: BiquadFilterNode | null = null;
  private bassFilterLFO: OscillatorNode | null = null;
  private bassFilterLFOGain: GainNode | null = null;
  private droneA: OscillatorNode | null = null;
  private droneE: OscillatorNode | null = null;
  private droneFilter: BiquadFilterNode | null = null;
  private droneFilterLFO: OscillatorNode | null = null;
  private droneFilterLFOGain: GainNode | null = null;
  private arpInterval: number | null = null;
  private bassInterval: number | null = null;
  private arpStep = 0;

  constructor(public settings: ScreensaverSettings) {}

  start() {
    if (!this.settings.sound.master) return;
    try {
      this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
      this.master = this.ctx.createGain();
      this.master.gain.value = this.settings.sound.volume;
      this.master.connect(this.ctx.destination);
      if (this.settings.sound.hum) this.startHum();
      if (this.settings.sound.scanlineWhine) this.startWhine();
      const sscape = this.settings.sound.synthSoundscape;
      if (sscape === 'carpenter') this.startCarpenterSoundscape();
      else if (sscape === 'bach') console.info('Bach soundscape pending');
    } catch (e) {
      console.warn('AudioLayer init failed:', e);
    }
  }

  private startHum() {
    if (!this.ctx || !this.master) return;
    this.humOsc = this.ctx.createOscillator();
    this.humOsc.type = 'sine'; this.humOsc.frequency.value = 60;
    const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 200;
    this.humGain = this.ctx.createGain(); this.humGain.gain.value = 0.025;
    this.humOsc.connect(lp).connect(this.humGain).connect(this.master);
    this.humOsc.start();
  }

  private startWhine() {
    if (!this.ctx || !this.master) return;
    this.whineOsc = this.ctx.createOscillator();
    this.whineOsc.type = 'sine'; this.whineOsc.frequency.value = 15700;
    this.whineGain = this.ctx.createGain(); this.whineGain.gain.value = 0.005;
    this.whineOsc.connect(this.whineGain).connect(this.master);
    this.whineOsc.start();
  }

  bootBeep() {
    if (!this.ctx || !this.master || !this.settings.sound.bootSounds) return;
    const o = this.ctx.createOscillator(); o.type = 'square'; o.frequency.value = 880;
    const g = this.ctx.createGain(); g.gain.setValueAtTime(0.0001, this.ctx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.06, this.ctx.currentTime + 0.005);
    g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime + 0.08);
    o.connect(g).connect(this.master); o.start(); o.stop(this.ctx.currentTime + 0.09);
  }

  sceneSwitch() {
    if (!this.ctx || !this.master || !this.settings.sound.sceneSwitch) return;
    const t = this.ctx.currentTime;
    const o = this.ctx.createOscillator(); o.type = 'sawtooth';
    o.frequency.setValueAtTime(220, t);
    o.frequency.exponentialRampToValueAtTime(880, t + 0.4);
    const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass';
    lp.frequency.setValueAtTime(400, t);
    lp.frequency.exponentialRampToValueAtTime(3000, t + 0.4);
    const g = this.ctx.createGain(); g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(0.08, t + 0.05);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
    o.connect(lp).connect(g).connect(this.master); o.start(t); o.stop(t + 0.55);
  }

  boostStart() {
    if (!this.ctx || !this.master || !this.settings.sound.boost) return;
    const t = this.ctx.currentTime;
    const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.6, this.ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.5;
    const src = this.ctx.createBufferSource(); src.buffer = buf;
    const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass';
    lp.frequency.setValueAtTime(60, t);
    lp.frequency.exponentialRampToValueAtTime(2000, t + 0.6);
    const g = this.ctx.createGain(); g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(0.18, t + 0.2);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.6);
    src.connect(lp).connect(g).connect(this.master); src.start(t);
  }

  ping() {
    if (!this.ctx || !this.master || !this.settings.sound.radarPing) return;
    const t = this.ctx.currentTime;
    const o = this.ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 800;
    const g = this.ctx.createGain(); g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(0.05, t + 0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 1.5);
    o.connect(g).connect(this.master); o.start(t); o.stop(t + 1.55);
  }

  setVolume(v: number) { if (this.master) this.master.gain.value = v; }

  // ── Carpenter-80s soundscape ──────────────────────────────────────────
  // Layered Dorian-on-A: arpeggio (square @80 BPM 16th) + bass-pulse (saw
  // through LP-LFO filter) + drone-pad (triangle A2+E3 through slow filter
  // sweep). Idle-drift pauses arpeggio + bass, drone stays.
  private startCarpenterSoundscape() {
    if (!this.ctx || !this.master) return;
    const ctx = this.ctx;

    this.synthMaster = ctx.createGain();
    this.synthMaster.gain.value = 0.4;        // 40% of master sub-bus
    this.synthMaster.connect(this.master);

    // ── Bass filter (lowpass) with slow LFO (period 4s, 300–900 Hz) ──
    this.bassFilter = ctx.createBiquadFilter();
    this.bassFilter.type = 'lowpass';
    this.bassFilter.frequency.value = 600;
    this.bassFilter.Q.value = 1.2;
    this.bassFilterLFO = ctx.createOscillator();
    this.bassFilterLFO.type = 'sine';
    this.bassFilterLFO.frequency.value = 0.25;    // 4s period
    this.bassFilterLFOGain = ctx.createGain();
    this.bassFilterLFOGain.gain.value = 300;      // ±300 around 600
    this.bassFilterLFO.connect(this.bassFilterLFOGain).connect(this.bassFilter.frequency);
    this.bassFilterLFO.start();
    // Bass sub-bus
    const bassGain = ctx.createGain();
    bassGain.gain.value = 0.55;
    this.bassFilter.connect(bassGain).connect(this.synthMaster);

    // ── Drone pad (A2 + E3 triangles) through slow filter sweep ──
    this.droneFilter = ctx.createBiquadFilter();
    this.droneFilter.type = 'lowpass';
    this.droneFilter.frequency.value = 1200;
    this.droneFilter.Q.value = 0.6;
    this.droneFilterLFO = ctx.createOscillator();
    this.droneFilterLFO.type = 'sine';
    this.droneFilterLFO.frequency.value = 1 / 30;  // 30s period
    this.droneFilterLFOGain = ctx.createGain();
    this.droneFilterLFOGain.gain.value = 800;      // 400–2000
    this.droneFilterLFO.connect(this.droneFilterLFOGain).connect(this.droneFilter.frequency);
    this.droneFilterLFO.start();
    const droneGain = ctx.createGain();
    droneGain.gain.value = 0.25;
    this.droneFilter.connect(droneGain).connect(this.synthMaster);
    this.droneA = ctx.createOscillator();
    this.droneA.type = 'triangle';
    this.droneA.frequency.value = 110;             // A2
    this.droneE = ctx.createOscillator();
    this.droneE.type = 'triangle';
    this.droneE.frequency.value = 164.81;          // E3
    this.droneA.connect(this.droneFilter);
    this.droneE.connect(this.droneFilter);
    this.droneA.start();
    this.droneE.start();

    this.startArpeggio();
    this.startBassPulse();
  }

  private startArpeggio() {
    if (!this.ctx || !this.synthMaster) return;
    // A4 → E4 → A4 → D5 — 16th notes @ 80 BPM = 187.5 ms each.
    const pattern = [440, 329.63, 440, 587.33];
    this.arpStep = 0;
    const tick = () => {
      if (!this.ctx || !this.synthMaster) return;
      const t = this.ctx.currentTime;
      const o = this.ctx.createOscillator();
      o.type = 'square';
      o.frequency.value = pattern[this.arpStep % pattern.length];
      const env = this.ctx.createGain();
      env.gain.setValueAtTime(0.0001, t);
      env.gain.exponentialRampToValueAtTime(0.12, t + 0.005);
      env.gain.exponentialRampToValueAtTime(0.01, t + 0.12);
      env.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
      o.connect(env).connect(this.synthMaster);
      o.start(t);
      o.stop(t + 0.2);
      this.arpStep++;
    };
    this.arpInterval = window.setInterval(tick, 187.5) as unknown as number;
  }

  private startBassPulse() {
    if (!this.ctx || !this.synthMaster || !this.bassFilter) return;
    // A2 pulse on each quarter note @ 80 BPM = 750 ms.
    const tick = () => {
      if (!this.ctx || !this.bassFilter) return;
      const t = this.ctx.currentTime;
      const o = this.ctx.createOscillator();
      o.type = 'sawtooth';
      o.frequency.value = 110;
      const env = this.ctx.createGain();
      env.gain.setValueAtTime(0.0001, t);
      env.gain.exponentialRampToValueAtTime(0.22, t + 0.005);
      env.gain.exponentialRampToValueAtTime(0.001, t + 0.65);
      o.connect(env).connect(this.bassFilter);
      o.start(t);
      o.stop(t + 0.7);
    };
    this.bassInterval = window.setInterval(tick, 750) as unknown as number;
  }

  /** Pause arpeggio + bass (idle-drift). Drone stays sustained. */
  pauseSoundscape() {
    if (this.arpInterval != null) { clearInterval(this.arpInterval); this.arpInterval = null; }
    if (this.bassInterval != null) { clearInterval(this.bassInterval); this.bassInterval = null; }
  }

  /** Resume arpeggio + bass after idle-drift wakeup. */
  resumeSoundscape() {
    if (!this.synthMaster) return;
    if (this.arpInterval == null) this.startArpeggio();
    if (this.bassInterval == null) this.startBassPulse();
  }

  private stopSoundscape() {
    this.pauseSoundscape();
    try {
      this.droneA?.stop(); this.droneE?.stop();
      this.bassFilterLFO?.stop(); this.droneFilterLFO?.stop();
    } catch {}
    this.synthMaster = null;
    this.bassFilter = this.bassFilterLFO = this.bassFilterLFOGain = null;
    this.droneA = this.droneE = this.droneFilterLFO = null;
    this.droneFilter = null;
    this.droneFilterLFOGain = null;
  }

  dispose() {
    try { this.humOsc?.stop(); this.whineOsc?.stop(); } catch {}
    this.stopSoundscape();
    this.ctx?.close();
    this.ctx = null;
  }
}