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:
-232
@@ -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
|
|
||||||
@@ -126,6 +126,27 @@ def record_utterance(
|
|||||||
return pcm
|
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():
|
def open_input_stream():
|
||||||
"""Real sounddevice input stream, imported lazily so pure-logic tests
|
"""Real sounddevice input stream, imported lazily so pure-logic tests
|
||||||
(record_utterance with a fake stream) don't need PortAudio installed."""
|
(record_utterance with a fake stream) don't need PortAudio installed."""
|
||||||
|
|||||||
+37
-1
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/)
|
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/)
|
||||||
@@ -29,6 +30,26 @@ def _load_env() -> None:
|
|||||||
_load_env()
|
_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:
|
def _node_name() -> str:
|
||||||
try:
|
try:
|
||||||
return platform.node() or "desktop"
|
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 / 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
|
SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None
|
||||||
|
|
||||||
RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
||||||
|
|||||||
+33
-12
@@ -11,13 +11,14 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QThread
|
from PySide6.QtCore import QThread
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
from .. import config, updater
|
from .. import config, updater
|
||||||
from ..controller import PetController
|
from ..controller import PetController
|
||||||
from ..hotkey import GlobalHotkey
|
from ..hotkey import GlobalHotkey
|
||||||
from ..state import PetState
|
from ..state import PetState
|
||||||
from .history_window import HistoryWindow
|
from .history_window import HistoryWindow
|
||||||
|
from .mic_picker import MicPickerDialog
|
||||||
from .pet_window import PetWindow
|
from .pet_window import PetWindow
|
||||||
from .tray import PetTray
|
from .tray import PetTray
|
||||||
from .wake_tuner import WakeTunerWindow
|
from .wake_tuner import WakeTunerWindow
|
||||||
@@ -57,6 +58,19 @@ def run() -> int:
|
|||||||
window.publish_monitors()
|
window.publish_monitors()
|
||||||
window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
|
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)
|
history_window = HistoryWindow(controller.history)
|
||||||
tuner_window = WakeTunerWindow(
|
tuner_window = WakeTunerWindow(
|
||||||
get_threshold=controller.wake_threshold,
|
get_threshold=controller.wake_threshold,
|
||||||
@@ -65,6 +79,23 @@ def run() -> int:
|
|||||||
on_reset=controller.reset_wake_stats,
|
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:
|
def _set_nap(napping: bool) -> None:
|
||||||
# Clicking the tray item pins the state; the schedule takes over again
|
# Clicking the tray item pins the state; the schedule takes over again
|
||||||
# only after a restart or a `petctl nap off`.
|
# only after a restart or a `petctl nap off`.
|
||||||
@@ -82,6 +113,7 @@ def run() -> int:
|
|||||||
on_set_nap=_set_nap,
|
on_set_nap=_set_nap,
|
||||||
on_show_history=history_window.show_refreshed,
|
on_show_history=history_window.show_refreshed,
|
||||||
on_show_wake_tuner=tuner_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,
|
on_reset_voice=controller.reset_voice,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,17 +136,6 @@ def run() -> int:
|
|||||||
elif hotkey.running:
|
elif hotkey.running:
|
||||||
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
|
_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)
|
controller.restart_requested.connect(_handle_restart)
|
||||||
|
|
||||||
def _shutdown() -> None:
|
def _shutdown() -> None:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -47,6 +47,7 @@ class PetTray(QSystemTrayIcon):
|
|||||||
on_set_nap: Optional[Callable[[bool], None]] = None,
|
on_set_nap: Optional[Callable[[bool], None]] = None,
|
||||||
on_show_history: Optional[Callable[[], None]] = None,
|
on_show_history: Optional[Callable[[], None]] = None,
|
||||||
on_show_wake_tuner: 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,
|
on_reset_voice: Optional[Callable[[], None]] = None,
|
||||||
parent=None,
|
parent=None,
|
||||||
):
|
):
|
||||||
@@ -104,6 +105,11 @@ class PetTray(QSystemTrayIcon):
|
|||||||
tuner_action.triggered.connect(on_show_wake_tuner)
|
tuner_action.triggered.connect(on_show_wake_tuner)
|
||||||
menu.addAction(tuner_action)
|
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 —
|
# 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
|
# it's the way back from "talk like a pirate", which nothing else
|
||||||
# undoes short of a restart.
|
# undoes short of a restart.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -100,6 +100,32 @@ def test_returns_none_when_should_continue_stops_before_speech():
|
|||||||
assert result is None
|
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():
|
def test_pcm_to_wav_bytes_round_trips_via_wave_module():
|
||||||
import wave
|
import wave
|
||||||
import io
|
import io
|
||||||
|
|||||||
Reference in New Issue
Block a user