diff --git a/cmd/server/web/css/audio.css b/cmd/server/web/css/audio.css
index e3730c9..be1cc24 100644
--- a/cmd/server/web/css/audio.css
+++ b/cmd/server/web/css/audio.css
@@ -1,4 +1,4 @@
-/* Стили аудио-индикатора */
+/* web/css/audio.css */
@keyframes pulse {
0%, 100% {
opacity: 1;
@@ -10,6 +10,7 @@
}
}
+/* ===== АУДИО ИНДИКАТОР ===== */
#audio-indicator {
font-family: var(--font-tech);
font-size: var(--font-size-lg);
@@ -17,14 +18,231 @@
border-radius: 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #333;
- min-width: 50px;
+ min-width: 60px; /* Фиксированная минимальная ширина */
+ width: 60px; /* Фиксированная ширина */
text-align: center;
user-select: none;
transition: all 0.3s ease;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
}
#audio-indicator.active {
border-color: #00ff88;
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
animation: pulse 1s ease-in-out infinite;
+}
+
+#audio-indicator.muted {
+ color: #666 !important;
+ border-color: #333;
+ animation: none !important;
+ box-shadow: none !important;
+}
+
+/* ===== ОБЕРТКА ДЛЯ АУДИО ===== */
+.audio-wrapper {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+/* ===== РЕГУЛЯТОР ГРОМКОСТИ ===== */
+.volume-control {
+ position: absolute;
+ top: calc(100% + 2px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(10, 20, 15, 0.95);
+ border: 1px solid rgba(0, 255, 136, 0.25);
+ border-radius: 8px;
+ padding: 10px 16px;
+ display: none;
+ align-items: center;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
+ z-index: 1000;
+ animation: volumeFadeIn 0.15s ease;
+ min-width: 100px;
+}
+
+.audio-wrapper:hover .volume-control {
+ display: flex;
+}
+
+.volume-control:hover {
+ display: flex !important;
+}
+
+@keyframes volumeFadeIn {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-4px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
+/* ===== ОБЕРТКА ДЛЯ ПОЛЗУНКА ===== */
+.volume-slider-wrapper {
+ position: relative;
+ width: 80px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+}
+
+/* ===== ТРЕК (фон) ===== */
+.volume-track {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 4px;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 2px;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+/* ===== ЗАЛИВКА ТРЕКА ===== */
+.volume-track-fill {
+ height: 100%;
+ width: 0%;
+ background: #00ff88;
+ border-radius: 2px;
+ transition: width 0.05s linear;
+ box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
+}
+
+/* ===== ПОЛЗУНОК ===== */
+.volume-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ position: relative;
+ width: 100%;
+ height: 20px;
+ background: transparent;
+ outline: none;
+ cursor: pointer;
+ z-index: 2;
+ margin: 0;
+ padding: 0;
+}
+
+.volume-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ background: #00ff88;
+ border-radius: 50%;
+ cursor: pointer;
+ box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
+ transition: all 0.15s ease;
+ border: 2px solid rgba(0, 255, 136, 0.3);
+ margin-top: -5px;
+}
+
+.volume-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
+ border-color: #00ff88;
+}
+
+.volume-slider::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 4px;
+ background: transparent;
+ border: none;
+ border-radius: 2px;
+}
+
+.volume-slider::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ background: #00ff88;
+ border: 2px solid rgba(0, 255, 136, 0.3);
+ border-radius: 50%;
+ cursor: pointer;
+ box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
+}
+
+.volume-slider::-moz-range-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
+ border-color: #00ff88;
+}
+
+.volume-slider::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ background: transparent;
+ border: none;
+ border-radius: 2px;
+}
+
+/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
+@media (max-width: 768px) {
+ .volume-control {
+ position: fixed;
+ bottom: 80px;
+ left: 50%;
+ transform: translateX(-50%);
+ top: auto;
+ padding: 12px 20px;
+ border-radius: 10px;
+ background: rgba(10, 20, 15, 0.98);
+ border: 1px solid rgba(0, 255, 136, 0.3);
+ display: none !important;
+ min-width: 140px;
+ }
+
+ .audio-wrapper .volume-control.visible {
+ display: flex !important;
+ animation: volumeFadeInMobile 0.2s ease;
+ }
+
+ @keyframes volumeFadeInMobile {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(10px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+ }
+
+ .volume-slider-wrapper {
+ width: 120px;
+ }
+}
+
+@media (max-width: 480px) {
+ .volume-control {
+ padding: 10px 16px;
+ bottom: 70px;
+ min-width: 120px;
+ }
+
+ .volume-slider-wrapper {
+ width: 100px;
+ }
+}
+
+@media (hover: none) {
+ .audio-wrapper:hover .volume-control {
+ display: none !important;
+ }
+}
+
+.audio-wrapper.touch-active .volume-control {
+ display: flex !important;
}
\ No newline at end of file
diff --git a/cmd/server/web/dashboard.html b/cmd/server/web/dashboard.html
index 2b9e427..08ab7b9 100644
--- a/cmd/server/web/dashboard.html
+++ b/cmd/server/web/dashboard.html
@@ -25,11 +25,25 @@
G-Port Warden
+
diff --git a/cmd/server/web/js/app.ts b/cmd/server/web/js/app.ts
index 664be08..5758a50 100644
--- a/cmd/server/web/js/app.ts
+++ b/cmd/server/web/js/app.ts
@@ -1,4 +1,4 @@
-// app.ts - Оркестратор приложения
+// web/src/app.ts - Оркестратор приложения
import {
fetchHealth,
@@ -38,12 +38,111 @@ const MAX_ERRORS: number = 3;
let pollTimer: number | null = null;
let lastSignalLevel: number = 0;
+// ===== ИНИЦИАЛИЗАЦИЯ РЕГУЛЯТОРА ГРОМКОСТИ =====
+function initVolumeControl(): void {
+ const slider = document.getElementById('volumeSlider') as HTMLInputElement | null;
+ const trackFill = document.getElementById('volumeTrackFill') as HTMLElement | null;
+ const indicator = document.getElementById('audio-indicator');
+ const wrapper = document.getElementById('audioWrapper');
+
+ if (!slider || !trackFill || !indicator) return;
+
+ // Функция обновления заливки
+ function updateTrackFill(volume: number): void {
+ if (trackFill) {
+ trackFill.style.width = `${volume}%`;
+ }
+ }
+
+ // Функция обновления индикатора в зависимости от громкости и уровня сигнала
+ function updateIndicator(volume: number, signalLevel: number): void {
+ if (!indicator) return;
+
+ if (volume === 0) {
+ indicator.textContent = '🔇';
+ indicator.classList.add('muted');
+ indicator.classList.remove('active');
+ } else if (signalLevel > 0) {
+ indicator.textContent = '🔊';
+ indicator.classList.remove('muted');
+ indicator.classList.add('active');
+ } else {
+ indicator.textContent = '🔇';
+ indicator.classList.add('muted');
+ indicator.classList.remove('active');
+ }
+ }
+
+ // Устанавливаем начальное состояние (звук выключен)
+ audioEngine.setVolume(0);
+ updateTrackFill(0);
+ lastSignalLevel = 0;
+ updateIndicator(0, 0);
+
+ // Обработчик изменения громкости
+ slider.addEventListener('input', () => {
+ const volume = parseInt(slider.value);
+
+ updateTrackFill(volume);
+ audioEngine.setVolume(volume);
+
+ if (volume === 0) {
+ // Громкость 0 - звук выключен
+ updateIndicator(0, 0);
+ if (lastSignalLevel > 0) {
+ audioEngine.updateLevel(0);
+ }
+ } else {
+ // Громкость > 0 - показываем состояние сигнала
+ updateIndicator(volume, lastSignalLevel);
+ if (lastSignalLevel > 0) {
+ audioEngine.updateLevel(lastSignalLevel);
+ }
+ }
+ });
+
+ // Для тач-устройств
+ if (wrapper) {
+ wrapper.addEventListener('click', (e) => {
+ const control = document.getElementById('volumeControl');
+ if (control && window.innerWidth <= 768) {
+ control.classList.toggle('visible');
+ e.stopPropagation();
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ const control = document.getElementById('volumeControl');
+ if (control && window.innerWidth <= 768) {
+ if (!wrapper.contains(e.target as Node)) {
+ control.classList.remove('visible');
+ }
+ }
+ });
+ }
+
+ // Сохраняем настройки
+ const savedVolume = localStorage.getItem('gport_volume');
+ if (savedVolume) {
+ const vol = parseInt(savedVolume);
+ if (vol >= 0 && vol <= 100) {
+ slider.value = String(vol);
+ slider.dispatchEvent(new Event('input'));
+ }
+ }
+
+ slider.addEventListener('change', () => {
+ localStorage.setItem('gport_volume', slider.value);
+ });
+}
+
// ================= INIT =================
window.addEventListener("DOMContentLoaded", () => {
initDOM();
initAccordion();
initResize();
initCamera();
+ initVolumeControl();
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
@@ -55,10 +154,32 @@ window.addEventListener("DOMContentLoaded", () => {
setSignal(state.peakSignalLevel);
const signalLevel = state.peakSignalLevel;
+ const currentVolume = audioEngine.getVolume();
+
if (signalLevel !== lastSignalLevel) {
lastSignalLevel = signalLevel;
- audioEngine.updateLevel(signalLevel);
- setAudioIndicator(signalLevel > 0, signalLevel);
+
+ // Обновляем индикатор в зависимости от громкости и уровня сигнала
+ const indicator = document.getElementById('audio-indicator');
+ if (indicator) {
+ if (currentVolume > 0 && signalLevel > 0) {
+ indicator.textContent = '🔊';
+ indicator.classList.remove('muted');
+ indicator.classList.add('active');
+ audioEngine.updateLevel(signalLevel);
+ } else if (currentVolume > 0 && signalLevel === 0) {
+ indicator.textContent = '🔇';
+ indicator.classList.add('muted');
+ indicator.classList.remove('active');
+ audioEngine.updateLevel(0);
+ } else {
+ indicator.textContent = '🔇';
+ indicator.classList.add('muted');
+ indicator.classList.remove('active');
+ audioEngine.updateLevel(0);
+ }
+ }
+ setAudioIndicator(signalLevel > 0 && currentVolume > 0, signalLevel);
}
}
});
@@ -102,14 +223,14 @@ document.addEventListener("keydown", (e: KeyboardEvent) => {
}
break;
case 'm':
- if (lastSignalLevel > 0) {
+ if (lastSignalLevel > 0 && audioEngine.getVolume() > 0) {
audioEngine.updateLevel(0);
setAudioIndicator(false);
window._mutedLevel = lastSignalLevel;
lastSignalLevel = 0;
} else {
const level = window._mutedLevel || state.peakSignalLevel;
- if (level > 0) {
+ if (level > 0 && audioEngine.getVolume() > 0) {
audioEngine.updateLevel(level);
lastSignalLevel = level;
setAudioIndicator(true, level);
@@ -239,12 +360,10 @@ async function update(): Promise {
if (arr.length === 0) {
DOM.history.innerHTML = 'Нет данных';
} else {
- // Разворачиваем массив для отображения новых сверху
const reversed = [...arr].reverse();
DOM.history.innerHTML = reversed
.map((b, i) => {
const originalIndex = arr.length - 1 - i;
- // Форматируем: индекс, бинарное представление, десятичное значение
const binary = (b & 0xFF).toString(2).padStart(8, "0");
return `[${originalIndex}] ${binary} = ${b & 0xFF}`;
})
diff --git a/cmd/server/web/js/audio.ts b/cmd/server/web/js/audio.ts
index d1ace6f..45ef6d0 100644
--- a/cmd/server/web/js/audio.ts
+++ b/cmd/server/web/js/audio.ts
@@ -1,4 +1,4 @@
-// audio.ts - Звуковой движок для сигнала
+// web/src/audio.ts - Звуковой движок для сигнала
export class AudioEngine {
private audioContext: AudioContext | null = null;
@@ -8,20 +8,73 @@ export class AudioEngine {
private currentLevel: number = 0;
private currentFrequency: number = 800;
private fadeTimeout: number | null = null;
- private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
+ private readonly FADE_TIME: number = 0.05;
private readonly FREQUENCY_MAP: Record = {
- 1: 800, // Уровень 1 → 800 Гц
+ 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() {
// Инициализация при первом использовании
}
/**
- * Инициализация AudioContext (требует взаимодействия пользователя)
+ * Установка громкости (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 = {
+ 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 {
try {
if (!this.audioContext) {
@@ -57,7 +110,6 @@ export class AudioEngine {
if (!this.oscillator) return;
try {
- // Плавное изменение частоты за 50 мс
const currentTime = this.audioContext?.currentTime || 0;
this.oscillator.frequency.exponentialRampToValueAtTime(
frequency,
@@ -65,7 +117,6 @@ export class AudioEngine {
);
this.currentFrequency = frequency;
} catch (error) {
- // Если не удалось плавно, устанавливаем сразу
this.oscillator.frequency.value = frequency;
this.currentFrequency = frequency;
}
@@ -77,29 +128,26 @@ export class AudioEngine {
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.gainNode.gain.value = 0;
- // Подключаем: осциллятор → усилитель → выход
this.oscillator.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
- // Запускаем осциллятор
this.oscillator.start();
-
this.isPlaying = true;
+
+ // Применяем текущую громкость
+ this.updateVolume();
}
/**
@@ -107,18 +155,14 @@ export class AudioEngine {
* @param level - уровень сигнала (0-3)
*/
public async updateLevel(level: number): Promise {
- // Инициализация при первом вызове
if (!this.audioContext) {
const initialized = await this.initAudio();
- if (!initialized) {
- return;
- }
+ if (!initialized) return;
}
const context = this.audioContext;
if (!context) return;
- // Если AudioContext не готов, пробуем восстановить
if (context.state !== 'running') {
try {
await context.resume();
@@ -128,80 +172,42 @@ export class AudioEngine {
}
}
- // Приводим уровень к числу
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
- // Если уровень не изменился — ничего не делаем
if (newLevel === this.currentLevel) {
return;
}
this.currentLevel = newLevel;
- // Получаем частоту для нового уровня
- const newFrequency = this.getFrequencyForLevel(newLevel);
-
- // Если уровень > 0 и звук не создан — создаём
- if (newLevel > 0 && !this.oscillator) {
- this.createSoundGraph(newLevel);
- // Устанавливаем громкость после создания
- this.setVolume(newLevel);
+ // Если громкость 0 - ничего не включаем
+ if (this.volume === 0) {
+ this.stopSound();
+ return;
+ }
+
+ const newFrequency = this.getFrequencyForLevel(newLevel);
+
+ if (newLevel > 0 && !this.oscillator) {
+ this.createSoundGraph(newLevel);
return;
}
- // Если уровень 0 — выключаем звук
if (newLevel === 0) {
this.fadeOut();
return;
}
- // Если звук не играет — включаем заново
if (!this.isPlaying || !this.oscillator) {
this.createSoundGraph(newLevel);
- this.setVolume(newLevel);
return;
}
- // Изменяем частоту, если она изменилась
if (newFrequency !== this.currentFrequency) {
this.updateFrequency(newFrequency);
}
- // Обновляем громкость
- this.setVolume(newLevel);
- }
-
- /**
- * Установка громкости для уровня
- */
- private setVolume(level: number): void {
- if (!this.gainNode || !this.audioContext) return;
-
- // Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45%
- const volumeMap: Record = {
- 1: 0.10,
- 2: 0.25,
- 3: 0.45
- };
-
- const targetVolume = volumeMap[level] || 0;
-
- try {
- 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
- );
- }
- } catch (error) {
- // fallback
- this.gainNode.gain.value = targetVolume;
- }
+ this.updateVolume();
}
/**
@@ -210,22 +216,18 @@ export class AudioEngine {
private fadeOut(): void {
if (!this.gainNode || !this.audioContext) return;
- // Отменяем старый таймер
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
try {
- // Плавно убавляем громкость за 100 мс
const currentTime = this.audioContext.currentTime;
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
} catch (error) {
- // fallback
this.gainNode.gain.value = 0;
}
- // Останавливаем звук после затухания
this.fadeTimeout = window.setTimeout(() => {
this.stopSound();
this.fadeTimeout = null;
@@ -240,18 +242,14 @@ export class AudioEngine {
try {
this.oscillator.stop();
this.oscillator.disconnect();
- } catch (e) {
- // Игнорируем ошибки при остановке
- }
+ } catch (e) {}
this.oscillator = null;
}
if (this.gainNode) {
try {
this.gainNode.disconnect();
- } catch (e) {
- // Игнорируем ошибки
- }
+ } catch (e) {}
this.gainNode = null;
}
@@ -270,15 +268,13 @@ export class AudioEngine {
/**
* Проверка, активен ли звук
*/
- // noinspection JSUnusedGlobalSymbols
public isActive(): boolean {
- return this.isPlaying && this.currentLevel > 0;
+ return this.isPlaying && this.currentLevel > 0 && this.volume > 0;
}
/**
* Получение текущей частоты
*/
- // noinspection JSUnusedGlobalSymbols
public getCurrentFrequency(): number {
return this.currentFrequency;
}
@@ -286,7 +282,6 @@ export class AudioEngine {
/**
* Получение текущего уровня
*/
- // noinspection JSUnusedGlobalSymbols
public getCurrentLevel(): number {
return this.currentLevel;
}
@@ -294,23 +289,19 @@ export class AudioEngine {
/**
* Полная остановка и освобождение ресурсов
*/
- // noinspection JSUnusedGlobalSymbols
public async dispose(): Promise {
this.stopSound();
if (this.audioContext) {
try {
await this.audioContext.close();
- } catch (e) {
- // Игнорируем
- }
+ } catch (e) {}
this.audioContext = null;
}
this.currentLevel = 0;
this.currentFrequency = 800;
+ this.volume = 0;
console.log('[Audio] Ресурсы освобождены');
}
}
-
-// noinspection JSUnusedGlobalSymbols
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 ff5d57d..0ca5433 100644
--- a/cmd/server/web/js/ui.ts
+++ b/cmd/server/web/js/ui.ts
@@ -134,16 +134,24 @@ export function setAudioIndicator(active: boolean, level: number = 0): void {
const el = DOM.audioIndicator;
if (!el) return;
- if (active && level > 0) {
- el.textContent = `🔊 ${level}`;
+ // Проверяем громкость
+ const volume = localStorage.getItem('gport_volume');
+ const currentVolume = volume ? parseInt(volume) : 0;
+
+ if (active && level > 0 && currentVolume > 0) {
+ el.textContent = '🔊';
el.style.color = '#00ff88';
el.style.opacity = '1';
el.style.animation = 'pulse 1s ease-in-out infinite';
+ el.classList.remove('muted');
+ el.classList.add('active');
} else {
el.textContent = '🔇';
el.style.color = '#666';
el.style.opacity = '0.5';
el.style.animation = 'none';
+ el.classList.add('muted');
+ el.classList.remove('active');
}
}