diff --git a/components/audio_client/CMakeLists.txt b/components/audio_client/CMakeLists.txt new file mode 100644 index 0000000..8e2a242 --- /dev/null +++ b/components/audio_client/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "audio_client.cpp" + REQUIRES espressif__esp_websocket_client port_bsp + PRIV_REQUIRES esp_event json + INCLUDE_DIRS "./") diff --git a/components/audio_client/audio_client.cpp b/components/audio_client/audio_client.cpp new file mode 100644 index 0000000..f747192 --- /dev/null +++ b/components/audio_client/audio_client.cpp @@ -0,0 +1,224 @@ +#include "audio_client.h" +#include "codec_bsp.h" +#include "esp_websocket_client.h" +#include "esp_log.h" +#include "cJSON.h" + +#include +#include +#include +#include +#include + +static const char *TAG = "audio_client"; + +#define AUDIO_CHUNK_SIZE 4096 +#define PCM_QUEUE_DEPTH 10 +#define PLAYBACK_STACK_SIZE (4 * 1024) +#define PLAYBACK_PRIORITY 4 +#define WS_BUFFER_SIZE 8192 + +static esp_websocket_client_handle_t s_client = NULL; +static CodecPort *s_codec = NULL; +static QueueHandle_t s_pcm_queue = NULL; +static TaskHandle_t s_playback_task = NULL; +static volatile audio_state_t s_state = AUDIO_IDLE; +static volatile bool s_playing = false; + +/* Forward declarations */ +static void playback_task(void *arg); +static void ws_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data); + +/* ---------- Queue helpers ---------- */ + +static void flush_queue(void) +{ + uint8_t *chunk; + while (xQueueReceive(s_pcm_queue, &chunk, 0) == pdTRUE) { + heap_caps_free(chunk); + } +} + +/* ---------- WebSocket event handler ---------- */ + +static void handle_text_frame(const char *data, int len) +{ + cJSON *root = cJSON_ParseWithLength(data, len); + if (!root) { + ESP_LOGW(TAG, "JSON parse failed"); + return; + } + + cJSON *type = cJSON_GetObjectItem(root, "type"); + if (!cJSON_IsString(type)) { + cJSON_Delete(root); + return; + } + + if (strcmp(type->valuestring, "alarm_start") == 0) { + int sr = 24000; + int ch = 2; + int bits = 16; + + cJSON *item; + item = cJSON_GetObjectItem(root, "sample_rate"); + if (cJSON_IsNumber(item)) sr = item->valueint; + item = cJSON_GetObjectItem(root, "channels"); + if (cJSON_IsNumber(item)) ch = item->valueint; + item = cJSON_GetObjectItem(root, "bits"); + if (cJSON_IsNumber(item)) bits = item->valueint; + + ESP_LOGI(TAG, "Alarm start: %dHz %dch %dbit", sr, ch, bits); + + /* Flush any stale data */ + flush_queue(); + + /* Open codec for playback */ + s_codec->CodecPort_SetInfo("es8311", 1, sr, ch, bits); + s_codec->CodecPort_SetSpeakerVol(70); + + s_playing = true; + s_state = AUDIO_PLAYING; + + } else if (strcmp(type->valuestring, "alarm_stop") == 0) { + ESP_LOGI(TAG, "Alarm stop"); + s_playing = false; + + /* Let playback task drain remaining chunks, then close */ + vTaskDelay(pdMS_TO_TICKS(100)); + flush_queue(); + s_codec->CodecPort_CloseSpeaker(); + + s_state = AUDIO_CONNECTED; + } + + cJSON_Delete(root); +} + +static void handle_binary_frame(const uint8_t *data, int len) +{ + if (!s_playing) return; + + uint8_t *chunk = heap_caps_malloc(len, MALLOC_CAP_SPIRAM); + if (!chunk) { + ESP_LOGW(TAG, "PSRAM alloc failed (%d bytes)", len); + return; + } + memcpy(chunk, data, len); + + if (xQueueSend(s_pcm_queue, &chunk, 0) != pdTRUE) { + ESP_LOGW(TAG, "PCM queue full, dropping chunk"); + heap_caps_free(chunk); + } +} + +static void ws_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + esp_websocket_event_data_t *ev = (esp_websocket_event_data_t *)event_data; + + switch (event_id) { + case WEBSOCKET_EVENT_CONNECTED: + ESP_LOGI(TAG, "Audio WS connected"); + s_state = AUDIO_CONNECTED; + break; + + case WEBSOCKET_EVENT_DISCONNECTED: + ESP_LOGW(TAG, "Audio WS disconnected"); + s_playing = false; + flush_queue(); + s_state = AUDIO_IDLE; + break; + + case WEBSOCKET_EVENT_DATA: + if (ev->op_code == 0x01 && ev->data_len > 0) { + handle_text_frame(ev->data_ptr, ev->data_len); + } else if (ev->op_code == 0x02 && ev->data_len > 0) { + handle_binary_frame((const uint8_t *)ev->data_ptr, ev->data_len); + } + break; + + case WEBSOCKET_EVENT_ERROR: + ESP_LOGE(TAG, "Audio WS error"); + s_playing = false; + s_state = AUDIO_ERROR; + break; + + default: + break; + } +} + +/* ---------- Playback task ---------- */ + +static void playback_task(void *arg) +{ + uint8_t *chunk; + + for (;;) { + if (xQueueReceive(s_pcm_queue, &chunk, pdMS_TO_TICKS(500)) == pdTRUE) { + if (s_playing) { + s_codec->CodecPort_PlayWrite(chunk, AUDIO_CHUNK_SIZE); + } + heap_caps_free(chunk); + } + } +} + +/* ---------- Public API ---------- */ + +void audio_client_init(const char *uri, void *codec) +{ + s_codec = (CodecPort *)codec; + + s_pcm_queue = xQueueCreate(PCM_QUEUE_DEPTH, sizeof(uint8_t *)); + if (!s_pcm_queue) { + ESP_LOGE(TAG, "Failed to create PCM queue"); + return; + } + + esp_websocket_client_config_t config = {}; + config.uri = uri; + config.reconnect_timeout_ms = 5000; + config.buffer_size = WS_BUFFER_SIZE; + + s_client = esp_websocket_client_init(&config); + esp_websocket_register_events(s_client, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL); + + ESP_LOGI(TAG, "Audio client initialized: %s", uri); +} + +void audio_client_start(void) +{ + if (!s_client) return; + + /* Create playback task pinned to Core 1 */ + xTaskCreatePinnedToCore(playback_task, "audio_play", PLAYBACK_STACK_SIZE, + NULL, PLAYBACK_PRIORITY, &s_playback_task, 1); + + esp_websocket_client_start(s_client); + ESP_LOGI(TAG, "Audio client started"); +} + +void audio_client_stop(void) +{ + if (!s_client) return; + + s_playing = false; + esp_websocket_client_stop(s_client); + flush_queue(); + + if (s_playback_task) { + vTaskDelete(s_playback_task); + s_playback_task = NULL; + } + + s_state = AUDIO_IDLE; + ESP_LOGI(TAG, "Audio client stopped"); +} + +audio_state_t audio_client_get_state(void) +{ + return s_state; +} diff --git a/components/audio_client/audio_client.h b/components/audio_client/audio_client.h new file mode 100644 index 0000000..de1192c --- /dev/null +++ b/components/audio_client/audio_client.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + AUDIO_IDLE = 0, + AUDIO_CONNECTED, + AUDIO_PLAYING, + AUDIO_ERROR, +} audio_state_t; + +/** + * Initialize the audio streaming client. + * @param uri WebSocket URI (e.g. "ws://192.168.2.199:8766") + * @param codec Pointer to CodecPort instance (passed as void* for C linkage) + */ +void audio_client_init(const char *uri, void *codec); + +/** Start the WebSocket connection and playback task. */ +void audio_client_start(void); + +/** Stop playback and disconnect. */ +void audio_client_stop(void); + +/** Get current audio client state. */ +audio_state_t audio_client_get_state(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/esp_wifi_bsp/wifi_config.h b/components/esp_wifi_bsp/wifi_config.h index 2844ff3..88b2e74 100644 --- a/components/esp_wifi_bsp/wifi_config.h +++ b/components/esp_wifi_bsp/wifi_config.h @@ -4,5 +4,6 @@ #define WIFI_SSID "Novoyuuparosk_H3C" #define WIFI_PASSWORD "northwich" #define WS_SERVER_URI "ws://192.168.2.199:8765" +#define AUDIO_SERVER_URI "ws://192.168.2.199:8766" #endif diff --git a/components/user_app/CMakeLists.txt b/components/user_app/CMakeLists.txt index 6770671..baf7f80 100644 --- a/components/user_app/CMakeLists.txt +++ b/components/user_app/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "user_app.cpp" "alert.cpp" REQUIRES app_bsp - PRIV_REQUIRES esp_wifi_bsp ws_client dashboard_ui port_bsp esp_timer codec_board + PRIV_REQUIRES esp_wifi_bsp ws_client dashboard_ui port_bsp esp_timer codec_board audio_client INCLUDE_DIRS "./") diff --git a/components/user_app/alert.cpp b/components/user_app/alert.cpp index 72018e4..de2a05b 100644 --- a/components/user_app/alert.cpp +++ b/components/user_app/alert.cpp @@ -1,4 +1,5 @@ #include "alert.h" +#include "audio_client.h" #include "codec_bsp.h" #include "i2c_bsp.h" #include @@ -84,6 +85,7 @@ void alert_trigger(alert_type_t type) if (type >= ALERT_TYPE_COUNT) return; if (s_muted) return; if (!s_tone_buf || !s_codec) return; + if (audio_client_get_state() == AUDIO_PLAYING) return; /* don't fight over codec */ if (xSemaphoreTake(s_alert_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return; diff --git a/components/user_app/user_app.cpp b/components/user_app/user_app.cpp index 3110596..559c2cb 100644 --- a/components/user_app/user_app.cpp +++ b/components/user_app/user_app.cpp @@ -9,6 +9,7 @@ #include "button_bsp.h" #include "codec_bsp.h" #include "alert.h" +#include "audio_client.h" #include "lvgl_bsp.h" #include @@ -57,6 +58,9 @@ void UserApp_AppInit(void) alert_init(); alert_set_codec(s_codec); + /* Audio streaming client */ + audio_client_init(AUDIO_SERVER_URI, s_codec); + /* WebSocket client init (not started yet) */ ws_client_init(WS_SERVER_URI); ws_client_set_data_callback(ws_data_cb); @@ -78,6 +82,9 @@ void UserApp_TaskInit(void) /* Start WebSocket client */ ws_client_start(); + /* Start audio streaming client */ + audio_client_start(); + /* Sensor polling task - Core 1, 4KB stack */ xTaskCreatePinnedToCore(sensor_task, "sensor", 4 * 1024, NULL, 3, NULL, 1); diff --git a/docs/ALARM_PROTOCOL.md b/docs/ALARM_PROTOCOL.md new file mode 100644 index 0000000..c3b29ad --- /dev/null +++ b/docs/ALARM_PROTOCOL.md @@ -0,0 +1,87 @@ +# Alarm Audio Streaming Protocol + +## Overview + +The Raspberry Pi streams alarm audio to the ESP32-S3 over a dedicated WebSocket connection on **port 8766**. The protocol uses mixed text and binary frames — text for control messages, binary for raw PCM data. + +## Connection + +| Parameter | Value | +|-----------|-------| +| Port | 8766 | +| Transport | WebSocket | +| Direction | Pi (server) → ESP32 (client) | + +The ESP32 connects and stays connected. The server initiates alarm playback when needed. + +## Message Sequence + +``` +Pi ESP32 + │ │ + │◄──── WS connect ──────────│ + │ │ + │ (idle until alarm fires) │ + │ │ + ├─ alarm_start (text) ──────►│ → open codec + ├─ PCM chunk (binary) ──────►│ → queue + play + ├─ PCM chunk (binary) ──────►│ + │ ... │ + ├─ alarm_stop (text) ────────►│ → drain + close codec + │ │ +``` + +## Control Messages (Text Frames) + +### alarm_start + +Sent before the first PCM chunk. The ESP32 uses these parameters to configure the DAC. + +```json +{ + "type": "alarm_start", + "sample_rate": 24000, + "channels": 2, + "bits": 16 +} +``` + +### alarm_stop + +Sent after the last PCM chunk. + +```json +{ + "type": "alarm_stop" +} +``` + +## PCM Data (Binary Frames) + +- **Format:** Raw signed 16-bit little-endian, interleaved stereo (L, R, L, R, ...) +- **Chunk size:** 4096 bytes (1024 stereo samples at 16-bit) +- **Pacing:** Chunks sent at ~90% real-time speed to maintain buffer without overflow +- **Byte order:** Little-endian (native for both Pi and ESP32) + +At 24kHz / 2ch / 16-bit: +- Bytes per second: 96,000 +- Chunk duration: ~42.7 ms +- Send interval: ~38.4 ms (90% pacing) + +## ESP32 Buffering + +- FreeRTOS queue: 10 slots of pointer-to-chunk +- Total buffer capacity: ~427 ms of audio +- Overflow policy: drop chunk and log warning + +## Future: Alarm Scheduling + +Not yet implemented. Planned JSON format for alarm configuration: + +```json +{ + "alarm_time": "070000", + "alarm_days": ["Mon", "Tue", "Wed"], + "alarm_audio": "path/to/file.wav" +} +``` diff --git a/pi/assets/alarm/alarm_test.lab b/pi/assets/alarm/alarm_test.lab new file mode 100644 index 0000000..cf732a8 --- /dev/null +++ b/pi/assets/alarm/alarm_test.lab @@ -0,0 +1,55 @@ +0 700000 j +700000 1440000 u +1440000 2370000 u +2370000 3110000 i +3110000 3710000 ch +3710000 4380000 i +4380000 4800000 g +4800000 5560000 a +5560000 6180000 ts +6180000 6850000 u +6850000 7500000 j +7500000 8210000 u +8210000 9130000 u +9130000 9880000 i +9880000 10460000 ch +10460000 11150000 i +11150000 11690000 g +11690000 12470000 a +12470000 13100000 ts +13100000 13770000 u +13770000 14420000 j +14420000 15140000 u +15140000 16070000 u +16070000 16810000 i +16810000 17420000 ch +17420000 18080000 i +18080000 18610000 g +18610000 19410000 a +19410000 20020000 ts +20020000 20680000 u +20680000 21320000 j +21320000 22030000 u +22030000 22900000 u +22900000 23640000 i +23640000 24250000 ch +24250000 24920000 i +24920000 25460000 g +25460000 26200000 a +26200000 26840000 ts +26840000 27480000 u +27480000 28130000 j +28130000 28830000 u +28830000 29720000 u +29720000 30440000 i +30440000 31040000 ch +31040000 31750000 i +31750000 32600000 by +32600000 33320000 o +33320000 34120000 o +34120000 34740000 j +34740000 35350000 a +35350000 35870000 s +35870000 36510000 u +36510000 36960000 t +36960000 38220000 o diff --git a/pi/assets/alarm/alarm_test.wav b/pi/assets/alarm/alarm_test.wav new file mode 100644 index 0000000..24fc245 Binary files /dev/null and b/pi/assets/alarm/alarm_test.wav differ diff --git a/pi/assets/img/idle.png b/pi/assets/img/idle.png new file mode 100644 index 0000000..f853294 Binary files /dev/null and b/pi/assets/img/idle.png differ diff --git a/pi/assets/img/on_alarm.png b/pi/assets/img/on_alarm.png new file mode 100644 index 0000000..a7512fc Binary files /dev/null and b/pi/assets/img/on_alarm.png differ diff --git a/pi/audio_server.py b/pi/audio_server.py new file mode 100644 index 0000000..43265b6 --- /dev/null +++ b/pi/audio_server.py @@ -0,0 +1,112 @@ +""" +Alarm audio streaming test server. + +Streams a WAV file as raw PCM chunks over WebSocket on port 8766. +Repeats every 30-60 seconds to exercise the ESP32 audio pipeline. + +Protocol: + 1. Text frame: {"type":"alarm_start","sample_rate":N,"channels":N,"bits":N} + 2. Binary frames: raw PCM chunks (4096 bytes each, paced at ~90% real-time) + 3. Text frame: {"type":"alarm_stop"} +""" + +import asyncio +import json +import logging +import struct +import wave +from pathlib import Path +from random import randint + +import websockets + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("audio_server") + +PORT = 8766 +CHUNK_SIZE = 4096 +AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" + + +def find_wav() -> Path: + """Find the first .wav file in the alarm assets directory.""" + wavs = list(AUDIO_DIR.glob("*.wav")) + if not wavs: + raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}") + log.info("Using audio file: %s", wavs[0].name) + return wavs[0] + + +def read_wav(path: Path) -> tuple[bytes, int, int, int]: + """Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample).""" + with wave.open(str(path), "rb") as wf: + sr = wf.getframerate() + ch = wf.getnchannels() + bits = wf.getsampwidth() * 8 + pcm = wf.readframes(wf.getnframes()) + log.info("WAV loaded: %dHz %dch %dbit, %.1fs, %d bytes", + sr, ch, bits, len(pcm) / (sr * ch * (bits // 8)), len(pcm)) + return pcm, sr, ch, bits + + +def chunk_bytes(data: bytes, size: int): + """Yield data in fixed-size chunks.""" + for i in range(0, len(data), size): + yield data[i : i + size] + + +async def stream_alarm(ws, pcm: bytes, sr: int, ch: int, bits: int): + """Stream one alarm cycle to the connected client.""" + # Compute pacing: how long each chunk represents in seconds + bytes_per_sec = sr * ch * (bits // 8) + chunk_duration = CHUNK_SIZE / bytes_per_sec + pace_delay = chunk_duration * 0.9 # 90% real-time to avoid underrun + + total_chunks = (len(pcm) + CHUNK_SIZE - 1) // CHUNK_SIZE + + # Start + start_msg = json.dumps({ + "type": "alarm_start", + "sample_rate": sr, + "channels": ch, + "bits": bits, + }) + await ws.send(start_msg) + log.info("Sent alarm_start (%d chunks, pace %.1fms)", total_chunks, pace_delay * 1000) + + # Stream PCM chunks + for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)): + await ws.send(chunk) # bytes → binary frame + await asyncio.sleep(pace_delay) + + # Stop + await ws.send(json.dumps({"type": "alarm_stop"})) + log.info("Sent alarm_stop") + + +async def handler(ws): + """Handle a single WebSocket connection.""" + remote = ws.remote_address + log.info("Client connected: %s:%d", remote[0], remote[1]) + + wav_path = find_wav() + pcm, sr, ch, bits = read_wav(wav_path) + + try: + while True: + delay = randint(30, 60) + log.info("Next alarm in %ds", delay) + await asyncio.sleep(delay) + await stream_alarm(ws, pcm, sr, ch, bits) + except websockets.exceptions.ConnectionClosed: + log.info("Client disconnected: %s:%d", remote[0], remote[1]) + + +async def main(): + log.info("Audio server starting on port %d", PORT) + async with websockets.serve(handler, "0.0.0.0", PORT): + await asyncio.Future() # run forever + + +if __name__ == "__main__": + main_loop = asyncio.run(main()) diff --git a/pi/run_all.py b/pi/run_all.py new file mode 100644 index 0000000..a6ba7e9 --- /dev/null +++ b/pi/run_all.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Launch stats_server and audio_server as child processes.""" +import subprocess, sys, signal +from pathlib import Path + +d = Path(__file__).parent +procs = [ + subprocess.Popen([sys.executable, d / "stats_server.py"]), + subprocess.Popen([sys.executable, d / "audio_server.py"]), +] +signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs]) +signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs]) +print(f"Running stats_server (PID {procs[0].pid}) + audio_server (PID {procs[1].pid})") +for p in procs: + p.wait()