Add microphone picker and device management features

- Implement `list_input_devices` to retrieve and filter input-capable audio devices.
- Create `MicPickerDialog` for selecting and saving microphone settings.
- Add `set_env_value` function to update .env file with selected microphone index.
- Update UI components to integrate microphone selection functionality.
- Enhance tests for microphone device handling and environment variable management.
This commit is contained in:
2026-09-24 23:31:18 -06:00
parent ad9925da02
commit 54030a7b31
8 changed files with 283 additions and 245 deletions
-232
View File
@@ -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-<hostname>" 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 <why>` 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 "<app>: <summary> <body>".
#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
+21
View File
@@ -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."""
+37 -1
View File
@@ -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"))
+33 -12
View File
@@ -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:
+98
View File
@@ -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()
+6
View File
@@ -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.
+62
View File
@@ -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
+26
View File
@@ -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