Initial EVS client + bridge setup

This commit is contained in:
Kai
2026-02-13 10:07:11 +01:00
commit c742d74860
14 changed files with 889 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
include/secrets.h
bridge/.env
bridge/data/

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

22
bridge/.env.example Normal file
View File

@@ -0,0 +1,22 @@
WS_HOST=0.0.0.0
WS_PORT=8765
WS_PATH=/audio
ECHO_ENABLED=true
LOG_LEVEL=INFO
MQTT_ENABLED=true
MQTT_HOST=homeassistant.local
MQTT_PORT=1883
MQTT_USER=
MQTT_PASSWORD=
MQTT_BASE_TOPIC=evs
MQTT_TTS_TOPIC=evs/+/play_pcm16le
MQTT_STATUS_RETAIN=true
# Optional webhook in Home Assistant:
# HA settings -> Automations & Scenes -> Webhooks
HA_WEBHOOK_URL=
SAVE_SESSIONS=true
SESSIONS_DIR=/data/sessions
PCM_SAMPLE_RATE=16000

10
bridge/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]

73
bridge/README.md Normal file
View File

@@ -0,0 +1,73 @@
# EVS Bridge (Home Assistant + MQTT + WebSocket)
This service is the audio bridge between your ESP32 client and your Home Assistant stack.
It provides:
- WebSocket endpoint for raw PCM audio (`/audio`)
- MQTT status/events (`evs/<device_id>/status`)
- MQTT playback input (`evs/<device_id>/play_pcm16le`)
- Optional Home Assistant webhook callbacks (`connected`, `start`, `stop`, `disconnected`)
## 1) Start the bridge
1. Copy env template:
```bash
cp .env.example .env
```
2. Edit `.env`:
- `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD`
- `HA_WEBHOOK_URL` (optional)
3. Start:
```bash
docker compose up -d --build
```
## 2) Configure ESP32
In `src/main.cpp`:
- no environment-specific values should be edited directly
In `include/secrets.h`:
- copy from `include/secrets.example.h`
- set WiFi credentials
- set bridge host
- set WS port/path
- set unique `EVS_DEVICE_ID`
Then upload firmware.
## 3) Test flow
1. Flash ESP32
2. Open serial monitor
3. Send `s` (stream mode)
4. In bridge logs, you should see the device connection
5. If `ECHO_ENABLED=true`, incoming audio is returned to ESP32 speaker
## 4) MQTT topics
- Status/events published by bridge:
- `evs/<device_id>/status` (JSON)
- Playback input to device:
- `evs/<device_id>/play_pcm16le`
- payload options:
- raw binary PCM16LE
- JSON `{ "pcm16le_b64": "<base64>" }`
## 5) Home Assistant integration
Use webhook for event hooks:
- Configure `HA_WEBHOOK_URL` in `.env`
- Bridge sends JSON with event and metadata on:
- `connected`
- `start`
- `stop`
- `disconnected`
You can build automations on these events (for STT/TTS pipelines or Node-RED handoff).
## 6) Notes
- Audio format: PCM16LE, mono, 16 kHz
- `SAVE_SESSIONS=true` stores `.wav` files in `bridge/data/sessions`
- MQTT is recommended for control/events, WebSocket for streaming audio

291
bridge/app.py Normal file
View File

