Add EVS control portal, io_mode switching, and DAC-only speaker path
Some checks failed
Build and Push EVS Bridge Image / docker (push) Has been cancelled

This commit is contained in:
Kai
2026-02-15 13:16:41 +01:00
parent 179440858b
commit 04c59c3b25
13 changed files with 1257 additions and 89 deletions

View File

@@ -1,7 +1,9 @@
import audioop
import logging
import math
import os
import socket
import subprocess
import time
from typing import Optional, Tuple
@@ -35,12 +37,27 @@ MUMBLE_HOST = os.getenv("MUMBLE_HOST", "")
MUMBLE_PORT = int(os.getenv("MUMBLE_PORT", "64738"))
MUMBLE_USERNAME = os.getenv("MUMBLE_USERNAME", f"EVS-{DEVICE_ID}")
MUMBLE_PASSWORD = os.getenv("MUMBLE_PASSWORD", "")
MUMBLE_CERTFILE = os.getenv("MUMBLE_CERTFILE", "").strip()
MUMBLE_KEYFILE = os.getenv("MUMBLE_KEYFILE", "").strip()
MUMBLE_AUTO_CERT = getenv_bool("MUMBLE_AUTO_CERT", True)
MUMBLE_CERT_DIR = os.getenv("MUMBLE_CERT_DIR", "/data/certs").strip()
MUMBLE_CERT_DAYS = int(os.getenv("MUMBLE_CERT_DAYS", "3650"))
MUMBLE_CERT_SUBJECT = os.getenv("MUMBLE_CERT_SUBJECT", "").strip()
MUMBLE_CERT_AUTO_RENEW = getenv_bool("MUMBLE_CERT_AUTO_RENEW", False)
MUMBLE_CERT_RENEW_BEFORE_DAYS = int(os.getenv("MUMBLE_CERT_RENEW_BEFORE_DAYS", "30"))
MUMBLE_CHANNEL = os.getenv("MUMBLE_CHANNEL", "").strip()
MUMBLE_CHANNEL_ID = int(os.getenv("MUMBLE_CHANNEL_ID", "0"))
MUMBLE_RECONNECT_SEC = int(os.getenv("MUMBLE_RECONNECT_SEC", "5"))
MUMBLE_VERBOSE = getenv_bool("MUMBLE_VERBOSE", False)
MUMBLE_CONNECT_TIMEOUT_SEC = int(os.getenv("MUMBLE_CONNECT_TIMEOUT_SEC", "30"))
MUMBLE_CONNECT_STRICT = getenv_bool("MUMBLE_CONNECT_STRICT", False)
BRIDGE_STATS_INTERVAL_SEC = int(os.getenv("BRIDGE_STATS_INTERVAL_SEC", "5"))
VAD_ENABLED = getenv_bool("VAD_ENABLED", False)
VAD_RMS_THRESHOLD = int(os.getenv("VAD_RMS_THRESHOLD", "700"))
VAD_OPEN_FRAMES = int(os.getenv("VAD_OPEN_FRAMES", "2"))
VAD_CLOSE_FRAMES = int(os.getenv("VAD_CLOSE_FRAMES", "20"))
HPF_ENABLED = getenv_bool("HPF_ENABLED", True)
HPF_CUTOFF_HZ = float(os.getenv("HPF_CUTOFF_HZ", "120.0"))
def _channel_name(ch) -> str:
@@ -179,18 +196,26 @@ def connect_mumble() -> pymumble.Mumble:
raise RuntimeError("MUMBLE_HOST is required")
log.info(
"connecting mumble: host=%s port=%s user=%s channel=%s channel_id=%s",
"connecting mumble: host=%s port=%s user=%s channel=%s channel_id=%s cert=%s key=%s",
MUMBLE_HOST,
MUMBLE_PORT,
MUMBLE_USERNAME,
MUMBLE_CHANNEL or "<unchanged>",
MUMBLE_CHANNEL_ID,
MUMBLE_CERTFILE or "<none>",
MUMBLE_KEYFILE or "<none>",
)
certfile, keyfile = resolve_cert_paths()
ensure_cert_material(certfile, keyfile)
mumble = pymumble.Mumble(
MUMBLE_HOST,
MUMBLE_USERNAME,
password=MUMBLE_PASSWORD or None,
port=MUMBLE_PORT,
certfile=certfile,
keyfile=keyfile,
reconnect=True,
debug=MUMBLE_VERBOSE,
)
@@ -266,6 +291,101 @@ def connect_mumble() -> pymumble.Mumble:
return mumble
def resolve_cert_paths() -> Tuple[Optional[str], Optional[str]]:
certfile = MUMBLE_CERTFILE or None
keyfile = MUMBLE_KEYFILE or None
if certfile is None and MUMBLE_AUTO_CERT:
certfile = os.path.join(MUMBLE_CERT_DIR, f"{DEVICE_ID}.crt")
if keyfile is None and MUMBLE_AUTO_CERT:
keyfile = os.path.join(MUMBLE_CERT_DIR, f"{DEVICE_ID}.key")
return certfile, keyfile
def ensure_cert_material(certfile: Optional[str], keyfile: Optional[str]) -> None:
if certfile is None and keyfile is None:
return
cert_exists = bool(certfile and os.path.exists(certfile))
key_exists = bool(keyfile and os.path.exists(keyfile))
should_renew = False
if cert_exists and key_exists and MUMBLE_CERT_AUTO_RENEW:
should_renew = cert_needs_renewal(certfile)
if should_renew:
log.warning(
"cert renewal required: cert=%s renew_before_days=%d",
certfile,
MUMBLE_CERT_RENEW_BEFORE_DAYS,
)
if cert_exists and key_exists and not should_renew:
return
if not MUMBLE_AUTO_CERT:
if certfile and not cert_exists:
raise RuntimeError(f"MUMBLE_CERTFILE not found: {certfile}")
if keyfile and not key_exists:
raise RuntimeError(f"MUMBLE_KEYFILE not found: {keyfile}")
return
if not certfile or not keyfile:
raise RuntimeError("auto cert generation requires both certfile and keyfile paths")
os.makedirs(os.path.dirname(certfile), exist_ok=True)
os.makedirs(os.path.dirname(keyfile), exist_ok=True)
subject = MUMBLE_CERT_SUBJECT or f"/CN={MUMBLE_USERNAME}"
cmd = [
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
keyfile,
"-out",
certfile,
"-days",
str(MUMBLE_CERT_DAYS),
"-subj",
subject,
]
log.info("creating self-signed mumble cert: cert=%s key=%s subject=%s", certfile, keyfile, subject)
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"openssl cert generation failed (rc={exc.returncode}): {exc.stderr.strip()}"
) from exc
def cert_needs_renewal(certfile: str) -> bool:
if MUMBLE_CERT_RENEW_BEFORE_DAYS <= 0:
return False
check_seconds = MUMBLE_CERT_RENEW_BEFORE_DAYS * 86400
cmd = [
"openssl",
"x509",
"-in",
certfile,
"-checkend",
str(check_seconds),
"-noout",
]
try:
# openssl x509 -checkend returns:
# 0 -> certificate valid longer than check_seconds
# 1 -> certificate will expire within check_seconds
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
return result.returncode != 0
except Exception:
log.exception("failed to check cert expiration, forcing renewal")
return True
def open_udp_socket() -> socket.socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_LISTEN_HOST, UDP_LISTEN_PORT))
@@ -274,7 +394,43 @@ def open_udp_socket() -> socket.socket:
return sock
def process_audio_chunk(raw_pcm: bytes, rate_state: Optional[Tuple]) -> Tuple[bytes, Optional[Tuple]]:
def apply_highpass_pcm16(data: bytes, prev_x: float, prev_y: float, sample_rate: int) -> Tuple[bytes, float, float]:
if len(data) < 2 or sample_rate <= 0:
return data, prev_x, prev_y
if HPF_CUTOFF_HZ <= 0.0:
return data, prev_x, prev_y
# 1st-order DC-block / high-pass:
# y[n] = alpha * (y[n-1] + x[n] - x[n-1])
rc = 1.0 / (2.0 * math.pi * HPF_CUTOFF_HZ)
dt = 1.0 / float(sample_rate)
alpha = rc / (rc + dt)
samples = memoryview(data).cast("h")
out = bytearray(len(data))
out_mv = memoryview(out).cast("h")
x_prev = prev_x
y_prev = prev_y
for i, x in enumerate(samples):
y = alpha * (y_prev + float(x) - x_prev)
if y > 32767.0:
y = 32767.0
elif y < -32768.0:
y = -32768.0
out_mv[i] = int(y)
x_prev = float(x)
y_prev = y
return bytes(out), x_prev, y_prev
def process_audio_chunk(
raw_pcm: bytes,
rate_state: Optional[Tuple],
hp_prev_x: float,
hp_prev_y: float,
) -> Tuple[bytes, Optional[Tuple], float, float]:
data = raw_pcm
if INPUT_SAMPLE_RATE != MUMBLE_SAMPLE_RATE:
data, rate_state = audioop.ratecv(
@@ -287,47 +443,120 @@ def process_audio_chunk(raw_pcm: bytes, rate_state: Optional[Tuple]) -> Tuple[by
)
if MUMBLE_AUDIO_GAIN != 1.0:
data = audioop.mul(data, 2, MUMBLE_AUDIO_GAIN)
return data, rate_state
if HPF_ENABLED:
data, hp_prev_x, hp_prev_y = apply_highpass_pcm16(data, hp_prev_x, hp_prev_y, MUMBLE_SAMPLE_RATE)
return data, rate_state, hp_prev_x, hp_prev_y
def run() -> None:
udp = open_udp_socket()
mumble = None
rate_state = None
hp_prev_x = 0.0
hp_prev_y = 0.0
out_buffer = bytearray()
frame_bytes = int((MUMBLE_SAMPLE_RATE * 2 * FRAME_MS) / 1000) # mono, 16-bit
udp_packets = 0
udp_bytes = 0
frames_sent = 0
send_errors = 0
vad_dropped_packets = 0
vad_open = not VAD_ENABLED
vad_voice_frames = 0
vad_silence_frames = 0
stats_t0 = time.time()
last_udp_from = None
while True:
if mumble is None:
try:
mumble = connect_mumble()
log.info("mumble ready")
# Reset ratecv state when reconnecting so timing is clean.
rate_state = None
hp_prev_x = 0.0
hp_prev_y = 0.0
except Exception:
log.exception("mumble connect failed, retrying in %ss", MUMBLE_RECONNECT_SEC)
time.sleep(MUMBLE_RECONNECT_SEC)
continue
try:
packet, _addr = udp.recvfrom(8192)
packet, addr = udp.recvfrom(8192)
except socket.timeout:
# No UDP data right now; keep loop alive.
continue
packet = None
except Exception:
log.exception("udp receive failed")
continue
now = time.time()
if BRIDGE_STATS_INTERVAL_SEC > 0 and (now - stats_t0) >= BRIDGE_STATS_INTERVAL_SEC:
dt = max(0.001, now - stats_t0)
log.info(
"bridge stats: udp_packets=%d udp_bytes=%d udp_kbps=%.1f frames_sent=%d send_errors=%d buffer_bytes=%d vad_open=%s vad_dropped=%d last_from=%s",
udp_packets,
udp_bytes,
(udp_bytes * 8.0 / 1000.0) / dt,
frames_sent,
send_errors,
len(out_buffer),
vad_open,
vad_dropped_packets,
last_udp_from or "-",
)
udp_packets = 0
udp_bytes = 0
frames_sent = 0
send_errors = 0
vad_dropped_packets = 0
stats_t0 = now
if not packet:
continue
try:
processed, rate_state = process_audio_chunk(packet, rate_state)
udp_packets += 1
udp_bytes += len(packet)
if last_udp_from != str(addr):
last_udp_from = str(addr)
log.info("udp source: %s", last_udp_from)
if VAD_ENABLED:
rms = audioop.rms(packet, 2)
if rms >= VAD_RMS_THRESHOLD:
vad_voice_frames += 1
vad_silence_frames = 0
if not vad_open and vad_voice_frames >= VAD_OPEN_FRAMES:
vad_open = True
log.info("vad open: rms=%d threshold=%d", rms, VAD_RMS_THRESHOLD)
else:
vad_silence_frames += 1
vad_voice_frames = 0
if vad_open and vad_silence_frames >= VAD_CLOSE_FRAMES:
vad_open = False
out_buffer.clear()
log.info("vad close: rms=%d threshold=%d", rms, VAD_RMS_THRESHOLD)
if not vad_open:
vad_dropped_packets += 1
continue
processed, rate_state, hp_prev_x, hp_prev_y = process_audio_chunk(
packet,
rate_state,
hp_prev_x,
hp_prev_y,
)
out_buffer.extend(processed)
while len(out_buffer) >= frame_bytes:
frame = bytes(out_buffer[:frame_bytes])
del out_buffer[:frame_bytes]
mumble.sound_output.add_sound(frame)
frames_sent += 1
except Exception:
send_errors += 1
log.exception("audio processing/send failed")
mumble = None
time.sleep(MUMBLE_RECONNECT_SEC)