Compare commits

..

23 Commits

Author SHA1 Message Date
1047268942 Обновить README.md 2025-12-29 01:47:29 +01:00
104dc610f1 Обновить README.md 2025-12-29 01:46:48 +01:00
e61f4720bc docs: добавлены README.md 2025-12-29 03:43:25 +03:00
804527133c Merge pull request 'feat/frontend' (#1) from feat/frontend into main
Reviewed-on: #1
2025-12-29 01:42:27 +01:00
5e0f22e39e fix(frontend): подготовка к прому 2025-12-29 01:25:59 +03:00
799a11b86d feat(frontend): добавлены виджеты Текущая частота и Форма волны 2025-12-29 00:46:36 +03:00
f8edaa0aaf fix(api): update 2025-12-29 00:46:07 +03:00
734c65253d feat(collector): добавлен сбор медианной частоты и громкости 2025-12-28 23:07:49 +03:00
bcc94b40fe feat(frontend): добавлены частоты ниже 1 2025-12-28 23:07:07 +03:00
c560b9be76 feat(frontend): add websocket send speed packet control 2025-12-28 22:25:46 +03:00
e6f361def4 feat(collector): add websocket 2025-12-28 22:25:45 +03:00
7334855ba2 feat(frontend): исправлен график 2025-12-26 21:51:59 +03:00
707a474ef3 feat(frontend): add basic frontend 2025-12-26 20:14:30 +03:00
262c42c1b3 chore: add Python .gitignore files to services 2025-12-26 19:29:46 +03:00
1b864228d4 feat(api): add backend
routes and WebSockets
2025-12-26 19:26:06 +03:00
cfec8d0ff6 feat(collector): add collector service 2025-12-26 18:04:17 +03:00
a7e5670d7c chore(compose): add docker-compose.yml 2025-12-26 18:03:26 +03:00
eaa0e0a3eb feat(fw/health): добавлено моргание светодиода 2025-12-26 02:47:32 +03:00
2a14a36797 feat(protocol): add packet protocol 2025-12-26 01:11:12 +03:00
97c59cdda2 feat(FFT): добавлено FFT, выделение основной частоты 2025-12-26 00:13:57 +03:00
063cced2a5 fix(mic): исправлен таймер на TIM3 2025-12-25 23:17:39 +03:00
3306b8083b feat(mic): try1: setup sound with usb 2025-12-25 21:06:50 +03:00
2f0527a3d8 fix(main.c): исправлены приоритеты прерывания и часы 2025-12-25 19:06:48 +03:00
107 changed files with 9253 additions and 126 deletions

12
.example.env Normal file
View File

@@ -0,0 +1,12 @@
# collector
SERIAL_PORT=/dev/ttyACM0
BAUDRATE=115200
# DB
DB_NAME=audio_analyzer
DB_USER=postgres
DB_PASSWORD=postgres
DB_PORT=5432
# api
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer

3
.gitignore vendored
View File

@@ -56,3 +56,6 @@ Mkfile.old
dkms.conf dkms.conf
# End of https://www.toptal.com/developers/gitignore/api/c # End of https://www.toptal.com/developers/gitignore/api/c
Build/
*.bin

3
.gitmodules vendored
View File

@@ -10,3 +10,6 @@
[submodule "firmware/Drivers/CMSIS/Core"] [submodule "firmware/Drivers/CMSIS/Core"]
path = firmware/Drivers/CMSIS/Core path = firmware/Drivers/CMSIS/Core
url = https://github.com/STMicroelectronics/cmsis_core.git url = https://github.com/STMicroelectronics/cmsis_core.git
[submodule "firmware/Middlewares/CMSIS-DSP"]
path = firmware/Middlewares/CMSIS-DSP
url = https://github.com/ARM-software/CMSIS-DSP.git

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# STM32 Audio Analyzer
STM32 Audio Analyzer — система реального времени для измерения уровня звука и доминантной частоты на базе STM32F103C8T6 (Blue Pill) с веб‑дашбордом и хранением истории в TimescaleDB.
Микроконтроллер оцифровывает сигнал с MAX4466, считает RMS (dBFS) и FFT (512 точек), отсылает метрики по USB CDC; на ПК коллектор пишет данные в PostgreSQL/TimescaleDB, FastAPI отдаёт REST/WS, Reactфронтенд показывает текущие значения и историю.
## Архитектура
- **[MCU (firmware)](firmware/README.md)**
- STM32F103C8T6, FreeRTOS, TinyUSB, CMSISDSP.
- ADC1 + DMA (circular, doublebuffer 2×512) с триггером от TIM3, частота дискретизации 22.05 кГц.
- Обработка: удаление DC, RMS в dBFS, Hannокно, RFFT 512, поиск пика 1008000 Гц.
- Передача каждые 100 мс пакетом 12 байт по USB CDC.
- **[Collector (Python)](services/collector/README.md)**
- Читает бинарный протокол с /dev/ttyACM0, ресинхронизация по SOF=0xAA.
- Проверка CRC8/ATM, диапазонов, подсчёт статистики ошибок.
- Запись в TimescaleDB (`audio_data`), параллельно пушит JSON по WebSocket (`ws://…/ws/live`).
- **[API (FastAPI)](services/api/README.md)**
- REST `/api/v1/audio/latest|range|export/csv`, `/api/v1/stats/summary`, `/api/v1/events/loud`.
- База: `audio_data` + непрерывный агрегат `audio_data_1min` (avg/max/min/доминирующая частота, доля тишины).
- **[Frontend (React)](services/frontend/README.md)**
- Liveдашборд: вертикальный аудио‑метр, peakhold за 3 секунды, история частоты, текущая нота.
- Источник данных — WebSocket `VITE_WS_URL`, REST для исторических запросов при необходимости.
## Структура репозитория
```text
.
├── README.md
├── docker-compose.yml
├── db/
│ └── init.sql # схема TimescaleDB + агрегаты/retention
├── firmware/ # прошивка STM32 (FreeRTOS + TinyUSB + CMSIS-DSP)
└── services/
├── collector/ # serial→WS→DB
├── api/ # FastAPI REST + DB
└── frontend/ # React/Vite дашборд
```
## Быстрый старт
```
git clone --recurse-submodules https://git.yolgins.ru/Iwwww/sound-analyze.git
```
```bash
cp .example.env .env
docker compose up --build
```
- БД: TimescaleDB на `localhost:5432` (`DB_NAME=audio_analyzer` по умолчанию).
- API: `http://localhost:8000/api/v1`.
- WebSocket коллектора: `ws://localhost:8001/ws/live`.
- Frontend: `http://localhost:3000` (devрежим Vite).

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env python3
import sys
import time
import serial
# Настройки порта
SERIAL_PORT = "/dev/ttyACM0"
BAUD_RATE = 115200
def read_serial_data():
try:
# Открытие порта. Timeout позволяет прерывать блокирующее чтение.
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
print(f"Connected to {SERIAL_PORT}")
while True:
if ser.in_waiting > 0:
# Чтение строки, декодирование и удаление пробелов
line = ser.readline().decode("utf-8", errors="replace").strip()
if line:
print(f"[RX]: {line}")
else:
# Небольшая пауза, чтобы не грузить CPU ПК
time.sleep(0.01)
except serial.SerialException as e:
print(f"Error opening serial port: {e}")
print(
"Hint: Check if /dev/ttyACM0 exists and you have permissions (group uucp)."
)
except KeyboardInterrupt:
print("\nExiting...")
finally:
if "ser" in locals() and ser.is_open:
ser.close()
if __name__ == "__main__":
read_serial_data()

56
db/init.sql Normal file
View File

@@ -0,0 +1,56 @@
-- TimescaleDB hypertable for time-series audio metrics
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create audio data table
CREATE TABLE IF NOT EXISTS audio_data (
time TIMESTAMPTZ NOT NULL,
rms_db REAL CHECK (rms_db >= -50.0 AND rms_db <= 0.0),
frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000),
is_silence BOOLEAN NOT NULL DEFAULT FALSE
);
-- Convert to hypertable (time-series optimization)
SELECT create_hypertable('audio_data', 'time', if_not_exists => TRUE);
-- Create index for frequency queries (exclude silence for performance)
CREATE INDEX IF NOT EXISTS idx_frequency
ON audio_data(frequency_hz)
WHERE NOT is_silence;
-- Create index for time-based queries
CREATE INDEX IF NOT EXISTS idx_time_desc
ON audio_data(time DESC);
-- Optional: Create continuous aggregate for 1-minute averages
CREATE MATERIALIZED VIEW IF NOT EXISTS audio_data_1min
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', time) AS bucket,
AVG(rms_db) AS avg_rms_db,
MAX(rms_db) AS max_rms_db,
MIN(rms_db) AS min_rms_db,
mode() WITHIN GROUP (ORDER BY frequency_hz) AS dominant_freq_hz,
SUM(CASE WHEN is_silence THEN 1 ELSE 0 END)::REAL / COUNT(*) AS silence_ratio
FROM audio_data
GROUP BY bucket
WITH NO DATA;
-- Refresh policy: update aggregate every 5 minutes
SELECT add_continuous_aggregate_policy('audio_data_1min',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '5 minutes',
if_not_exists => TRUE
);
-- Data retention: keep raw data for 7 days
SELECT add_retention_policy('audio_data',
INTERVAL '7 days',
if_not_exists => TRUE
);
-- Grant permissions
GRANT ALL ON audio_data TO postgres;
GRANT SELECT ON audio_data_1min TO postgres;

108
docker-compose.yml Normal file
View File

@@ -0,0 +1,108 @@
services:
db:
image: timescale/timescaledb:2.13.1-pg16
container_name: audio_timescaledb
environment:
POSTGRES_DB: ${DB_NAME:-audio_analyzer}
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
volumes:
- timescale_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "${DB_PORT:-5432}:5432"
networks:
- audio_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
collector:
build:
context: ./services/collector
dockerfile: Dockerfile
container_name: audio_collector
depends_on:
db:
condition: service_healthy
environment:
SERIAL_PORT: ${SERIAL_PORT:-/dev/ttyACM0}
BAUDRATE: ${BAUDRATE:-115200}
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-audio_analyzer}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
WS_HOST: 0.0.0.0
WS_PORT: 8000
ports:
- "8001:8000"
devices:
- "${SERIAL_PORT:-/dev/ttyACM0}:${SERIAL_PORT:-/dev/ttyACM0}"
networks:
- audio_network
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: audio_api
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer
ports:
- "8000:8000"
volumes:
- ./services/api:/app
networks:
- audio_network
healthcheck:
test:
[
"CMD-SHELL",
"curl --fail http://localhost:8000/api/v1/health/live || exit 1",
]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
frontend:
build:
context: ./services/frontend
dockerfile: Dockerfile
container_name: audio_frontend_dev
ports:
- "3000:5173"
environment:
VITE_API_URL: http://localhost:8000
VITE_WS_URL: ws://localhost:8001/ws/live
# VITE_API_URL: http://api:8000
# VITE_WS_URL: ws://api:8001
volumes:
- ./services/frontend:/app
- /app/node_modules
networks:
- audio_network
stdin_open: true
tty: true
restart: unless-stopped
volumes:
timescale_data:
driver: local
networks:
audio_network:
driver: bridge

View File

@@ -1,6 +1,9 @@
#ifndef FREERTOS_CONFIG_H #ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H #define FREERTOS_CONFIG_H
#define configCHECK_FOR_STACK_OVERFLOW 2
#define configUSE_MALLOC_FAILED_HOOK 1
#define configUSE_PREEMPTION 1 #define configUSE_PREEMPTION 1
#define configUSE_IDLE_HOOK 0 #define configUSE_IDLE_HOOK 0
#define configUSE_TICK_HOOK 0 #define configUSE_TICK_HOOK 0
@@ -8,7 +11,7 @@
#define configTICK_RATE_HZ ((TickType_t)1000) #define configTICK_RATE_HZ ((TickType_t)1000)
#define configMAX_PRIORITIES (5) #define configMAX_PRIORITIES (5)
#define configMINIMAL_STACK_SIZE ((unsigned short)128) #define configMINIMAL_STACK_SIZE ((unsigned short)128)
#define configTOTAL_HEAP_SIZE ((size_t)(10 * 1024)) #define configTOTAL_HEAP_SIZE ((size_t)(8 * 1024))
#define configMAX_TASK_NAME_LEN (16) #define configMAX_TASK_NAME_LEN (16)
#define configUSE_16_BIT_TICKS 0 #define configUSE_16_BIT_TICKS 0
#define configIDLE_SHOULD_YIELD 1 #define configIDLE_SHOULD_YIELD 1

View File

@@ -0,0 +1,29 @@
#ifndef AUDIO_ADC_H
#define AUDIO_ADC_H
#include <stdbool.h>
#include "audio_config.h"
/**
* @brief Инициализация ADC, DMA и Timer для audio capture
* @param callback Функция, вызываемая при заполнении буфера
* @return true если успешно, false при ошибке
*/
bool audio_adc_init(audio_buffer_ready_callback_t callback);
/**
* @brief Запуск непрерывного захвата аудио
*/
void audio_adc_start(void);
/**
* @brief Остановка захвата аудио
*/
void audio_adc_stop(void);
/**
* @brief Получить текущее количество обработанных буферов
*/
uint32_t audio_adc_get_buffer_count(void);
#endif /* AUDIO_ADC_H */

View File

@@ -0,0 +1,22 @@
#ifndef AUDIO_CONFIG_H
#define AUDIO_CONFIG_H
#include <stdint.h>
// Audio Configuration
#define AUDIO_SAMPLE_RATE 22050U
#define AUDIO_BUFFER_SIZE 512U
// ADC Configuration
#define AUDIO_ADC_CHANNEL 1U // PA1 = ADC1_IN1
// Timer Configuration (TIM2 для 72 MHz)
#define AUDIO_TIMER_PRESCALER 0U
#define AUDIO_TIMER_PERIOD 3264U // 72MHz / 3265 ≈ 22050 Hz
// Data Types
typedef uint16_t audio_sample_t;
typedef void (
*audio_buffer_ready_callback_t)(audio_sample_t* buffer, uint32_t size);
#endif /* AUDIO_CONFIG_H */

View File

@@ -0,0 +1,20 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "audio_config.h"
typedef struct {
// dBFS (потом можно добавить калибровочный оффсет до dB SPL)
float rms_dbfs;
float peak_hz; // доминантная частота
float peak_mag; // амплитуда бина (относительная)
uint8_t clipped; // 1 если был клиппинг (0 или 4095)
} audio_metrics_t;
bool audio_processor_init(void);
// process ровно 512 сэмплов
bool audio_processor_process_512(
const audio_sample_t* samples,
audio_metrics_t* out);

15
firmware/App/Inc/health.h Normal file
View File

@@ -0,0 +1,15 @@
#ifndef HEALTH_H
#define HEALTH_H
#include <stdbool.h>
#include <stdint.h>
void health_kick_watchdog(void);
void health_init_watchdog(void);
void health_update_led(float freq_hz, float rms_dbfs);
void health_led_task(void *param);
#endif // HEALTH_H

View File

@@ -0,0 +1,40 @@
#ifndef PROTOCOL_H
#define PROTOCOL_H
#include <stddef.h>
#include <stdint.h>
// Protocol Constants
#define PROTOCOL_SOF 0xAA
#define PACKET_TYPE_AUDIO 0x02
#define PACKET_LEN_V1 0x08 // Payload length (excluding SOF, TYPE, LEN, CRC)
#define PACKET_TOTAL_SIZE 12
// CRC8-ATM Constants
#define CRC8_POLY 0x07
#define CRC8_INIT 0x00
/**
* @brief Calculates CRC-8/ATM over the data buffer.
* Polynomial: x^8 + x^2 + x + 1 (0x07)
* Init: 0x00, RefIn: false, RefOut: false, XorOut: 0x00
* @param data Pointer to data buffer
* @param len Length of data
* @return Calculated CRC8
*/
uint8_t crc8_atm(const uint8_t *data, size_t len);
/**
* @brief Encodes the audio metric packet into the wire format.
* @param buf Output buffer (must be at least 12 bytes)
* @param timestamp_ms Timestamp in milliseconds
* @param rms_dbfs RMS value in dBFS (float)
* @param freq_hz Peak frequency in Hz (float)
*/
void protocol_pack_v1(
uint8_t *buf,
uint32_t timestamp_ms,
float rms_dbfs,
float freq_hz);
#endif // PROTOCOL_H

