307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
// 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(); |