feature: alarm
This commit is contained in:
55
assets/alarm/alarm_test.lab
Normal file
55
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
assets/alarm/alarm_test.wav
Normal file
BIN
assets/alarm/alarm_test.wav
Normal file
Binary file not shown.
BIN
assets/img/idle.png
Normal file
BIN
assets/img/idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/img/on_alarm.png
Normal file
BIN
assets/img/on_alarm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
112
audio_server.py
Normal file
112
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
run_all.py
Normal file
15
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