diff --git a/cmd/server/web/css/style.css b/cmd/server/web/css/style.css
index c75c9f7..2b3d72a 100644
--- a/cmd/server/web/css/style.css
+++ b/cmd/server/web/css/style.css
@@ -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 {
diff --git a/cmd/server/web/dashboard.html b/cmd/server/web/dashboard.html
index 86381e2..c33b563 100644
--- a/cmd/server/web/dashboard.html
+++ b/cmd/server/web/dashboard.html
@@ -16,6 +16,7 @@
diff --git a/cmd/server/web/js/app.ts b/cmd/server/web/js/app.ts
index 3bb5bec..b9955a1 100644
--- a/cmd/server/web/js/app.ts
+++ b/cmd/server/web/js/app.ts
@@ -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 {
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 {
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 {
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();
\ No newline at end of file
diff --git a/cmd/server/web/js/audio.ts b/cmd/server/web/js/audio.ts
new file mode 100644
index 0000000..2c07fe7
--- /dev/null
+++ b/cmd/server/web/js/audio.ts
@@ -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 = {
+ 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();
\ No newline at end of file
diff --git a/cmd/server/web/js/ui.ts b/cmd/server/web/js/ui.ts
index 6b9a368..e0616da 100644
--- a/cmd/server/web/js/ui.ts
+++ b/cmd/server/web/js/ui.ts
@@ -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';
+ }
+}
\ No newline at end of file