/** * AudioManager — фоновый звук сцены через Web Audio API. * * Все звуки генерируются ПРОЦЕДУРНО — никаких mp3-файлов не нужно. * * Каналы: * ambient — длительный фон (ветер/лес/вода/пещера) через шум + фильтры * music — короткие мелодии-петли через oscillator (минорный/мажорный лад) * * Воспроизводится только в Play-режиме. */ export const AMBIENT_PRESETS = [ { id: 'none', name: 'Без звука' }, { id: 'forest', name: 'Лес' }, { id: 'wind', name: 'Ветер' }, { id: 'water', name: 'Вода' }, { id: 'cave', name: 'Пещера' }, ]; export const MUSIC_PRESETS = [ { id: 'none', name: 'Без музыки' }, { id: 'calm', name: 'Спокойная' }, { id: 'happy', name: 'Весёлая' }, { id: 'epic', name: 'Эпическая' }, { id: 'mystery', name: 'Загадочная' }, ]; // Ноты в Hz (4-я октава для калькуляций) const NOTES = { C: 261.63, 'C#': 277.18, D: 293.66, 'D#': 311.13, E: 329.63, F: 349.23, 'F#': 369.99, G: 392.00, 'G#': 415.30, A: 440.00, 'A#': 466.16, B: 493.88, }; function n(note, octave = 4) { return NOTES[note] * Math.pow(2, octave - 4); } // Простые мелодии — массив [нота, октава, длительность в "тиках" по 0.25с] const MELODIES = { calm: { bpm: 60, notes: [ ['C',5,2],['E',5,2],['G',5,2],['E',5,2], ['F',5,2],['A',5,2],['G',5,4], ['D',5,2],['F',5,2],['A',5,2],['F',5,2], ['E',5,2],['G',5,2],['C',5,4], ], }, happy: { bpm: 120, notes: [ ['C',5,1],['E',5,1],['G',5,1],['C',6,1], ['G',5,1],['E',5,1],['C',5,2], ['F',5,1],['A',5,1],['C',6,1],['F',6,1], ['C',6,1],['A',5,1],['F',5,2], ], }, epic: { bpm: 80, notes: [ ['A',3,2],['E',4,2],['A',4,2],['C',5,2], ['B',4,2],['G',4,2],['E',4,4], ['F',4,2],['A',4,2],['C',5,2],['E',5,2], ['D',5,2],['B',4,2],['A',4,4], ], }, mystery: { bpm: 70, notes: [ ['A',4,3],['C',5,1],['E',5,2],['F',5,2], ['E',5,2],['D',5,2],['C',5,4], ['G',4,3],['B',4,1],['D',5,2],['F',5,2], ['E',5,2],['C',5,2],['A',4,4], ], }, }; export class AudioManager { constructor() { this.ambientId = 'none'; this.ambientVolume = 0.3; this.musicId = 'none'; this.musicVolume = 0.25; this._ctx = null; this._ambientNodes = []; this._musicTimer = null; this._musicNodes = []; this._playing = false; } setAmbient({ preset, volume } = {}) { if (preset !== undefined) this.ambientId = preset; if (volume !== undefined) this.ambientVolume = Math.max(0, Math.min(1, volume)); if (this._playing) this._restartAmbient(); } setMusic({ preset, volume } = {}) { if (preset !== undefined) this.musicId = preset; if (volume !== undefined) this.musicVolume = Math.max(0, Math.min(1, volume)); if (this._playing) this._restartMusic(); } start() { this._ensureContext(); if (!this._ctx) return; // Браузеры требуют user-gesture перед воспроизведением. // Кнопка Play даёт его — resume() работает. if (this._ctx.state === 'suspended') { this._ctx.resume().catch(() => {}); } this._playing = true; this._restartAmbient(); this._restartMusic(); } stop() { this._playing = false; this._stopAmbient(); this._stopMusic(); } _ensureContext() { if (this._ctx) return; try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; this._ctx = new Ctx(); } catch (e) { /* ignore */ } } // ============ AMBIENT ============ _restartAmbient() { this._stopAmbient(); if (this.ambientId === 'none' || !this._ctx) return; const ctx = this._ctx; const dest = ctx.destination; // Главный gain — общая громкость канала const masterGain = ctx.createGain(); masterGain.gain.value = this.ambientVolume * 0.5; masterGain.connect(dest); if (this.ambientId === 'wind') { // Розовый шум через белый шум + фильтр + медленная LFO для амплитуды const noise = this._createWhiteNoise(); const filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 800; filt.Q.value = 0.8; const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); lfo.frequency.value = 0.15; lfoGain.gain.value = 0.3; lfo.connect(lfoGain); const winGain = ctx.createGain(); winGain.gain.value = 0.5; lfoGain.connect(winGain.gain); noise.connect(filt); filt.connect(winGain); winGain.connect(masterGain); noise.start(); lfo.start(); this._ambientNodes.push(noise, lfo, filt, lfoGain, winGain); } else if (this.ambientId === 'forest') { // Шум листьев + случайные «птицы» (короткие чирикания) const noise = this._createWhiteNoise(); const filt = ctx.createBiquadFilter(); filt.type = 'highpass'; filt.frequency.value = 1500; const ng = ctx.createGain(); ng.gain.value = 0.15; noise.connect(filt); filt.connect(ng); ng.connect(masterGain); noise.start(); this._ambientNodes.push(noise, filt, ng); // Птицы каждые 3-7 секунд this._scheduleBirds(masterGain); } else if (this.ambientId === 'water') { // Журчание — фильтрованный шум с медленным сдвигом частоты const noise = this._createWhiteNoise(); const filt = ctx.createBiquadFilter(); filt.type = 'bandpass'; filt.frequency.value = 1200; filt.Q.value = 1.2; const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); lfo.frequency.value = 0.5; lfoGain.gain.value = 400; lfo.connect(lfoGain); lfoGain.connect(filt.frequency); const wg = ctx.createGain(); wg.gain.value = 0.6; noise.connect(filt); filt.connect(wg); wg.connect(masterGain); noise.start(); lfo.start(); this._ambientNodes.push(noise, lfo, filt, lfoGain, wg); } else if (this.ambientId === 'cave') { // Низкий гул + редкие «капли» const osc1 = ctx.createOscillator(); osc1.type = 'sine'; osc1.frequency.value = 60; const osc2 = ctx.createOscillator(); osc2.type = 'sine'; osc2.frequency.value = 90; const g = ctx.createGain(); g.gain.value = 0.4; osc1.connect(g); osc2.connect(g); g.connect(masterGain); osc1.start(); osc2.start(); this._ambientNodes.push(osc1, osc2, g); this._scheduleDrips(masterGain); } this._ambientMaster = masterGain; } _stopAmbient() { for (const n of this._ambientNodes) { try { n.stop?.(); } catch (e) {} try { n.disconnect?.(); } catch (e) {} } this._ambientNodes = []; if (this._birdsTimer) { clearTimeout(this._birdsTimer); this._birdsTimer = null; } if (this._dripsTimer) { clearTimeout(this._dripsTimer); this._dripsTimer = null; } if (this._ambientMaster) { try { this._ambientMaster.disconnect(); } catch (e) {} this._ambientMaster = null; } } _createWhiteNoise() { const ctx = this._ctx; const bufSize = 2 * ctx.sampleRate; const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1; const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; return src; } _scheduleBirds(dest) { if (!this._playing) return; const ctx = this._ctx; const delay = 2 + Math.random() * 5; this._birdsTimer = setTimeout(() => { if (!this._playing) return; const t = ctx.currentTime; const baseFreq = 1500 + Math.random() * 1500; for (let i = 0; i < 3; i++) { const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(baseFreq * (1 + i * 0.1), t + i * 0.08); const g = ctx.createGain(); g.gain.setValueAtTime(0, t + i * 0.08); g.gain.linearRampToValueAtTime(0.15, t + i * 0.08 + 0.02); g.gain.linearRampToValueAtTime(0, t + i * 0.08 + 0.1); osc.connect(g); g.connect(dest); osc.start(t + i * 0.08); osc.stop(t + i * 0.08 + 0.12); } this._scheduleBirds(dest); }, delay * 1000); } _scheduleDrips(dest) { if (!this._playing) return; const ctx = this._ctx; const delay = 1.5 + Math.random() * 4; this._dripsTimer = setTimeout(() => { if (!this._playing) return; const t = ctx.currentTime; const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(800 + Math.random() * 400, t); osc.frequency.exponentialRampToValueAtTime(200, t + 0.3); const g = ctx.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.2, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4); osc.connect(g); g.connect(dest); osc.start(t); osc.stop(t + 0.5); this._scheduleDrips(dest); }, delay * 1000); } // ============ MUSIC ============ _restartMusic() { this._stopMusic(); if (this.musicId === 'none' || !MELODIES[this.musicId] || !this._ctx) return; const ctx = this._ctx; const masterGain = ctx.createGain(); masterGain.gain.value = this.musicVolume * 0.4; masterGain.connect(ctx.destination); this._musicMaster = masterGain; this._playMelodyLoop(); } _stopMusic() { if (this._musicTimer) { clearTimeout(this._musicTimer); this._musicTimer = null; } for (const n of this._musicNodes) { try { n.stop?.(); } catch (e) {} try { n.disconnect?.(); } catch (e) {} } this._musicNodes = []; if (this._musicMaster) { try { this._musicMaster.disconnect(); } catch (e) {} this._musicMaster = null; } } _playMelodyLoop() { if (!this._playing) return; const melody = MELODIES[this.musicId]; if (!melody) return; const ctx = this._ctx; const tickDur = 60 / melody.bpm / 4; // 16-я нота let t = ctx.currentTime + 0.05; let totalTime = 0; for (const [note, octave, ticks] of melody.notes) { const dur = ticks * tickDur; const freq = n(note, octave); this._scheduleNote(freq, t, dur * 0.95); t += dur; totalTime += dur; } // Перезапускаем мелодию по окончании this._musicTimer = setTimeout(() => this._playMelodyLoop(), (totalTime + 0.5) * 1000); } _scheduleNote(freq, startTime, duration) { const ctx = this._ctx; const osc = ctx.createOscillator(); osc.type = 'triangle'; // мягкий тон osc.frequency.value = freq; const g = ctx.createGain(); // ADSR-огибающая g.gain.setValueAtTime(0, startTime); g.gain.linearRampToValueAtTime(0.4, startTime + 0.02); g.gain.linearRampToValueAtTime(0.2, startTime + 0.1); g.gain.exponentialRampToValueAtTime(0.001, startTime + duration); osc.connect(g); g.connect(this._musicMaster); osc.start(startTime); osc.stop(startTime + duration + 0.05); this._musicNodes.push(osc, g); // Чистим старые ноды чтобы не накапливались if (this._musicNodes.length > 200) { this._musicNodes.splice(0, 100); } } // ============ STATE ============ serialize() { return { ambientId: this.ambientId, ambientVolume: this.ambientVolume, musicId: this.musicId, musicVolume: this.musicVolume, }; } load(data) { if (!data) return; if (typeof data.ambientId === 'string') this.ambientId = data.ambientId; if (typeof data.ambientVolume === 'number') this.ambientVolume = data.ambientVolume; if (typeof data.musicId === 'string') this.musicId = data.musicId; if (typeof data.musicVolume === 'number') this.musicVolume = data.musicVolume; } dispose() { this.stop(); if (this._ctx) { try { this._ctx.close(); } catch (e) {} this._ctx = null; } } }