Files
pi-dashboard-server/audio_server.py
Mikkeli Matlock d6f0726f47 feature: alarm
2026-02-15 21:11:33 +09:00

113 lines
3.4 KiB
Python

"""
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())