diff --git a/assets/alarm/alarm_test.lab b/assets/alarm/alarm_test.lab new file mode 100644 index 0000000..cf732a8 --- /dev/null +++ b/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/assets/alarm/alarm_test.wav b/assets/alarm/alarm_test.wav new file mode 100644 index 0000000..24fc245 Binary files /dev/null and b/assets/alarm/alarm_test.wav differ diff --git a/assets/img/idle.png b/assets/img/idle.png new file mode 100644 index 0000000..f853294 Binary files /dev/null and b/assets/img/idle.png differ diff --git a/assets/img/on_alarm.png b/assets/img/on_alarm.png new file mode 100644 index 0000000..a7512fc Binary files /dev/null and b/assets/img/on_alarm.png differ diff --git a/audio_server.py b/audio_server.py new file mode 100644 index 0000000..43265b6 --- /dev/null +++ b/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/run_all.py b/run_all.py new file mode 100644 index 0000000..a6ba7e9 --- /dev/null +++ b/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()