View File

@@ -19,8 +19,8 @@ extern "C" {
// Конфигурация CDC (Communication Device Class) // Конфигурация CDC (Communication Device Class)
#define CFG_TUD_CDC 1 #define CFG_TUD_CDC 1
#define CFG_TUD_CDC_RX_BUFSIZE 64 #define CFG_TUD_CDC_RX_BUFSIZE 256
#define CFG_TUD_CDC_TX_BUFSIZE 64 #define CFG_TUD_CDC_TX_BUFSIZE 256
// Endpoint буферизация // Endpoint буферизация
#define CFG_TUD_ENDPOINT0_SIZE 64 #define CFG_TUD_ENDPOINT0_SIZE 64

View File

@@ -0,0 +1,168 @@
#include "audio_adc.h"
#include <string.h>
#include "stm32f1xx.h"
// Один непрерывный DMA-буфер: 2 * 512 = 1024 семпла
static audio_sample_t dma_buffer[2 * AUDIO_BUFFER_SIZE];
// Callback функция
static audio_buffer_ready_callback_t user_callback = NULL;
// Статистика (для отладки)
static volatile uint32_t buffer_count = 0;
static volatile uint32_t dma_half_transfer_count = 0;
static volatile uint32_t dma_full_transfer_count = 0;
// Private Function Prototypes
static void audio_gpio_init(void);
static void audio_timer_init(void);
static void audio_adc_hw_init(void);
static void audio_dma_init(void);
bool audio_adc_init(audio_buffer_ready_callback_t callback) {
if (callback == NULL) return false;
user_callback = callback;
audio_gpio_init();
audio_timer_init();
audio_adc_hw_init();
audio_dma_init();
return true;
}
void audio_adc_start(void) {
DMA1_Channel1->CCR |= DMA_CCR_EN;
ADC1->CR2 |= ADC_CR2_ADON;
TIM3->CR1 |= TIM_CR1_CEN;
}
void audio_adc_stop(void) {
TIM3->CR1 &= ~TIM_CR1_CEN;
ADC1->CR2 &= ~ADC_CR2_ADON;
DMA1_Channel1->CCR &= ~DMA_CCR_EN;
}
uint32_t audio_adc_get_buffer_count(void) {
return buffer_count;
}
static void audio_gpio_init(void) {
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// PA1 analog
GPIOA->CRL &= ~(0xF << 4);
}
static void audio_timer_init(void) {
// Включаем тактирование Timer3
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
// Настраиваем TIM3 для 22050 Hz
TIM3->PSC = AUDIO_TIMER_PRESCALER;
TIM3->ARR = AUDIO_TIMER_PERIOD;
// TRGO = Update event
TIM3->CR2 &= ~TIM_CR2_MMS;
TIM3->CR2 |= TIM_CR2_MMS_1;
TIM3->CR1 |= TIM_CR1_ARPE;
TIM3->EGR |= TIM_EGR_UG;
TIM3->CNT = 0;
}
static void audio_adc_hw_init(void) {
RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;
RCC->CFGR &= ~RCC_CFGR_ADCPRE;
RCC->CFGR |= RCC_CFGR_ADCPRE_DIV6;
ADC1->CR2 = 0;
ADC1->CR1 = 0;
ADC1->CR1 &= ~ADC_CR1_SCAN;
ADC1->CR2 &= ~ADC_CR2_CONT;
// EXTSEL = 011 (Timer2 TRGO), EXTTRIG enable
ADC1->CR2 &= ~ADC_CR2_EXTSEL;
ADC1->CR2 |= (0x4U << 17); // TIM3_TRGO
ADC1->CR2 |= ADC_CR2_EXTTRIG;
ADC1->CR2 |= ADC_CR2_DMA;
ADC1->CR2 &= ~ADC_CR2_ALIGN;
ADC1->SQR1 &= ~ADC_SQR1_L;
ADC1->SQR3 &= ~ADC_SQR3_SQ1;
ADC1->SQR3 |= (AUDIO_ADC_CHANNEL << ADC_SQR3_SQ1_Pos);
ADC1->SMPR2 &= ~ADC_SMPR2_SMP1;
ADC1->SMPR2 |= ADC_SMPR2_SMP1_0; // 7.5 cycles
// calibration
ADC1->CR2 |= ADC_CR2_ADON;
for (volatile int i = 0; i < 1000; i++) {}
ADC1->CR2 |= ADC_CR2_CAL;
while (ADC1->CR2 & ADC_CR2_CAL) {}
ADC1->CR2 &= ~ADC_CR2_ADON;
}
static void audio_dma_init(void) {
RCC->AHBENR |= RCC_AHBENR_DMA1EN;
DMA1_Channel1->CCR &= ~DMA_CCR_EN;
while (DMA1_Channel1->CCR & DMA_CCR_EN) {}
// ADC1 DR -> RAM
DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR;
// ВАЖНО: CMAR указывает на непрерывный буфер 1024 samples
DMA1_Channel1->CMAR = (uint32_t)dma_buffer;
// ВАЖНО: 2 * 512 = 1024 samples
DMA1_Channel1->CNDTR = 2 * AUDIO_BUFFER_SIZE;
uint32_t ccr = 0;
ccr |= DMA_CCR_MINC;
ccr |= DMA_CCR_CIRC;
ccr |= DMA_CCR_HTIE;
ccr |= DMA_CCR_TCIE;
ccr |= DMA_CCR_PL_1; // high
ccr |= DMA_CCR_MSIZE_0; // 16-bit
ccr |= DMA_CCR_PSIZE_0; // 16-bit
DMA1_Channel1->CCR = ccr;
NVIC_SetPriority(DMA1_Channel1_IRQn, 6);
NVIC_EnableIRQ(DMA1_Channel1_IRQn);
}
void DMA1_Channel1_IRQHandler(void) {
uint32_t isr = DMA1->ISR;
if (isr & DMA_ISR_HTIF1) {
DMA1->IFCR = DMA_IFCR_CHTIF1;
dma_half_transfer_count++;
// первая половина: [0 .. 511]
if (user_callback) { user_callback(&dma_buffer[0], AUDIO_BUFFER_SIZE); }
buffer_count++;
}
if (isr & DMA_ISR_TCIF1) {
DMA1->IFCR = DMA_IFCR_CTCIF1;
dma_full_transfer_count++;
// вторая половина: [512 .. 1023]
if (user_callback) {
user_callback(&dma_buffer[AUDIO_BUFFER_SIZE], AUDIO_BUFFER_SIZE);
}
buffer_count++;
}
if (isr & DMA_ISR_TEIF1) {
DMA1->IFCR = DMA_IFCR_CTEIF1;
// TODO: error handling
}
}

View File

@@ -0,0 +1,112 @@
#include "audio_processor.h"
#include <math.h>
#include <string.h>
#include "arm_math.h"
#include "stm32f1xx.h"
#ifndef AUDIO_FFT_SIZE
#define AUDIO_FFT_SIZE 512U
#endif
#if (AUDIO_FFT_SIZE != 512U)
#error "This module currently expects AUDIO_FFT_SIZE == 512"
#endif
#define ADC_FULL_SCALE 4095.0f
#define ADC_MID_SCALE 2048.0f
#define EPS_RMS 1e-12f
static arm_rfft_fast_instance_f32 rfft;
// ОПТИМИЗАЦИЯ: используем один буфер для in/out FFT
static float32_t fft_buffer[AUDIO_FFT_SIZE]; // 2KB
static float32_t mag[AUDIO_FFT_SIZE / 2U]; // 1KB (bins 0..N/2-1)
static uint32_t bin_min = 0;
static uint32_t bin_max = 0;
static float32_t hz_per_bin = 0.0f;
// Inline Hann window (без хранения коэффициентов)
static inline float32_t hann_coeff(uint32_t i, uint32_t n) {
const float32_t two_pi = 6.28318530717958647693f;
const float32_t denom = (float32_t)(n - 1U);
return 0.5f - 0.5f * arm_cos_f32(two_pi * (float32_t)i / denom);
}
bool audio_processor_init(void) {
// FFT init
if (arm_rfft_fast_init_512_f32(&rfft) != ARM_MATH_SUCCESS) { return false; }
hz_per_bin = ((float32_t)AUDIO_SAMPLE_RATE) / ((float32_t)AUDIO_FFT_SIZE);
// Диапазон поиска пика 100..8000 Hz
bin_min = (uint32_t)ceilf(100.0f / hz_per_bin);
bin_max = (uint32_t)floorf(8000.0f / hz_per_bin);
// safety
if (bin_min < 1U) bin_min = 1U;
const uint32_t last_bin = (AUDIO_FFT_SIZE / 2U) - 1U;
if (bin_max > last_bin) bin_max = last_bin;
return true;
}
bool audio_processor_process_512(
const audio_sample_t* samples,
audio_metrics_t* out) {
if (!samples || !out) return false;
// 1) Mean + clipping detect
uint32_t sum = 0;
uint8_t clipped = 0;
for (uint32_t i = 0; i < AUDIO_FFT_SIZE; i++) {
const uint16_t s = samples[i];
sum += s;
if (s == 0U || s == 4095U) clipped = 1;
}
const float32_t mean = (float32_t)sum / (float32_t)AUDIO_FFT_SIZE;
// 2) RMS of AC component + prepare FFT input (normalized, windowed)
float32_t acc = 0.0f;
for (uint32_t i = 0; i < AUDIO_FFT_SIZE; i++) {
// centered around 0, normalized to roughly [-1..1]
float32_t x = ((float32_t)samples[i] - mean) / ADC_MID_SCALE;
acc += x * x;
// apply window inline (saves 2KB RAM)
fft_buffer[i] = x * hann_coeff(i, AUDIO_FFT_SIZE);
}
const float32_t rms = sqrtf(acc / (float32_t)AUDIO_FFT_SIZE);
const float32_t rms_dbfs = 20.0f * log10f(rms + EPS_RMS);
// 3) FFT (in-place: fft_buffer используется для in/out)
arm_rfft_fast_f32(&rfft, fft_buffer, fft_buffer, 0);
// 4) Magnitudes for bins 0..N/2-1
// CMSIS layout: [Re(0), Im(0)=0, Re(1), Im(1), ..., Re(N/2), Im(N/2)=0]
// Мы берём bin 1..N/2-1 для поиска пика
arm_cmplx_mag_f32(fft_buffer, mag, AUDIO_FFT_SIZE / 2U);
// 5) Peak search in desired band (skip DC bin 0)
uint32_t best_bin = bin_min;
float32_t best_mag = 0.0f;
for (uint32_t k = bin_min; k <= bin_max; k++) {
const float32_t m = mag[k];
if (m > best_mag) {
best_mag = m;
best_bin = k;
}
}
out->rms_dbfs = rms_dbfs;
out->peak_mag = best_mag;
out->peak_hz = (float32_t)best_bin * hz_per_bin;
out->clipped = clipped;
return true;
}

68
firmware/App/Src/health.c Normal file
View File

@@ -0,0 +1,68 @@
#include "health.h"
#include <math.h>
#include "FreeRTOS.h"
#include "stm32f1xx.h"
#include "task.h"
// Default to "Heartbeat" mode (1 Hz)
static volatile uint32_t led_period_ms = 3000;
void health_init_watchdog(void) {
IWDG->KR = 0x5555;
IWDG->PR = 0x04; // Prescaler /64 -> 625 Hz
IWDG->RLR = 1875; // ~3 seconds
IWDG->KR = 0xCCCC;
IWDG->KR = 0xAAAA;
}
void health_kick_watchdog(void) {
IWDG->KR = 0xAAAA;
}
void health_update_led(float freq_hz, float rms_dbfs) {
if (rms_dbfs < -35.0f) {
led_period_ms = 3000;
return;
}
if (freq_hz < 100.0f) freq_hz = 100.0f;
if (freq_hz > 6000.0f) freq_hz = 6000.0f;
// 100..8000 Hz -> 1..5 Hz blink (period 1000..200 ms)
float t = (log10f(freq_hz) - log10f(100.0f)) /
(log10f(6000.0f) - log10f(100.0f)); // 0..1
float blink_hz = 1.0f + t * 20.0f; // 1..5
uint32_t period = (uint32_t)(3000.0f / blink_hz); // 1000..200 ms
if (period < 200) period = 50;
if (period > 3000) period = 3000;
led_period_ms = period;
}
void health_led_task(void *param) {
(void)param;
// 1) Включаем тактирование GPIOC и настраиваем PC13 как выход push-pull
// 2MHz
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
GPIOC->CRH &= ~(GPIO_CRH_MODE13 | GPIO_CRH_CNF13);
GPIOC->CRH |= GPIO_CRH_MODE13_1; // 2 MHz output, push-pull (CNF=00)
// LED off initially (Blue Pill LED is active-low)
GPIOC->ODR |= GPIO_ODR_ODR13;
while (1) {
uint32_t period = led_period_ms;
uint32_t on_ms = 40;
if (on_ms > period / 2) on_ms = period / 2;
uint32_t off_ms = period - on_ms;
// LED active-low: 0 = ON, 1 = OFF
GPIOC->BSRR = GPIO_BSRR_BR13; // ON
vTaskDelay(pdMS_TO_TICKS(on_ms)); // держим заметный импульс
GPIOC->BSRR = GPIO_BSRR_BS13; // OFF
vTaskDelay(pdMS_TO_TICKS(off_ms)); // пауза
health_kick_watchdog();
}
}

View File

@@ -1,61 +1,220 @@
#include <stdio.h>
#include <string.h>
#include "FreeRTOS.h" #include "FreeRTOS.h"
#include "audio_adc.h"
#include "audio_processor.h" // НОВОЕ
#include "health.h"
#include "protocol.h"
#include "queue.h"
#include "stm32f1xx.h" #include "stm32f1xx.h"
#include "task.h" #include "task.h"
#include "tusb.h" #include "tusb.h"
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
(void)xTask;
(void)pcTaskName;
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
for (volatile int i = 0; i < 50000; i++);
}
}
void vApplicationMallocFailedHook(void) {
taskDISABLE_INTERRUPTS();
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
for (volatile int i = 0; i < 200000; i++) {}
}
}
static void panic_blink_forever(uint32_t delay_ms) {
// PC13 уже сконфигурирован в main
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
vTaskDelay(pdMS_TO_TICKS(delay_ms));
}
}
// === Структуры данных ===
typedef struct {
float rms_dbfs;
float peak_hz;
float peak_mag;
uint8_t clipped;
uint32_t timestamp_ms;
} audio_metrics_packet_t;
static QueueHandle_t audio_metrics_queue = NULL;
static volatile uint32_t buffer_counter = 0;
// === System Clock ===
void SystemClock_Config(void) { void SystemClock_Config(void) {
RCC->CR |= RCC_CR_HSEON; RCC->CR |= RCC_CR_HSEON;
while (!(RCC->CR & RCC_CR_HSERDY)); while (!(RCC->CR & RCC_CR_HSERDY));
FLASH->ACR |= FLASH_ACR_LATENCY_2; FLASH->ACR = FLASH_ACR_LATENCY_2;
// ЯВНО обнуляем бит USBPRE (div 1.5 для получения 48MHz USB) RCC->CFGR &= ~RCC_CFGR_PLLMULL;
RCC->CFGR &= ~RCC_CFGR_USBPRE; // <-- ДОБАВЛЕНО! RCC->CFGR |= RCC_CFGR_PLLMULL9;
RCC->CFGR |= (RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9); RCC->CFGR |= RCC_CFGR_PLLSRC;
RCC->CFGR &= ~RCC_CFGR_USBPRE;
RCC->CR |= RCC_CR_PLLON; RCC->CR |= RCC_CR_PLLON;
while (!(RCC->CR & RCC_CR_PLLRDY)); while (!(RCC->CR & RCC_CR_PLLRDY));
RCC->CFGR &= ~RCC_CFGR_SW;
RCC->CFGR |= RCC_CFGR_SW_PLL; RCC->CFGR |= RCC_CFGR_SW_PLL;
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL); while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
SystemCoreClock = 72000000; SystemCoreClock = 72000000;
} }
// Задача USB (ТОЛЬКО tud_task, ничего больше!) // === Audio Callback (НОВОЕ: копируем в очередь для обработки) ===
// Буфер для копирования из ISR
static audio_sample_t processing_buffer[AUDIO_BUFFER_SIZE];
void audio_buffer_ready(audio_sample_t *buffer, uint32_t size) {
buffer_counter++;
// Мигаем LED
/* if (buffer_counter % 5 == 0) { GPIOC->ODR ^= GPIO_ODR_ODR13; } */
// Копируем данные (ISR должен быть быстрым)
memcpy(processing_buffer, buffer, size * sizeof(audio_sample_t));
// Сигналим задаче обработки через notification
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
extern TaskHandle_t audio_process_task_handle;
if (audio_process_task_handle != NULL) {
vTaskNotifyGiveFromISR(
audio_process_task_handle,
&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// === Tasks ===
void usb_device_task(void *param) { void usb_device_task(void *param) {
(void)param; (void)param;
while (1) { while (1) {
tud_task(); tud_task();
// Без задержки! tud_task() должен вызываться как можно чаще vTaskDelay(pdMS_TO_TICKS(1));
}
}
TaskHandle_t audio_process_task_handle = NULL;
void audio_process_task(void *param) {
(void)param;
if (!audio_processor_init()) {
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
vTaskDelay(pdMS_TO_TICKS(50));
health_kick_watchdog();
}
}
audio_metrics_t metrics;
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(100); // 10 Hz
while (1) {
// 1) Ждём хотя бы один новый буфер от ISR
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// 2) Выкидываем накопившиеся уведомления, чтобы не пытаться "догонять"
// прошлое
while (ulTaskNotifyTake(pdTRUE, 0) > 0) {}
// 3) Обрабатываем самый свежий буфер (processing_buffer
// перезаписывается в ISR)
if (audio_processor_process_512(processing_buffer, &metrics)) {
health_update_led(metrics.peak_hz, metrics.rms_dbfs);
audio_metrics_packet_t packet = {
.rms_dbfs = metrics.rms_dbfs,
.peak_hz = metrics.peak_hz,
.peak_mag = metrics.peak_mag,
.clipped = metrics.clipped,
.timestamp_ms = xTaskGetTickCount(),
};
(void)xQueueSend(audio_metrics_queue, &packet, 0);
}
health_kick_watchdog();
// ограничиваем частоту обработки
vTaskDelayUntil(&last_wake, period);
} }
} }
// Задача CDC
void cdc_task(void *param) { void cdc_task(void *param) {
(void)param; (void)param;
// Buffer for packet (12 bytes)
uint8_t tx_buffer[PACKET_TOTAL_SIZE];
while (1) { while (1) {
// Check if USB is connected
if (tud_cdc_connected()) { if (tud_cdc_connected()) {
if (tud_cdc_available()) { audio_metrics_packet_t packet;
uint8_t buf[64];
uint32_t count = tud_cdc_read(buf, sizeof(buf)); // Wait for data from DSP task
tud_cdc_write(buf, count); if (xQueueReceive(
tud_cdc_write_flush(); audio_metrics_queue,
&packet,
pdMS_TO_TICKS(10)) == pdPASS) {
// Pack data according to FR-1.4 spec
protocol_pack_v1(
tx_buffer,
packet.timestamp_ms,
packet.rms_dbfs,
packet.peak_hz);
// Write to USB CDC
// Check available space just in case
if (tud_cdc_write_available() >= sizeof(tx_buffer)) {
tud_cdc_write(tx_buffer, sizeof(tx_buffer));
tud_cdc_write_flush();
}
} }
} else {
// Flush queue if USB not connected to prevent stalling DSP task
// or just sleep longer.
vTaskDelay(pdMS_TO_TICKS(100));
} }
vTaskDelay(pdMS_TO_TICKS(10)); // 10мс достаточно
} }
} }
// Задача LED (отдельно!) void audio_init_task(void *param) {
void led_task(void *param) {
(void)param; (void)param;
while (1) {
GPIOC->BSRR = GPIO_BSRR_BR13; // LED ON // Индикация старта
vTaskDelay(pdMS_TO_TICKS(500)); for (int i = 0; i < 3; i++) {
GPIOC->BSRR = GPIO_BSRR_BS13; // LED OFF GPIOC->ODR ^= GPIO_ODR_ODR13;
vTaskDelay(pdMS_TO_TICKS(500)); vTaskDelay(pdMS_TO_TICKS(100));
} }
if (!audio_adc_init(audio_buffer_ready)) {
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
vTaskDelay(pdMS_TO_TICKS(50));
}
}
audio_adc_start();
for (int i = 0; i < 5; i++) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
vTaskDelay(pdMS_TO_TICKS(200));
}
vTaskDelete(NULL);
} }
void force_usb_reset(void) { void force_usb_reset(void) {
@@ -68,28 +227,43 @@ void force_usb_reset(void) {
GPIOA->CRH |= GPIO_CRH_CNF12_0; GPIOA->CRH |= GPIO_CRH_CNF12_0;
} }
// === Main ===
int main(void) { int main(void) {
SystemClock_Config(); SystemClock_Config();
// Настройка LED // LED GPIO (will be managed by health_led_task)
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
GPIOC->CRH &= ~GPIO_CRH_CNF13; GPIOC->CRH &= ~(GPIO_CRH_MODE13 | GPIO_CRH_CNF13);
GPIOC->CRH |= GPIO_CRH_MODE13_1; GPIOC->CRH |= GPIO_CRH_MODE13_1;
force_usb_reset(); force_usb_reset();
// Включаем USB // USB
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
RCC->APB1ENR |= RCC_APB1ENR_USBEN; RCC->APB1ENR |= RCC_APB1ENR_USBEN;
NVIC_SetPriority(USB_HP_CAN1_TX_IRQn, 6);
// Прерывания USB NVIC_SetPriority(USB_LP_CAN1_RX0_IRQn, 6);
NVIC_SetPriority(USBWakeUp_IRQn, 6);
NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn); NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn); NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
NVIC_EnableIRQ(USBWakeUp_IRQn); NVIC_EnableIRQ(USBWakeUp_IRQn);
tusb_init(); tusb_init();
// УВЕЛИЧИЛИ стек до 256! // Initialize watchdog BEFORE starting tasks
health_init_watchdog();
// FFT queue
audio_metrics_queue = xQueueCreate(10, sizeof(audio_metrics_packet_t));
if (audio_metrics_queue == NULL) {
while (1) {
GPIOC->ODR ^= GPIO_ODR_ODR13;
for (volatile int i = 0; i < 100000; i++);
}
}
// Create tasks
xTaskCreate( xTaskCreate(
usb_device_task, usb_device_task,
"usbd", "usbd",
@@ -97,13 +271,31 @@ int main(void) {
NULL, NULL,
configMAX_PRIORITIES - 1, configMAX_PRIORITIES - 1,
NULL); NULL);
xTaskCreate(cdc_task, "cdc", 256, NULL, configMAX_PRIORITIES - 2, NULL); xTaskCreate(cdc_task, "cdc", 320, NULL, configMAX_PRIORITIES - 2, NULL);
xTaskCreate(led_task, "led", 128, NULL, 1, NULL);
xTaskCreate(health_led_task, "health_led", 128, NULL, 1, NULL);
xTaskCreate(audio_init_task, "audio_init", 128, NULL, 2, NULL);
xTaskCreate(
audio_process_task,
"audio_proc",
512,
NULL,
configMAX_PRIORITIES - 2,
&audio_process_task_handle);
if (xTaskCreate(health_led_task, "health_led", 128, NULL, 1, NULL) !=
pdPASS) {
panic_blink_forever(100);
}
vTaskStartScheduler(); vTaskStartScheduler();
while (1);
while (1); // Should never reach here
} }
// === USB Handlers ===
void USB_HP_CAN1_TX_IRQHandler(void) { void USB_HP_CAN1_TX_IRQHandler(void) {
tud_int_handler(0); tud_int_handler(0);
} }

