diff --git a/.env.example b/.env.example deleted file mode 100644 index 8e5445f..0000000 --- a/.env.example +++ /dev/null @@ -1,232 +0,0 @@ -# Copy this file to .env and fill in your values. - -# ── Bolt server (required) ────────────────────────────────────────────────── -# The same server URL + key desk_client/bolt_desk.py and the Android app use. -# DESK_API_KEY is the master key from the server's own .env, or a per-user -# key minted via the api_key_generate desk marker (see the main repo's -# CLAUDE.md "Per-user API keys" section). -BOLT_SERVER_URL=http://your-server:5002 -DESK_API_KEY= - -# Unique per machine/install so sessions don't collide. Defaults to -# "pet-" if unset. -#DESK_SESSION_ID=pet-my-desktop - -# ── Wake word ──────────────────────────────────────────────────────────────── -# openWakeWord model trained for "thunderbolt" — thunderbolt.onnx ships in -# the project root next to this file. Point WAKE_MODEL_FILE at a different -# .onnx model to change the phrase (same convention as desk_client's -# WAKE_MODEL_FILE / bolt.onnx in the main repo). -#WAKE_MODEL_FILE=thunderbolt.onnx -#WAKE_WORD_THRESHOLD=0.5 -#WAKE_CHECK_INTERVAL_SECONDS=1.2 - -# ── STT / TTS (server-hosted — same /desk/stt and /desk/tts the Android app -# uses) ────────────────────────────────────────────────────────────────────── -# No Deepgram or ElevenLabs account needed here: both go through -# BOLT_SERVER_URL/DESK_API_KEY above, the same as the rest of this file. -# ELEVENLABS_VOICE_ID just tells the server which voice to request — omit it -# to fall back to offline pyttsx3 TTS instead (STT still works either way). -ELEVENLABS_VOICE_ID= -#TTS_SAMPLE_RATE=16000 - -# Ask Bolt to use a different voice (or another language) and the server -# picks one from the ElevenLabs voice library and tags the reply with it. -# It only tags one reply, and it can't remember the id afterwards — so the -# pet keeps using that voice until a new one is picked or you choose "Use -# default voice" in the tray. VOICE_STICKY=false makes each pick last for -# exactly the one reply it came with instead. -#VOICE_STICKY=true - -# ── Multi-voice dialogue (ElevenLabs Text to Dialogue) ────────────────────── -# The one feature the server has no endpoint for, so this is the only place -# in the whole app that still needs a local ElevenLabs API key — everything -# else (the normal reply voice, transcription) goes through the server above. -# Lets Bolt play a short scene in several voices with delivery tags the v3 -# model acts on ("[cheerfully] Hello", "[whispering] He is lying"), driven by -# the server through a relayed `dialoguectl` command. Name the cast here — -# "self" always means whatever voice the pet is currently using. -ELEVENLABS_API_KEY= -#DIALOGUE=true -#DIALOGUE_MODEL_ID=eleven_v3 -#DIALOGUE_VOICES=narrator:9BWtsMINqrJLrRacOk9x,villain:IKne3meq5aSn9XLyUdCD - -# ── Self-restart (optional) ───────────────────────────────────────────────── -# `petctl self_restart ` lets Bolt reload the pet after editing its own -# code, so he can check the change live. The code is import-checked first, the -# restart waits for the current turn to finish, and the reason is carried -# across so the new process reports back. The guard refuses more than -# SELF_RESTART_MAX restarts within SELF_RESTART_WINDOW_SECONDS. -#SELF_RESTART=true -#SELF_RESTART_MAX=5 -#SELF_RESTART_WINDOW_SECONDS=900 - -# ── Audio devices (optional — leave blank for the system default) ────────── -#MIC_DEVICE= -#SPEAKER_DEVICE= - -# ── VAD tuning (optional) ─────────────────────────────────────────────────── -#VAD_RMS_THRESHOLD=300 -#VAD_SILENCE_END_SEC=1.2 -#VAD_MAX_UTTERANCE_SECONDS=15 -#VAD_MIN_UTTERANCE_SECONDS=0.4 -#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking - -# ── Follow-up listening (optional) ────────────────────────────────────────── -# When a reply ends on a question, the pet keeps listening for your answer -# instead of dropping back to idle and making you say the wake word again. -# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain -# without you re-triggering it (0 = no cap) — a stop on runaway loops if the -# server ends every reply with "?" and the mic keeps feeding it noise. -#FOLLOW_UP_LISTEN=true -#FOLLOW_UP_MAX_TURNS=3 -#FOLLOW_UP_GRACE_SECONDS=7 # longer than VAD_GRACE_SECONDS: you were asked something - -# ── Pet window (optional) ─────────────────────────────────────────────────── -#PET_SIZE=160 -#PET_START_X= -#PET_START_Y= -#PET_ALWAYS_ON_TOP=true -#IDLE_ANIMATION_FPS=6 - -# ── Wandering (optional) — the pet strolls to a random spot while idle ─────── -#PET_WANDER=true -#PET_WANDER_INTERVAL_SECONDS=45 # average pause between strolls (randomized 0.5x-1.5x) -#PET_WANDER_SPEED=90 # pixels per second -#PET_WANDER_MAX_DISTANCE=600 # cap on a single stroll's length -#PET_WANDER_MARGIN=20 # keep this far off the screen edges - -# ── Mouse behaviour (optional) ────────────────────────────────────────────── -# Shaped input = the square window's transparent corners stop eating clicks. -# Click-through = the pet ignores the mouse entirely (control it from the tray). -#PET_SHAPED_INPUT=true -#PET_CLICK_THROUGH=false -#PET_EDGE_SNAP=true -#PET_SNAP_MARGIN=48 -# Start where you last dragged it. A position on a monitor that's no longer -# connected is ignored, so unplugging a screen can't hide the pet off-desktop. -#PET_REMEMBER_POSITION=true - -# ── Barge-in (optional) — interrupt the pet mid-sentence ──────────────────── -# BARGE_IN_MODE decides what counts as an interruption: -# wake — only the wake word cuts playback (default). Background noise, -# coughs and the TV can't stop it mid-sentence. -# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster to -# trigger, but interrupts on anything loud. That threshold defaults -# to 4x VAD_RMS_THRESHOLD because the mic also hears the pet's own -# voice out of the speakers; raise it if playback self-interrupts. -#BARGE_IN=true -#BARGE_IN_MODE=wake -#BARGE_IN_RMS_THRESHOLD=1200 # energy mode only -#BARGE_IN_FRAMES=4 # energy mode only -# Wake mode only. Blank tracks the live WAKE_WORD_THRESHOLD (tray tuner); -# set a number to make interrupting harder than waking the pet from idle, -# e.g. if Bolt's own voice occasionally trips the model. -#BARGE_IN_WAKE_THRESHOLD=0.6 - -# ── Auto-update (optional) ────────────────────────────────────────────────── -# Watches the Gitea releases page for a tag newer than bolt_pet.__version__, -# then `git checkout`s it and restarts — only ever between turns, never -# mid-conversation. Requires the install to be a git clone; a working tree -# with local changes is skipped (never stashed), and any failure after the -# checkout rolls back to the ref that was live before. -#AUTO_UPDATE=true -#UPDATE_REPO_API=https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet -#UPDATE_CHECK_INTERVAL_SECONDS=3600 -#UPDATE_GIT_REMOTE=origin -#UPDATE_INSTALL_DEPS=true -# Only needed if the repo is private (a Gitea token with read:repository). -#UPDATE_TOKEN= - -# ── Streaming TTS (optional) — starts talking on the first chunk ──────────── -#TTS_STREAMING=true - -# ── Latency: streaming the reply and the transcript ───────────────────────── -# STREAMING_REPLIES speaks each sentence as the server generates it, instead of -# waiting out the whole model call before the first word. STT_STREAMING sends -# mic frames to the server's /desk/stt relay as you talk, so the transcript is -# ready the moment you stop, instead of uploading the whole clip afterward. -# Both fall back to the old path automatically if anything goes wrong. -# VAD_SILENCE_END_SEC is the other half: it is dead air on every single turn, -# so 0.8-1.0 feels markedly snappier than the 1.2 default. -#STREAMING_REPLIES=true -#STT_STREAMING=true - -# ── Screen context (optional) ─────────────────────────────────────────────── -# Sends the focused window's title along with what you said, so "what's this -# error?" has a referent. Text only — no screenshots leave the machine. -#SCREEN_CONTEXT=true - -# ── Monitors (optional) ───────────────────────────────────────────────────── -# Tacks a one-line summary of your screen layout onto each utterance (how -# many, their sizes, which one the pet is standing on) so Bolt can decide to -# `petctl jump 2` without asking what you've got plugged in. Costs nothing — -# the list comes from the UI, nothing is probed per turn. -#MONITOR_CONTEXT=true - -# ── Screen text / OCR (optional) ──────────────────────────────────────────── -# Lets Bolt read what's actually on a monitor with `petctl read [n|here|all]` -# and use it in his reply. Pull-only — nothing is captured unless he asks, -# and every read is logged. -# -# Needs the extras from requirements.txt plus an OCR engine: -# pip install mss pytesseract && sudo apt install tesseract-ocr -# or, without sudo: -# pip install mss rapidocr-onnxruntime -# mss captures on X11/Windows/macOS but NOT Wayland. -# -# This sends the text of a whole screen to the server when used. That's not a -# new capability — the shell relay could already screenshot and OCR — but it's -# a far easier one to reach for. SCREEN_TEXT=false removes it entirely. -#SCREEN_TEXT=true -#SCREEN_TEXT_MAX_CHARS=4000 - -# ── Quiet hours / do-not-disturb (optional) ──────────────────────────────── -# Comma-separated HH:MM-HH:MM ranges; wrapping past midnight is fine. While -# napping the pet dims, stops wandering and makes no proactive noise — the -# wake word and click-to-talk still work. -#QUIET_HOURS=23:00-08:00 -#DND_ON_FULLSCREEN=true - -# ── Desktop notification bridge (optional, Linux/D-Bus) ──────────────────── -# Forwards matching desktop notifications to the server so Bolt can react to -# them. Off by default: each forwarded notification is a converse() round -# trip. NOTIFICATION_FILTER is a regex over ": ". -#NOTIFICATION_BRIDGE=false -#NOTIFICATION_FILTER=build|deploy|calendar -#NOTIFICATION_MIN_INTERVAL_SECONDS=60 - -# ── Push-to-talk (optional) ───────────────────────────────────────────────── -# Global hotkey; needs pynput and a session that allows global key hooks -# (most Wayland sessions don't). Leave blank to disable. -#PUSH_TO_TALK_HOTKEY=ctrl+alt+space - -# ── Wake-word tuning (optional) ───────────────────────────────────────────── -# Scores within this margin below the threshold show up as "near misses" in -# the tray's wake-word tuner. -#WAKE_NEAR_MISS_MARGIN=0.2 -#WAKE_NEAR_MISS_LIMIT=40 - -# ── sudo password prompts (optional) ──────────────────────────────────────── -# The pet has no terminal, so a server-relayed `sudo` would block forever on -# a tty nobody is watching. With this on, bare `sudo` becomes `sudo -A` and -# the password is collected in a desktop dialog you have to answer — a real -# askpass binary if one is installed, otherwise a generated zenity/kdialog -# wrapper in ~/.cache/bolt-pet/askpass.sh. -# Set it to false if you'd rather Bolt never be able to ask for root: sudo -# commands then just fail. Read the dialogs — that box is the only thing -# between "Bolt decided to run sudo" and it running. -#SUDO_ASKPASS_PROMPT=true -#SUDO_ASKPASS_HELPER= # blank = auto-detect -#SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer - -# ── File delivery (optional) ──────────────────────────────────────────────── -# The server's deliver_files tool ("send me that report") queues workspace -# files on this session; the pet fetches and saves them automatically. -#RECEIVE_FILES=true -#DELIVERED_FILES_DIR=~/Downloads/Bolt - -# ── Misc (optional) ────────────────────────────────────────────────────────── -#COMMAND_TIMEOUT_SECONDS=30 -#HEARTBEAT_INTERVAL_SECONDS=60 -#HISTORY_LIMIT=100 diff --git a/bolt_pet/audio/mic.py b/bolt_pet/audio/mic.py index e3a6a3c..c8e2502 100644 --- a/bolt_pet/audio/mic.py +++ b/bolt_pet/audio/mic.py @@ -126,6 +126,27 @@ def record_utterance( return pcm +def list_input_devices() -> list[dict]: + """Every input-capable device sounddevice can see, as + {"index", "name", "hostapi"}. + + On Windows in particular, the *same* physical (or virtual) device is + normally listed once per host API — MME, DirectSound, WASAPI, WDM-KS — + all sharing (or near enough sharing) one name. That makes the name alone + an unreliable, often ambiguous way to pick a device (sounddevice matches + a string device as a substring and refuses to guess between ties), so the + tray's mic picker saves the numeric index instead, which this list exists + to let it show alongside a human-readable label.""" + import sounddevice as sd + + devices = [] + for index, info in enumerate(sd.query_devices()): + if info.get("max_input_channels", 0) > 0: + hostapi = sd.query_hostapis(info["hostapi"])["name"] + devices.append({"index": index, "name": info["name"], "hostapi": hostapi}) + return devices + + def open_input_stream(): """Real sounddevice input stream, imported lazily so pure-logic tests (record_utterance with a fake stream) don't need PortAudio installed.""" diff --git a/bolt_pet/config.py b/bolt_pet/config.py index 1c63585..57aaabc 100644 --- a/bolt_pet/config.py +++ b/bolt_pet/config.py @@ -9,6 +9,7 @@ from __future__ import annotations import os import platform +import re from pathlib import Path HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/) @@ -29,6 +30,26 @@ def _load_env() -> None: _load_env() +def set_env_value(key: str, value: str) -> None: + """Write *key=value* into .env, in place — replacing (or uncommenting) + an existing line for it, appending a new one otherwise. Used by the tray's + microphone picker so a device pick survives a restart without hand-editing + the file. Settings read once at import time (like MIC_DEVICE) still need + that restart to take effect; this only updates the file and os.environ.""" + env_path = HERE / ".env" + lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else [] + pattern = re.compile(rf"^\s*#?\s*{re.escape(key)}\s*=") + new_line = f"{key}={value}" + for i, line in enumerate(lines): + if pattern.match(line): + lines[i] = new_line + break + else: + lines.append(new_line) + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + os.environ[key] = value + + def _node_name() -> str: try: return platform.node() or "desktop" @@ -115,7 +136,22 @@ DIALOGUE_VOICES = os.environ.get("DIALOGUE_VOICES", "") # ── mic / VAD (same tuning knobs as bolt_desk.py) ─────────────────────────── -MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index +def _mic_device(raw: str): + """sounddevice treats an int index and a string name very differently: a + string is matched as a (fragile, often ambiguous) substring against every + device name, while an int is used as-is. .env only ever gives us a + string, so a numeric one — what the tray's mic picker saves, since the + same physical device usually shows up once per host API (MME, + DirectSound, WASAPI, ...) under the same name — has to be cast back to + int here, or it goes through the substring path and matches nothing (or + everything) instead.""" + raw = raw.strip() + if not raw: + return None + return int(raw) if raw.lstrip("-").isdigit() else raw + + +MIC_DEVICE = _mic_device(os.environ.get("MIC_DEVICE", "")) SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300")) diff --git a/bolt_pet/ui/app.py b/bolt_pet/ui/app.py index 7725858..954393f 100644 --- a/bolt_pet/ui/app.py +++ b/bolt_pet/ui/app.py @@ -11,13 +11,14 @@ from __future__ import annotations import sys from PySide6.QtCore import QThread -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMessageBox from .. import config, updater from ..controller import PetController from ..hotkey import GlobalHotkey from ..state import PetState from .history_window import HistoryWindow +from .mic_picker import MicPickerDialog from .pet_window import PetWindow from .tray import PetTray from .wake_tuner import WakeTunerWindow @@ -57,6 +58,19 @@ def run() -> int: window.publish_monitors() window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}")) + # The updater has already moved the checkout by the time this fires; all + # that's left is to let Qt tear down cleanly (so the mic and the tray + # icon are released) and then exec the new code. Doing the exec after + # app.exec() returns, rather than from the controller thread, is what + # guarantees the audio device is free before the new process opens it. + # Defined early so the mic picker (below) can trigger the same restart + # a self-update or self-restart does. + pending_restart = {"tag": None} + + def _handle_restart(tag: str) -> None: + pending_restart["tag"] = tag + app.quit() + history_window = HistoryWindow(controller.history) tuner_window = WakeTunerWindow( get_threshold=controller.wake_threshold, @@ -65,6 +79,23 @@ def run() -> int: on_reset=controller.reset_wake_stats, ) + def _save_mic_device(value) -> None: + config.set_env_value("MIC_DEVICE", str(value) if value is not None else "") + _log(f"Microphone set to device #{value}." if value is not None else + "Microphone set to system default.") + answer = QMessageBox.question( + None, "Restart Bolt?", + "Restart now so Bolt starts listening on the new microphone?", + QMessageBox.Yes | QMessageBox.No, + ) + if answer == QMessageBox.Yes: + _handle_restart("microphone change") + + mic_picker = MicPickerDialog( + get_current=lambda: config.MIC_DEVICE, + on_save=_save_mic_device, + ) + def _set_nap(napping: bool) -> None: # Clicking the tray item pins the state; the schedule takes over again # only after a restart or a `petctl nap off`. @@ -82,6 +113,7 @@ def run() -> int: on_set_nap=_set_nap, on_show_history=history_window.show_refreshed, on_show_wake_tuner=tuner_window.show_refreshed, + on_show_mic_picker=mic_picker.show_refreshed, on_reset_voice=controller.reset_voice, ) @@ -104,17 +136,6 @@ def run() -> int: elif hotkey.running: _log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}") - # The updater has already moved the checkout by the time this fires; all - # that's left is to let Qt tear down cleanly (so the mic and the tray - # icon are released) and then exec the new code. Doing the exec after - # app.exec() returns, rather than from the controller thread, is what - # guarantees the audio device is free before the new process opens it. - pending_restart = {"tag": None} - - def _handle_restart(tag: str) -> None: - pending_restart["tag"] = tag - app.quit() - controller.restart_requested.connect(_handle_restart) def _shutdown() -> None: diff --git a/bolt_pet/ui/mic_picker.py b/bolt_pet/ui/mic_picker.py new file mode 100644 index 0000000..a1547fd --- /dev/null +++ b/bolt_pet/ui/mic_picker.py @@ -0,0 +1,98 @@ +"""Microphone picker — MIC_DEVICE in .env is a device index most people have +no way to look up short of running a script. This dialog lists whatever +sounddevice can actually see, lets you click one, and writes its index back +to .env so it survives a restart. + +The index, not the name, is what gets saved: the same device is commonly +listed once per host API (MME, DirectSound, WASAPI, WDM-KS, ...) under the +same or a near-identical name, which makes a bare name an ambiguous pick that +sounddevice will flatly refuse to resolve (see audio/mic.list_input_devices). +The index is exact. + +The mic is only ever opened once, at startup (see controller.run()), so a +pick made here still needs a restart before Bolt actually uses it — this +dialog just makes finding and saving the right device painless, and offers +to do the restart for you. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QListWidget, + QListWidgetItem, QPushButton, QVBoxLayout, +) + +from ..audio.mic import list_input_devices + +_DEFAULT_LABEL = "System default" + + +class MicPickerDialog(QDialog): + def __init__( + self, + get_current: Callable[[], Optional[int]], + on_save: Callable[[Optional[int]], None], + ): + super().__init__() + self._get_current = get_current + self._on_save = on_save + + self.setWindowTitle("Bolt — select microphone") + self.resize(440, 320) + + self._list = QListWidget() + refresh_button = QPushButton("Refresh") + refresh_button.clicked.connect(self.refresh) + + top = QHBoxLayout() + top.addWidget(QLabel("Pick the microphone Bolt should listen on:")) + top.addStretch(1) + top.addWidget(refresh_button) + + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._save) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(top) + layout.addWidget(self._list) + layout.addWidget(buttons) + + def refresh(self) -> None: + self._list.clear() + default_item = QListWidgetItem(_DEFAULT_LABEL) + default_item.setData(Qt.UserRole, None) + self._list.addItem(default_item) + + current = self._get_current() + selected = default_item + try: + devices = list_input_devices() + except Exception as exc: + self._list.addItem(f"Could not list devices: {exc}") + devices = [] + + for device in devices: + item = QListWidgetItem( + f"{device['name']} [{device['hostapi']}] (#{device['index']})" + ) + item.setData(Qt.UserRole, device["index"]) + self._list.addItem(item) + if current == device["index"]: + selected = item + self._list.setCurrentItem(selected) + + def _save(self) -> None: + item = self._list.currentItem() + value = item.data(Qt.UserRole) if item is not None else None + self._on_save(value) + self.accept() + + def show_refreshed(self) -> None: + self.refresh() + self.show() + self.raise_() + self.activateWindow() diff --git a/bolt_pet/ui/tray.py b/bolt_pet/ui/tray.py index e2d2867..113686c 100644 --- a/bolt_pet/ui/tray.py +++ b/bolt_pet/ui/tray.py @@ -47,6 +47,7 @@ class PetTray(QSystemTrayIcon): on_set_nap: Optional[Callable[[bool], None]] = None, on_show_history: Optional[Callable[[], None]] = None, on_show_wake_tuner: Optional[Callable[[], None]] = None, + on_show_mic_picker: Optional[Callable[[], None]] = None, on_reset_voice: Optional[Callable[[], None]] = None, parent=None, ): @@ -104,6 +105,11 @@ class PetTray(QSystemTrayIcon): tuner_action.triggered.connect(on_show_wake_tuner) menu.addAction(tuner_action) + if on_show_mic_picker is not None: + mic_action = QAction("Select microphone…", menu) + mic_action.triggered.connect(on_show_mic_picker) + menu.addAction(mic_action) + # Only ever enabled while a server-picked voice (speak_as) is in use — # it's the way back from "talk like a pirate", which nothing else # undoes short of a restart. diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5dd3106 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,62 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from bolt_pet import config + + +def test_set_env_value_uncomments_existing_line(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("#MIC_DEVICE=\nOTHER=1\n", encoding="utf-8") + monkeypatch.setattr(config, "HERE", tmp_path) + + config.set_env_value("MIC_DEVICE", "USB Mic") + + lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() + assert lines == ["MIC_DEVICE=USB Mic", "OTHER=1"] + assert config.os.environ["MIC_DEVICE"] == "USB Mic" + + +def test_set_env_value_overwrites_existing_uncommented_line(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("MIC_DEVICE=Old Mic\n", encoding="utf-8") + monkeypatch.setattr(config, "HERE", tmp_path) + + config.set_env_value("MIC_DEVICE", "New Mic") + + lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() + assert lines == ["MIC_DEVICE=New Mic"] + + +def test_set_env_value_appends_when_key_is_missing(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("OTHER=1\n", encoding="utf-8") + monkeypatch.setattr(config, "HERE", tmp_path) + + config.set_env_value("MIC_DEVICE", "New Mic") + + lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() + assert lines == ["OTHER=1", "MIC_DEVICE=New Mic"] + + +def test_set_env_value_creates_file_if_missing(tmp_path, monkeypatch): + monkeypatch.setattr(config, "HERE", tmp_path) + + config.set_env_value("MIC_DEVICE", "New Mic") + + assert (tmp_path / ".env").read_text(encoding="utf-8") == "MIC_DEVICE=New Mic\n" + + +def test_mic_device_parses_a_numeric_index_as_int(): + # sounddevice only takes an int index as-is; a string (even "1") goes + # through substring matching against every device name instead, which is + # not what a saved index means — see the tray's mic picker. + assert config._mic_device("1") == 1 + assert isinstance(config._mic_device("1"), int) + + +def test_mic_device_keeps_a_name_as_a_string(): + assert config._mic_device("USB Mic") == "USB Mic" + + +def test_mic_device_blank_is_none(): + assert config._mic_device("") is None + assert config._mic_device(" ") is None diff --git a/tests/test_mic.py b/tests/test_mic.py index f20a9de..6f67a5a 100644 --- a/tests/test_mic.py +++ b/tests/test_mic.py @@ -100,6 +100,32 @@ def test_returns_none_when_should_continue_stops_before_speech(): assert result is None +def test_list_input_devices_filters_to_input_capable(monkeypatch): + fake_devices = [ + {"name": "Speakers", "max_input_channels": 0, "hostapi": 0}, + {"name": "USB Mic", "max_input_channels": 2, "hostapi": 0}, + {"name": "Line In", "max_input_channels": 1, "hostapi": 1}, + ] + fake_hostapis = [{"name": "MME"}, {"name": "WASAPI"}] + + class _FakeSoundDevice: + @staticmethod + def query_devices(): + return fake_devices + + @staticmethod + def query_hostapis(index): + return fake_hostapis[index] + + monkeypatch.setitem(sys.modules, "sounddevice", _FakeSoundDevice()) + + devices = mic.list_input_devices() + assert devices == [ + {"index": 1, "name": "USB Mic", "hostapi": "MME"}, + {"index": 2, "name": "Line In", "hostapi": "WASAPI"}, + ] + + def test_pcm_to_wav_bytes_round_trips_via_wave_module(): import wave import io