221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
// audio.ts - Звуковой движок для сигнала
|
||
|
||
export class AudioEngine {
|
||
private audioContext: AudioContext | null = null;
|
||
private oscillator: OscillatorNode | null = null;
|
||
private gainNode: GainNode | null = null;
|
||
private isPlaying: boolean = false;
|
||
private currentLevel: number = 0;
|
||
private fadeTimeout: number | null = null;
|
||
private readonly FREQUENCY: number = 800; // 800 Гц
|
||
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
|
||
|
||
constructor() {
|
||
// Инициализация при первом использовании
|
||
}
|
||
|
||
/**
|
||
* Инициализация AudioContext (требует взаимодействия пользователя)
|
||
*/
|
||
private initAudio(): boolean {
|
||
try {
|
||
if (!this.audioContext) {
|
||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||
}
|
||
|
||
if (this.audioContext.state === 'suspended') {
|
||
this.audioContext.resume();
|
||
}
|
||
|
||
return this.audioContext.state === 'running';
|
||
} catch (error) {
|
||
console.warn('[Audio] Ошибка инициализации:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Создание звукового графа
|
||
*/
|
||
private createSoundGraph(): void {
|
||
if (!this.audioContext) return;
|
||
|
||
// Останавливаем старый звук
|
||
this.stopSound();
|
||
|
||
// Создаём осциллятор
|
||
this.oscillator = this.audioContext.createOscillator();
|
||
this.oscillator.type = 'sine';
|
||
this.oscillator.frequency.value = this.FREQUENCY;
|
||
|
||
// Создаём усилитель
|
||
this.gainNode = this.audioContext.createGain();
|
||
this.gainNode.gain.value = 0; // Начинаем с тишины
|
||
|
||
// Подключаем: осциллятор → усилитель → выход
|
||
this.oscillator.connect(this.gainNode);
|
||
this.gainNode.connect(this.audioContext.destination);
|
||
|
||
// Запускаем осциллятор
|
||
this.oscillator.start();
|
||
|
||
this.isPlaying = true;
|
||
console.log('[Audio] Звук создан, частота:', this.FREQUENCY, 'Гц');
|
||
}
|
||
|
||
/**
|
||
* Обновление уровня сигнала
|
||
* @param level - уровень сигнала (0-3)
|
||
*/
|
||
public updateLevel(level: number): void {
|
||
// Инициализация при первом вызове
|
||
if (!this.audioContext) {
|
||
if (!this.initAudio()) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Если AudioContext не готов, пробуем восстановить
|
||
if (this.audioContext?.state !== 'running') {
|
||
this.audioContext?.resume();
|
||
return;
|
||
}
|
||
|
||
// Приводим уровень к числу
|
||
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
|
||
|
||
// Если уровень не изменился — ничего не делаем
|
||
if (newLevel === this.currentLevel) {
|
||
return;
|
||
}
|
||
|
||
this.currentLevel = newLevel;
|
||
|
||
// Если уровень > 0 и звук не создан — создаём
|
||
if (newLevel > 0 && !this.oscillator) {
|
||
this.createSoundGraph();
|
||
}
|
||
|
||
// Если уровень 0 — выключаем звук
|
||
if (newLevel === 0) {
|
||
this.fadeOut();
|
||
return;
|
||
}
|
||
|
||
// Если звук не играет — включаем
|
||
if (!this.isPlaying || !this.oscillator) {
|
||
this.createSoundGraph();
|
||
}
|
||
|
||
// Расчёт громкости: уровень 1 = 15%, 2 = 30%, 3 = 50%
|
||
const volumeMap: Record<number, number> = {
|
||
1: 0.10,
|
||
2: 0.25,
|
||
3: 0.45
|
||
};
|
||
|
||
const targetVolume = volumeMap[newLevel] || 0;
|
||
|
||
// Плавно меняем громкость
|
||
if (this.gainNode) {
|
||
const currentGain = this.gainNode.gain.value;
|
||
// Если звук выключен или очень тихий, включаем сразу
|
||
if (currentGain < 0.01) {
|
||
this.gainNode.gain.setValueAtTime(targetVolume, this.audioContext!.currentTime);
|
||
} else {
|
||
// Плавное изменение за 50 мс
|
||
this.gainNode.gain.exponentialRampToValueAtTime(
|
||
Math.max(targetVolume, 0.001),
|
||
this.audioContext!.currentTime + this.FADE_TIME
|
||
);
|
||
}
|
||
}
|
||
|
||
console.log(`[Audio] Уровень: ${newLevel}, Громкость: ${(targetVolume * 100).toFixed(0)}%`);
|
||
}
|
||
|
||
/**
|
||
* Плавное затухание и остановка
|
||
*/
|
||
private fadeOut(): void {
|
||
if (!this.gainNode || !this.audioContext) return;
|
||
|
||
// Отменяем старый таймер
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
this.fadeTimeout = null;
|
||
}
|
||
|
||
// Плавно убавляем громкость за 100 мс
|
||
const currentTime = this.audioContext.currentTime;
|
||
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
|
||
|
||
// Останавливаем звук после затухания
|
||
this.fadeTimeout = window.setTimeout(() => {
|
||
this.stopSound();
|
||
this.fadeTimeout = null;
|
||
}, this.FADE_TIME * 2 * 1000 + 50);
|
||
|
||
console.log('[Audio] Затухание...');
|
||
}
|
||
|
||
/**
|
||
* Немедленная остановка звука
|
||
*/
|
||
private stopSound(): void {
|
||
if (this.oscillator) {
|
||
try {
|
||
this.oscillator.stop();
|
||
this.oscillator.disconnect();
|
||
} catch (e) {
|
||
// Игнорируем ошибки при остановке
|
||
}
|
||
this.oscillator = null;
|
||
}
|
||
|
||
if (this.gainNode) {
|
||
try {
|
||
this.gainNode.disconnect();
|
||
} catch (e) {
|
||
// Игнорируем ошибки
|
||
}
|
||
this.gainNode = null;
|
||
}
|
||
|
||
this.isPlaying = false;
|
||
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
this.fadeTimeout = null;
|
||
}
|
||
|
||
console.log('[Audio] Звук остановлен');
|
||
}
|
||
|
||
/**
|
||
* Проверка, активен ли звук
|
||
*/
|
||
public isActive(): boolean {
|
||
return this.isPlaying && this.currentLevel > 0;
|
||
}
|
||
|
||
/**
|
||
* Полная остановка и освобождение ресурсов
|
||
*/
|
||
public dispose(): void {
|
||
this.stopSound();
|
||
if (this.audioContext) {
|
||
try {
|
||
this.audioContext.close();
|
||
} catch (e) {
|
||
// Игнорируем
|
||
}
|
||
this.audioContext = null;
|
||
}
|
||
this.currentLevel = 0;
|
||
console.log('[Audio] Ресурсы освобождены');
|
||
}
|
||
}
|
||
|
||
// Экспортируем одиночный экземпляр
|
||
export const audioEngine = new AudioEngine(); |