View File

@@ -0,0 +1,51 @@
#include "protocol.h"
#include <math.h>
uint8_t crc8_atm(const uint8_t *data, size_t len) {
uint8_t crc = CRC8_INIT;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x80) {
crc = (crc << 1) ^ CRC8_POLY;
} else {
crc <<= 1;
}
}
}
return crc;
}
void protocol_pack_v1(
uint8_t *buf,
uint32_t timestamp_ms,
float rms_dbfs,
float freq_hz) {
// Header
buf[0] = PROTOCOL_SOF;
buf[1] = PACKET_TYPE_AUDIO;
buf[2] = PACKET_LEN_V1;
// Payload: Timestamp (4 bytes, Little Endian)
buf[3] = (uint8_t)(timestamp_ms & 0xFF);
buf[4] = (uint8_t)((timestamp_ms >> 8) & 0xFF);
buf[5] = (uint8_t)((timestamp_ms >> 16) & 0xFF);
buf[6] = (uint8_t)((timestamp_ms >> 24) & 0xFF);
// Payload: RMS_DB (2 bytes, Little Endian, x10, int16)
// Range check implicit by int16 cast, but clamping is safer
// Spec: -40..80 dB -> -400..800
// Note: Since DSP returns dBFS (negative), we just send it as is.
// E.g. -60.5 dB -> -605.
int16_t rms_fixed = (int16_t)(rms_dbfs * 10.0f);
buf[7] = (uint8_t)(rms_fixed & 0xFF);
buf[8] = (uint8_t)((rms_fixed >> 8) & 0xFF);
// Payload: FREQ_HZ (2 bytes, Little Endian, uint16)
uint16_t freq_fixed = (uint16_t)freq_hz;
buf[9] = (uint8_t)(freq_fixed & 0xFF);
buf[10] = (uint8_t)((freq_fixed >> 8) & 0xFF);
// CRC8 (Calculated over bytes 1..10: TYPE, LEN, Payload)
buf[11] = crc8_atm(&buf[1], 10);
}

View File

@@ -1,14 +1,17 @@
TARGET = stm32-usb-freertos TARGET = stm32-usb-freertos
BUILD_DIR = Build BUILD_DIR = Build
# --- Исходники --- # Приложение
# 1. Приложение
C_SOURCES = \ C_SOURCES = \
App/Src/main.c \ App/Src/main.c \
App/Src/audio_adc.c \
App/Src/audio_processor.c \
App/Src/protocol.c \
App/Src/health.c \
App/Src/usb_descriptors.c \ App/Src/usb_descriptors.c \
App/Src/system_stm32f1xx.c \ App/Src/system_stm32f1xx.c \
# 2. FreeRTOS # FreeRTOS
C_SOURCES += \ C_SOURCES += \
Middlewares/FreeRTOS/croutine.c \ Middlewares/FreeRTOS/croutine.c \
Middlewares/FreeRTOS/event_groups.c \ Middlewares/FreeRTOS/event_groups.c \
@@ -19,7 +22,7 @@ Middlewares/FreeRTOS/timers.c \
Middlewares/FreeRTOS/portable/GCC/ARM_CM3/port.c \ Middlewares/FreeRTOS/portable/GCC/ARM_CM3/port.c \
Middlewares/FreeRTOS/portable/MemMang/heap_4.c Middlewares/FreeRTOS/portable/MemMang/heap_4.c
# 3. TinyUSB # TinyUSB
C_SOURCES += \ C_SOURCES += \
Middlewares/TinyUSB/src/tusb.c \ Middlewares/TinyUSB/src/tusb.c \
Middlewares/TinyUSB/src/common/tusb_fifo.c \ Middlewares/TinyUSB/src/common/tusb_fifo.c \
@@ -28,7 +31,23 @@ Middlewares/TinyUSB/src/device/usbd_control.c \
Middlewares/TinyUSB/src/class/cdc/cdc_device.c \ Middlewares/TinyUSB/src/class/cdc/cdc_device.c \
Middlewares/TinyUSB/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c Middlewares/TinyUSB/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c
# 4. Startup # CMSIS-DSP sources
C_SOURCES += \
$(CMSIS_DSP)/Source/TransformFunctions/arm_cfft_radix8_f32.c \
$(CMSIS_DSP)/Source/TransformFunctions/arm_bitreversal2.c \
$(CMSIS_DSP)/Source/TransformFunctions/arm_rfft_fast_f32.c \
$(CMSIS_DSP)/Source/TransformFunctions/arm_rfft_fast_init_f32.c \
$(CMSIS_DSP)/Source/TransformFunctions/arm_cfft_f32.c \
$(CMSIS_DSP)/Source/TransformFunctions/arm_cfft_init_f32.c \
$(CMSIS_DSP)/Source/ComplexMathFunctions/arm_cmplx_mag_f32.c \
$(CMSIS_DSP)/Source/CommonTables/arm_const_structs.c \
$(CMSIS_DSP)/Source/CommonTables/arm_common_tables.c \
$(CMSIS_DSP)/Source/FastMathFunctions/arm_cos_f32.c
# CMSIS-DSP
CMSIS_DSP = Middlewares/CMSIS-DSP
# Startup
ASM_SOURCES = App/Src/startup_stm32f103xb.s ASM_SOURCES = App/Src/startup_stm32f103xb.s
# --- Настройки компилятора --- # --- Настройки компилятора ---
@@ -47,19 +66,29 @@ C_INCLUDES = \
-IDrivers/CMSIS/Device/ST/STM32F1xx/Include \ -IDrivers/CMSIS/Device/ST/STM32F1xx/Include \
-IMiddlewares/FreeRTOS/include \ -IMiddlewares/FreeRTOS/include \
-IMiddlewares/FreeRTOS/portable/GCC/ARM_CM3 \ -IMiddlewares/FreeRTOS/portable/GCC/ARM_CM3 \
-IMiddlewares/TinyUSB/src -IMiddlewares/TinyUSB/src \
-I$(CMSIS_DSP)/Include \
-I$(CMSIS_DSP)/PrivateInclude
# Defines # Defines
C_DEFS = \ C_DEFS = \
-DSTM32F103xB \ -DSTM32F103xB \
-DCFG_TUSB_MCU=OPT_MCU_STM32F1 -DCFG_TUSB_MCU=OPT_MCU_STM32F1 \
-DARM_MATH_CM3
CFLAGS = $(MCU) $(C_DEFS) $(C_INCLUDES) -O2 -Wall -fdata-sections -ffunction-sections -g -gdwarf-2 CFLAGS = $(MCU) $(C_DEFS) $(C_INCLUDES) -Os -Wall -fdata-sections -ffunction-sections -g -gdwarf-2
# Linker # Linker
LDSCRIPT = stm32f103c8.ld LDSCRIPT = stm32f103c8.ld
LIBS = -lc -lm -lnosys # LIBS = -lc -lm -lnosys
LDFLAGS = $(MCU) -T$(LDSCRIPT) $(LIBS) -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections -Wl,--no-warn-rwx-segments # LDFLAGS = $(MCU) -T$(LDSCRIPT) $(LIBS) -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections -Wl,--no-warn-rwx-segments
# LDFLAGS = $(MCU) -T$(LDSCRIPT) --specs=nano.specs --specs=nosys.specs \
# -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections
LIBS = -Wl,--start-group -lc_nano -lm -lgcc -lnosys -Wl,--end-group
LDFLAGS = $(MCU) -T$(LDSCRIPT) $(LIBS) \
-Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref \
-Wl,--gc-sections \
-Wl,--no-warn-rwx-segments
# --- Генерация списка объектов --- # --- Генерация списка объектов ---
OBJECTS = $(addprefix $(BUILD_DIR)/,$(notdir $(C_SOURCES:.c=.o))) OBJECTS = $(addprefix $(BUILD_DIR)/,$(notdir $(C_SOURCES:.c=.o)))
@@ -103,4 +132,3 @@ flash:
st-flash write $(BUILD_DIR)/$(TARGET).bin 0x8000000 st-flash write $(BUILD_DIR)/$(TARGET).bin 0x8000000
.PHONY: all clean flash .PHONY: all clean flash

