diff --git a/cmd/server/web/js/audio.ts b/cmd/server/web/js/audio.ts index 40c6628..3b4a23c 100644 --- a/cmd/server/web/js/audio.ts +++ b/cmd/server/web/js/audio.ts @@ -22,17 +22,21 @@ export class AudioEngine { /** * Инициализация AudioContext (требует взаимодействия пользователя) */ - private initAudio(): boolean { + private async initAudio(): Promise { try { if (!this.audioContext) { - this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext; + this.audioContext = new AudioContextClass(); } - if (this.audioContext.state === 'suspended') { - this.audioContext.resume(); + const context = this.audioContext; + if (!context) return false; + + if (context.state === 'suspended') { + await context.resume(); } - return this.audioContext.state === 'running'; + return context.state === 'running'; } catch (error) { console.warn('[Audio] Ошибка инициализации:', error); return false; @@ -104,18 +108,26 @@ export class AudioEngine { * Обновление уровня сигнала * @param level - уровень сигнала (0-3) */ - public updateLevel(level: number): void { + public async updateLevel(level: number): Promise { // Инициализация при первом вызове if (!this.audioContext) { - if (!this.initAudio()) { + const initialized = await this.initAudio(); + if (!initialized) { return; } } + const context = this.audioContext; + if (!context) return; + // Если AudioContext не готов, пробуем восстановить - if (this.audioContext?.state !== 'running') { - this.audioContext?.resume(); - return; + if (context.state !== 'running') { + try { + await context.resume(); + } catch (error) { + console.warn('[Audio] Ошибка возобновления контекста:', error); + return; + } } // Приводим уровень к числу @@ -260,9 +272,14 @@ export class AudioEngine { console.log('[Audio] Звук остановлен'); } + // ============================================ + // ПУБЛИЧНЫЕ МЕТОДЫ + // ============================================ + /** * Проверка, активен ли звук */ + // noinspection JSUnusedGlobalSymbols public isActive(): boolean { return this.isPlaying && this.currentLevel > 0; } @@ -270,6 +287,7 @@ export class AudioEngine { /** * Получение текущей частоты */ + // noinspection JSUnusedGlobalSymbols public getCurrentFrequency(): number { return this.currentFrequency; } @@ -277,6 +295,7 @@ export class AudioEngine { /** * Получение текущего уровня */ + // noinspection JSUnusedGlobalSymbols public getCurrentLevel(): number { return this.currentLevel; } @@ -284,11 +303,12 @@ export class AudioEngine { /** * Полная остановка и освобождение ресурсов */ - public dispose(): void { + // noinspection JSUnusedGlobalSymbols + public async dispose(): Promise { this.stopSound(); if (this.audioContext) { try { - this.audioContext.close(); + await this.audioContext.close(); } catch (e) { // Игнорируем } @@ -300,5 +320,6 @@ export class AudioEngine { } } -// Экспортируем одиночный экземпляр + +// noinspection JSUnusedGlobalSymbols export const audioEngine = new AudioEngine(); \ No newline at end of file diff --git a/cmd/server/web/js/chart.ts b/cmd/server/web/js/chart.ts index a44a923..bf89c16 100644 --- a/cmd/server/web/js/chart.ts +++ b/cmd/server/web/js/chart.ts @@ -1,140 +1,138 @@ // chart.js export function drawChart(canvas: HTMLCanvasElement, history: number[]): void { - if (!canvas || !history?.length) return; + if (!canvas || !history?.length) return; - const rect = canvas.getBoundingClientRect(); - const displayHeight = rect.height || 200; - const displayWidth = rect.width || 800; + const rect = canvas.getBoundingClientRect(); + const displayHeight = rect.height || 200; + canvas.width = rect.width || 800; + canvas.height = displayHeight; - canvas.width = displayWidth; - canvas.height = displayHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const w = canvas.width; + const h = canvas.height; - const w = canvas.width; - const h = canvas.height; + const padLeft = 45; + const padRight = 15; + const padTop = 10; + const padBottom = 25; + const plotW = w - padLeft - padRight; + const plotH = h - padTop - padBottom; - const padLeft = 45; - const padRight = 15; - const padTop = 10; - const padBottom = 25; - const plotW = w - padLeft - padRight; - const plotH = h - padTop - padBottom; + // Фон + ctx.fillStyle = "#0a0f0a"; + ctx.fillRect(0, 0, w, h); - // Фон - ctx.fillStyle = "#0a0f0a"; - ctx.fillRect(0, 0, w, h); + // Сетка + const maxVal = 63; + const gridLines = 4; - // Сетка - const maxVal = 63; - const gridLines = 4; + ctx.strokeStyle = "#0d2a0d"; + ctx.lineWidth = 0.5; + ctx.setLineDash([4, 4]); - ctx.strokeStyle = "#0d2a0d"; - ctx.lineWidth = 0.5; - ctx.setLineDash([4, 4]); + for (let i = 0; i <= gridLines; i++) { + const val = (maxVal / gridLines) * i; + const y = padTop + plotH - (val / maxVal) * plotH; - for (let i = 0; i <= gridLines; i++) { - const val = (maxVal / gridLines) * i; - const y = padTop + plotH - (val / maxVal) * plotH; + ctx.beginPath(); + ctx.moveTo(padLeft, y); + ctx.lineTo(w - padRight, y); + ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(padLeft, y); - ctx.lineTo(w - padRight, y); - ctx.stroke(); - - ctx.fillStyle = "#00ff88"; - ctx.font = "11px monospace"; - ctx.textAlign = "right"; - ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4); - } - - ctx.setLineDash([]); - - // Подписи осей - ctx.fillStyle = "#00ff88"; - ctx.font = "bold 11px monospace"; - - ctx.save(); - ctx.translate(12, padTop + plotH / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.fillText("ЗНАЧЕНИЕ", 0, 0); - ctx.restore(); - - ctx.textAlign = "center"; - ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4); - - const hasSignal = history.some(v => v > 0); - - if (!hasSignal) { - ctx.fillStyle = "#ff4444"; - ctx.font = "14px monospace"; - ctx.textAlign = "center"; - ctx.fillText("Ожидание сигнала...", w / 2, h / 2); - return; - } - - let plotData = history; - if (history.length > plotW) { - const step = history.length / plotW; - plotData = []; - for (let i = 0; i < plotW; i++) { - const idx = Math.floor(i * step); - plotData.push(history[idx]); + ctx.fillStyle = "#00ff88"; + ctx.font = "11px monospace"; + ctx.textAlign = "right"; + ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4); } - } - ctx.strokeStyle = "#e6852d"; - ctx.lineWidth = 1.5; - ctx.shadowColor = "#e6852d"; - ctx.shadowBlur = 3; - - ctx.beginPath(); - const stepX = plotW / Math.max(plotData.length - 1, 1); - - for (let i = 0; i < plotData.length; i++) { - const x = padLeft + i * stepX; - const y = padTop + plotH - (plotData[i] / maxVal) * plotH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - - ctx.stroke(); - ctx.shadowBlur = 0; - - if (history.length > 0) { - const lastVal = history[history.length - 1]; - const lastX = padLeft + (plotData.length - 1) * stepX; - const lastY = padTop + plotH - (lastVal / maxVal) * plotH; - - ctx.fillStyle = "#c46b1f"; - ctx.beginPath(); - ctx.arc(lastX, lastY, 4, 0, Math.PI * 2); - ctx.fill(); - - const labelText = `${lastVal}`; - const labelWidth = ctx.measureText(labelText).width; - - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18); + ctx.setLineDash([]); + // Подписи осей ctx.fillStyle = "#00ff88"; - ctx.font = "bold 12px monospace"; + ctx.font = "bold 11px monospace"; + + ctx.save(); + ctx.translate(12, padTop + plotH / 2); + ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; - ctx.fillText(labelText, lastX, lastY - 8); + ctx.fillText("ЗНАЧЕНИЕ", 0, 0); + ctx.restore(); - const peak = Math.max(...history); - const min = Math.min(...history); + ctx.textAlign = "center"; + ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4); - ctx.fillStyle = "rgba(0,0,0,0.7)"; - ctx.fillRect(w - 120, padTop, 110, 42); + const hasSignal = history.some(v => v > 0); - ctx.fillStyle = "#00ff88"; - ctx.font = "10px monospace"; - ctx.textAlign = "left"; - ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14); - ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28); - ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42); - } + if (!hasSignal) { + ctx.fillStyle = "#ff4444"; + ctx.font = "14px monospace"; + ctx.textAlign = "center"; + ctx.fillText("Ожидание сигнала...", w / 2, h / 2); + return; + } + + let plotData = history; + if (history.length > plotW) { + const step = history.length / plotW; + plotData = []; + for (let i = 0; i < plotW; i++) { + const idx = Math.floor(i * step); + plotData.push(history[idx]); + } + } + + ctx.strokeStyle = "#e6852d"; + ctx.lineWidth = 1.5; + ctx.shadowColor = "#e6852d"; + ctx.shadowBlur = 3; + + ctx.beginPath(); + const stepX = plotW / Math.max(plotData.length - 1, 1); + + for (let i = 0; i < plotData.length; i++) { + const x = padLeft + i * stepX; + const y = padTop + plotH - (plotData[i] / maxVal) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + + ctx.stroke(); + ctx.shadowBlur = 0; + + if (history.length > 0) { + const lastVal = history[history.length - 1]; + const lastX = padLeft + (plotData.length - 1) * stepX; + const lastY = padTop + plotH - (lastVal / maxVal) * plotH; + + ctx.fillStyle = "#c46b1f"; + ctx.beginPath(); + ctx.arc(lastX, lastY, 4, 0, Math.PI * 2); + ctx.fill(); + + const labelText = `${lastVal}`; + const labelWidth = ctx.measureText(labelText).width; + + ctx.fillStyle = "rgba(0,0,0,0.8)"; + ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18); + + ctx.fillStyle = "#00ff88"; + ctx.font = "bold 12px monospace"; + ctx.textAlign = "center"; + ctx.fillText(labelText, lastX, lastY - 8); + + const peak = Math.max(...history); + const min = Math.min(...history); + + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(w - 120, padTop, 110, 42); + + ctx.fillStyle = "#00ff88"; + ctx.font = "10px monospace"; + ctx.textAlign = "left"; + ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14); + ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28); + ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42); + } } \ No newline at end of file diff --git a/cmd/server/web/js/state.ts b/cmd/server/web/js/state.ts index be902f1..1228c34 100644 --- a/cmd/server/web/js/state.ts +++ b/cmd/server/web/js/state.ts @@ -1,192 +1,243 @@ // state.ts - Peak Holder алгоритм с таймером interface AppState { - maxPoints: number; - signalHistory: number[]; - lastBufferSize: number; + maxPoints: number; + signalHistory: number[]; + lastBufferSize: number; - // Peak Holder - peakValue: number; // Текущее удерживаемое пиковое значение амплитуды - currentValue: number; // Текущее реальное значение амплитуды - peakTimer: number | null; // Таймер для сброса пика амплитуды - peakHoldTime: number; // Время удержания пика в миллисекундах + // Peak Holder + peakValue: number; // Текущее удерживаемое пиковое значение амплитуды + currentValue: number; // Текущее реальное значение амплитуды + peakTimer: number | null; // Таймер для сброса пика амплитуды + peakHoldTime: number; // Время удержания пика в миллисекундах - // Peak Holder для уровня сигнала - peakSignalLevel: number; - currentSignalLevel: number; - signalPeakTimer: number | null; - signalPeakHoldTime: number; + // Peak Holder для уровня сигнала + peakSignalLevel: number; + currentSignalLevel: number; + signalPeakTimer: number | null; + signalPeakHoldTime: number; - hasPeak: boolean; + hasPeak: boolean; } type PeakHolderListener = () => void; export const state: AppState = { - maxPoints: 1000, - signalHistory: [], - lastBufferSize: 0, + maxPoints: 1000, + signalHistory: [], + lastBufferSize: 0, - peakValue: 0, - currentValue: 0, - peakTimer: null, - peakHoldTime: 3000, // 3 секунды + peakValue: 0, + currentValue: 0, + peakTimer: null, + peakHoldTime: 3000, // 3 секунды - peakSignalLevel: 0, - currentSignalLevel: 0, - signalPeakTimer: null, - signalPeakHoldTime: 3000, + peakSignalLevel: 0, + currentSignalLevel: 0, + signalPeakTimer: null, + signalPeakHoldTime: 3000, - hasPeak: false, + hasPeak: false, }; let peakHolderListener: PeakHolderListener | null = null; -/** Обновление UI при сбросе peak holder по таймеру (между опросами API). */ -export function setPeakHolderListener(listener: PeakHolderListener | null): void { - peakHolderListener = listener; +// ============================================ +// ПУБЛИЧНЫЕ ФУНКЦИИ +// ============================================ + +/** + * Установка времени удержания пика сигнала + * @param ms - время в миллисекундах + */ +// noinspection JSUnusedGlobalSymbols +export function setSignalPeakHoldTime(ms: number): void { + state.signalPeakHoldTime = ms; } +/** + * Получение информации о пике сигнала + * @returns { peak: number; current: number; timerActive: boolean } + */ +// noinspection JSUnusedGlobalSymbols +export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } { + return { + peak: state.peakSignalLevel, + current: state.currentSignalLevel, + timerActive: state.signalPeakTimer !== null, + }; +} + +/** + * Добавление нового значения сигнала в историю + * @param value - значение сигнала + */ +// noinspection JSUnusedGlobalSymbols +export function addSignal(value: number): void { + state.signalHistory.push(value); + if (state.signalHistory.length > state.maxPoints) { + state.signalHistory = state.signalHistory.slice(-state.maxPoints); + } +} + +/** + * Установка слушателя для обновления пика + * @param listener - функция-слушатель + */ +// noinspection JSUnusedGlobalSymbols +export function setPeakHolderListener(listener: PeakHolderListener | null): void { + peakHolderListener = listener; +} + +/** + * Обновление пикового значения амплитуды + * @param target - текущее значение + * @returns пиковое значение + */ +// noinspection JSUnusedGlobalSymbols +export function updatePeakHolder(target: number): number { + // Обновляем текущее значение + state.currentValue = target; + + // Если новое значение больше текущего пика + if (target > state.peakValue) { + // Устанавливаем новый пик + state.peakValue = target; + state.hasPeak = true; + scheduleAmplitudePeakRelease(); + } else if (target < state.peakValue && state.peakTimer === null) { + // Пик выше текущего значения, но таймер пропал — перезапускаем удержание + scheduleAmplitudePeakRelease(); + } // Возвращаем текущий пик + return state.peakValue; +} + +/** + * Обновление пикового уровня сигнала + * @param level - текущий уровень + * @returns пиковый уровень + */ +// noinspection JSUnusedGlobalSymbols +export function updateSignalPeakHolder(level: number): number { + // Обновляем текущее значение + state.currentSignalLevel = level; + + // Если новый уровень больше пика — устанавливаем новый пик + if (level > state.peakSignalLevel) { + state.peakSignalLevel = level; + state.hasPeak = true; + scheduleSignalPeakRelease(); + } else if (level < state.peakSignalLevel && state.signalPeakTimer === null) { + scheduleSignalPeakRelease(); + } + + // Возвращаем текущий пик + return state.peakSignalLevel; +} + +/** + * Принудительный сброс пика сигнала + */ +// noinspection JSUnusedGlobalSymbols +export function forceResetSignalPeak(): void { + if (state.signalPeakTimer !== null) { + clearTimeout(state.signalPeakTimer); + state.signalPeakTimer = null; + } + state.peakSignalLevel = 0; + state.currentSignalLevel = 0; + state.hasPeak = false; +} + +/** + * Принудительный сброс всех пиков + */ +// noinspection JSUnusedGlobalSymbols +export function forceResetPeak(): void { + if (state.peakTimer !== null) { + clearTimeout(state.peakTimer); + state.peakTimer = null; + } + state.peakValue = 0; + state.currentValue = 0; + state.hasPeak = false; + forceResetSignalPeak(); +} + +/** + * Добавление массива значений сигнала + * @param values - массив значений + * @param bufferSize - размер буфера + */ +// noinspection JSUnusedGlobalSymbols +export function addSignals(values: number[], bufferSize: number): void { + if (!Array.isArray(values) || values.length === 0) return; + + const newBytes = bufferSize - state.lastBufferSize; + + if (newBytes > 0 && newBytes <= values.length) { + const fresh = values.slice(-newBytes); + const decoded = fresh.map(b => b & 0x3F); + state.signalHistory.push(...decoded); + } + + state.lastBufferSize = bufferSize; + + if (state.signalHistory.length > state.maxPoints) { + state.signalHistory = state.signalHistory.slice(-state.maxPoints); + } +} + +/** + * Сброс отслеживания буфера + */ +// noinspection JSUnusedGlobalSymbols +export function resetBufferTracking(): void { + state.lastBufferSize = 0; + forceResetPeak(); +} + +// ============================================ +// ВНУТРЕННИЕ ФУНКЦИИ +// ============================================ + function notifyPeakHolderChange(): void { - if (peakHolderListener) { - peakHolderListener(); - } + if (peakHolderListener) { + peakHolderListener(); + } } function scheduleAmplitudePeakRelease(): void { - // Сбрасываем старый таймер - if (state.peakTimer !== null) { - clearTimeout(state.peakTimer); - state.peakTimer = null; - } + // Сбрасываем старый таймер + if (state.peakTimer !== null) { + clearTimeout(state.peakTimer); + state.peakTimer = null; + } - // Запускаем новый таймер на 3 секунды - state.peakTimer = window.setTimeout(() => { - state.peakValue = state.currentValue; - state.peakTimer = null; - state.hasPeak = false; - console.log(`[Peak] Сброс пика до: ${state.peakValue}`); - notifyPeakHolderChange(); - }, state.peakHoldTime); + // Запускаем новый таймер на 3 секунды + state.peakTimer = window.setTimeout(() => { + state.peakValue = state.currentValue; + state.peakTimer = null; + state.hasPeak = false; + console.log(`[Peak] Сброс пика до: ${state.peakValue}`); + notifyPeakHolderChange(); + }, state.peakHoldTime); } function scheduleSignalPeakRelease(): void { - // Сбрасываем старый таймер - if (state.signalPeakTimer !== null) { - clearTimeout(state.signalPeakTimer); - state.signalPeakTimer = null; - } + // Сбрасываем старый таймер + if (state.signalPeakTimer !== null) { + clearTimeout(state.signalPeakTimer); + state.signalPeakTimer = null; + } - // Запускаем новый таймер на 3 секунды - state.signalPeakTimer = window.setTimeout(() => { - // Сбрасываем пик к текущему уровню - state.peakSignalLevel = state.currentSignalLevel; - state.signalPeakTimer = null; - state.hasPeak = false; - console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`); - notifyPeakHolderChange(); - }, state.signalPeakHoldTime); -} - -// ========== ФУНКЦИИ ДЛЯ АМПЛИТУДЫ ========== - -export function updatePeakHolder(target: number): number { - // Обновляем текущее значение - state.currentValue = target; - - // Если новое значение больше текущего пика - if (target > state.peakValue) { - // Устанавливаем новый пик - state.peakValue = target; - state.hasPeak = true; - scheduleAmplitudePeakRelease(); - } else if (target < state.peakValue && state.peakTimer === null) { - // Пик выше текущего значения, но таймер пропал — перезапускаем удержание - scheduleAmplitudePeakRelease(); - } // Возвращаем текущий пик - return state.peakValue; -} - -// ========== ФУНКЦИИ ДЛЯ УРОВНЯ СИГНАЛА ========== - -export function updateSignalPeakHolder(level: number): number { - // Обновляем текущее значение - state.currentSignalLevel = level; - - // Если новый уровень больше пика — устанавливаем новый пик - if (level > state.peakSignalLevel) { - state.peakSignalLevel = level; - state.hasPeak = true; - scheduleSignalPeakRelease(); - } else if (level < state.peakSignalLevel && state.signalPeakTimer === null) { - scheduleSignalPeakRelease(); - } - - // Возвращаем текущий пик - return state.peakSignalLevel; -} - -// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ========== - -export function forceResetSignalPeak(): void { - if (state.signalPeakTimer !== null) { - clearTimeout(state.signalPeakTimer); - state.signalPeakTimer = null; - } - state.peakSignalLevel = 0; - state.currentSignalLevel = 0; - state.hasPeak = false; -} - -export function setSignalPeakHoldTime(ms: number): void { - state.signalPeakHoldTime = ms; -} - -export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } { - return { - peak: state.peakSignalLevel, - current: state.currentSignalLevel, - timerActive: state.signalPeakTimer !== null, - }; -} - -export function forceResetPeak(): void { - if (state.peakTimer !== null) { - clearTimeout(state.peakTimer); - state.peakTimer = null; - } - state.peakValue = 0; - state.currentValue = 0; - state.hasPeak = false; - forceResetSignalPeak(); -} - -export function addSignal(value: number): void { - state.signalHistory.push(value); - if (state.signalHistory.length > state.maxPoints) { - state.signalHistory = state.signalHistory.slice(-state.maxPoints); - } -} - -export function addSignals(values: number[], bufferSize: number): void { - if (!Array.isArray(values) || values.length === 0) return; - - const newBytes = bufferSize - state.lastBufferSize; - - if (newBytes > 0 && newBytes <= values.length) { - const fresh = values.slice(-newBytes); - const decoded = fresh.map(b => b & 0x3F); - state.signalHistory.push(...decoded); - } - - state.lastBufferSize = bufferSize; - - if (state.signalHistory.length > state.maxPoints) { - state.signalHistory = state.signalHistory.slice(-state.maxPoints); - } -} - -export function resetBufferTracking(): void { - state.lastBufferSize = 0; - forceResetPeak(); -} + // Запускаем новый таймер на 3 секунды + state.signalPeakTimer = window.setTimeout(() => { + // Сбрасываем пик к текущему уровню + state.peakSignalLevel = state.currentSignalLevel; + state.signalPeakTimer = null; + state.hasPeak = false; + console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`); + notifyPeakHolderChange(); + }, state.signalPeakHoldTime); +} \ No newline at end of file