Files
go-service/cmd/server/web/js/audio.ts
2026-06-17 15:25:46 +03:00

325 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();