74
firmware/README.md Normal file
View File

@@ -0,0 +1,74 @@
# firmware/
## Цель
Прошивка для Blue Pill, которая снимает аудио с MAX4466, считает уровень в dBFS и доминантную частоту, мигает LED с частотой звука и публикует метрики по USB CDC в формате фиксированного бинарного пакета.
## Железо
- STM32F103C8T6 (Blue Pill, 72 МГц, 64К Flash, 20К RAM).
- Микрофон: MAX4466 (выход → **PA1 / ADC1_IN1**, питание 3.3 В).
- USB FS через TinyUSB CDC (Virtual COM).
- Светодиод: PC13 (on = 0, off = 1).
## Обработка аудио (DSP)
- Частота дискретизации: 22050 Гц (`AUDIO_SAMPLE_RATE`).
- DMAбуфер: 2×512 выборок `uint16_t` (двойная буферизация через half/full interrupt).
- RMS считается по формуле \(\mathrm{RMS} = \sqrt{\frac{1}{N}\sum\_{i=1}^{N} x_i^2}\) после удаления среднего и нормализации к диапазону около \([-1, 1]\).
- Перевод в dBFS: \(\mathrm{dBFS} = 20 \log\_{10} (\mathrm{RMS} + \varepsilon)\), где \(\varepsilon\) — малый стабилизатор для нуля.
- FFT: `arm_rfft_fast_f32` на 512 точках, Hannокно рассчитывается “на лету” через `arm_cos_f32`.
- Поиск пика по модулю `mag[k]` в диапазоне частот 1008000 Гц, шаг частоты \( \Delta f = \frac{22050}{512} \approx 43 \text{ Гц} \).
## Протокол
Пакет фиксированного размера 12 байт:
```c
// firmware/App/Inc/protocol.h + protocol.c
[SOF=0xAA][TYPE=0x02][LEN=0x08]
[timestamp_ms: uint32]
[rms_db_x10: int16] // dBFS * 10, ожидается -50.0 .. 0.0
[frequency_hz: uint16] // 100 .. 8000
[crc8: uint8] // CRC-8/ATM
```
- CRC: CRC8/ATM (poly=0x07, init=0x00, без отражения), считается по байтам с индексами 1..10.
## FreeRTOS задачи
- `audio_process_task`
- Ждёт уведомление от ISR DMA, обрабатывает последний буфер (512 сэмплов).
- Вычисляет метрики, обновляет режим LED, отправляет метрику в очередь для USBзадачи.
- `cdc_task`
- Читает из очереди, упаковывает в `protocol_pack_v1`, пишет в USB CDC, целевая частота ~10 Гц.
- `health_led_task`
- Настраивает PC13 и мигает в зависимости от частоты/уровня сигнала, плюс подкармливает watchdog.
## Сборка и прошивка
- Зависимости: `arm-none-eabi-gcc`, `make`, `st-flash`.
- Сборка:
```bash
cd firmware
make
```
- Прошивка (через STLink):
```bash
make flash
```
## Зависимости
| Библиотека | Путь | Репозиторий |
| ------------------- | -------------------------------------------- | ------------------------------------------------------------------- |
| **CMSIS-Core** | `firmware/Drivers/CMSIS/Core` | [ST GitHub](https://github.com/STMicroelectronics/cmsis_core)
| **CMSIS-Device F1** | `firmware/Drivers/CMSIS/Device/ST/STM32F1xx` | [ST GitHub](https://github.com/STMicroelectronics/cmsis_device_f1) |
| **FreeRTOS** | `firmware/Middlewares/FreeRTOS` | [FreeRTOS-Kernel](https://github.com/FreeRTOS/FreeRTOS-Kernel) |
| **TinyUSB** | `firmware/Middlewares/TinyUSB` | [hathach/tinyusb](https://github.com/hathach/tinyusb) |
| **CMSIS-DSP** | `firmware/Middlewares/CMSIS-DSP` | [ARM-software/CMSIS-DSP](https://github.com/ARM-software/CMSIS-DSP) |

View File

@@ -4,8 +4,8 @@ ENTRY(Reset_Handler)
/* Highest address of the user mode stack */ /* Highest address of the user mode stack */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */ _estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */
_Min_Heap_Size = 0x200; /* required amount of heap */ _Min_Heap_Size = 0x000; /* required amount of heap */
_Min_Stack_Size = 0x400; /* required amount of stack */ _Min_Stack_Size = 0x800; /* required amount of stack */
/* Memories definition */ /* Memories definition */
MEMORY MEMORY

176
services/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

15
services/api/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

40
services/api/README.md Normal file
View File

@@ -0,0 +1,40 @@
# services/api
## Назначение
FastAPIсервис поверх TimescaleDB, который предоставляет RESTдоступ к аудио‑метрикам и агрегированным статистикам.
## Основные endpointы
- `GET /api/v1/audio/latest?limit=100`
- Возвращает последние N точек `audio_data`.
- `GET /api/v1/audio/range?from=<ts>&to=<ts>`
- Данные за заданный интервал, проверка `from < to`.
- `GET /api/v1/stats/summary?period=1h`
- Агрегированная статистика (avg/max/min по dB, доминантная частота, процент тишины) по окну (`10s|1m|1h|6h|24h|7d|30d`).
- `GET /api/v1/events/loud?threshold=-35&from=<ts>&to=<ts>`
- “Громкие” события, где `rms_db` выше порога, опционально с интервалом времени.
- `GET /api/v1/export/csv?from=<ts>&to=<ts>`
- Экспорт диапазона в CSV: `time,rms_db,frequency_hz,is_silence`.
- `GET /api/v1/health/live` / `ready`
- Примитивный healthcheck и проверка доступности БД.
## Модель данных
Таблица `audio_data` в TimescaleDB:
```sql
CREATE TABLE audio_data (
time TIMESTAMPTZ NOT NULL,
rms_db REAL CHECK (rms_db >= -50.0 AND rms_db <= 0.0),
frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000),
is_silence BOOLEAN NOT NULL DEFAULT FALSE
);
```
Плюс continuous aggregate `audio_data_1min` и политики retention/refresh.

View File

@@ -0,0 +1,36 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.audio import AudioPoint
from app.services.audio_service import AudioService
router = APIRouter()
@router.get("/latest", response_model=ApiResponse[list[AudioPoint]])
async def latest(
limit: int = Query(100, ge=1, le=10000), db: AsyncSession = Depends(get_db)
):
service = AudioService(AudioRepository(db))
items = await service.latest(limit)
return ApiResponse(data=items, count=len(items))
@router.get("/range", response_model=ApiResponse[list[AudioPoint]])
async def range_(
time_from: datetime = Query(..., alias="from"),
time_to: datetime = Query(..., alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
service = AudioService(AudioRepository(db))
items = await service.range(time_from, time_to)
return ApiResponse(data=items, count=len(items))

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.events import LoudEvent
from app.services.events_service import EventsService
router = APIRouter()
@router.get("/loud", response_model=ApiResponse[list[LoudEvent]])
async def loud_events(
threshold: float = Query(
default=-35.0, ge=-50.0, le=0.0, description="RMS dB threshold"
),
time_from: Optional[datetime] = Query(None, alias="from"),
time_to: Optional[datetime] = Query(None, alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from and time_to and time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
service = EventsService(AudioRepository(db))
events = await service.loud_events(
threshold=threshold, time_from=time_from, time_to=time_to
)
return ApiResponse(data=events, count=len(events))

View File

@@ -0,0 +1,42 @@
from datetime import datetime
import csv
import io
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
router = APIRouter()
@router.get("/csv")
async def export_csv(
time_from: datetime = Query(..., alias="from"),
time_to: datetime = Query(..., alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
repo = AudioRepository(db)
rows = await repo.range(time_from, time_to)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["time", "rms_db", "frequency_hz", "is_silence"])
for r in rows:
w.writerow([r.time.isoformat(), r.rms_db, r.frequency_hz, r.is_silence])
buf.seek(0)
filename = f"audio_{time_from:%Y%m%d_%H%M%S}_to_{time_to:%Y%m%d_%H%M%S}.csv"
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.schemas.base import ApiResponse
router = APIRouter()
@router.get("/live", response_model=ApiResponse[dict])
async def live() -> ApiResponse[dict]:
return ApiResponse(data={"status": "alive"})
@router.get("/ready", response_model=ApiResponse[dict])
async def ready(db: AsyncSession = Depends(get_db)) -> ApiResponse[dict]:
await db.execute(text("SELECT 1"))
return ApiResponse(data={"status": "ready", "db": "ok"})

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.stats import StatsSummary
from app.services.stats_service import StatsService
router = APIRouter()
@router.get("/summary", response_model=ApiResponse[StatsSummary])
async def summary(
period: str = Query("1h", pattern="^(10s|1m|1h|6h|24h|7d|30d)$"),
db: AsyncSession = Depends(get_db),
):
service = StatsService(AudioRepository(db))
try:
data = await service.summary(period)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return ApiResponse(data=data)

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.api.v1.endpoints import audio, stats, events, export, health
router = APIRouter()
router.include_router(health.router, prefix="/health", tags=["health"])
router.include_router(audio.router, prefix="/audio", tags=["audio"])
router.include_router(stats.router, prefix="/stats", tags=["stats"])
router.include_router(events.router, prefix="/events", tags=["events"])
router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer"
API_V1_PREFIX: str = "/api/v1"
settings = Settings()

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,12 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session

46
services/api/app/main.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import FastAPI
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import router as v1_router
from app.core.config import settings
from app.ws.router import router as ws_router
from app.ws.broadcaster import audio_live_broadcaster
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(audio_live_broadcaster())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
def create_app() -> FastAPI:
app = FastAPI(
title="Audio Analyzer API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1_router, prefix=settings.API_V1_PREFIX)
app.include_router(ws_router) # /ws/live
return app
app = create_app()

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Boolean, Integer, Float
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AudioData(Base):
__tablename__ = "audio_data"
time: Mapped[object] = mapped_column(TIMESTAMP(timezone=True), primary_key=True)
rms_db: Mapped[float] = mapped_column(Float, nullable=False)
frequency_hz: Mapped[int] = mapped_column(Integer, nullable=False)
is_silence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timedelta
from sqlalchemy import and_, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audio_data import AudioData
class AudioRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def latest(self, limit: int) -> list[AudioData]:
q = select(AudioData).order_by(AudioData.time.desc()).limit(limit)
res = await self.db.execute(q)
return list(res.scalars().all())
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioData]:
q = (
select(AudioData)
.where(and_(AudioData.time >= time_from, AudioData.time <= time_to))
.order_by(AudioData.time.asc())
)
res = await self.db.execute(q)
return list(res.scalars().all())
async def loud_samples(
self,
threshold: float,
time_from: datetime | None,
time_to: datetime | None,
) -> list[AudioData]:
cond = [AudioData.rms_db >= threshold]
if time_from:
cond.append(AudioData.time >= time_from)
if time_to:
cond.append(AudioData.time <= time_to)
q = select(AudioData).where(and_(*cond)).order_by(AudioData.time.asc())
res = await self.db.execute(q)
return list(res.scalars().all())
async def summary_since(self, since: datetime) -> dict:
q = select(
func.avg(AudioData.rms_db).label("avg_db"),
func.max(AudioData.rms_db).label("max_db"),
func.sum(func.case((AudioData.is_silence.is_(True), 1), else_=0)).label(
"silence_count"
),
func.count().label("total_count"),
).where(AudioData.time >= since)
res = await self.db.execute(q)
row = res.one()
# dominant freq excluding silence
fq = (
select(AudioData.frequency_hz, func.count().label("cnt"))
.where(and_(AudioData.time >= since, AudioData.is_silence.is_(False)))
.group_by(AudioData.frequency_hz)
.order_by(text("cnt DESC"))
.limit(1)
)
fres = await self.db.execute(fq)
frow = fres.first()
return {
"avg_db": float(row.avg_db or 0.0),
"max_db": float(row.max_db or 0.0),
"dominant_freq": int(frow[0]) if frow else 0,
"silence_count": int(row.silence_count or 0),
"total_count": int(row.total_count or 0),
}

View File

@@ -0,0 +1,9 @@
from datetime import datetime
from pydantic import BaseModel
class AudioPoint(BaseModel):
time: datetime
rms_db: float
frequency_hz: int
is_silence: bool

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiError(BaseModel):
code: str = Field(..., examples=["validation_error", "db_error"])
message: str
details: dict | None = None
class ApiResponse(BaseModel, Generic[T]):
success: bool = True
errors: list[ApiError] | None = None
count: int | None = None
data: T | None = None

View File

@@ -0,0 +1,9 @@
from datetime import datetime
from pydantic import BaseModel
class LoudEvent(BaseModel):
time: datetime
rms_db: float
frequency_hz: int
duration_sec: float | None = None

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel
class StatsSummary(BaseModel):
avg_db: float
max_db: float
dominant_freq: int
silence_percent: float

View File

@@ -0,0 +1,16 @@
from datetime import datetime
from app.repositories.audio_repository import AudioRepository
from app.schemas.audio import AudioPoint
class AudioService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def latest(self, limit: int) -> list[AudioPoint]:
rows = await self.repo.latest(limit)
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioPoint]:
rows = await self.repo.range(time_from, time_to)
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from app.repositories.audio_repository import AudioRepository
from app.schemas.events import LoudEvent
class EventsService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def loud_events(
self,
threshold: float,
time_from: datetime | None,
time_to: datetime | None,
max_gap_sec: float = 1.0,
) -> list[LoudEvent]:
samples = await self.repo.loud_samples(threshold, time_from, time_to)
if not samples:
return []
events: list[LoudEvent] = []
start = samples[0].time
end = samples[0].time
max_db = samples[0].rms_db
freq = samples[0].frequency_hz
for s in samples[1:]:
gap = (s.time - end).total_seconds()
if gap <= max_gap_sec:
end = s.time
if s.rms_db > max_db:
max_db = s.rms_db
else:
events.append(
LoudEvent(
time=start,
rms_db=round(float(max_db), 2),
frequency_hz=int(freq),
duration_sec=round((end - start).total_seconds(), 2),
)
)
start, end, max_db, freq = s.time, s.time, s.rms_db, s.frequency_hz
events.append(
LoudEvent(
time=start,
rms_db=round(float(max_db), 2),
frequency_hz=int(freq),
duration_sec=round((end - start).total_seconds(), 2),
)
)
return events

View File

@@ -0,0 +1,35 @@
from datetime import datetime, timedelta
from app.repositories.audio_repository import AudioRepository
from app.schemas.stats import StatsSummary
_PERIODS = {
"10s": timedelta(seconds=10),
"1m": timedelta(minutes=1),
"1h": timedelta(hours=1),
"6h": timedelta(hours=6),
"24h": timedelta(hours=24),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
}
class StatsService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def summary(self, period: str) -> StatsSummary:
if period not in _PERIODS:
raise ValueError(f"Unsupported period: {period}")
since = datetime.utcnow() - _PERIODS[period]
raw = await self.repo.summary_since(since)
total = raw["total_count"]
silence_percent = (raw["silence_count"] / total * 100.0) if total else 0.0
return StatsSummary(
avg_db=round(raw["avg_db"], 2),
max_db=round(raw["max_db"], 2),
dominant_freq=raw["dominant_freq"],
silence_percent=round(silence_percent, 2),
)

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import timezone
from app.db.session import SessionLocal
from app.repositories.audio_repository import AudioRepository
from app.ws.router import manager # используем тот же manager, что и в ws/router.py
def _iso_z(dt) -> str:
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
async def audio_live_broadcaster(poll_interval_sec: float = 0.05) -> None:
"""
Poll latest row and broadcast only when a NEW row appears.
Throttling per client is handled by manager.broadcast_json().
"""
last_time = None
while True:
try:
async with SessionLocal() as db:
repo = AudioRepository(db)
rows = await repo.latest(1)
if rows:
row = rows[0]
if last_time is None or row.time > last_time:
last_time = row.time
await manager.broadcast_json(
{
"time": _iso_z(row.time),
"rms_db": float(row.rms_db),
"freq_hz": int(row.frequency_hz),
}
)
except Exception:
# не даём таске умереть при временных проблемах БД
pass
await asyncio.sleep(poll_interval_sec)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import Any
from fastapi import WebSocket
@dataclass(slots=True)
class ClientConn:
ws: WebSocket
hz: int
min_interval: float
last_sent_monotonic: float
class ConnectionManager:
def __init__(self) -> None:
self._conns: dict[WebSocket, ClientConn] = {}
self._lock = asyncio.Lock()
async def connect(self, ws: WebSocket, hz: int) -> None:
await ws.accept()
now = time.monotonic()
client = ClientConn(
ws=ws, hz=hz, min_interval=1.0 / hz, last_sent_monotonic=0.0
)
async with self._lock:
self._conns[ws] = client
# Небольшой лог (можно заменить на structlog/loguru)
print(f"[ws] connected client={id(ws)} hz={hz} at={now:.3f}")
async def disconnect(self, ws: WebSocket) -> None:
async with self._lock:
existed = ws in self._conns
self._conns.pop(ws, None)
if existed:
print(f"[ws] disconnected client={id(ws)}")
async def broadcast_json(self, payload: dict[str, Any]) -> None:
now = time.monotonic()
async with self._lock:
clients = list(self._conns.values())
to_remove: list[WebSocket] = []
for c in clients:
# throttling per connection
if c.last_sent_monotonic and (now - c.last_sent_monotonic) < c.min_interval:
continue
try:
await c.ws.send_json(payload)
c.last_sent_monotonic = now
except Exception:
to_remove.append(c.ws)
if to_remove:
async with self._lock:
for ws in to_remove:
self._conns.pop(ws, None)

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from fastapi import APIRouter, WebSocket, status
from fastapi.exceptions import WebSocketException
from starlette.websockets import WebSocketDisconnect
from app.ws.manager import ConnectionManager
router = APIRouter()
manager = ConnectionManager()
DEFAULT_HZ = 10
MIN_HZ = 1
MAX_HZ = 60
def _parse_hz(ws: WebSocket) -> int:
raw = ws.query_params.get("hz")
if raw is None:
return DEFAULT_HZ
try:
hz = int(raw)
except ValueError:
raise WebSocketException(
code=status.WS_1008_POLICY_VIOLATION, reason="Invalid 'hz' (int expected)"
)
if hz < MIN_HZ or hz > MAX_HZ:
raise WebSocketException(
code=status.WS_1008_POLICY_VIOLATION,
reason=f"Invalid 'hz' (allowed {MIN_HZ}..{MAX_HZ})",
)
return hz
@router.websocket("/ws/live")
async def ws_live(ws: WebSocket) -> None:
hz = _parse_hz(ws)
await manager.connect(ws, hz=hz)
try:
# Не обязательно принимать сообщения от клиента
# Но чтобы корректно ловить disconnect в некоторых клиентах - держим receive loop
while True:
await ws.receive_text()
except WebSocketDisconnect:
await manager.disconnect(ws)
except WebSocketException:
# если прилетит exception после accept — корректно удалим
await manager.disconnect(ws)
raise
except Exception:
await manager.disconnect(ws)

View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
sqlalchemy
asyncpg
pydantic
pydantic-settings
python-dateutil
websockets

176
services/collector/.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

View File

@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
--no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py ./
# Run as non-root user
RUN useradd -m -u 1000 collector && chown -R collector:collector /app
USER collector
CMD ["python", "main.py"]

View File

@@ -0,0 +1,34 @@
# services/collector
## Назначение
Сервис, который читает бинарные пакеты с STM32 через USB CDC, валидирует и логирует их, пишет данные в TimescaleDB и рассылает в реальном времени по WebSocket.
## Основные компоненты
- `serial_reader.py` — асинхронное чтение порта `SERIAL_PORT` (`/dev/ttyACM0` по умолчанию), nonblocking read, reconnectлогика.
- `protocol_parser.py` — парсер протокола: поиск SOF, проверка длины, CRC8/ATM, конвертация `rms_db_x10` → float.
- `audio_validator.py` — проверка диапазонов: `rms_db ∈ [-50.0, 0.0]`, `frequency_hz ∈ [100, 8000]`, детекция и пометка тишины.
- `db_writer.py` — batchзапись в PostgreSQL/TimescaleDB (`audio_data`), батч по количеству/таймеру.
- `ws_app.py` + `ws_manager.py` — WebSocket сервер, который бродкастит последние метрики всем подписчикам.
- `monitor.py` — вывод статистики: пакетов/с, CRCошибки, lengthошибки, rangeошибки.
## Формат данных в WebSocket
```json
{
"time": "2025-12-25T19:00:00Z",
"rms_db": -18.5,
"freq_hz": 440
}
```
Время берётся по хосту при приёме пакета, а не из MCUтаймстампа, чтобы быть в одной временной зоне с БД.
## Конфигурация
Через переменные окружения (см. `.example.env` и `docker-compose.yml`):
- `SERIAL_PORT`, `BAUDRATE`.
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`.
- `WS_HOST`, `WS_PORT` (по умолчанию `0.0.0.0:8000`, наружу проброшен как `8001`).

View File

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
FR-2.2: Audio Data Validation
Validates audio metrics against expected ranges
"""
from typing import NamedTuple
class ValidationResult(NamedTuple):
"""Validation result"""
valid: bool
error: str = ""
class AudioValidator:
"""
Validates audio metrics against hardware constraints and realistic ranges.
"""
# Hardware constraints (from FR spec)
RMS_MIN_DB = -40.0 # Noise floor
RMS_MAX_DB = 80.0 # Clipping threshold
FREQ_MIN_HZ = 100 # Below = unreliable (FFT bin size ~43Hz)
FREQ_MAX_HZ = 8000 # Nyquist @ 22.05kHz with safety margin
# Extended ranges for detection (not storage)
FREQ_MIN_EXTENDED_HZ = 20
FREQ_MAX_EXTENDED_HZ = 11000
@staticmethod
def validate_rms(rms_db: float) -> ValidationResult:
"""
Validate RMS value.
Args:
rms_db: RMS in dB
Returns:
ValidationResult with valid flag and error message
"""
if not isinstance(rms_db, (int, float)):
return ValidationResult(False, "RMS must be numeric")
if rms_db < AudioValidator.RMS_MIN_DB:
return ValidationResult(
False, f"RMS {rms_db:.1f}dB below minimum {AudioValidator.RMS_MIN_DB}dB"
)
if rms_db > AudioValidator.RMS_MAX_DB:
return ValidationResult(
False,
f"RMS {rms_db:.1f}dB exceeds maximum {AudioValidator.RMS_MAX_DB}dB",
)
return ValidationResult(True)
@staticmethod
def validate_frequency(freq_hz: int, strict: bool = True) -> ValidationResult:
"""
Validate frequency value.
Args:
freq_hz: Frequency in Hz
strict: If True, use tight range (100-8000Hz), else extended (20-11000Hz)
Returns:
ValidationResult with valid flag and error message
"""
if not isinstance(freq_hz, int):
return ValidationResult(False, "Frequency must be integer")
if strict:
min_hz = AudioValidator.FREQ_MIN_HZ
max_hz = AudioValidator.FREQ_MAX_HZ
else:
min_hz = AudioValidator.FREQ_MIN_EXTENDED_HZ
max_hz = AudioValidator.FREQ_MAX_EXTENDED_HZ
if freq_hz < min_hz:
return ValidationResult(
False, f"Frequency {freq_hz}Hz below minimum {min_hz}Hz"
)
if freq_hz > max_hz:
return ValidationResult(
False, f"Frequency {freq_hz}Hz exceeds maximum {max_hz}Hz"
)
return ValidationResult(True)
@staticmethod
def validate_packet(rms_db: float, freq_hz: int) -> ValidationResult:
"""
Validate complete audio packet.
Args:
rms_db: RMS in dB
freq_hz: Frequency in Hz
Returns:
ValidationResult with valid flag and error message
"""
rms_result = AudioValidator.validate_rms(rms_db)
if not rms_result.valid:
return rms_result
freq_result = AudioValidator.validate_frequency(freq_hz, strict=True)
if not freq_result.valid:
return freq_result
return ValidationResult(True)

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
FR-2.3: Database Writer with Batch Processing
Buffers audio metrics and writes in batches
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import List, Optional, final
from dataclasses import dataclass
import asyncpg
logger = logging.getLogger(__name__)
@dataclass
class AudioRecord:
"""Single audio measurement record"""
timestamp: datetime
rms_db: float
freq_hz: int
is_silence: bool
class DatabaseWriter:
"""
Batched database writer for audio metrics.
Flushes on: 50 records OR 5 seconds timeout
"""
BATCH_SIZE = 50
BATCH_TIMEOUT: float = 5.0 # seconds
SILENCE_THRESHOLD_DB: float = -30.0 # dB below = silence
def __init__(self, db_url: str):
self.db_url = db_url
self.pool: Optional[asyncpg.Pool] = None
self.buffer: List[AudioRecord] = []
self.last_flush_time = asyncio.get_event_loop().time()
self._flush_task: Optional[asyncio.Task] = None
self._running = False
async def connect(self):
"""Establish database connection pool"""
try:
self.pool = await asyncpg.create_pool(
self.db_url, min_size=2, max_size=5, command_timeout=10.0
)
logger.info("Database connection pool established")
# Test connection
async with self.pool.acquire() as conn:
await conn.fetchval("SELECT 1")
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
raise
async def close(self):
"""Close database connection and flush remaining data"""
self._running = False
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
await self.flush()
if self.pool:
await self.pool.close()
logger.info("Database connection closed")
async def start_auto_flush(self):
"""Start background task for timeout-based flushing"""
self._running = True
self._flush_task = asyncio.create_task(self._auto_flush_loop())
async def _auto_flush_loop(self):
"""Background task: flush buffer every BATCH_TIMEOUT seconds"""
while self._running:
try:
await asyncio.sleep(self.BATCH_TIMEOUT)
current_time = asyncio.get_event_loop().time()
if self.buffer and (
current_time - self.last_flush_time >= self.BATCH_TIMEOUT
):
await self.flush()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in auto-flush loop: {e}")
async def add_record(self, timestamp_ms: int, rms_db: float, freq_hz: int):
"""
Add single record to buffer. Flushes if batch size reached.
Args:
timestamp_ms: MCU timestamp in milliseconds
rms_db: RMS value in dB
freq_hz: Dominant frequency in Hz
"""
# Convert timestamp to datetime (use current time with ms offset)
# Note: MCU timestamp wraps around, use server time for absolute reference
timestamp = datetime.now(timezone.utc)
# Detect silence
is_silence = rms_db < self.SILENCE_THRESHOLD_DB
record = AudioRecord(
timestamp=timestamp, rms_db=rms_db, freq_hz=freq_hz, is_silence=is_silence
)
self.buffer.append(record)
logger.debug(
f"Buffered: rms={rms_db:.1f}dB freq={freq_hz}Hz "
f"silence={is_silence} (buffer={len(self.buffer)})"
)
if len(self.buffer) >= self.BATCH_SIZE:
await self.flush()
async def flush(self):
"""Write all buffered records to database"""
if not self.buffer:
return
if not self.pool:
logger.error("Database pool not initialized, cannot flush")
return
records_to_write = self.buffer[:]
self.buffer.clear()
self.last_flush_time = asyncio.get_event_loop().time()
try:
async with self.pool.acquire() as conn:
# Prepare batch insert
records = [
(r.timestamp, r.rms_db, r.freq_hz, r.is_silence)
for r in records_to_write
]
await conn.executemany(
"""
INSERT INTO audio_data (time, rms_db, frequency_hz, is_silence)
VALUES ($1, $2, $3, $4)
""",
records,
)
logger.info(f"Flushed {len(records)} records to database")
except Exception as e:
logger.error(f"Failed to write batch to database: {e}")
# Re-add to buffer for retry (optional, could cause memory issues)
# self.buffer.extend(records_to_write)

248
services/collector/main.py Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
FR-2: Audio Data Collector Service with WebSocket Live Streaming
Reads audio metrics from STM32, validates, writes to DB, and streams via WebSocket.
"""
from __future__ import annotations
import asyncio
import logging
import os
import signal
import sys
from contextlib import suppress
from datetime import datetime, timezone
from typing import Callable, Optional
import uvicorn
from audio_validator import AudioValidator
from db_writer import DatabaseWriter
from protocol_parser import AudioMetrics
from serial_reader import SerialReader
from ws_app import app as ws_app
from ws_app import manager
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def _iso_z(dt: datetime) -> str:
"""Format datetime as ISO8601 with 'Z' suffix (UTC)."""
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
class CollectorService:
"""Main collector service: serial → validate → DB + WebSocket."""
def __init__(self, serial_port: str, db_url: str, baudrate: int = 115200) -> None:
self.serial_reader = SerialReader(
port=serial_port, baudrate=baudrate, on_packet=self._handle_packet
)
self.db_writer = DatabaseWriter(db_url=db_url)
self.validator = AudioValidator()
self._shutdown_event = asyncio.Event()
# WebSocket broadcast queue (bounded to prevent memory issues)
self._ws_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=200)
self._ws_broadcast_task: Optional[asyncio.Task[None]] = None
# Uvicorn server for WebSocket endpoint
self._uvicorn_server: Optional[uvicorn.Server] = None
self._ws_server_task: Optional[asyncio.Task[None]] = None
def shutdown(self) -> None:
"""Trigger graceful shutdown (called from signal handler)."""
logger.info("Shutdown requested")
self._shutdown_event.set()
async def _ws_broadcast_loop(self) -> None:
"""Background task: consume queue and broadcast to WebSocket clients."""
try:
while True:
msg = await self._ws_queue.get()
try:
await manager.broadcast_json(msg)
except Exception as e:
logger.error("WS broadcast error: %s", e)
finally:
self._ws_queue.task_done()
except asyncio.CancelledError:
logger.debug("WS broadcast loop cancelled")
async def _start_ws_server(self) -> None:
"""Start uvicorn server for WebSocket endpoint."""
host = os.getenv("WS_HOST", "0.0.0.0")
port = int(os.getenv("WS_PORT", "8001"))
config = uvicorn.Config(
ws_app,
host=host,
port=port,
log_level="warning",
loop="asyncio",
access_log=False,
)
self._uvicorn_server = uvicorn.Server(config)
try:
logger.info("Starting WebSocket server on ws://%s:%d/ws/live", host, port)
await self._uvicorn_server.serve()
except SystemExit as e:
logger.error("WS server failed (port %d already in use?): %s", port, e)
self.shutdown()
except Exception as e:
logger.exception("WS server crashed: %s", e)
self.shutdown()
async def _handle_packet(self, packet: AudioMetrics) -> None:
"""
Process received audio packet: validate, write to DB, push to WebSocket.
Args:
packet: Parsed audio metrics from STM32
"""
# Validate packet
validation = self.validator.validate_packet(packet.rms_db, packet.freq_hz)
if not validation.valid:
logger.warning(
"Invalid packet: %s (rms=%.1fdB freq=%dHz)",
validation.error,
packet.rms_db,
packet.freq_hz,
)
return
# Push to WebSocket queue (non-blocking)
msg = {
"time": _iso_z(datetime.now(timezone.utc)),
"rms_db": float(packet.rms_db),
"freq_hz": int(packet.freq_hz),
}
try:
self._ws_queue.put_nowait(msg)
except asyncio.QueueFull:
# Drop oldest message if queue full
try:
_ = self._ws_queue.get_nowait()
self._ws_queue.task_done()
except asyncio.QueueEmpty:
pass
self._ws_queue.put_nowait(msg)
# Write to database
try:
await self.db_writer.add_record(
timestamp_ms=packet.timestamp_ms,
rms_db=packet.rms_db,
freq_hz=packet.freq_hz,
)
except Exception as e:
logger.error("Failed to write to database: %s", e)
async def start(self) -> None:
"""Start collector service: DB, WS, serial reader."""
logger.info("Starting Audio Data Collector Service")
try:
# Connect to database
await self.db_writer.connect()
await self.db_writer.start_auto_flush()
# Start WebSocket server and broadcaster
self._ws_broadcast_task = asyncio.create_task(self._ws_broadcast_loop())
self._ws_server_task = asyncio.create_task(self._start_ws_server())
# Give uvicorn a moment to bind (avoid race on port check)
await asyncio.sleep(0.5)
# Connect to serial port
await self.serial_reader.connect()
await self.serial_reader.start_reading()
logger.info("Service started successfully")
# Wait for shutdown signal
await self._shutdown_event.wait()
except Exception as e:
logger.error("Service startup failed: %s", e)
raise
finally:
await self.stop()
async def stop(self) -> None:
"""Stop collector service gracefully."""
logger.info("Stopping Audio Data Collector Service")
# Stop serial reader
await self.serial_reader.disconnect()
# Stop WebSocket server
if self._uvicorn_server is not None:
self._uvicorn_server.should_exit = True
if self._ws_server_task is not None:
self._ws_server_task.cancel()
with suppress(asyncio.CancelledError, SystemExit, Exception):
await self._ws_server_task
# Stop WebSocket broadcaster
if self._ws_broadcast_task is not None:
self._ws_broadcast_task.cancel()
with suppress(asyncio.CancelledError):
await self._ws_broadcast_task
# Close database writer (flushes remaining data)
await self.db_writer.close()
logger.info("Service stopped")
async def _amain() -> None:
"""Async main entry point."""
# Read configuration from environment
serial_port = os.getenv("SERIAL_PORT", "/dev/ttyACM0")
baudrate = int(os.getenv("BAUDRATE", "115200"))
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "5432")
db_name = os.getenv("DB_NAME", "audio_analyzer")
db_user = os.getenv("DB_USER", "postgres")
db_password = os.getenv("DB_PASSWORD", "postgres")
db_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
# Create service
service = CollectorService(
serial_port=serial_port, db_url=db_url, baudrate=baudrate
)
# Setup signal handlers for graceful shutdown
loop = asyncio.get_running_loop()
shutdown_callback: Callable[[], None] = service.shutdown
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown_callback)
await service.start()
def main() -> None:
"""Main entry point."""
try:
asyncio.run(_amain())
except KeyboardInterrupt:
logger.info("Interrupted by user")
except Exception:
logger.exception("Service error")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import argparse
import time
import serial
from protocol_parser import ProtocolParser
def main():
ap = argparse.ArgumentParser(description="FR-1.4 binary stream monitor")
ap.add_argument("--port", default="/dev/ttyACM0")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--timeout", type=float, default=0.2)
args = ap.parse_args()
parser = ProtocolParser()
with serial.Serial(args.port, args.baud, timeout=args.timeout) as ser:
while True:
data = ser.read(ser.in_waiting or 1)
if not data:
continue
packets = parser.feed(data)
st = parser.get_stats()
for pkt in packets:
# Одна строка на пакет + счётчик CRC ошибок
print(
f"{pkt.timestamp_ms:010d} "
f"rms_db={pkt.rms_db:+6.1f} "
f"freq_hz={pkt.freq_hz:4d} "
f"crc_err={st.crc_errors}"
)
time.sleep(0.001)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
FR-1.4 Data Transmission Protocol Parser (v1)
12-byte binary packet: [0xAA][TYPE=0x02][LEN=8][TIMESTAMP(4)][RMS_DB(2)][FREQ_HZ(2)][CRC8(1)]
"""
import struct
from typing import Optional, NamedTuple
from dataclasses import dataclass
class AudioMetrics(NamedTuple):
"""Parsed audio metrics packet"""
timestamp_ms: int
rms_db: float
freq_hz: int
valid: bool = True
@dataclass
class ProtocolStats:
"""Protocol statistics"""
packets_received: int = 0
crc_errors: int = 0
length_errors: int = 0
range_errors: int = 0
class ProtocolParser:
"""
Stream parser for FR-1.4 protocol with automatic resynchronization.
"""
SOF = 0xAA
TYPE_AUDIO_V1 = 0x02
PAYLOAD_LEN = 0x08
PACKET_SIZE = 12
def __init__(self):
self.buffer = bytearray()
self.stats = ProtocolStats()
@staticmethod
def _crc8_atm(data: bytes) -> int:
"""CRC-8/ATM: poly=0x07, init=0x00, refin=false, refout=false, xorout=0x00"""
crc = 0x00
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x80:
crc = ((crc << 1) ^ 0x07) & 0xFF
else:
crc = (crc << 1) & 0xFF
return crc
def feed(self, data: bytes) -> list[AudioMetrics]:
"""
Feed incoming bytes, return list of parsed packets.
Args:
data: Raw bytes from serial port
Returns:
List of successfully parsed AudioMetrics
"""
self.buffer.extend(data)
packets = []
while len(self.buffer) >= self.PACKET_SIZE:
# Find SOF
sof_idx = self.buffer.find(self.SOF)
if sof_idx == -1:
# No SOF found, discard all but last byte
self.buffer = self.buffer[-1:]
break
# Discard bytes before SOF
if sof_idx > 0:
self.buffer = self.buffer[sof_idx:]
# Need at least 3 bytes for SOF + TYPE + LEN
if len(self.buffer) < 3:
break
packet_type = self.buffer[1]
payload_len = self.buffer[2]
# Validate TYPE and LEN
if packet_type != self.TYPE_AUDIO_V1 or payload_len != self.PAYLOAD_LEN:
self.stats.length_errors += 1
self.buffer.pop(0) # Remove false SOF, retry
continue
# Full packet size = SOF(1) + TYPE(1) + LEN(1) + PAYLOAD(8) + CRC(1) = 12
total_len = 3 + payload_len + 1
if len(self.buffer) < total_len:
break # Wait for more data
packet = bytes(self.buffer[:total_len])
# Verify CRC (over bytes 1..10: TYPE, LEN, payload)
crc_data = packet[1:11]
expected_crc = packet[11]
calculated_crc = self._crc8_atm(crc_data)
if calculated_crc != expected_crc:
self.stats.crc_errors += 1
self.buffer.pop(0) # Remove bad packet, retry
continue
# Parse payload (little-endian)
timestamp_ms, rms_db_x10, freq_hz = struct.unpack_from('<IhH', packet, 3)
# Convert and validate ranges
rms_db = rms_db_x10 / 10.0
valid = True
if not (-40.0 <= rms_db <= 80.0) or not (100 <= freq_hz <= 8000):
self.stats.range_errors += 1
valid = False
self.stats.packets_received += 1
packets.append(AudioMetrics(
timestamp_ms=timestamp_ms,
rms_db=rms_db,
freq_hz=freq_hz,
valid=valid
))
# Remove processed packet
self.buffer = self.buffer[total_len:]
return packets
def get_stats(self) -> ProtocolStats:
"""Get current statistics"""
return self.stats

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Example client for FR-1.4 protocol
"""
import serial
import time
from protocol_parser import ProtocolParser
def main():
SERIAL_PORT = "/dev/ttyACM0"
BAUDRATE = 115200
parser = ProtocolParser()
try:
with serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1) as ser:
print(f"Connected to {SERIAL_PORT}")
while True:
# Read available data
if ser.in_waiting > 0:
data = ser.read(ser.in_waiting)
# Parse packets
packets = parser.feed(data)
for pkt in packets:
if pkt.valid:
print(
f"[{pkt.timestamp_ms:010d}] "
f"RMS: {pkt.rms_db:+6.1f} dB "
f"Freq: {pkt.freq_hz:4d} Hz"
)
else:
print(f"[WARN] Invalid packet: {pkt}")
# Show stats every 30 packets
stats = parser.get_stats()
if stats.packets_received % 30 == 0 and stats.packets_received > 0:
print(
f"Stats: RX={stats.packets_received} "
f"CRC_err={stats.crc_errors} "
f"LEN_err={stats.length_errors} "
f"Range_err={stats.range_errors}"
)
time.sleep(0.01)
except KeyboardInterrupt:
print("\nExiting...")
except serial.SerialException as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
pyserial
asyncpg
numpy
pytest
pytest-asyncio
fastapi
uvicorn
websockets

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
FR-2.1: Asynchronous Serial Reader
Reads binary packets from STM32 via USB CDC
"""
import asyncio
import logging
from typing import Callable, Awaitable
import serial
from serial import SerialException
from protocol_parser import ProtocolParser, AudioMetrics
logger = logging.getLogger(__name__)
class SerialReader:
"""
Asynchronous serial port reader with protocol parsing.
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
on_packet: Callable[[AudioMetrics], Awaitable[None]] = None,
):
"""
Initialize serial reader.
Args:
port: Serial port path (e.g., /dev/ttyACM0)
baudrate: Serial baudrate (default 115200)
on_packet: Async callback for each received packet
"""
self.port = port
self.baudrate = baudrate
self.on_packet = on_packet
self.serial: serial.Serial = None
self.parser = ProtocolParser()
self._running = False
self._read_task: asyncio.Task = None
async def connect(self):
"""Open serial port connection"""
try:
self.serial = serial.Serial(
self.port, self.baudrate, timeout=0.1, write_timeout=1.0
)
logger.info(f"Connected to {self.port} @ {self.baudrate} baud")
except SerialException as e:
logger.error(f"Failed to open serial port {self.port}: {e}")
raise
async def disconnect(self):
"""Close serial port connection"""
self._running = False
if self._read_task and not self._read_task.done():
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
if self.serial and self.serial.is_open:
self.serial.close()
logger.info("Serial port closed")
async def start_reading(self):
"""Start background task for reading serial data"""
self._running = True
self._read_task = asyncio.create_task(self._read_loop())
async def _read_loop(self):
"""Background task: continuously read and parse serial data"""
while self._running:
try:
# Read available data (non-blocking due to timeout)
if self.serial.in_waiting > 0:
data = self.serial.read(self.serial.in_waiting or 1)
if data:
# Parse packets
packets = self.parser.feed(data)
# Process each packet
for packet in packets:
if self.on_packet:
try:
await self.on_packet(packet)
except Exception as e:
logger.error(f"Error in packet handler: {e}")
# Log statistics periodically
stats = self.parser.get_stats()
if stats.packets_received % 100 == 0:
logger.info(
f"Stats: RX={stats.packets_received} "
f"CRC_err={stats.crc_errors} "
f"LEN_err={stats.length_errors} "
f"Range_err={stats.range_errors}"
)
# Small delay to prevent CPU spinning
await asyncio.sleep(0.01)
except SerialException as e:
logger.error(f"Serial read error: {e}")
self._running = False
break
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Unexpected error in read loop: {e}")
def get_stats(self):
"""Get protocol parser statistics"""
return self.parser.get_stats()

View File

@@ -0,0 +1,81 @@
import struct
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from protocol_parser import ProtocolParser
def build_packet(timestamp_ms: int, rms_db_x10: int, freq_hz: int) -> bytes:
sof = bytes([ProtocolParser.SOF])
header = bytes([ProtocolParser.TYPE_AUDIO_V1, ProtocolParser.PAYLOAD_LEN])
payload = struct.pack("<IhH", timestamp_ms, rms_db_x10, freq_hz)
crc_data = header + payload # bytes 1..10 in the wire format
crc = ProtocolParser._crc8_atm(crc_data)
return sof + header + payload + bytes([crc])
def test_valid_packet():
p = ProtocolParser()
raw = build_packet(timestamp_ms=1234, rms_db_x10=-123, freq_hz=440)
packets = p.feed(raw)
assert len(packets) == 1
pkt = packets[0]
assert pkt.valid is True
assert pkt.timestamp_ms == 1234
assert pkt.rms_db == -12.3
assert pkt.freq_hz == 440
st = p.get_stats()
assert st.packets_received == 1
assert st.crc_errors == 0
def test_bad_crc_packet():
p = ProtocolParser()
raw = bytearray(build_packet(timestamp_ms=1, rms_db_x10=-10, freq_hz=1000))
raw[-1] ^= 0xFF # ломаем CRC
packets = p.feed(bytes(raw))
assert packets == []
st = p.get_stats()
assert st.packets_received == 0
assert st.crc_errors == 1
def test_garbage_then_valid_packet_resync():
p = ProtocolParser()
garbage = b"\x00\xff\xaa\x01\x02\x03\x04" # содержит ложный SOF и мусор
raw = garbage + build_packet(timestamp_ms=777, rms_db_x10=-321, freq_hz=1234)
packets = p.feed(raw)
assert len(packets) == 1
assert packets[0].timestamp_ms == 777
assert packets[0].rms_db == -32.1
assert packets[0].freq_hz == 1234
assert p.get_stats().length_errors >= 1 # парсер должен "проглотить" мусор
def test_two_packets_in_one_chunk():
p = ProtocolParser()
raw = build_packet(timestamp_ms=10, rms_db_x10=-100, freq_hz=500) + build_packet(
timestamp_ms=20, rms_db_x10=-200, freq_hz=600
)
packets = p.feed(raw)
assert len(packets) == 2
assert packets[0].timestamp_ms == 10
assert packets[1].timestamp_ms == 20

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""FastAPI WebSocket endpoint with rate limiting support."""
from __future__ import annotations
import logging
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from ws_manager import ConnectionManager
logger = logging.getLogger(__name__)
app = FastAPI(title="Audio Analyzer WebSocket")
manager = ConnectionManager()
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok"}
@app.websocket("/ws/live")
async def ws_live(
websocket: WebSocket,
hz: float = Query(default=10.0, ge=0.1, le=100.0, description="Update rate in Hz"),
) -> None:
"""
WebSocket endpoint for real-time audio data streaming.
Query parameters:
hz: Update rate in Hz (0.1 - 100.0, default: 10.0)
Examples:
- ws://localhost:8001/ws/live?hz=10 → 10 messages/sec
- ws://localhost:8001/ws/live?hz=1 → 1 message/sec
- ws://localhost:8001/ws/live?hz=30 → 30 messages/sec
"""
await manager.connect(websocket, rate_hz=hz)
try:
while True:
# Keep connection alive; client can send pings
await websocket.receive_text()
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("WS connection error: %s", e)
finally:
await manager.disconnect(websocket)

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""WebSocket connection manager with per-client rate limiting and median aggregation."""
from __future__ import annotations
import asyncio
import json
import logging
import statistics
from typing import Any
from starlette.websockets import WebSocket, WebSocketState
logger = logging.getLogger(__name__)
class ThrottledClient:
"""WebSocket client wrapper with rate limiting and median aggregation."""
def __init__(self, ws: WebSocket, rate_hz: float) -> None:
self.ws = ws
self.rate_hz = rate_hz
self.interval = 1.0 / rate_hz if rate_hz > 0 else 0.0
self._last_send_time = 0.0
# Accumulator for aggregation (list of samples within current window)
self._buffer: list[dict[str, Any]] = []
self._buffer_lock = asyncio.Lock()
self._task: asyncio.Task[None] | None = None
async def start(self) -> None:
"""Start background sender task."""
self._task = asyncio.create_task(self._send_loop())
async def stop(self) -> None:
"""Stop background sender task."""
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def enqueue(self, message: dict[str, Any]) -> None:
"""Add message to aggregation buffer."""
async with self._buffer_lock:
self._buffer.append(message)
# Limit buffer size (prevent memory issues if rate is very low)
if len(self._buffer) > 1000:
self._buffer.pop(0)
async def _send_loop(self) -> None:
"""Background task: aggregate and send messages respecting rate limit."""
try:
while True:
# Wait for next send window
if self.interval > 0:
now = asyncio.get_event_loop().time()
elapsed = now - self._last_send_time
if elapsed < self.interval:
await asyncio.sleep(self.interval - elapsed)
self._last_send_time = asyncio.get_event_loop().time()
else:
await asyncio.sleep(0.01) # Minimal delay
# Get accumulated samples
async with self._buffer_lock:
if not self._buffer:
continue
samples = self._buffer[:]
self._buffer.clear()
# Aggregate: compute median
aggregated = self._aggregate_median(samples)
# Send aggregated message
try:
if self.ws.client_state == WebSocketState.CONNECTED:
payload = json.dumps(
aggregated, ensure_ascii=False, separators=(",", ":")
)
await self.ws.send_text(payload)
else:
break # Connection closed
except Exception:
break # Send failed
except asyncio.CancelledError:
pass
@staticmethod
def _aggregate_median(samples: list[dict[str, Any]]) -> dict[str, Any]:
"""
Aggregate samples by computing median of rms_db and freq_hz.
Args:
samples: List of raw messages
Returns:
Aggregated message with median values
"""
if not samples:
return {}
if len(samples) == 1:
return samples[0]
# Extract numeric values
rms_values = [s["rms_db"] for s in samples if "rms_db" in s]
freq_values = [s["freq_hz"] for s in samples if "freq_hz" in s]
# Compute medians
rms_median = statistics.median(rms_values) if rms_values else 0.0
freq_median = statistics.median(freq_values) if freq_values else 0
# Use timestamp from last sample (most recent)
time_value = samples[-1].get("time", "")
return {
"time": time_value,
"rms_db": round(rms_median, 1),
"freq_hz": int(round(freq_median)),
}
class ConnectionManager:
"""Manages WebSocket connections with per-client rate limiting."""
def __init__(self) -> None:
self._clients: dict[WebSocket, ThrottledClient] = {}
self._lock = asyncio.Lock()
async def connect(self, ws: WebSocket, rate_hz: float = 10.0) -> None:
"""Accept and register new WebSocket connection with rate limiting."""
await ws.accept()
client = ThrottledClient(ws, rate_hz)
await client.start()
async with self._lock:
self._clients[ws] = client
logger.info(
"WS client connected (rate=%.1fHz, total=%d)", rate_hz, len(self._clients)
)
async def disconnect(self, ws: WebSocket) -> None:
"""Remove WebSocket connection and stop its sender."""
async with self._lock:
client = self._clients.pop(ws, None)
if client:
await client.stop()
logger.info("WS client disconnected (total=%d)", len(self._clients))
async def broadcast_json(self, message: dict[str, Any]) -> None:
"""Broadcast JSON message to all connected clients (respecting per-client rates)."""
async with self._lock:
clients = list(self._clients.values())
dead: list[WebSocket] = []
for client in clients:
try:
await client.enqueue(message)
except Exception:
dead.append(client.ws)
if dead:
async with self._lock:
for ws in dead:
self._clients.pop(ws, None)
logger.debug("WS cleanup: removed %d dead clients", len(dead))

24
services/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1 @@
/home/mikhail/repos/sound-analizer/services/api/.gitignore

View File

@@ -0,0 +1,13 @@
# Node 20 + Vite dev server
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3877
services/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.4",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,9 @@
import { DashboardPage } from "../pages/dashboard/ui/DashboardPage";
export function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<DashboardPage />
</div>
);
}

View File

@@ -0,0 +1,7 @@
import type { PropsWithChildren } from "react";
export function WithLiveStream({ children }: PropsWithChildren) {
// placeholder for future providers
// theme, router, query-client, etc.
return children;
}

View File

@@ -0,0 +1,137 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 4s linear infinite;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,31 @@
const NOTE_NAMES = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
] as const;
export type NoteName = `${(typeof NOTE_NAMES)[number]}${number}`;
/**
* A4 = 440 Hz, MIDI 69.
* Returns nearest tempered note (e.g. 440 -> A4).
*/
export function freqToNote(freqHz: number): NoteName | "--" {
if (!Number.isFinite(freqHz) || freqHz <= 0) return "--";
const midi = 69 + 12 * Math.log2(freqHz / 440);
const midiRounded = Math.round(midi);
const name = NOTE_NAMES[((midiRounded % 12) + 12) % 12];
const octave = Math.floor(midiRounded / 12) - 1;
return `${name}${octave}` as NoteName;
}

View File

@@ -0,0 +1,6 @@
export type AudioSample = {
time: string; // ISO
timeMs: number;
rms_db: number; // expected [-50..0]
freq_hz: number; // expected [20..8000]
};

View File

@@ -0,0 +1,87 @@
import type { WsStatus } from "../model/types";
export type LiveWsClientHandlers = {
onStatus: (status: WsStatus) => void;
onMessage: (data: unknown) => void;
};
export class LiveWsClient {
private ws: WebSocket | null = null;
private closedByUser = false;
private retryAttempt = 0;
private retryTimer: number | null = null;
constructor(
private readonly url: string,
private readonly handlers: LiveWsClientHandlers,
) {}
connect(): void {
this.closedByUser = false;
this.open("connecting");
}
close(): void {
this.closedByUser = true;
this.clearRetry();
this.handlers.onStatus("disconnected");
this.ws?.close();
this.ws = null;
}
private open(status: WsStatus): void {
this.handlers.onStatus(status);
try {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.onopen = () => {
this.retryAttempt = 0;
this.handlers.onStatus("open");
};
ws.onmessage = (ev) => {
this.handlers.onMessage(ev.data);
};
ws.onerror = () => {
// let onclose handle reconnect
};
ws.onclose = () => {
this.ws = null;
if (this.closedByUser) return;
this.scheduleReconnect();
};
} catch {
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
this.handlers.onStatus("reconnecting");
// backoff: 0.5s, 1s, 2s, 4s ... max 10s (+ jitter)
const base = 500 * Math.pow(2, this.retryAttempt);
const capped = Math.min(base, 10_000);
const jitter = capped * (Math.random() * 0.2); // 0..20%
const delay = Math.round(capped + jitter);
this.retryAttempt += 1;
this.clearRetry();
this.retryTimer = window.setTimeout(() => {
if (this.closedByUser) return;
this.open("reconnecting");
}, delay);
}
private clearRetry(): void {
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
}

View File

@@ -0,0 +1,60 @@
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { parseIsoToMs } from "../../../shared/lib/time";
type RawMessage = {
time: unknown;
rms_db: unknown;
freq_hz: unknown;
};
export type ParseResult =
| { ok: true; sample: AudioSample }
| { ok: false; reason: string };
function devWarn(...args: unknown[]) {
if (import.meta.env.DEV) console.warn(...args);
}
export function parseAndValidateMessage(data: unknown): ParseResult {
let obj: RawMessage;
try {
obj = JSON.parse(String(data)) as RawMessage;
} catch {
devWarn("[liveStream] drop: invalid JSON", data);
return { ok: false, reason: "invalid_json" };
}
if (typeof obj.time !== "string") {
devWarn("[liveStream] drop: time is not string", obj);
return { ok: false, reason: "invalid_time_type" };
}
const timeMs = parseIsoToMs(obj.time);
if (timeMs === null) {
devWarn("[liveStream] drop: time is not ISO", obj.time);
return { ok: false, reason: "invalid_time_value" };
}
const rms = typeof obj.rms_db === "number" ? obj.rms_db : Number.NaN;
const freq = typeof obj.freq_hz === "number" ? obj.freq_hz : Number.NaN;
if (!Number.isFinite(rms) || rms < -50 || rms > 0) {
devWarn("[liveStream] drop: rms_db out of range", rms);
return { ok: false, reason: "rms_out_of_range" };
}
if (!Number.isFinite(freq) || freq < 20 || freq > 8000) {
devWarn("[liveStream] drop: freq_hz out of range", freq);
return { ok: false, reason: "freq_out_of_range" };
}
return {
ok: true,
sample: {
time: obj.time,
timeMs,
rms_db: rms,
freq_hz: freq,
},
};
}

View File

@@ -0,0 +1,260 @@
import { create } from "zustand";
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { env } from "../../../shared/config/env";
import { RingBuffer } from "../../../shared/lib/ringBuffer";
import type { WsStatus } from "./types";
import { LiveWsClient } from "../lib/liveWsClient";
import { parseAndValidateMessage } from "../lib/parseAndValidate";
type LiveStreamState = {
status: WsStatus;
lastMessageAt: number | null;
// WS frequency control
requestedHz: number; // 0.1-60
setRequestedHz: (hz: number) => void;
// Window selection
windowMs: number;
setWindowMs: (ms: number) => void;
// Derived UI state (throttled)
latest: AudioSample | null;
peakHoldDb3s: number | null;
chartHistory: AudioSample[];
connect: () => void;
disconnect: () => void;
loadLatest: (limit?: number) => Promise<void>;
};
const PEAK_WINDOW_MS = 3_000;
const WINDOW_OPTIONS_MS = [15_000, 30_000, 60_000, 120_000, 300_000] as const;
const DEFAULT_WINDOW_MS = 60_000;
const MIN_HZ = 0.1;
// const MAX_HZ = 60;
const DEFAULT_REQUESTED_HZ = 10;
// If there are more than 600 points in selected window -> downsample
const MAX_CHART_POINTS = 600;
// UI updates not more than ~12 Hz
const UI_FLUSH_HZ = 12;
const UI_FLUSH_MS = Math.round(1000 / UI_FLUSH_HZ);
// Max window is 5m; worst-case 60 Hz => 18k points (+ headroom)
const RAW_CAPACITY = 20_000;
// -------- Module-level raw buffers (NOT in Zustand state) --------
const rawHistory = new RingBuffer<AudioSample>(RAW_CAPACITY);
let peakWindow: AudioSample[] = [];
let client: LiveWsClient | null = null;
let flushTimer: number | null = null;
let lastSeenSample: AudioSample | null = null;
function clampInt(v: number, min: number): number {
if (!Number.isFinite(v)) return min;
return Math.max(min, v);
}
function isAllowedWindowMs(
ms: number,
): ms is (typeof WINDOW_OPTIONS_MS)[number] {
return (WINDOW_OPTIONS_MS as readonly number[]).includes(ms);
}
function buildWsUrl(base: string, hz: number): string {
const safeHz = clampInt(hz, MIN_HZ);
// Prefer URL() for correctness (keeps existing params)
try {
const u = new URL(base);
u.searchParams.set("hz", String(safeHz));
return u.toString();
} catch {
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}hz=${encodeURIComponent(String(safeHz))}`;
}
}
function trimPeakWindow(nowSampleMs: number): void {
const cutoff = nowSampleMs - PEAK_WINDOW_MS;
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff)
peakWindow.shift();
// Safety cap: even at 60 Hz, 3 sec ~ 180 points; allow some jitter
if (peakWindow.length > 512) peakWindow = peakWindow.slice(-512);
}
function computePeakDb3s(): number | null {
if (!peakWindow.length) return null;
let max = -Infinity;
for (const s of peakWindow) max = Math.max(max, s.rms_db);
return Number.isFinite(max) ? max : null;
}
function makeChartHistory(windowMs: number): AudioSample[] {
const latest = lastSeenSample ?? rawHistory.last();
if (!latest) return [];
const cutoff = latest.timeMs - windowMs;
// Note: rawHistory.toArray() allocates, but it's called at ~12 Hz, not per WS packet
const windowed = rawHistory.toArray().filter((s) => s.timeMs >= cutoff);
if (windowed.length <= MAX_CHART_POINTS) return windowed;
const step = Math.ceil(windowed.length / MAX_CHART_POINTS);
const out: AudioSample[] = [];
for (let i = 0; i < windowed.length; i += step) out.push(windowed[i]!);
// Ensure last point is present (prevents “missing tail” effect)
const last = windowed.at(-1);
if (last && out.at(-1)?.timeMs !== last.timeMs) out.push(last);
return out;
}
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => {
function clearFlushTimer(): void {
if (flushTimer !== null) {
window.clearTimeout(flushTimer);
flushTimer = null;
}
}
function flushToUi(): void {
clearFlushTimer();
const { windowMs } = get();
const latest = lastSeenSample ?? rawHistory.last();
const chartHistory = makeChartHistory(windowMs);
set({
latest: latest ?? null,
peakHoldDb3s: computePeakDb3s(),
chartHistory,
lastMessageAt: Date.now(),
});
}
function scheduleFlush(): void {
if (flushTimer !== null) return;
flushTimer = window.setTimeout(flushToUi, UI_FLUSH_MS);
}
function ensureClientConnected(): void {
if (client) return;
const hz = get().requestedHz;
const url = buildWsUrl(env.wsUrl, hz);
client = new LiveWsClient(url, {
onStatus: (st) => set({ status: st }),
onMessage: (data) => {
const parsed = parseAndValidateMessage(data);
if (!parsed.ok) return;
const sample = parsed.sample;
lastSeenSample = sample;
rawHistory.push(sample);
peakWindow.push(sample);
trimPeakWindow(sample.timeMs);
// Throttled UI update
scheduleFlush();
},
});
client.connect();
}
function reconnectWithNewHz(): void {
if (!client) return;
client.close();
client = null;
ensureClientConnected();
}
return {
status: "disconnected",
lastMessageAt: null,
requestedHz: DEFAULT_REQUESTED_HZ,
setRequestedHz: (hz) => {
const next = clampInt(hz, MIN_HZ);
const prev = get().requestedHz;
if (next === prev) return;
set({ requestedHz: next });
reconnectWithNewHz();
},
windowMs: DEFAULT_WINDOW_MS,
setWindowMs: (ms) => {
const next = isAllowedWindowMs(ms) ? ms : DEFAULT_WINDOW_MS;
if (next === get().windowMs) return;
set({ windowMs: next });
// Recompute immediately (doesn't touch WS)
flushToUi();
},
latest: null,
peakHoldDb3s: null,
chartHistory: [],
connect: () => {
ensureClientConnected();
},
disconnect: () => {
clearFlushTimer();
client?.close();
client = null;
set({ status: "disconnected" });
// Raw history intentionally preserved to avoid “jumping” on reconnect
},
loadLatest: async (limit = 300) => {
const safeLimit = clampInt(limit, 1);
const base = env.apiUrl.replace(/\/$/, "");
const url = `${base}/api/v1/audio/latest?limit=${safeLimit}`;
try {
const res = await fetch(url);
if (!res.ok) return;
const raw = (await res.json()) as Array<{
time: string;
rms_db: number;
freq_hz: number;
}>;
// Push historical points into raw buffer (no per-item setState!)
for (const item of raw) {
const parsed = parseAndValidateMessage(JSON.stringify(item));
if (!parsed.ok) continue;
rawHistory.push(parsed.sample);
lastSeenSample = parsed.sample;
// Warm-up peak window too (optional but consistent)
peakWindow.push(parsed.sample);
trimPeakWindow(parsed.sample.timeMs);
}
// Single UI update after warm-up
flushToUi();
} catch {
// graceful fallback: ignore (dashboard must stay usable without REST)
}
},
};
});

View File

@@ -0,0 +1 @@
export type WsStatus = "connecting" | "open" | "reconnecting" | "disconnected";

View File

@@ -0,0 +1,15 @@
import { Badge } from "../../../shared/ui/badge";
import type { WsStatus } from "../model/types";
const toneByStatus: Record<WsStatus, string> = {
connecting: "bg-slate-600",
open: "bg-emerald-600",
reconnecting: "bg-amber-600",
disconnected: "bg-rose-600",
};
export function WsStatusBadge({ status }: { status: WsStatus }) {
return (
<Badge className={`${toneByStatus[status]} text-white`}>ws: {status}</Badge>
);
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useLiveStreamStore } from "../model/liveStream.store";
export function useLiveStream(options?: { loadLatest?: boolean }) {
const connect = useLiveStreamStore((s) => s.connect);
const disconnect = useLiveStreamStore((s) => s.disconnect);
const loadLatest = useLiveStreamStore((s) => s.loadLatest);
useEffect(() => {
// 1. Подключаемся по WS
connect();
// 2. Опционально грузим историю, чтобы график не был пустым первые секунды
if (options?.loadLatest) {
void loadLatest(300); // 300 точек ~30 сек при 10Hz
}
return () => disconnect();
}, []);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { WithLiveStream } from "./app/providers/withLiveStream";
import "./app/styles/index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<WithLiveStream>
<App />
</WithLiveStream>
</React.StrictMode>,
);

View File

@@ -0,0 +1,19 @@
import { AudioLiveWidget } from "../../../widgets/audioLive/ui/AudioLiveWidget";
import { useLiveStream } from "../../../features/liveStream/ui/useLiveStream";
export function DashboardPage() {
useLiveStream({ loadLatest: true });
return (
<div className="mx-auto w-full max-w-6xl p-4 md:p-6">
<header className="mb-4">
<h1 className="text-2xl font-semibold">STM32 Панель анализа звука</h1>
<p className="text-sm text-muted-foreground">
Частоты: 129 Гц - 5,5 кГц; Громкость: -50 - 0 дБ
</p>
</header>
<AudioLiveWidget />
</div>
);
}

View File

@@ -0,0 +1,16 @@
export type EnvConfig = {
wsUrl: string;
apiUrl: string;
};
function required(name: string, value: unknown): string {
if (typeof value !== "string" || !value.length) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
export const env: EnvConfig = {
wsUrl: required("VITE_WS_URL", import.meta.env.VITE_WS_URL),
apiUrl: required("VITE_API_URL", import.meta.env.VITE_API_URL),
};

View File

@@ -0,0 +1,50 @@
export class RingBuffer<T> {
private buf: (T | undefined)[];
private head = 0;
private len = 0;
constructor(private readonly capacity: number) {
if (!Number.isFinite(capacity) || capacity <= 0) {
throw new Error("RingBuffer capacity must be > 0");
}
this.buf = new Array<T | undefined>(capacity);
}
size(): number {
return this.len;
}
maxSize(): number {
return this.capacity;
}
clear(): void {
this.head = 0;
this.len = 0;
this.buf.fill(undefined);
}
push(item: T): void {
this.buf[this.head] = item;
this.head = (this.head + 1) % this.capacity;
if (this.len < this.capacity) this.len += 1;
}
toArray(): T[] {
const out: T[] = [];
out.length = this.len;
const start = (this.head - this.len + this.capacity) % this.capacity;
for (let i = 0; i < this.len; i++) {
const idx = (start + i) % this.capacity;
out[i] = this.buf[idx] as T;
}
return out;
}
last(): T | null {
if (this.len === 0) return null;
const idx = (this.head - 1 + this.capacity) % this.capacity;
return (this.buf[idx] as T) ?? null;
}
}

View File

@@ -0,0 +1,13 @@
export function parseIsoToMs(iso: string): number | null {
const ms = Date.parse(iso);
return Number.isFinite(ms) ? ms : null;
}
export function formatTimeHHMMSS(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour12: false });
}
export function isStale(lastMs: number | null, thresholdMs: number): boolean {
if (lastMs === null) return true;
return Date.now() - lastMs > thresholdMs;
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,145 @@
import { useMemo } from "react";
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
import { AudioMeter } from "./AudioMeter";
import { FrequencyHistoryChart } from "./FrequencyHistoryChart";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
import { freqToNote } from "../../../entities/audioSample/lib/note";
import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time";
import { FrequencyCurrentDisplay } from "./FrequencyCurrentDisplay";
import { WaveformDisplay } from "./WaveFormDisplay";
const HZ_OPTIONS: number[] = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15] as const;
const WINDOW_OPTIONS: Array<{ label: string; ms: number }> = [
{ label: "15s", ms: 15_000 },
{ label: "30s", ms: 30_000 },
{ label: "60s", ms: 60_000 },
{ label: "2m", ms: 120_000 },
{ label: "5m", ms: 300_000 },
];
export const LOUDNESS_THRESHOLD = -35.0;
export const MIN_FREQUENCY = 129;
export const MAX_FREQUENCY = 5500;
export function AudioLiveWidget() {
const status = useLiveStreamStore((s) => s.status);
const latest = useLiveStreamStore((s) => s.latest);
const chartHistory = useLiveStreamStore((s) => s.chartHistory);
const peakHoldDb3s = useLiveStreamStore((s) => s.peakHoldDb3s);
const lastMessageAt = useLiveStreamStore((s) => s.lastMessageAt);
const requestedHz = useLiveStreamStore((s) => s.requestedHz);
const setRequestedHz = useLiveStreamStore((s) => s.setRequestedHz);
const windowMs = useLiveStreamStore((s) => s.windowMs);
const setWindowMs = useLiveStreamStore((s) => s.setWindowMs);
const stale = isStale(lastMessageAt, 1500);
const note = useMemo(() => {
return latest ? freqToNote(latest.freq_hz) : "--";
}, [latest]);
return (
<div className="grid gap-4 lg:grid-cols-4">
<div className="lg:col-span-1 row-span-2">
<AudioMeter
rmsDb={latest?.rms_db ?? null}
peakHoldDb3s={peakHoldDb3s}
/>
</div>
<div className="lg:col-span-3 row-span-3 grid gap-4">
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">Состояние</CardTitle>
<div className="flex items-center gap-3">
<WsStatusBadge status={status} />
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="text-sm text-muted-foreground">Последнее обновление</div>
<div className="text-lg font-medium tabular-nums">
{lastMessageAt ? formatTimeHHMMSS(lastMessageAt) : "—"}
<span
className={`ml-2 text-xs ${stale ? "text-rose-600" : "text-emerald-600"}`}
>
{stale ? "stale" : "ok"}
</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Частота</div>
<div className="text-lg font-medium tabular-nums">
{latest ? `${latest.freq_hz.toFixed(0)} Hz` : "—"}{" "}
<span className="text-muted-foreground">({note})</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">RMS</div>
<div className="text-lg font-medium tabular-nums">
{latest ? `${latest.rms_db.toFixed(1)} dB` : "—"}
</div>
</div>
{/* Controls */}
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1">
<span className="text-sm text-muted-foreground">Частота обновлений</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={requestedHz}
onChange={(e) => setRequestedHz(Number(e.target.value))}
>
{HZ_OPTIONS.map((hz) => (
<option key={hz} value={hz}>
{hz} Гц
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className="text-sm text-muted-foreground">Окно</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={windowMs}
onChange={(e) => setWindowMs(Number(e.target.value))}
>
{WINDOW_OPTIONS.map((opt) => (
<option key={opt.ms} value={opt.ms}>
{opt.label}
</option>
))}
</select>
</label>
</div>
</CardContent>
</Card>
<FrequencyHistoryChart history={chartHistory} />
</div>
<div className="lg:col-span-1 row-span-2 grid gap-4">
<FrequencyCurrentDisplay
latest={latest}
history={chartHistory}
/>
</div>
<div className="lg:col-span-3 grid gap-4">
<WaveformDisplay latest={latest} />
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useMemo } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
type Props = {
rmsDb: number | null;
peakHoldDb3s: number | null;
};
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function colorByDb(db: number): string {
// default thresholds from spec:
// green: < -20, yellow: < -10, red: >= -10
if (db < -20) return "bg-emerald-500";
if (db < -10) return "bg-amber-500";
return "bg-rose-500";
}
export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
const minDb = -50;
const maxDb = 0;
const fillPct = useMemo(() => {
if (rmsDb === null) return 0;
const v = clamp(rmsDb, minDb, maxDb);
return ((v - minDb) / (maxDb - minDb)) * 100;
}, [rmsDb]);
const peakTopPct = useMemo(() => {
if (peakHoldDb3s === null) return null;
const v = clamp(peakHoldDb3s, minDb, maxDb);
const pct = ((v - minDb) / (maxDb - minDb)) * 100;
return 100 - pct; // from top
}, [peakHoldDb3s]);
const barColor = rmsDb === null ? "bg-slate-300" : colorByDb(rmsDb);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Аудио измеритель</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-4">
{/* Scale + meter */}
<div className="flex items-stretch gap-3">
{/* ticks */}
<div className="flex flex-col justify-between text-xs text-muted-foreground h-56 select-none">
<div>0 дБ</div>
<div>-10</div>
<div>-20</div>
<div>-30</div>
<div>-40</div>
<div>-50</div>
</div>
<div className="relative h-56 w-10 rounded-md bg-slate-200 overflow-hidden">
<div
className={`absolute bottom-0 left-0 right-0 ${barColor} transition-[height] duration-100`}
style={{ height: `${fillPct}%` }}
/>
{peakTopPct !== null && (
<div
className="absolute left-0 right-0 h-[2px] bg-slate-900/80"
style={{ top: `${peakTopPct}%` }}
title={`Peak hold (3s): ${peakHoldDb3s?.toFixed(1)} dB`}
/>
)}
</div>
</div>
{/* Values */}
<div className="min-w-40">
<div className="text-sm text-muted-foreground">Сейчас</div>
<div className="text-2xl font-semibold tabular-nums">
{rmsDb === null ? "—" : `${rmsDb.toFixed(1)} dB`}
</div>
<div className="mt-3 text-sm text-muted-foreground">
Пик (3с)
</div>
<div className="text-lg font-medium tabular-nums">
{peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,186 @@
import { memo, useMemo } from "react";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { freqToNote } from "../../../entities/audioSample/lib/note";
import { formatTimeHHMMSS } from "../../../shared/lib/time";
import {
LOUDNESS_THRESHOLD,
MAX_FREQUENCY,
MIN_FREQUENCY,
} from "./AudioLiveWidget";
type Props = {
latest: AudioSample | null;
history: AudioSample[]; // последние ~15 секунд
};
const HISTORY_WINDOW_MS = 15_000; // 15 секунд истории
const STABILITY_WINDOW = 5; // анализируем последние 10 точек
function getStability(history: AudioSample[]): number {
if (history.length < STABILITY_WINDOW) return 1;
const recent = history.slice(-STABILITY_WINDOW);
const values = recent.map((s) => s.freq_hz);
// Среднее арифметическое
const mean = values.reduce((a, b) => a + b, 0) / values.length;
// Дисперсия
const variance =
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
// Коэффициент вариации
return Math.max(0, 1 - Math.sqrt(variance) / mean); // 0..1, где 1 = идеально стабильно
}
const ORANGE_500: [number, number, number] = [249, 215, 22]; // #f97316
const GRAY: [number, number, number] = [50, 50, 50];
// Helper function to convert an RGB array to a CSS rgb() string
const toRgbString = (color: [number, number, number]): string =>
`rgb(${color[0]},${color[1]},${color[2]})`;
function getActivityColor(freq: number, rms: number): string {
let fromRgb: [number, number, number];
let toRgb: [number, number, number];
if (rms > LOUDNESS_THRESHOLD) {
const factor = (freq - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY);
fromRgb = [
Math.round(ORANGE_500[0] * factor),
ORANGE_500[1],
Math.round(255 - (255 - ORANGE_500[2]) * factor),
];
toRgb = fromRgb;
} else {
fromRgb = GRAY;
toRgb = GRAY;
}
// Return a CSS linear-gradient string
return `linear-gradient(to right, ${toRgbString(fromRgb)}, ${toRgbString(toRgb)})`;
}
export const FrequencyCurrentDisplay = memo(function FrequencyCurrentDisplay({
latest,
history,
}: Props) {
const recentHistory = useMemo(() => {
if (!latest) return [];
const cutoff = latest.timeMs - HISTORY_WINDOW_MS;
return history.filter((s) => s.timeMs >= cutoff);
}, [latest?.timeMs, history]);
const stability = useMemo(() => getStability(recentHistory), [recentHistory]);
const note = latest ? freqToNote(latest.freq_hz) : "--";
const gradientColor = latest
? getActivityColor(latest.freq_hz, latest.rms_db)
: "from-slate-400";
const chartData = useMemo(() => {
return recentHistory.map((s) => ({
timeMs: s.timeMs,
freq_hz: s.freq_hz,
}));
}, [recentHistory]);
return (
<Card className="col-span-full lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Текущая частота</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Центральный круглый индикатор */}
<div className="flex flex-col items-center space-y-2">
<div className="relative">
{/* Фон круга */}
<div
className="w-32 h-32 rounded-full border-8 border-slate-200 dark:border-slate-800 shadow-lg"
style={{
background: `radial-gradient(circle at center, hsl(var(--background)) 0%, hsl(var(--muted)) 100%)`,
}}
/>
{/* Градиентный круг по активности */}
{latest && (
<div
className={`absolute inset-0 w-32 h-32 rounded-full border-8 animate-spin-slow border-transparent bg-gradient-to-r opacity-70`}
style={{
background: gradientColor,
mask: "radial-gradient(circle at center, black 60%, transparent 70%)",
WebkitMask:
"radial-gradient(circle at center, black 60%, transparent 70%)",
}}
/>
)}
{/* Центральный текст */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-3xl font-bold tabular-nums text-foreground">
{latest ? `${latest.freq_hz.toFixed(0)}` : "---"}
</div>
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Hz
</div>
<div className="text-xs font-mono bg-background/80 px-2 py-0.5 rounded-full mt-1">
{note}
</div>
</div>
</div>
{/* Стабильность */}
<div className="flex items-center gap-2 text-xs">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: `hsl(${240 * stability}, 70%, 50%)` }}
/>Текущая частота</div>
</div>
{/* Мини-график последних 15 секунд */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Последние изменения ({Math.round(HISTORY_WINDOW_MS / 1000)}с)</span>
<span>{recentHistory.length} pts</span>
</div>
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="freqTrend" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.8} />
<stop offset="100%" stopColor="#1d4ed8" stopOpacity={0.4} />
</linearGradient>
</defs>
<XAxis
dataKey="timeMs"
hide
type="number"
domain={["dataMin", "dataMax"]}
/>
<Tooltip
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]}
/>
<Line
type="monotone"
dataKey="freq_hz"
stroke="url(#freqTrend)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
);
});

View File

@@ -0,0 +1,109 @@
// src/widgets/audioLive/ui/FrequencyHistoryChart.tsx
import { memo, useMemo } from "react";
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { formatTimeHHMMSS } from "../../../shared/lib/time";
type Props = {
history?: AudioSample[];
};
type ChartPoint = {
timeMs: number;
freq_hz: number;
};
const Y_MIN = 129;
const Y_MAX = 5500;
const Y_TICKS = [139, 200, 500, 1000, 2000, 5000, 5500];
function formatHzTick(v: number): string {
const n = Number(v);
if (!Number.isFinite(n)) return "";
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
return `${Math.round(n)}`;
}
export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
history = [],
}: Props) {
const data: ChartPoint[] = useMemo(() => {
if (!history?.length) return [];
return history.map((s) => ({ timeMs: s.timeMs, freq_hz: s.freq_hz }));
}, [history]);
const hasData = data.length > 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Доминантная частота</CardTitle>
</CardHeader>
<CardContent>
<div className="relative h-56 min-h-[14rem] w-full">
{!hasData && (
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
Пока нет данных
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 10, right: 12, left: 0, bottom: 0 }}
>
<XAxis
dataKey="timeMs"
type="number"
domain={["dataMin", "dataMax"]}
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis
type="number"
scale="log"
domain={[Y_MIN, Y_MAX]}
ticks={Y_TICKS}
allowDataOverflow
tick={{ fontSize: 12 }}
width={52}
tickFormatter={(v) => formatHzTick(Number(v))}
/>
<Tooltip
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
formatter={(v) => [`${Number(v).toFixed(0)} Гц`, "част."]}
/>
<Line
type="monotone"
dataKey="freq_hz"
stroke="oklch(0.208 0.042 265.755)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
});

Some files were not shown because too many files have changed in this diff Show More