Use soundcard instead of sounddevice

This commit is contained in:
Michael Hansen committed 2025-10-16 16:41:09 -05:00
1 parent 72c8f021c8
commit 0f73f2e830
5 files changed
+129 -106

No files matched your search

+4 -1
View File
@@ -2,8 +2,11 @@
## Unreleased ## Unreleased
- Switch to `soundcard` instead of `sounddevice`
- Add `--list-input-devices` and `--list-output-devices`
- Use `pymicro-wakeword` for microWakeWord
- Add zeroconf/mDNS discovery - Add zeroconf/mDNS discovery
- Support openWakeWord - Support openWakeWord with `pyopen-wakeword`
- Support multiple wake words - Support multiple wake words
- Save active wake words to preferences JSON file - Save active wake words to preferences JSON file
- Refactor main into separate files - Refactor main into separate files
+117 -91
View File
@@ -3,6 +3,7 @@ import argparse
import asyncio import asyncio
import json import json
import logging import logging
import sys
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -10,28 +11,22 @@ from queue import Queue
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union
import numpy as np import numpy as np
import sounddevice as sd import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
from .util import get_mac, is_arm from .util import get_mac
from .zeroconf import HomeAssistantZeroconf from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent _MODULE_DIR = Path(__file__).parent
_REPO_DIR = _MODULE_DIR.parent _REPO_DIR = _MODULE_DIR.parent
_WAKEWORDS_DIR = _REPO_DIR / "wakewords" _WAKEWORDS_DIR = _REPO_DIR / "wakewords"
_OWW_DIR = _WAKEWORDS_DIR / "openWakeWord"
_SOUNDS_DIR = _REPO_DIR / "sounds" _SOUNDS_DIR = _REPO_DIR / "sounds"
if is_arm():
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
else:
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -41,11 +36,23 @@ async def main() -> None:
parser.add_argument("--name", required=True) parser.add_argument("--name", required=True)
parser.add_argument( parser.add_argument(
"--audio-input-device", "--audio-input-device",
default="default", help="soundcard name for input device (see --list-input-devices)",
help="sounddevice name for input device", )
parser.add_argument(
"--list-input-devices",
action="store_true",
help="List audio input devices and exit",
) )
parser.add_argument("--audio-input-block-size", type=int, default=1024) parser.add_argument("--audio-input-block-size", type=int, default=1024)
parser.add_argument("--audio-output-device", help="mpv name for output device") parser.add_argument(
"--audio-output-device",
help="mpv name for output device (see --list-output-devices)",
)
parser.add_argument(
"--list-output-devices",
action="store_true",
help="List audio output devices and exit",
)
parser.add_argument( parser.add_argument(
"--wake-word-dir", "--wake-word-dir",
default=[_WAKEWORDS_DIR], default=[_WAKEWORDS_DIR],
@@ -85,13 +92,37 @@ async def main() -> None:
) )
args = parser.parse_args() args = parser.parse_args()
if args.list_input_devices:
print("Input devices")
print("=" * 13)
for idx, mic in enumerate(sc.all_microphones()):
print(f"[{idx}]", mic.name)
return
if args.list_output_devices:
from mpv import MPV
player = MPV()
print("Output devices")
print("=" * 14)
for speaker in player.audio_device_list: # type: ignore
print(speaker["name"] + ":", speaker["description"])
return
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args) _LOGGER.debug(args)
try: # Resolve microphone
args.audio_input_device = int(args.audio_input_device) if args.audio_input_device is not None:
except ValueError: try:
pass args.audio_input_device = int(args.audio_input_device)
except ValueError:
pass
mic = sc.get_microphone(args.audio_input_device)
else:
mic = sc.default_microphone()
# Load available wake words # Load available wake words
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir] wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
@@ -188,13 +219,12 @@ async def main() -> None:
) )
process_audio_thread = threading.Thread( process_audio_thread = threading.Thread(
target=process_audio, args=(state,), daemon=True target=process_audio,
args=(state, mic, args.audio_input_block_size),
daemon=True,
) )
process_audio_thread.start() process_audio_thread.start()
def sd_callback(indata, _frames, _time, _status):
state.audio_queue.put_nowait(bytes(indata))
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
server = await loop.create_server( server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
@@ -205,18 +235,9 @@ async def main() -> None:
await discovery.register_server() await discovery.register_server()
try: try:
_LOGGER.debug("Opening audio input device: %s", args.audio_input_device) async with server:
with sd.RawInputStream( _LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port)
samplerate=16000, await server.serve_forever()
blocksize=args.audio_input_block_size,
device=args.audio_input_device,
dtype="int16",
channels=1,
callback=sd_callback,
):
async with server:
_LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port)
await server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
@@ -229,7 +250,7 @@ async def main() -> None:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def process_audio(state: ServerState): def process_audio(state: ServerState, mic, block_size: int):
"""Process audio chunks from the microphone.""" """Process audio chunks from the microphone."""
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
@@ -243,80 +264,85 @@ def process_audio(state: ServerState):
last_active: Optional[float] = None last_active: Optional[float] = None
try: try:
while True: _LOGGER.debug("Opening audio input device: %s", mic.name)
audio_chunk = state.audio_queue.get() with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
if audio_chunk is None: while True:
break audio_chunk_array = mic_in.record(block_size).reshape(-1)
audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2") # little-endian 16-bit signed
.tobytes()
)
if state.satellite is None: if state.satellite is None:
continue continue
if (not wake_words) or (state.wake_words_changed and state.wake_words): if (not wake_words) or (state.wake_words_changed and state.wake_words):
# Update list of wake word models to process # Update list of wake word models to process
state.wake_words_changed = False state.wake_words_changed = False
wake_words = [ wake_words = [
ww ww
for ww in state.wake_words.values() for ww in state.wake_words.values()
if ww.id in state.active_wake_words if ww.id in state.active_wake_words
] ]
has_oww = False has_oww = False
for wake_word in wake_words: for wake_word in wake_words:
if isinstance(wake_word, OpenWakeWord): if isinstance(wake_word, OpenWakeWord):
has_oww = True has_oww = True
if micro_features is None: if micro_features is None:
micro_features = MicroWakeWordFeatures() micro_features = MicroWakeWordFeatures()
if has_oww and (oww_features is None): if has_oww and (oww_features is None):
oww_features = OpenWakeWordFeatures.from_builtin() oww_features = OpenWakeWordFeatures.from_builtin()
try: try:
state.satellite.handle_audio(audio_chunk) state.satellite.handle_audio(audio_chunk)
assert micro_features is not None assert micro_features is not None
micro_inputs.clear() micro_inputs.clear()
micro_inputs.extend(micro_features.process_streaming(audio_chunk)) micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if has_oww: if has_oww:
assert oww_features is not None assert oww_features is not None
oww_inputs.clear() oww_inputs.clear()
oww_inputs.extend(oww_features.process_streaming(audio_chunk)) oww_inputs.extend(oww_features.process_streaming(audio_chunk))
for wake_word in wake_words: for wake_word in wake_words:
activated = False activated = False
if isinstance(wake_word, MicroWakeWord): if isinstance(wake_word, MicroWakeWord):
for micro_input in micro_inputs: for micro_input in micro_inputs:
if wake_word.process_streaming(micro_input): if wake_word.process_streaming(micro_input):
activated = True
elif isinstance(wake_word, OpenWakeWord):
for oww_input in oww_inputs:
for prob in wake_word.process_streaming(oww_input):
if prob > 0.5:
activated = True activated = True
elif isinstance(wake_word, OpenWakeWord):
for oww_input in oww_inputs:
for prob in wake_word.process_streaming(oww_input):
if prob > 0.5:
activated = True
if activated: if activated:
# Check refractory # Check refractory
now = time.monotonic() now = time.monotonic()
if (last_active is None) or ( if (last_active is None) or (
(now - last_active) > state.refractory_seconds (now - last_active) > state.refractory_seconds
): ):
state.satellite.wakeup(wake_word) state.satellite.wakeup(wake_word)
last_active = now last_active = now
# Always process to keep state correct # Always process to keep state correct
stopped = False stopped = False
for micro_input in micro_inputs: for micro_input in micro_inputs:
if state.stop_word.process_streaming(micro_input): if state.stop_word.process_streaming(micro_input):
stopped = True stopped = True
if stopped and (state.stop_word.id in state.active_wake_words):
state.satellite.stop()
except Exception:
_LOGGER.exception("Unexpected error handling audio")
if stopped and (state.stop_word.id in state.active_wake_words):
state.satellite.stop()
except Exception:
_LOGGER.exception("Unexpected error handling audio")
except Exception: except Exception:
_LOGGER.exception("Unexpected error processing audio") _LOGGER.exception("Unexpected error processing audio")
sys.exit(1)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
-6
View File
@@ -1,6 +1,5 @@
"""Utility methods.""" """Utility methods."""
import platform
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from typing import Optional from typing import Optional
@@ -15,8 +14,3 @@ def get_mac() -> str:
def call_all(*callables: Optional[Callable[[], None]]) -> None: def call_all(*callables: Optional[Callable[[], None]]) -> None:
for item in filter(None, callables): for item in filter(None, callables):
item() item()
def is_arm() -> bool:
machine = platform.machine()
return ("arm" in machine) or ("aarch" in machine)
+1 -1
View File
@@ -13,7 +13,7 @@ ignore_missing_imports = True
[mypy-pyopen_wakeword.*] [mypy-pyopen_wakeword.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-sounddevice.*] [mypy-soundcard.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-mpv.*] [mypy-mpv.*]
+7 -7
View File
@@ -26,21 +26,21 @@ classifiers = [
requires-python = ">=3.9.0" requires-python = ">=3.9.0"
dependencies = [ dependencies = [
"aioesphomeapi==37.2.1", "aioesphomeapi==37.2.1",
"sounddevice<1", "soundcard<1",
"numpy>=2,<3", "numpy>=2,<3",
"pymicro-wakeword>=2,<3", "pymicro-wakeword>=2,<3",
"pyopen-wakeword>=1,<2", "pyopen-wakeword>=1,<2",
"python-mpv>=1,<2", "python-mpv>=1,<2",
"zeroconf", "zeroconf<1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"black==24.8.0", "black",
"flake8==7.2.0", "flake8",
"mypy==1.14.0", "mypy",
"pylint==3.2.7", "pylint",
"pytest==8.3.5", "pytest",
] ]
[project.urls] [project.urls]