@@ -0,0 +1,291 @@
import asyncio
import base64
import json
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional
import aiohttp
import paho.mqtt.client as mqtt
import websockets
from websockets.server import WebSocketServerProtocol
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("evs-bridge")
def getenv_bool(name: str, default: bool) -> bool:
val = os.getenv(name)
if val is None:
return default
return val.strip().lower() in {"1", "true", "yes", "on"}
WS_HOST = os.getenv("WS_HOST", "0.0.0.0")
WS_PORT = int(os.getenv("WS_PORT", "8765"))
WS_PATH = os.getenv("WS_PATH", "/audio")
ECHO_ENABLED = getenv_bool("ECHO_ENABLED", True)
MQTT_ENABLED = getenv_bool("MQTT_ENABLED", True)
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
MQTT_USER = os.getenv("MQTT_USER", "")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
MQTT_BASE_TOPIC = os.getenv("MQTT_BASE_TOPIC", "evs")
MQTT_TTS_TOPIC = os.getenv("MQTT_TTS_TOPIC", f"{MQTT_BASE_TOPIC}/+/play_pcm16le")
MQTT_STATUS_RETAIN = getenv_bool("MQTT_STATUS_RETAIN", True)
HA_WEBHOOK_URL = os.getenv("HA_WEBHOOK_URL", "").strip()
SAVE_SESSIONS = getenv_bool("SAVE_SESSIONS", True)
SESSIONS_DIR = Path(os.getenv("SESSIONS_DIR", "/data/sessions"))
PCM_SAMPLE_RATE = int(os.getenv("PCM_SAMPLE_RATE", "16000"))
@dataclass
class DeviceSession:
device_id: str
ws: WebSocketServerProtocol
connected_at: float = field(default_factory=time.time)
ptt_active: bool = False
pcm_bytes: bytearray = field(default_factory=bytearray)
last_rx_ts: float = field(default_factory=time.time)
class BridgeState:
def __init__(self) -> None:
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.devices: Dict[str, DeviceSession] = {}
self.mqtt_client: Optional[mqtt.Client] = None
def publish_status(self, device_id: str, payload: dict) -> None:
if not self.mqtt_client:
return
topic = f"{MQTT_BASE_TOPIC}/{device_id}/status"
try:
self.mqtt_client.publish(topic, json.dumps(payload), qos=0, retain=MQTT_STATUS_RETAIN)
except Exception:
log.exception("mqtt publish failed")
async def send_binary_to_device(self, device_id: str, pcm_data: bytes) -> bool:
session = self.devices.get(device_id)
if not session:
return False
try:
await session.ws.send(pcm_data)
return True
except Exception:
log.exception("ws send to device failed")
return False
state = BridgeState()
def build_metrics(device_id: str, session: DeviceSession) -> dict:
samples = len(session.pcm_bytes) // 2
seconds = samples / float(PCM_SAMPLE_RATE)
return {
"device_id": device_id,
"ptt_active": session.ptt_active,
"rx_bytes": len(session.pcm_bytes),
"duration_s": round(seconds, 3),
"last_rx_ts": session.last_rx_ts,
}
async def call_ha_webhook(event: str, payload: dict) -> None:
if not HA_WEBHOOK_URL:
return
data = {"event": event, **payload}
try:
async with aiohttp.ClientSession() as client:
async with client.post(HA_WEBHOOK_URL, json=data, timeout=10) as resp:
if resp.status >= 400:
log.warning("ha webhook error status=%s", resp.status)
except Exception:
log.exception("ha webhook call failed")
def save_session_wav(device_id: str, pcm: bytes) -> Optional[str]:
if not SAVE_SESSIONS or not pcm:
return None
try:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
path = SESSIONS_DIR / f"{device_id}_{ts}.wav"
import wave
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(PCM_SAMPLE_RATE)
wf.writeframes(pcm)
return str(path)
except Exception:
log.exception("failed to save wav")
return None
async def handle_text_message(device_id: str, session: DeviceSession, raw: str) -> None:
try:
msg = json.loads(raw)
except Exception:
log.warning("invalid text frame from %s: %s", device_id, raw[:80])
return
msg_type = msg.get("type")
if msg_type == "start":
session.ptt_active = True
session.pcm_bytes.clear()
payload = {"type": "start", "ts": time.time(), "device_id": device_id}
state.publish_status(device_id, payload)
await call_ha_webhook("start", payload)
return
if msg_type == "stop":
session.ptt_active = False
metrics = build_metrics(device_id, session)
wav_path = save_session_wav(device_id, bytes(session.pcm_bytes))
payload = {"type": "stop", "ts": time.time(), "device_id": device_id, **metrics}
if wav_path:
payload["wav_path"] = wav_path
state.publish_status(device_id, payload)
await call_ha_webhook("stop", payload)
return
if msg_type == "ping":
await session.ws.send(json.dumps({"type": "pong", "ts": time.time()}))
return
log.info("text msg from %s: %s", device_id, msg)
async def handle_binary_message(device_id: str, session: DeviceSession, data: bytes) -> None:
session.last_rx_ts = time.time()
if session.ptt_active:
session.pcm_bytes.extend(data)
if ECHO_ENABLED:
await session.ws.send(data)
def parse_device_id(path: str) -> str:
# expected:
# /audio
# /audio?device_id=esp32-kitchen
if "?" not in path:
return "esp32-unknown"
try:
from urllib.parse import parse_qs, urlsplit
q = parse_qs(urlsplit(path).query)
return q.get("device_id", ["esp32-unknown"])[0]
except Exception:
return "esp32-unknown"
async def ws_handler(ws: WebSocketServerProtocol, path: str) -> None:
if not path.startswith(WS_PATH):
await ws.close(code=1008, reason="Invalid path")
return
device_id = parse_device_id(path)
session = DeviceSession(device_id=device_id, ws=ws)
state.devices[device_id] = session
state.publish_status(device_id, {"type": "connected", "ts": time.time(), "device_id": device_id})
await call_ha_webhook("connected", {"device_id": device_id, "ts": time.time()})
log.info("device connected: %s", device_id)
try:
async for message in ws:
if isinstance(message, bytes):
await handle_binary_message(device_id, session, message)
else:
await handle_text_message(device_id, session, message)
except websockets.ConnectionClosed:
pass
finally:
if state.devices.get(device_id) is session:
del state.devices[device_id]
state.publish_status(device_id, {"type": "disconnected", "ts": time.time(), "device_id": device_id})
await call_ha_webhook("disconnected", {"device_id": device_id, "ts": time.time()})
log.info("device disconnected: %s", device_id)
def on_mqtt_connect(client: mqtt.Client, _userdata, _flags, reason_code, _properties=None):
if reason_code == 0:
log.info("mqtt connected")
client.subscribe(MQTT_TTS_TOPIC, qos=0)
else:
log.error("mqtt connect failed reason=%s", reason_code)
def on_mqtt_message(_client: mqtt.Client, _userdata, msg: mqtt.MQTTMessage):
# topic: evs/<device_id>/play_pcm16le
try:
parts = msg.topic.split("/")
if len(parts) < 3:
return
device_id = parts[1]
# payload options:
# 1) raw binary PCM16LE
# 2) json {"pcm16le_b64":"..."}
payload = msg.payload
if payload.startswith(b"{"):
doc = json.loads(payload.decode("utf-8"))
b64 = doc.get("pcm16le_b64", "")
if not b64:
return
pcm = base64.b64decode(b64)
else:
pcm = bytes(payload)
if not state.loop:
return
fut = asyncio.run_coroutine_threadsafe(state.send_binary_to_device(device_id, pcm), state.loop)
_ = fut.result(timeout=2)
except Exception:
log.exception("mqtt message handling failed")
def setup_mqtt(loop: asyncio.AbstractEventLoop) -> Optional[mqtt.Client]:
if not MQTT_ENABLED:
log.info("mqtt disabled")
return None
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="evs-bridge")
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
client.on_connect = on_mqtt_connect
client.on_message = on_mqtt_message
client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
client.loop_start()
log.info("mqtt connecting to %s:%s", MQTT_HOST, MQTT_PORT)
return client
async def main():
state.loop = asyncio.get_running_loop()
state.mqtt_client = setup_mqtt(state.loop)
ws_server = await websockets.serve(ws_handler, WS_HOST, WS_PORT, max_size=2**22)
log.info("ws listening on ws://%s:%s%s", WS_HOST, WS_PORT, WS_PATH)
try:
await ws_server.wait_closed()
finally:
if state.mqtt_client:
state.mqtt_client.loop_stop()
state.mqtt_client.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

