Compare commits
23 Commits
82b5280567
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1047268942 | |||
| 104dc610f1 | |||
| e61f4720bc | |||
| 804527133c | |||
| 5e0f22e39e | |||
| 799a11b86d | |||
| f8edaa0aaf | |||
| 734c65253d | |||
| bcc94b40fe | |||
| c560b9be76 | |||
| e6f361def4 | |||
| 7334855ba2 | |||
| 707a474ef3 | |||
| 262c42c1b3 | |||
| 1b864228d4 | |||
| cfec8d0ff6 | |||
| a7e5670d7c | |||
| eaa0e0a3eb | |||
| 2a14a36797 | |||
| 97c59cdda2 | |||
| 063cced2a5 | |||
| 3306b8083b | |||
| 2f0527a3d8 |
12
.example.env
Normal file
12
.example.env
Normal 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
3
.gitignore
vendored
@@ -56,3 +56,6 @@ Mkfile.old
|
||||
dkms.conf
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/c
|
||||
|
||||
Build/
|
||||
*.bin
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -10,3 +10,6 @@
|
||||
[submodule "firmware/Drivers/CMSIS/Core"]
|
||||
path = firmware/Drivers/CMSIS/Core
|
||||
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
56
README.md
Normal 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, CMSIS‑DSP.
|
||||
- ADC1 + DMA (circular, double‑buffer 2×512) с триггером от TIM3, частота дискретизации 22.05 кГц.
|
||||
- Обработка: удаление DC, RMS в dBFS, Hann‑окно, RFFT 512, поиск пика 100–8000 Гц.
|
||||
- Передача каждые 100 мс пакетом 12 байт по USB CDC.
|
||||
|
||||
- **[Collector (Python)](services/collector/README.md)**
|
||||
- Читает бинарный протокол с /dev/ttyACM0, ресинхронизация по SOF=0xAA.
|
||||
- Проверка CRC‑8/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‑дашборд: вертикальный аудио‑метр, peak‑hold за 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).
|
||||
@@ -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
56
db/init.sql
Normal 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
108
docker-compose.yml
Normal 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
|
||||
@@ -1,6 +1,9 @@
|
||||
#ifndef 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_IDLE_HOOK 0
|
||||
#define configUSE_TICK_HOOK 0
|
||||
@@ -8,7 +11,7 @@
|
||||
#define configTICK_RATE_HZ ((TickType_t)1000)
|
||||
#define configMAX_PRIORITIES (5)
|
||||
#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 configUSE_16_BIT_TICKS 0
|
||||
#define configIDLE_SHOULD_YIELD 1
|
||||
|
||||
29
firmware/App/Inc/audio_adc.h
Normal file
29
firmware/App/Inc/audio_adc.h
Normal 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 */
|
||||
22
firmware/App/Inc/audio_config.h
Normal file
22
firmware/App/Inc/audio_config.h
Normal 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 */
|
||||
20
firmware/App/Inc/audio_processor.h
Normal file
20
firmware/App/Inc/audio_processor.h
Normal 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
15
firmware/App/Inc/health.h
Normal 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
|
||||
40
firmware/App/Inc/protocol.h
Normal file
40
firmware/App/Inc/protocol.h
Normal 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
|
||||
@@ -19,8 +19,8 @@ extern "C" {
|
||||
|
||||
// Конфигурация CDC (Communication Device Class)
|
||||
#define CFG_TUD_CDC 1
|
||||
#define CFG_TUD_CDC_RX_BUFSIZE 64
|
||||
#define CFG_TUD_CDC_TX_BUFSIZE 64
|
||||
#define CFG_TUD_CDC_RX_BUFSIZE 256
|
||||
#define CFG_TUD_CDC_TX_BUFSIZE 256
|
||||
|
||||
// Endpoint буферизация
|
||||
#define CFG_TUD_ENDPOINT0_SIZE 64
|
||||
|
||||
168
firmware/App/Src/audio_adc.c
Normal file
168
firmware/App/Src/audio_adc.c
Normal 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
|
||||
}
|
||||
}
|
||||
112
firmware/App/Src/audio_processor.c
Normal file
112
firmware/App/Src/audio_processor.c
Normal 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
68
firmware/App/Src/health.c
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,220 @@
|
||||
#include <stdio.h>
|
||||
#include <string.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 "task.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) {
|
||||
RCC->CR |= RCC_CR_HSEON;
|
||||
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_USBPRE; // <-- ДОБАВЛЕНО!
|
||||
RCC->CFGR |= (RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9);
|
||||
RCC->CFGR &= ~RCC_CFGR_PLLMULL;
|
||||
RCC->CFGR |= RCC_CFGR_PLLMULL9;
|
||||
RCC->CFGR |= RCC_CFGR_PLLSRC;
|
||||
RCC->CFGR &= ~RCC_CFGR_USBPRE;
|
||||
|
||||
RCC->CR |= RCC_CR_PLLON;
|
||||
while (!(RCC->CR & RCC_CR_PLLRDY));
|
||||
|
||||
RCC->CFGR &= ~RCC_CFGR_SW;
|
||||
RCC->CFGR |= RCC_CFGR_SW_PLL;
|
||||
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
|
||||
|
||||
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)param;
|
||||
while (1) {
|
||||
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)param;
|
||||
// Buffer for packet (12 bytes)
|
||||
uint8_t tx_buffer[PACKET_TOTAL_SIZE];
|
||||
|
||||
while (1) {
|
||||
// Check if USB is connected
|
||||
if (tud_cdc_connected()) {
|
||||
if (tud_cdc_available()) {
|
||||
uint8_t buf[64];
|
||||
uint32_t count = tud_cdc_read(buf, sizeof(buf));
|
||||
tud_cdc_write(buf, count);
|
||||
audio_metrics_packet_t packet;
|
||||
|
||||
// Wait for data from DSP task
|
||||
if (xQueueReceive(
|
||||
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();
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10)); // 10мс достаточно
|
||||
} else {
|
||||
// Flush queue if USB not connected to prevent stalling DSP task
|
||||
// or just sleep longer.
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Задача LED (отдельно!)
|
||||
void led_task(void *param) {
|
||||
void audio_init_task(void *param) {
|
||||
(void)param;
|
||||
while (1) {
|
||||
GPIOC->BSRR = GPIO_BSRR_BR13; // LED ON
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
GPIOC->BSRR = GPIO_BSRR_BS13; // LED OFF
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
|
||||
// Индикация старта
|
||||
for (int i = 0; i < 3; i++) {
|
||||
GPIOC->ODR ^= GPIO_ODR_ODR13;
|
||||
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) {
|
||||
@@ -68,28 +227,43 @@ void force_usb_reset(void) {
|
||||
GPIOA->CRH |= GPIO_CRH_CNF12_0;
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
|
||||
int main(void) {
|
||||
SystemClock_Config();
|
||||
|
||||
// Настройка LED
|
||||
// LED GPIO (will be managed by health_led_task)
|
||||
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
|
||||
GPIOC->CRH &= ~GPIO_CRH_CNF13;
|
||||
GPIOC->CRH &= ~(GPIO_CRH_MODE13 | GPIO_CRH_CNF13);
|
||||
GPIOC->CRH |= GPIO_CRH_MODE13_1;
|
||||
|
||||
force_usb_reset();
|
||||
|
||||
// Включаем USB
|
||||
// USB
|
||||
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
|
||||
RCC->APB1ENR |= RCC_APB1ENR_USBEN;
|
||||
|
||||
// Прерывания USB
|
||||
NVIC_SetPriority(USB_HP_CAN1_TX_IRQn, 6);
|
||||
NVIC_SetPriority(USB_LP_CAN1_RX0_IRQn, 6);
|
||||
NVIC_SetPriority(USBWakeUp_IRQn, 6);
|
||||
NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
|
||||
NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
|
||||
NVIC_EnableIRQ(USBWakeUp_IRQn);
|
||||
|
||||
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(
|
||||
usb_device_task,
|
||||
"usbd",
|
||||
@@ -97,13 +271,31 @@ int main(void) {
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 1,
|
||||
NULL);
|
||||
xTaskCreate(cdc_task, "cdc", 256, NULL, configMAX_PRIORITIES - 2, NULL);
|
||||
xTaskCreate(led_task, "led", 128, NULL, 1, NULL);
|
||||
xTaskCreate(cdc_task, "cdc", 320, NULL, configMAX_PRIORITIES - 2, 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();
|
||||
while (1);
|
||||
|
||||
while (1); // Should never reach here
|
||||
}
|
||||
|
||||
// === USB Handlers ===
|
||||
|
||||
void USB_HP_CAN1_TX_IRQHandler(void) {
|
||||
tud_int_handler(0);
|
||||
}
|
||||
|
||||
51
firmware/App/Src/protocol.c
Normal file
51
firmware/App/Src/protocol.c
Normal 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);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
TARGET = stm32-usb-freertos
|
||||
BUILD_DIR = Build
|
||||
|
||||
# --- Исходники ---
|
||||
# 1. Приложение
|
||||
# Приложение
|
||||
C_SOURCES = \
|
||||
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/system_stm32f1xx.c \
|
||||
|
||||
# 2. FreeRTOS
|
||||
# FreeRTOS
|
||||
C_SOURCES += \
|
||||
Middlewares/FreeRTOS/croutine.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/MemMang/heap_4.c
|
||||
|
||||
# 3. TinyUSB
|
||||
# TinyUSB
|
||||
C_SOURCES += \
|
||||
Middlewares/TinyUSB/src/tusb.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/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
|
||||
|
||||
# --- Настройки компилятора ---
|
||||
@@ -47,19 +66,29 @@ C_INCLUDES = \
|
||||
-IDrivers/CMSIS/Device/ST/STM32F1xx/Include \
|
||||
-IMiddlewares/FreeRTOS/include \
|
||||
-IMiddlewares/FreeRTOS/portable/GCC/ARM_CM3 \
|
||||
-IMiddlewares/TinyUSB/src
|
||||
-IMiddlewares/TinyUSB/src \
|
||||
-I$(CMSIS_DSP)/Include \
|
||||
-I$(CMSIS_DSP)/PrivateInclude
|
||||
|
||||
# Defines
|
||||
C_DEFS = \
|
||||
-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
|
||||
LDSCRIPT = stm32f103c8.ld
|
||||
LIBS = -lc -lm -lnosys
|
||||
LDFLAGS = $(MCU) -T$(LDSCRIPT) $(LIBS) -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections -Wl,--no-warn-rwx-segments
|
||||
# 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) --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)))
|
||||
@@ -103,4 +132,3 @@ flash:
|
||||
st-flash write $(BUILD_DIR)/$(TARGET).bin 0x8000000
|
||||
|
||||
.PHONY: all clean flash
|
||||
|
||||
|
||||
1
firmware/Middlewares/CMSIS-DSP
Submodule
1
firmware/Middlewares/CMSIS-DSP
Submodule
Submodule firmware/Middlewares/CMSIS-DSP added at 78f09340f8
74
firmware/README.md
Normal file
74
firmware/README.md
Normal 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]` в диапазоне частот 100–8000 Гц, шаг частоты \( \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: CRC‑8/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
|
||||
```
|
||||
|
||||
- Прошивка (через ST‑Link):
|
||||
|
||||
```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) |
|
||||
@@ -4,8 +4,8 @@ ENTRY(Reset_Handler)
|
||||
/* Highest address of the user mode stack */
|
||||
_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */
|
||||
|
||||
_Min_Heap_Size = 0x200; /* required amount of heap */
|
||||
_Min_Stack_Size = 0x400; /* required amount of stack */
|
||||
_Min_Heap_Size = 0x000; /* required amount of heap */
|
||||
_Min_Stack_Size = 0x800; /* required amount of stack */
|
||||
|
||||
/* Memories definition */
|
||||
MEMORY
|
||||
|
||||
176
services/api/.gitignore
vendored
Normal file
176
services/api/.gitignore
vendored
Normal 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
15
services/api/Dockerfile
Normal 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
40
services/api/README.md
Normal 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.
|
||||
36
services/api/app/api/v1/endpoints/audio.py
Normal file
36
services/api/app/api/v1/endpoints/audio.py
Normal 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))
|
||||
35
services/api/app/api/v1/endpoints/events.py
Normal file
35
services/api/app/api/v1/endpoints/events.py
Normal 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))
|
||||
42
services/api/app/api/v1/endpoints/export.py
Normal file
42
services/api/app/api/v1/endpoints/export.py
Normal 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}"},
|
||||
)
|
||||
19
services/api/app/api/v1/endpoints/health.py
Normal file
19
services/api/app/api/v1/endpoints/health.py
Normal 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"})
|
||||
23
services/api/app/api/v1/endpoints/stats.py
Normal file
23
services/api/app/api/v1/endpoints/stats.py
Normal 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)
|
||||
9
services/api/app/api/v1/router.py
Normal file
9
services/api/app/api/v1/router.py
Normal 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"])
|
||||
11
services/api/app/core/config.py
Normal file
11
services/api/app/core/config.py
Normal 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()
|
||||
5
services/api/app/db/base.py
Normal file
5
services/api/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
12
services/api/app/db/session.py
Normal file
12
services/api/app/db/session.py
Normal 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
46
services/api/app/main.py
Normal 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()
|
||||
14
services/api/app/models/audio_data.py
Normal file
14
services/api/app/models/audio_data.py
Normal 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)
|
||||
74
services/api/app/repositories/audio_repository.py
Normal file
74
services/api/app/repositories/audio_repository.py
Normal 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),
|
||||
}
|
||||
9
services/api/app/schemas/audio.py
Normal file
9
services/api/app/schemas/audio.py
Normal 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
|
||||
19
services/api/app/schemas/base.py
Normal file
19
services/api/app/schemas/base.py
Normal 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
|
||||
9
services/api/app/schemas/events.py
Normal file
9
services/api/app/schemas/events.py
Normal 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
|
||||
8
services/api/app/schemas/stats.py
Normal file
8
services/api/app/schemas/stats.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StatsSummary(BaseModel):
|
||||
avg_db: float
|
||||
max_db: float
|
||||
dominant_freq: int
|
||||
silence_percent: float
|
||||
16
services/api/app/services/audio_service.py
Normal file
16
services/api/app/services/audio_service.py
Normal 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]
|
||||
54
services/api/app/services/events_service.py
Normal file
54
services/api/app/services/events_service.py
Normal 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
|
||||
35
services/api/app/services/stats_service.py
Normal file
35
services/api/app/services/stats_service.py
Normal 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),
|
||||
)
|
||||
43
services/api/app/ws/broadcaster.py
Normal file
43
services/api/app/ws/broadcaster.py
Normal 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)
|
||||
65
services/api/app/ws/manager.py
Normal file
65
services/api/app/ws/manager.py
Normal 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)
|
||||
52
services/api/app/ws/router.py
Normal file
52
services/api/app/ws/router.py
Normal 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)
|
||||
15
services/api/production.Dockerfile
Normal file
15
services/api/production.Dockerfile
Normal 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"]
|
||||
8
services/api/requirements.txt
Normal file
8
services/api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
asyncpg
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-dateutil
|
||||
websockets
|
||||
176
services/collector/.gitignore
vendored
Normal file
176
services/collector/.gitignore
vendored
Normal 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
|
||||
19
services/collector/Dockerfile
Normal file
19
services/collector/Dockerfile
Normal 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"]
|
||||
34
services/collector/README.md
Normal file
34
services/collector/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# services/collector
|
||||
|
||||
## Назначение
|
||||
|
||||
Сервис, который читает бинарные пакеты с STM32 через USB CDC, валидирует и логирует их, пишет данные в TimescaleDB и рассылает в реальном времени по WebSocket.
|
||||
|
||||
## Основные компоненты
|
||||
|
||||
- `serial_reader.py` — асинхронное чтение порта `SERIAL_PORT` (`/dev/ttyACM0` по умолчанию), non‑blocking read, reconnect‑логика.
|
||||
- `protocol_parser.py` — парсер протокола: поиск SOF, проверка длины, CRC‑8/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`).
|
||||
0
services/collector/__init__.py
Normal file
0
services/collector/__init__.py
Normal file
114
services/collector/audio_validator.py
Normal file
114
services/collector/audio_validator.py
Normal 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)
|
||||
164
services/collector/db_writer.py
Normal file
164
services/collector/db_writer.py
Normal 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
248
services/collector/main.py
Normal 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()
|
||||
41
services/collector/monitor.py
Normal file
41
services/collector/monitor.py
Normal 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()
|
||||
136
services/collector/protocol_parser.py
Normal file
136
services/collector/protocol_parser.py
Normal 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
|
||||
56
services/collector/receiver.py
Normal file
56
services/collector/receiver.py
Normal 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()
|
||||
8
services/collector/requirements.txt
Normal file
8
services/collector/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
pyserial
|
||||
asyncpg
|
||||
numpy
|
||||
pytest
|
||||
pytest-asyncio
|
||||
fastapi
|
||||
uvicorn
|
||||
websockets
|
||||
122
services/collector/serial_reader.py
Normal file
122
services/collector/serial_reader.py
Normal 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()
|
||||
81
services/collector/tests/test_protocol_parser.py
Normal file
81
services/collector/tests/test_protocol_parser.py
Normal 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
|
||||
49
services/collector/ws_app.py
Normal file
49
services/collector/ws_app.py
Normal 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)
|
||||
175
services/collector/ws_manager.py
Normal file
175
services/collector/ws_manager.py
Normal 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
24
services/frontend/.gitignore
vendored
Normal 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?
|
||||
1
services/frontend/.gitignore_1
Symbolic link
1
services/frontend/.gitignore_1
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/mikhail/repos/sound-analizer/services/api/.gitignore
|
||||
13
services/frontend/Dockerfile
Normal file
13
services/frontend/Dockerfile
Normal 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"]
|
||||
|
||||
73
services/frontend/README.md
Normal file
73
services/frontend/README.md
Normal 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
services/frontend/components.json
Normal file
22
services/frontend/components.json
Normal 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": {}
|
||||
}
|
||||
23
services/frontend/eslint.config.js
Normal file
23
services/frontend/eslint.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
services/frontend/index.html
Normal file
13
services/frontend/index.html
Normal 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
3877
services/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
services/frontend/package.json
Normal file
44
services/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
services/frontend/public/vite.svg
Normal file
1
services/frontend/public/vite.svg
Normal 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 |
9
services/frontend/src/app/App.tsx
Normal file
9
services/frontend/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
services/frontend/src/app/providers/withLiveStream.tsx
Normal file
7
services/frontend/src/app/providers/withLiveStream.tsx
Normal 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;
|
||||
}
|
||||
137
services/frontend/src/app/styles/index.css
Normal file
137
services/frontend/src/app/styles/index.css
Normal 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;
|
||||
}
|
||||
1
services/frontend/src/assets/react.svg
Normal file
1
services/frontend/src/assets/react.svg
Normal 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 |
31
services/frontend/src/entities/audioSample/lib/note.ts
Normal file
31
services/frontend/src/entities/audioSample/lib/note.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type AudioSample = {
|
||||
time: string; // ISO
|
||||
timeMs: number;
|
||||
rms_db: number; // expected [-50..0]
|
||||
freq_hz: number; // expected [20..8000]
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type WsStatus = "connecting" | "open" | "reconnecting" | "disconnected";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
6
services/frontend/src/lib/utils.ts
Normal file
6
services/frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
13
services/frontend/src/main.tsx
Normal file
13
services/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
19
services/frontend/src/pages/dashboard/ui/DashboardPage.tsx
Normal file
19
services/frontend/src/pages/dashboard/ui/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
services/frontend/src/shared/config/env.ts
Normal file
16
services/frontend/src/shared/config/env.ts
Normal 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),
|
||||
};
|
||||
50
services/frontend/src/shared/lib/ringBuffer.ts
Normal file
50
services/frontend/src/shared/lib/ringBuffer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
services/frontend/src/shared/lib/time.ts
Normal file
13
services/frontend/src/shared/lib/time.ts
Normal 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;
|
||||
}
|
||||
36
services/frontend/src/shared/ui/badge.tsx
Normal file
36
services/frontend/src/shared/ui/badge.tsx
Normal 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 }
|
||||
57
services/frontend/src/shared/ui/button.tsx
Normal file
57
services/frontend/src/shared/ui/button.tsx
Normal 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 }
|
||||
76
services/frontend/src/shared/ui/card.tsx
Normal file
76
services/frontend/src/shared/ui/card.tsx
Normal 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 }
|
||||
145
services/frontend/src/widgets/audioLive/ui/AudioLiveWidget.tsx
Normal file
145
services/frontend/src/widgets/audioLive/ui/AudioLiveWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
services/frontend/src/widgets/audioLive/ui/AudioMeter.tsx
Normal file
96
services/frontend/src/widgets/audioLive/ui/AudioMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user