Files
tuimble/tests/tools/voice-smoke
2026-02-24 12:23:50 +01:00

207 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Headless voice smoke test against a live Mumble server.
Connects to a server, starts the audio pipeline, captures mic for a few
seconds, sends encoded frames, listens for incoming audio, then disconnects.
No TUI needed — pure terminal output.
Usage:
voice-smoke HOST [--port PORT] [--user NAME] [--seconds SEC]
voice-smoke --help
"""
from __future__ import annotations
import argparse
import sys
import time
RST = "\033[0m"
DIM = "\033[2m"
GRN = "\033[38;5;108m"
RED = "\033[38;5;131m"
YEL = "\033[38;5;179m"
CYN = "\033[38;5;109m"
BLD = "\033[1m"
def ok(msg: str) -> None:
print(f" {GRN}\u2713{RST} {msg}")
def fail(msg: str) -> None:
print(f" {RED}\u2717{RST} {msg}")
def warn(msg: str) -> None:
print(f" {YEL}\u26a0{RST} {msg}")
def info(msg: str) -> None:
print(f" {DIM}{msg}{RST}")
def heading(msg: str) -> None:
print(f"\n{CYN}{msg}{RST}")
def main() -> int:
parser = argparse.ArgumentParser(
description="Headless voice smoke test",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("host", help="mumble server hostname")
parser.add_argument("--port", type=int, default=64738, help="server port")
parser.add_argument("--user", default="tuimble-test", help="username")
parser.add_argument(
"--seconds", type=float, default=5.0,
help="capture+send duration (default: 5)",
)
parser.add_argument(
"--version", action="version", version="voice-smoke 0.1.0",
)
args = parser.parse_args()
print(f"{CYN}voice smoke test{RST} {DIM}{args.host}:{args.port}{RST}")
# -- dependencies --------------------------------------------------------
heading("Prerequisites")
try:
import sounddevice as sd # noqa: F401
ok("sounddevice + PortAudio")
except OSError as e:
fail(f"PortAudio: {e}")
return 1
try:
import opuslib # noqa: F401
ok("opuslib")
except ImportError:
fail("opuslib not installed")
return 1
from tuimble.audio import AudioPipeline
from tuimble.client import MumbleClient
ok("tuimble imports")
# -- connect -------------------------------------------------------------
heading("Connection")
info(f"connecting to {args.host}:{args.port} as {args.user}...")
client = MumbleClient(
host=args.host, port=args.port, username=args.user,
)
rx_stats = {"frames": 0, "bytes": 0}
def on_sound(_user, pcm_data: bytes) -> None:
rx_stats["frames"] += 1
rx_stats["bytes"] += len(pcm_data)
client.on_sound_received = on_sound
try:
client.connect()
except Exception as e:
fail(f"connect: {e}")
return 1
ok(f"connected (channels={len(client.channels)} users={len(client.users)})")
for cid, ch in client.channels.items():
n_users = sum(
1 for u in client.users.values() if u.channel_id == cid
)
info(f" {ch.name} ({n_users} users)")
# -- audio pipeline ------------------------------------------------------
heading("Audio Pipeline")
audio = AudioPipeline()
try:
audio.start()
ok("streams opened (input + output)")
except Exception as e:
fail(f"audio start: {e}")
client.disconnect()
return 1
# -- capture + send ------------------------------------------------------
heading(f"Transmit ({args.seconds:.1f}s)")
info("capturing mic + encoding + sending to server...")
audio.capturing = True
tx_frames = 0
deadline = time.monotonic() + args.seconds
try:
while time.monotonic() < deadline:
frame = audio.get_capture_frame()
if frame is not None:
client.send_audio(frame)
tx_frames += 1
else:
time.sleep(0.005)
except KeyboardInterrupt:
pass
audio.capturing = False
expected = int(args.seconds * 48000 / 960)
pct = (tx_frames / expected * 100) if expected else 0
if tx_frames == 0:
warn(f"sent 0 frames (expected ~{expected})")
info("mic may be muted or no input device")
elif pct < 80:
warn(f"sent {tx_frames} frames ({pct:.0f}% of expected ~{expected})")
else:
ok(f"sent {tx_frames} frames ({pct:.0f}% of ~{expected})")
# -- listen for incoming -------------------------------------------------
heading("Receive (2s)")
info("listening for incoming voice...")
time.sleep(2.0)
if rx_stats["frames"] > 0:
ok(f"received {rx_stats['frames']} frames "
f"({rx_stats['bytes']} bytes)")
else:
info("no incoming audio (normal if alone on server)")
# -- cleanup -------------------------------------------------------------
heading("Cleanup")
audio.stop()
ok("audio stopped")
client.disconnect()
ok("disconnected")
# -- summary -------------------------------------------------------------
heading("Summary")
results = []
if tx_frames > 0:
results.append(f"tx={tx_frames}")
if rx_stats["frames"] > 0:
results.append(f"rx={rx_stats['frames']}")
if results:
ok(f"voice pipeline working ({', '.join(results)})")
else:
warn("no audio transmitted or received")
info("check: mic not muted, other users in channel")
return 0
if __name__ == "__main__":
sys.exit(main())