11
bridge/docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
evs-bridge:
build: .
container_name: evs-bridge
restart: unless-stopped
env_file:
- .env
ports:
- "${WS_PORT:-8765}:${WS_PORT:-8765}"
volumes:
- ./data:/data

3
bridge/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
websockets==12.0
paho-mqtt==2.1.0
aiohttp==3.10.11

37
include/README Normal file
View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

21
include/secrets.example.h Normal file
View File

@@ -0,0 +1,21 @@
#ifndef EVS_SECRETS_H
#define EVS_SECRETS_H
// Copy to: include/secrets.h
// Keep include/secrets.h out of version control.
static const char* WIFI_SSID = "REPLACE_WIFI_SSID";
static const char* WIFI_PASSWORD = "REPLACE_WIFI_PASSWORD";
// EVS bridge endpoint
static const char* EVS_BRIDGE_HOST = "REPLACE_BRIDGE_IP_OR_HOST";
static constexpr uint16_t EVS_WS_PORT = 8765;
static const char* EVS_WS_PATH = "/audio";
// Unique device name per ESP32
static const char* EVS_DEVICE_ID = "esp32-room-name";
// Connectivity behavior
static constexpr uint32_t EVS_RECONNECT_MS = 5000;
#endif // EVS_SECRETS_H

