Use soundcard instead of sounddevice
This commit is contained in:
1 parent
72c8f021c8
commit
0f73f2e830
5 files changed
+129
-106
No files matched your search
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in new issue
Block a user