доработка звука при обнаружении амплитуды сигнала

This commit is contained in:
Maxim
2026-06-17 11:19:53 +03:00
parent 4180353b65
commit 5938d94a8f
5 changed files with 363 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
View 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();

View File

@@ -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';
}
}