feature: alarm
This commit is contained in:
5
components/audio_client/CMakeLists.txt
Normal file
5
components/audio_client/CMakeLists.txt
Normal file
@@ -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 "./")
|
||||||
224
components/audio_client/audio_client.cpp
Normal file
224
components/audio_client/audio_client.cpp
Normal file
@@ -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 <string.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/queue.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
34
components/audio_client/audio_client.h
Normal file
34
components/audio_client/audio_client.h
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
#define WIFI_SSID "Novoyuuparosk_H3C"
|
#define WIFI_SSID "Novoyuuparosk_H3C"
|
||||||
#define WIFI_PASSWORD "northwich"
|
#define WIFI_PASSWORD "northwich"
|
||||||
#define WS_SERVER_URI "ws://192.168.2.199:8765"
|
#define WS_SERVER_URI "ws://192.168.2.199:8765"
|
||||||
|
#define AUDIO_SERVER_URI "ws://192.168.2.199:8766"
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "user_app.cpp" "alert.cpp"
|
SRCS "user_app.cpp" "alert.cpp"
|
||||||
REQUIRES app_bsp
|
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 "./")
|
INCLUDE_DIRS "./")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "alert.h"
|
#include "alert.h"
|
||||||
|
#include "audio_client.h"
|
||||||
#include "codec_bsp.h"
|
#include "codec_bsp.h"
|
||||||
#include "i2c_bsp.h"
|
#include "i2c_bsp.h"
|
||||||
#include <esp_log.h>
|
#include <esp_log.h>
|
||||||
@@ -84,6 +85,7 @@ void alert_trigger(alert_type_t type)
|
|||||||
if (type >= ALERT_TYPE_COUNT) return;
|
if (type >= ALERT_TYPE_COUNT) return;
|
||||||
if (s_muted) return;
|
if (s_muted) return;
|
||||||
if (!s_tone_buf || !s_codec) 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;
|
if (xSemaphoreTake(s_alert_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "button_bsp.h"
|
#include "button_bsp.h"
|
||||||
#include "codec_bsp.h"
|
#include "codec_bsp.h"
|
||||||
#include "alert.h"
|
#include "alert.h"
|
||||||
|
#include "audio_client.h"
|
||||||
#include "lvgl_bsp.h"
|
#include "lvgl_bsp.h"
|
||||||
|
|
||||||
#include <esp_log.h>
|
#include <esp_log.h>
|
||||||
@@ -57,6 +58,9 @@ void UserApp_AppInit(void)
|
|||||||
alert_init();
|
alert_init();
|
||||||
alert_set_codec(s_codec);
|
alert_set_codec(s_codec);
|
||||||
|
|
||||||
|
/* Audio streaming client */
|
||||||
|
audio_client_init(AUDIO_SERVER_URI, s_codec);
|
||||||
|
|
||||||
/* WebSocket client init (not started yet) */
|
/* WebSocket client init (not started yet) */
|
||||||
ws_client_init(WS_SERVER_URI);
|
ws_client_init(WS_SERVER_URI);
|
||||||
ws_client_set_data_callback(ws_data_cb);
|
ws_client_set_data_callback(ws_data_cb);
|
||||||
@@ -78,6 +82,9 @@ void UserApp_TaskInit(void)
|
|||||||
/* Start WebSocket client */
|
/* Start WebSocket client */
|
||||||
ws_client_start();
|
ws_client_start();
|
||||||
|
|
||||||
|
/* Start audio streaming client */
|
||||||
|
audio_client_start();
|
||||||
|
|
||||||
/* Sensor polling task - Core 1, 4KB stack */
|
/* Sensor polling task - Core 1, 4KB stack */
|
||||||
xTaskCreatePinnedToCore(sensor_task, "sensor", 4 * 1024, NULL, 3, NULL, 1);
|
xTaskCreatePinnedToCore(sensor_task, "sensor", 4 * 1024, NULL, 3, NULL, 1);
|
||||||
|
|
||||||
|
|||||||
87
docs/ALARM_PROTOCOL.md
Normal file
87
docs/ALARM_PROTOCOL.md
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
```
|
||||||
55
pi/assets/alarm/alarm_test.lab
Normal file
55
pi/assets/alarm/alarm_test.lab
Normal file
@@ -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
|
||||||
BIN
pi/assets/alarm/alarm_test.wav
Normal file
BIN
pi/assets/alarm/alarm_test.wav
Normal file
Binary file not shown.
BIN
pi/assets/img/idle.png
Normal file
BIN
pi/assets/img/idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
pi/assets/img/on_alarm.png
Normal file
BIN
pi/assets/img/on_alarm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
112
pi/audio_server.py
Normal file
112
pi/audio_server.py
Normal file
@@ -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())
|
||||||
15
pi/run_all.py
Normal file
15
pi/run_all.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user