// Fellwing sound, synthesised with Web Audio (no sound files): piston or turbofan engine, wind that rises with // airspeed, the stall horn, tyre rumble on the runway and a gate chime. Starts on the first tap or key. // (c) 2026 CyberMax. All rights reserved. export class Sound { constructor() { this.ctx = null; this.on = true; this.kind = 'prop'; } start() { if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; } const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; const c = this.ctx = new AC(); this.master = c.createGain(); this.master.gain.value = this.on ? 0.55 : 0; this.master.connect(c.destination); const noiseBuf = c.createBuffer(1, c.sampleRate * 2, c.sampleRate); const d = noiseBuf.getChannelData(0); let b = 0; for (let i = 0; i < d.length; i++) { const w = Math.random() * 2 - 1; b = 0.97 * b + 0.03 * w; d[i] = b * 6 + w * 0.15; } const noise = () => { const s = c.createBufferSource(); s.buffer = noiseBuf; s.loop = true; s.start(); return s; }; // engine: two detuned saws through a low-pass + a filtered noise bed this.eLp = c.createBiquadFilter(); this.eLp.type = 'lowpass'; this.eLp.frequency.value = 600; this.eLp.Q.value = 2; this.eG = c.createGain(); this.eG.gain.value = 0; this.eLp.connect(this.eG); this.eG.connect(this.master); this.o1 = c.createOscillator(); this.o1.type = 'sawtooth'; this.o2 = c.createOscillator(); this.o2.type = 'square'; this.o1.connect(this.eLp); const g2 = c.createGain(); g2.gain.value = 0.35; this.o2.connect(g2); g2.connect(this.eLp); this.o1.start(); this.o2.start(); this.eN = noise(); this.eNf = c.createBiquadFilter(); this.eNf.type = 'bandpass'; this.eNf.frequency.value = 900; this.eNf.Q.value = 0.7; this.eNg = c.createGain(); this.eNg.gain.value = 0; this.eN.connect(this.eNf); this.eNf.connect(this.eNg); this.eNg.connect(this.master); // jet whine this.jw = c.createOscillator(); this.jw.type = 'sine'; this.jwG = c.createGain(); this.jwG.gain.value = 0; this.jw.connect(this.jwG); this.jwG.connect(this.master); this.jw.start(); // wind this.wN = noise(); this.wF = c.createBiquadFilter(); this.wF.type = 'lowpass'; this.wF.frequency.value = 400; this.wG = c.createGain(); this.wG.gain.value = 0; this.wN.connect(this.wF); this.wF.connect(this.wG); this.wG.connect(this.master); // stall horn this.hO = c.createOscillator(); this.hO.type = 'square'; this.hO.frequency.value = 440; this.hG = c.createGain(); this.hG.gain.value = 0; const hf = c.createBiquadFilter(); hf.type = 'lowpass'; hf.frequency.value = 1400; this.hO.connect(hf); hf.connect(this.hG); this.hG.connect(this.master); this.hO.start(); // rumble this.rN = noise(); this.rF = c.createBiquadFilter(); this.rF.type = 'lowpass'; this.rF.frequency.value = 120; this.rG = c.createGain(); this.rG.gain.value = 0; this.rN.connect(this.rF); this.rF.connect(this.rG); this.rG.connect(this.master); } setOn(on) { this.on = on; if (this.master) this.master.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05); } update(P, paused) { if (!this.ctx) return; const t = this.ctx.currentTime, k = 0.08, thr = paused || P.crashed ? 0 : P.surf.thr, v = paused ? 0 : P.tas; const jet = P.A.engine === 'jet'; const rpm = jet ? 0.25 + 0.75 * thr : 0.3 + 0.7 * thr + Math.min(0.15, v / 400); if (jet) { this.eG.gain.setTargetAtTime(0, t, k); this.eNf.frequency.setTargetAtTime(500 + rpm * 2400, t, k); this.eNg.gain.setTargetAtTime(paused || P.crashed ? 0 : 0.05 + rpm * 0.22, t, k); this.jw.frequency.setTargetAtTime(2200 + rpm * 4200, t, k); this.jwG.gain.setTargetAtTime(paused || P.crashed ? 0 : 0.012 + rpm * 0.03, t, k); } else { const f = 18 + rpm * 62; this.o1.frequency.setTargetAtTime(f, t, k); this.o2.frequency.setTargetAtTime(f * 2.01, t, k); this.eLp.frequency.setTargetAtTime(260 + rpm * 1100, t, k); this.eG.gain.setTargetAtTime(paused || P.crashed ? 0 : 0.10 + thr * 0.16, t, k); this.eNf.frequency.setTargetAtTime(700 + rpm * 900, t, k); this.eNg.gain.setTargetAtTime(paused || P.crashed ? 0 : 0.02 + thr * 0.06, t, k); this.jwG.gain.setTargetAtTime(0, t, k); } this.wF.frequency.setTargetAtTime(250 + v * 9, t, k); this.wG.gain.setTargetAtTime(Math.min(0.32, (v / 120) ** 2 * 0.2), t, k); this.hG.gain.setTargetAtTime(!paused && !P.crashed && P.buffet > 0.4 && !P.onGround ? 0.06 : 0, t, 0.02); this.rG.gain.setTargetAtTime(!paused && P.onGround ? Math.min(0.5, v / 30) * 0.5 : 0, t, k); } chime(hi = false) { if (!this.ctx) return; const c = this.ctx, t = c.currentTime; for (const [f, d] of hi ? [[660, 0], [880, 0.09], [1320, 0.18]] : [[880, 0], [1175, 0.08]]) { const o = c.createOscillator(), g = c.createGain(); o.type = 'triangle'; o.frequency.value = f; g.gain.setValueAtTime(0, t + d); g.gain.linearRampToValueAtTime(0.18, t + d + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + d + 0.5); o.connect(g); g.connect(this.master); o.start(t + d); o.stop(t + d + 0.6); } } crash() { if (!this.ctx) return; const c = this.ctx, t = c.currentTime, s = c.createBufferSource(); s.buffer = this.eN.buffer; const f = c.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(1800, t); f.frequency.exponentialRampToValueAtTime(80, t + 1.4); const g = c.createGain(); g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.001, t + 1.6); s.connect(f); f.connect(g); g.connect(this.master); s.start(); s.stop(t + 1.7); } }