"""The default microphone in, the default speaker out, and two short chimes. sounddevice (PortAudio) for both directions; MIC_DEVICE / SPEAKER_DEVICE in .env pick something other than the system default. The chimes are made here from sine waves — no sound files to ship. """ from __future__ import annotations import array import math from typing import Callable, Iterable from . import config def open_mic(on_frame: Callable[[bytes], None]): """Start the microphone: 16 kHz mono int16, one 80 ms frame per call to *on_frame*, from PortAudio's thread. Returns the stream (stop()/close()).""" import sounddevice as sd stream = sd.RawInputStream( samplerate=config.SAMPLE_RATE, channels=1, dtype="int16", blocksize=config.FRAME_SAMPLES, device=config.MIC_DEVICE, callback=lambda data, _frames, _time, _status: on_frame(bytes(data)), ) stream.start() return stream def play(chunks: Iterable[bytes], sample_rate: int = config.TTS_SAMPLE_RATE, should_stop: Callable[[], bool] = lambda: False) -> bool: """Play PCM16 mono chunks as they arrive. False if stopped early. Through `pacat`, not sounddevice: PortAudio only sees raw ALSA devices, so a Bluetooth or PipeWire sink is invisible to it and the audio goes somewhere nobody can hear. pacat talks to the audio server, so it follows the system's default sink like every other player. """ import shutil import subprocess if shutil.which("pacat") is None: return _play_sounddevice(chunks, sample_rate, should_stop) proc = subprocess.Popen( ["pacat", "--rate", str(sample_rate), "--channels", "1", "--format", "s16le", "--latency-msec", "60"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) try: for chunk in chunks: if should_stop(): return False proc.stdin.write(chunk) proc.stdin.close() proc.wait(timeout=10) except (BrokenPipeError, OSError): return False finally: if proc.poll() is None: proc.kill() return True def _play_sounddevice(chunks: Iterable[bytes], sample_rate: int, should_stop: Callable[[], bool]) -> bool: """Fallback for a machine without pacat.""" import sounddevice as sd with sd.RawOutputStream(samplerate=sample_rate, channels=1, dtype="int16", device=config.SPEAKER_DEVICE) as out: for chunk in chunks: if should_stop(): out.abort() return False out.write(chunk) return True def tone(notes, sample_rate: int = config.TTS_SAMPLE_RATE, volume: float = 0.18) -> bytes: """(frequency Hz, seconds) notes as PCM16, each with a quick fade in and out so it doesn't click.""" samples = array.array("h") for freq, seconds in notes: count = int(sample_rate * seconds) fade = max(1, int(sample_rate * 0.012)) for i in range(count): envelope = min(1.0, i / fade, (count - i) / fade) samples.append(int(32767 * volume * envelope * math.sin(2 * math.pi * freq * i / sample_rate))) return samples.tobytes() # Rising two notes: "I'm listening". Falling: "didn't work". WAKE_CHIME = tone([(880, 0.07), (1320, 0.09)]) ERROR_CHIME = tone([(520, 0.10), (390, 0.14)])