Files
go-service/cmd/server/web/js/audio.ts
2026-06-23 10:10:14 +03:00

307 lines
8.9 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.

// web/src/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;
private readonly FREQUENCY_MAP: Record<number, number> = {
1: 600, // Уровень 1 → 600 Гц
2: 1000, // Уровень 2 → 1000 Гц
3: 1200 // Уровень 3 → 1200 Гц
};
// ===== НОВОЕ: громкость (0-100) =====
private volume: number = 0;
private maxVolume: number = 0.45; // 45% максимум
constructor() {
// Инициализация при первом использовании
}
/**
* Установка громкости (0-100)
*/
public setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(100, volume));
this.updateVolume();
}
/**
* Получение текущей громкости (0-100)
*/
public getVolume(): number {
return this.volume;
}
/**
* Применение громкости к усилителю
*/
private updateVolume(): void {
if (!this.gainNode) return;
const normalized = (this.volume / 100) * this.maxVolume;
const currentLevel = this.currentLevel;
// Если уровень 0 или громкость 0 - выключаем звук
if (currentLevel === 0 || this.volume === 0) {
this.gainNode.gain.value = 0;
return;
}
// Рассчитываем громкость на основе уровня и общей громкости
const volumeMap: Record<number, number> = {
1: 0.10 * (normalized / this.maxVolume),
2: 0.25 * (normalized / this.maxVolume),
3: 0.45 * (normalized / this.maxVolume)
};
const targetVolume = Math.min(volumeMap[currentLevel] || 0, this.maxVolume * (this.volume / 100));
try {
const currentTime = this.audioContext?.currentTime || 0;
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(targetVolume, 0.001),
currentTime + this.FADE_TIME
);
} catch (error) {
this.gainNode.gain.value = targetVolume;
}
}
// ===== ОСТАЛЬНЫЕ МЕТОДЫ =====
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 {
const currentTime = this.audioContext?.currentTime || 0;
this.oscillator.frequency.exponentialRampToValueAtTime(
frequency,
currentTime + this.FADE_TIME
);
this.currentFrequency = 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;
// Применяем текущую громкость
this.updateVolume();
}
/**
* Обновление уровня сигнала
* @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;
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;
}
this.currentLevel = newLevel;
// Если громкость 0 - ничего не включаем
if (this.volume === 0) {
this.stopSound();
return;
}
const newFrequency = this.getFrequencyForLevel(newLevel);
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
return;
}
if (newLevel === 0) {
this.fadeOut();
return;
}
if (!this.isPlaying || !this.oscillator) {
this.createSoundGraph(newLevel);
return;
}
if (newFrequency !== this.currentFrequency) {
this.updateFrequency(newFrequency);
}
this.updateVolume();
}
/**
* Плавное затухание и остановка
*/
private fadeOut(): void {
if (!this.gainNode || !this.audioContext) return;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
try {
const currentTime = this.audioContext.currentTime;
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
} catch (error) {
this.gainNode.gain.value = 0;
}
this.fadeTimeout = window.setTimeout(() => {
this.stopSound();
this.fadeTimeout = null;
}, this.FADE_TIME * 2 * 1000 + 50);
}
/**
* Немедленная остановка звука
*/
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;
}
}
// ============================================
// ПУБЛИЧНЫЕ МЕТОДЫ
// ============================================
/**
* Проверка, активен ли звук
*/
public isActive(): boolean {
return this.isPlaying && this.currentLevel > 0 && this.volume > 0;
}
/**
* Получение текущей частоты
*/
public getCurrentFrequency(): number {
return this.currentFrequency;
}
/**
* Получение текущего уровня
*/
public getCurrentLevel(): number {
return this.currentLevel;
}
/**
* Полная остановка и освобождение ресурсов
*/
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;
this.volume = 0;
console.log('[Audio] Ресурсы освобождены');
}
}
export const audioEngine = new AudioEngine();