доработка звука при обнаружении амплитуды сигнала
This commit is contained in:
@@ -419,6 +419,53 @@ h1 {
|
||||
inset 0 0 10px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
/* ===== AUDIO INDICATOR ===== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
#audio-indicator {
|
||||
transition: all 0.3s ease;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid #333;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#audio-indicator.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== ГОРЯЧИЕ КЛАВИШИ (подсказка) ===== */
|
||||
.hotkey-hint {
|
||||
font-size: 11px;
|
||||
opacity: 0.4;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.hotkey-hint kbd {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ===== MOBILE ===== */
|
||||
@media (max-width: 500px) {
|
||||
.header-bar {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<a href="/logs.html" style="color:#00ff88; text-decoration:none; font-size:14px; border:1px solid #00ff88; padding:6px 14px; border-radius:6px;">📋 Журнал</a>
|
||||
<div id="server-time" class="header-time">--:--:--</div>
|
||||
<div id="audio-indicator" style="font-size:18px; padding:4px 12px; border-radius:6px; background:rgba(0,0,0,0.4); border:1px solid #333; min-width:50px; text-align:center;">🔇</div>
|
||||
<button id="camToggle" class="cam-btn">📷 КАМЕРА</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,18 +18,21 @@ import {
|
||||
setMeta,
|
||||
setBars,
|
||||
setSignal,
|
||||
setAudioIndicator,
|
||||
DOM
|
||||
} from "./ui.js";
|
||||
import { initAccordion } from "./accordion.js";
|
||||
import { drawChart } from "./chart.js";
|
||||
import { initResize } from "./layout.js";
|
||||
import { initCamera } from "./camera.js";
|
||||
import { audioEngine } from "./audio.js";
|
||||
|
||||
let lastChart: number = 0;
|
||||
let connectionLost: boolean = false;
|
||||
let consecutiveErrors: number = 0;
|
||||
const MAX_ERRORS: number = 3;
|
||||
let pollTimer: number | null = null;
|
||||
let lastSignalLevel: number = 0;
|
||||
|
||||
// ================= INIT =================
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -38,10 +41,25 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||
initResize();
|
||||
initCamera();
|
||||
|
||||
// Инициализация звука при первом клике
|
||||
document.addEventListener('click', () => {
|
||||
audioEngine.updateLevel(0);
|
||||
}, { once: true });
|
||||
|
||||
setPeakHolderListener(() => {
|
||||
if (!connectionLost) {
|
||||
setBars(state.peakValue);
|
||||
setSignal(state.peakSignalLevel);
|
||||
|
||||
const signalLevel = state.peakSignalLevel;
|
||||
if (signalLevel !== lastSignalLevel) {
|
||||
lastSignalLevel = signalLevel;
|
||||
audioEngine.updateLevel(signalLevel);
|
||||
|
||||
// Обновляем индикатор звука
|
||||
const isActive = signalLevel > 0;
|
||||
setAudioIndicator(isActive, signalLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -70,6 +88,27 @@ document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
rightPanel.style.flex = `0 0 ${total - leftWidth}px`;
|
||||
}
|
||||
break;
|
||||
case 'm':
|
||||
// Toggle звука
|
||||
if (lastSignalLevel > 0) {
|
||||
// Временно отключаем
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
console.log('[Audio] Звук отключён (M)');
|
||||
// Сохраняем уровень для восстановления
|
||||
window._mutedLevel = lastSignalLevel;
|
||||
lastSignalLevel = 0;
|
||||
} else {
|
||||
// Восстанавливаем уровень
|
||||
const level = window._mutedLevel || state.peakSignalLevel;
|
||||
if (level > 0) {
|
||||
audioEngine.updateLevel(level);
|
||||
lastSignalLevel = level;
|
||||
setAudioIndicator(true, level);
|
||||
console.log('[Audio] Звук включён (M)');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,6 +139,11 @@ async function update(): Promise<void> {
|
||||
setStatus("Нет связи", true);
|
||||
if (!connectionLost) resetBufferTracking();
|
||||
connectionLost = true;
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
const arr = Array.isArray(history.bytes) ? history.bytes : [];
|
||||
@@ -120,9 +164,21 @@ async function update(): Promise<void> {
|
||||
|
||||
const displaySignalLevel = updateSignalPeakHolder(level);
|
||||
setSignal(displaySignalLevel);
|
||||
|
||||
const currentSignalLevel = state.peakSignalLevel;
|
||||
if (currentSignalLevel !== lastSignalLevel) {
|
||||
lastSignalLevel = currentSignalLevel;
|
||||
audioEngine.updateLevel(currentSignalLevel);
|
||||
setAudioIndicator(currentSignalLevel > 0, currentSignalLevel);
|
||||
}
|
||||
} else {
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (DOM.lastByte) {
|
||||
@@ -157,6 +213,12 @@ async function update(): Promise<void> {
|
||||
setSignal(0, true);
|
||||
resetBufferTracking();
|
||||
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
|
||||
if (DOM.lastByte) DOM.lastByte.textContent = "--";
|
||||
|
||||
if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
|
||||
@@ -181,5 +243,15 @@ window.addEventListener("focus", () => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
audioEngine.dispose();
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_mutedLevel?: number;
|
||||
}
|
||||
}
|
||||
|
||||
startPolling();
|
||||
update();
|
||||
221
cmd/server/web/js/audio.ts
Normal file
221
cmd/server/web/js/audio.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// 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();
|
||||
@@ -11,6 +11,7 @@ export interface DOMElements {
|
||||
barFill: HTMLElement | null;
|
||||
historyHeader: HTMLElement | null;
|
||||
historyBody: HTMLElement | null;
|
||||
audioIndicator: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const DOM: DOMElements = {
|
||||
@@ -25,6 +26,7 @@ export const DOM: DOMElements = {
|
||||
barFill: null,
|
||||
historyHeader: null,
|
||||
historyBody: null,
|
||||
audioIndicator: null,
|
||||
};
|
||||
|
||||
export function initDOM(): void {
|
||||
@@ -39,6 +41,7 @@ export function initDOM(): void {
|
||||
DOM.historyHeader = document.getElementById("history-header");
|
||||
DOM.historyBody = document.getElementById("history-body");
|
||||
DOM.bars = document.querySelectorAll(".sig-bar");
|
||||
DOM.audioIndicator = document.getElementById("audio-indicator");
|
||||
}
|
||||
|
||||
export function setServerTime(v: string): void {
|
||||
@@ -107,3 +110,22 @@ export function setSignal(level: number, noConnection: boolean = false): void {
|
||||
bars[2].classList.remove("dim");
|
||||
}
|
||||
}
|
||||
|
||||
// ФУНКЦИЮ ДЛЯ ИНДИКАТОРА ЗВУКА
|
||||
export function setAudioIndicator(active: boolean, level: number = 0): void {
|
||||
const el = DOM.audioIndicator;
|
||||
if (!el) return;
|
||||
|
||||
if (active && level > 0) {
|
||||
el.textContent = `🔊 ${level}`;
|
||||
el.style.color = '#00ff88';
|
||||
el.style.opacity = '1';
|
||||
// Анимация пульсации
|
||||
el.style.animation = 'pulse 1s ease-in-out infinite';
|
||||
} else {
|
||||
el.textContent = '🔇';
|
||||
el.style.color = '#666';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.animation = 'none';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user