From 5938d94a8f185aebe0f9a39178fdaca02c0118ad Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 17 Jun 2026 11:19:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B2=D1=83=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=B0=D1=80=D1=83=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B0=D0=BC=D0=BF=D0=BB=D0=B8=D1=82=D1=83=D0=B4?= =?UTF-8?q?=D1=8B=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/web/css/style.css | 47 ++++++++ cmd/server/web/dashboard.html | 1 + cmd/server/web/js/app.ts | 72 +++++++++++ cmd/server/web/js/audio.ts | 221 ++++++++++++++++++++++++++++++++++ cmd/server/web/js/ui.ts | 22 ++++ 5 files changed, 363 insertions(+) create mode 100644 cmd/server/web/js/audio.ts 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