46
lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

19
platformio.ini Normal file
View File

@@ -0,0 +1,19 @@
; PlatformIO Project Configuration File
[env]
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
gilmaimon/ArduinoWebsockets @ ^0.5.4
; Stable baseline (your current toolchain family)
[env:esp32dev_core2]
platform = espressif32@6.12.0
; Newer Arduino-ESP32 Core (3.x) for API/behavior comparison
; Build with: pio run -e esp32dev_core3
[env:esp32dev_core3]
platform = espressif32@6.12.0
platform_packages =
framework-arduinoespressif32@https://github.com/espressif/arduino-esp32.git#3.0.7

327
src/main.cpp Normal file
View File

@@ -0,0 +1,327 @@
#include <Arduino.h>
#include <WiFi.h>
#include <ArduinoWebsockets.h>
#include "driver/i2s.h"
#include "secrets.h"
using namespace websockets;
// ---------------------------
// Project config
// ---------------------------
static constexpr i2s_port_t MIC_I2S_PORT = I2S_NUM_0;
static constexpr uint32_t MIC_SAMPLE_RATE = 16000;
static constexpr i2s_bits_per_sample_t MIC_BITS = I2S_BITS_PER_SAMPLE_32BIT;
static constexpr size_t MIC_FRAME_SAMPLES = 256;
// INMP441 -> ESP32
static constexpr int PIN_I2S_WS = 25;
static constexpr int PIN_I2S_SCK = 26;
static constexpr int PIN_I2S_SD = 33;
// ESP32 PWM -> PAM8403 IN (L+ or R+)
static constexpr int PIN_AUDIO_OUT = 27;
static constexpr int PWM_CHANNEL = 0;
static constexpr uint32_t PWM_FREQ = 22050;
static constexpr uint8_t PWM_RES_BITS = 8;
static constexpr uint32_t SPEAKER_SAMPLE_RATE = 16000;
// WiFi / WebSocket
enum class DeviceMode : uint8_t {
Idle,
StreamToServer, // Placeholder: ship PCM to remote STT/LLM/TTS service
LocalLoopback, // Debug mode: mic directly to speaker
};
static DeviceMode g_mode = DeviceMode::Idle;
static int32_t g_micBuffer[MIC_FRAME_SAMPLES];
static WebsocketsClient g_ws;
static bool g_wsConnected = false;
static uint32_t g_lastConnectTryMs = 0;
static uint32_t g_nextOutUs = 0;
static bool g_streamingActive = false;
static constexpr size_t RX_SAMPLES_CAP = 16000;
static int16_t g_rxSamples[RX_SAMPLES_CAP];
static size_t g_rxHead = 0;
static size_t g_rxTail = 0;
static size_t g_rxCount = 0;
static bool initMicI2s() {
const i2s_config_t i2sConfig = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = MIC_SAMPLE_RATE,
.bits_per_sample = MIC_BITS,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0,
.dma_buf_count = 8,
.dma_buf_len = 256,
.use_apll = false,
.tx_desc_auto_clear = false,
.fixed_mclk = 0,
};
const i2s_pin_config_t pinConfig = {
.bck_io_num = PIN_I2S_SCK,
.ws_io_num = PIN_I2S_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = PIN_I2S_SD,
};
if (i2s_driver_install(MIC_I2S_PORT, &i2sConfig, 0, nullptr) != ESP_OK) {
return false;
}
if (i2s_set_pin(MIC_I2S_PORT, &pinConfig) != ESP_OK) {
return false;
}
return true;
}
static bool initPwmOut() {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
return ledcAttach(PIN_AUDIO_OUT, PWM_FREQ, PWM_RES_BITS);
#else
ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RES_BITS);
ledcAttachPin(PIN_AUDIO_OUT, PWM_CHANNEL);
return true;
#endif
}
static inline void pwmWrite(uint8_t value) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
ledcWrite(PIN_AUDIO_OUT, value);
#else
ledcWrite(PWM_CHANNEL, value);
#endif
}
// Convert signed 16-bit PCM to unsigned 8-bit PWM domain.
static inline uint8_t pcm16ToPwm8(int16_t s) {
return (uint16_t)(s + 32768) >> 8;
}
static bool enqueuePcmSample(int16_t s) {
if (g_rxCount >= RX_SAMPLES_CAP) {
return false;
}
g_rxSamples[g_rxTail] = s;
g_rxTail = (g_rxTail + 1) % RX_SAMPLES_CAP;
++g_rxCount;
return true;
}
static bool dequeuePcmSample(int16_t& out) {
if (g_rxCount == 0) {
return false;
}
out = g_rxSamples[g_rxHead];
g_rxHead = (g_rxHead + 1) % RX_SAMPLES_CAP;
--g_rxCount;
return true;
}
static void enqueuePcmFrame(const int16_t* frame, size_t count) {
for (size_t i = 0; i < count; ++i) {
if (!enqueuePcmSample(frame[i])) {
break;
}
}
}
static void onWsMessageCallback(WebsocketsMessage message) {
if (!message.isBinary()) {
return;
}
const String payload = message.data();
const size_t n = payload.length();
if (n < 2) {
return;
}
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(payload.c_str());
const size_t sampleCount = n / 2;
for (size_t i = 0; i < sampleCount; ++i) {
const uint8_t lo = bytes[2 * i];
const uint8_t hi = bytes[2 * i + 1];
const int16_t s = (int16_t)((uint16_t)hi << 8 | lo);
if (!enqueuePcmSample(s)) {
break;
}
}
}
static void onWsEventCallback(WebsocketsEvent event, String) {
if (event == WebsocketsEvent::ConnectionOpened) {
g_wsConnected = true;
if (g_mode == DeviceMode::StreamToServer && !g_streamingActive) {
g_ws.send("{\"type\":\"start\"}");
g_streamingActive = true;
}
Serial.println("WS connected");
} else if (event == WebsocketsEvent::ConnectionClosed) {
g_wsConnected = false;
g_streamingActive = false;
Serial.println("WS disconnected");
}
}
static String makeWsUrl() {
String url = "ws://";
url += EVS_BRIDGE_HOST;
url += ":";
url += String(EVS_WS_PORT);
url += EVS_WS_PATH;
url += "?device_id=";
url += EVS_DEVICE_ID;
return url;
}
static void ensureConnectivity() {
const uint32_t now = millis();
if ((now - g_lastConnectTryMs) < EVS_RECONNECT_MS) {
return;
}
g_lastConnectTryMs = now;
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi connecting...");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
return;
}
if (!g_wsConnected) {
const String url = makeWsUrl();
Serial.print("WS connecting: ");
Serial.println(url);
g_ws.connect(url);
}
}
static void setMode(DeviceMode mode) {
if (g_mode == mode) {
return;
}
if (g_mode == DeviceMode::StreamToServer && g_wsConnected && g_streamingActive) {
g_ws.send("{\"type\":\"stop\"}");
g_streamingActive = false;
}
g_mode = mode;
if (g_mode == DeviceMode::StreamToServer && g_wsConnected && !g_streamingActive) {
g_ws.send("{\"type\":\"start\"}");
g_streamingActive = true;
}
}
// Send PCM16 mono frame to server.
static void handleFrameForServer(const int16_t* frame, size_t count) {
if (!g_wsConnected || count == 0) {
return;
}
g_ws.sendBinary(reinterpret_cast<const char*>(frame), count * sizeof(int16_t));
}
static void serviceSpeaker() {
const uint32_t periodUs = 1000000UL / SPEAKER_SAMPLE_RATE;
const uint32_t now = micros();
if ((int32_t)(now - g_nextOutUs) < 0) {
return;
}
g_nextOutUs += periodUs;
int16_t s = 0;
if (dequeuePcmSample(s)) {
pwmWrite(pcm16ToPwm8(s));
} else {
pwmWrite(128);
}
}
static void printHelp() {
Serial.println();
Serial.println("Commands:");
Serial.println(" i = idle");
Serial.println(" s = stream mode (stub)");
Serial.println(" l = local loopback mode");
Serial.println(" p = print network status");
Serial.println(" h = help");
}
static void handleSerialCommands() {
while (Serial.available()) {
const char c = (char)Serial.read();
if (c == 'i') {
setMode(DeviceMode::Idle);
Serial.println("Mode -> Idle");
} else if (c == 's') {
setMode(DeviceMode::StreamToServer);
Serial.println("Mode -> StreamToServer");
} else if (c == 'l') {
setMode(DeviceMode::LocalLoopback);
Serial.println("Mode -> LocalLoopback");
} else if (c == 'p') {
Serial.print("WiFi: ");
Serial.print((WiFi.status() == WL_CONNECTED) ? "connected " : "disconnected ");
Serial.print("IP=");
Serial.println(WiFi.localIP());
Serial.print("WS: ");
Serial.println(g_wsConnected ? "connected" : "disconnected");
} else if (c == 'h') {
printHelp();
}
}
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("EVS Client boot");
if (!initMicI2s()) {
Serial.println("ERROR: I2S init failed");
while (true) delay(1000);
}
if (!initPwmOut()) {
Serial.println("ERROR: PWM init failed");
while (true) delay(1000);
}
g_ws.onMessage(onWsMessageCallback);
g_ws.onEvent(onWsEventCallback);
g_nextOutUs = micros();
setMode(DeviceMode::LocalLoopback);
Serial.println("Audio init ok");
Serial.println("Set local environment values in include/secrets.h");
printHelp();
}
void loop() {
handleSerialCommands();
ensureConnectivity();
g_ws.poll();
serviceSpeaker();
size_t bytesRead = 0;
const esp_err_t res = i2s_read(
MIC_I2S_PORT, g_micBuffer, sizeof(g_micBuffer), &bytesRead, 0);
if (res != ESP_OK || bytesRead == 0) {
return;
}
const size_t sampleCount = bytesRead / sizeof(int32_t);
static int16_t pcm16[MIC_FRAME_SAMPLES];
for (size_t i = 0; i < sampleCount; ++i) {
// INMP441 delivers meaningful data in the high bits for 32-bit slot formats.
pcm16[i] = (int16_t)(g_micBuffer[i] >> 14);
}
if (g_mode == DeviceMode::StreamToServer) {
handleFrameForServer(pcm16, sampleCount);
} else if (g_mode == DeviceMode::LocalLoopback) {
enqueuePcmFrame(pcm16, sampleCount);
} else {
// idle
}
}

11
test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html