325 lines
11 KiB
TypeScript
325 lines
11 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 currentFrequency: number = 800;
|
||
private fadeTimeout: number | null = null;
|
||
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
|
||
private readonly FREQUENCY_MAP: Record<number, number> = {
|
||
1: 800, // Уровень 1 → 800 Гц
|
||
2: 1000, // Уровень 2 → 1000 Гц
|
||
3: 1200 // Уровень 3 → 1200 Гц
|
||
};
|
||
|
||
constructor() {
|
||
// Инициализация при первом использовании
|
||
}
|
||
|
||
/**
|
||
* Инициализация AudioContext (требует взаимодействия пользователя)
|
||
*/
|
||
private async initAudio(): Promise<boolean> {
|
||
try {
|
||
if (!this.audioContext) {
|
||
const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||
this.audioContext = new AudioContextClass();
|
||
}
|
||
|
||
const context = this.audioContext;
|
||
if (!context) return false;
|
||
|
||
if (context.state === 'suspended') {
|
||
await context.resume();
|
||
}
|
||
|
||
return context.state === 'running';
|
||
} catch (error) {
|
||
console.warn('[Audio] Ошибка инициализации:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Получение частоты для уровня
|
||
*/
|
||
private getFrequencyForLevel(level: number): number {
|
||
return this.FREQUENCY_MAP[level] || 800;
|
||
}
|
||
|
||
/**
|
||
* Обновление частоты осциллятора
|
||
*/
|
||
private updateFrequency(frequency: number): void {
|
||
if (!this.oscillator) return;
|
||
|
||
try {
|
||
// Плавное изменение частоты за 50 мс
|
||
const currentTime = this.audioContext?.currentTime || 0;
|
||
this.oscillator.frequency.exponentialRampToValueAtTime(
|
||
frequency,
|
||
currentTime + this.FADE_TIME
|
||
);
|
||
this.currentFrequency = frequency;
|
||
console.log(`[Audio] Частота: ${frequency} Гц`);
|
||
} catch (error) {
|
||
// Если не удалось плавно, устанавливаем сразу
|
||
this.oscillator.frequency.value = frequency;
|
||
this.currentFrequency = frequency;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Создание звукового графа
|
||
*/
|
||
private createSoundGraph(level: number): void {
|
||
if (!this.audioContext) return;
|
||
|
||
// Останавливаем старый звук
|
||
this.stopSound();
|
||
|
||
const frequency = this.getFrequencyForLevel(level);
|
||
this.currentFrequency = frequency;
|
||
|
||
// Создаём осциллятор
|
||
this.oscillator = this.audioContext.createOscillator();
|
||
this.oscillator.type = 'sine';
|
||
this.oscillator.frequency.value = 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] Звук создан, частота: ${frequency} Гц, уровень: ${level}`);
|
||
}
|
||
|
||
/**
|
||
* Обновление уровня сигнала
|
||
* @param level - уровень сигнала (0-3)
|
||
*/
|
||
public async updateLevel(level: number): Promise<void> {
|
||
// Инициализация при первом вызове
|
||
if (!this.audioContext) {
|
||
const initialized = await this.initAudio();
|
||
if (!initialized) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const context = this.audioContext;
|
||
if (!context) return;
|
||
|
||
// Если AudioContext не готов, пробуем восстановить
|
||
if (context.state !== 'running') {
|
||
try {
|
||
await context.resume();
|
||
} catch (error) {
|
||
console.warn('[Audio] Ошибка возобновления контекста:', error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Приводим уровень к числу
|
||
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
|
||
|
||
// Если уровень не изменился — ничего не делаем
|
||
if (newLevel === this.currentLevel) {
|
||
return;
|
||
}
|
||
|
||
const oldLevel = this.currentLevel;
|
||
this.currentLevel = newLevel;
|
||
|
||
// Получаем частоту для нового уровня
|
||
const newFrequency = this.getFrequencyForLevel(newLevel);
|
||
|
||
// Если уровень > 0 и звук не создан — создаём
|
||
if (newLevel > 0 && !this.oscillator) {
|
||
this.createSoundGraph(newLevel);
|
||
// Устанавливаем громкость после создания
|
||
this.setVolume(newLevel);
|
||
return;
|
||
}
|
||
|
||
// Если уровень 0 — выключаем звук
|
||
if (newLevel === 0) {
|
||
this.fadeOut();
|
||
return;
|
||
}
|
||
|
||
// Если звук не играет — включаем заново
|
||
if (!this.isPlaying || !this.oscillator) {
|
||
this.createSoundGraph(newLevel);
|
||
this.setVolume(newLevel);
|
||
return;
|
||
}
|
||
|
||
// Изменяем частоту, если она изменилась
|
||
if (newFrequency !== this.currentFrequency) {
|
||
this.updateFrequency(newFrequency);
|
||
}
|
||
|
||
// Обновляем громкость
|
||
this.setVolume(newLevel);
|
||
|
||
console.log(`[Audio] Уровень: ${oldLevel} → ${newLevel}, Частота: ${newFrequency} Гц`);
|
||
}
|
||
|
||
/**
|
||
* Установка громкости для уровня
|
||
*/
|
||
private setVolume(level: number): void {
|
||
if (!this.gainNode || !this.audioContext) return;
|
||
|
||
// Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45%
|
||
const volumeMap: Record<number, number> = {
|
||
1: 0.10,
|
||
2: 0.25,
|
||
3: 0.45
|
||
};
|
||
|
||
const targetVolume = volumeMap[level] || 0;
|
||
|
||
try {
|
||
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
|
||
);
|
||
}
|
||
} catch (error) {
|
||
// fallback
|
||
this.gainNode.gain.value = targetVolume;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Плавное затухание и остановка
|
||
*/
|
||
private fadeOut(): void {
|
||
if (!this.gainNode || !this.audioContext) return;
|
||
|
||
// Отменяем старый таймер
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
this.fadeTimeout = null;
|
||
}
|
||
|
||
try {
|
||
// Плавно убавляем громкость за 100 мс
|
||
const currentTime = this.audioContext.currentTime;
|
||
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
|
||
} catch (error) {
|
||
// fallback
|
||
this.gainNode.gain.value = 0;
|
||
}
|
||
|
||
// Останавливаем звук после затухания
|
||
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] Звук остановлен');
|
||
}
|
||
|
||
// ============================================
|
||
// ПУБЛИЧНЫЕ МЕТОДЫ
|
||
// ============================================
|
||
|
||
/**
|
||
* Проверка, активен ли звук
|
||
*/
|
||
// noinspection JSUnusedGlobalSymbols
|
||
public isActive(): boolean {
|
||
return this.isPlaying && this.currentLevel > 0;
|
||
}
|
||
|
||
/**
|
||
* Получение текущей частоты
|
||
*/
|
||
// noinspection JSUnusedGlobalSymbols
|
||
public getCurrentFrequency(): number {
|
||
return this.currentFrequency;
|
||
}
|
||
|
||
/**
|
||
* Получение текущего уровня
|
||
*/
|
||
// noinspection JSUnusedGlobalSymbols
|
||
public getCurrentLevel(): number {
|
||
return this.currentLevel;
|
||
}
|
||
|
||
/**
|
||
* Полная остановка и освобождение ресурсов
|
||
*/
|
||
// noinspection JSUnusedGlobalSymbols
|
||
public async dispose(): Promise<void> {
|
||
this.stopSound();
|
||
if (this.audioContext) {
|
||
try {
|
||
await this.audioContext.close();
|
||
} catch (e) {
|
||
// Игнорируем
|
||
}
|
||
this.audioContext = null;
|
||
}
|
||
this.currentLevel = 0;
|
||
this.currentFrequency = 800;
|
||
console.log('[Audio] Ресурсы освобождены');
|
||
}
|
||
}
|
||
|
||
|
||
// noinspection JSUnusedGlobalSymbols
|
||
export const audioEngine = new AudioEngine(); |