#!/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())