Streaming audio satellite for Home Assistant
"""Streaming satellite for Home Assistant."""

homeassistant_satellite/__init__.py
synesthesiam/homeassistant-satellite
#!/usr/bin/env python3
import argparse
import asyncio
import functools
import logging
import os
import shlex
import shutil
import socket
import sys
import threading
import time
import wave
from collections import deque
from pathlib import Path
from typing import Deque, Final, Optional, Tuple

from .mic import (
    ARECORD_WITH_DEVICE,
    CHANNELS,
    DEFAULT_ARECORD,
    RATE,
    SAMPLES_PER_CHUNK,
    WIDTH,
    record_subprocess,
    record_udp,
)
from .remote import stream
from .snd import APLAY_WITH_DEVICE, DEFAULT_APLAY, play_subprocess, play_udp
from .state import MicState, State
from .util import multiply_volume
from .vad import SileroVoiceActivityDetector, VoiceActivityDetector

VAD_DISABLED = "disabled"
_LOGGER = logging.getLogger(__name__)
_DIR = Path(__file__).parent


async def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", required=True, help="Home Assistant server host")
    parser.add_argument("--token", required=True, help="Long-lived access token")
    parser.add_argument(
        "--port", type=int, help="Port for Home Assistant server", default=8123
    )
    parser.add_argument("--api-path", default="/api")
    parser.add_argument("--pipeline", help="Name of pipeline")
    parser.add_argument(
        "--protocol",
        default="http",
        choices=("http", "https"),
        help="Home Assistant protocol",
    )
    #
    parser.add_argument("--mic-device", help="Name of ALSA microphone device")
    parser.add_argument("--snd-device", help="Name of ALSA sound device")
    #
    parser.add_argument(
        "--mic-command",
        help="External command to run for raw audio data (16Khz, 16-bit mono PCM)",
    )
    parser.add_argument(
        "--snd-command",
        help="External command to run for raw audio out (see --snd-command-sample-rate)",
    )
    parser.add_argument(
        "--snd-command-sample-rate",
        type=int,
        default=22050,
        help="Sample rate for --snd-command (default: 22050)",
    )
    #
    parser.add_argument(
        "--awake-sound", help="Audio file to play when wake word is detected"
    )
    parser.add_argument(
        "--done-sound", help="Audio file to play when voice command is done"
    )
    parser.add_argument(
        "--volume", type=float, default=1.0, help="Playback volume (0-1)"
    )
    #
    parser.add_argument(
        "--vad", choices=(VAD_DISABLED, "webrtcvad", "silero"), default=VAD_DISABLED
    )
    parser.add_argument(
        "--vad-model",
        help="Path to Silero VAD onnx model (v4)",
        default=_DIR / "models" / "silero_vad.onnx",
    )
    parser.add_argument("--vad-threshold", type=float, default=0.5)
    parser.add_argument("--vad-trigger-level", type=int, default=3)
    parser.add_argument("--vad-buffer-chunks", type=int, default=40)
    #
    parser.add_argument("--wake-buffer-seconds", type=float, default=0)
    #
    parser.add_argument(
        "--noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
    )
    parser.add_argument("--auto-gain", type=int, default=0, choices=list(range(32)))
    parser.add_argument("--volume-multiplier", type=float, default=1.0)
    #
    parser.add_argument("--udp-mic", type=int, help="UDP port to receive input audio")
    parser.add_argument("--udp-snd", type=int, help="UDP port to send output audio")
    parser.add_argument(
        "--udp-snd-sample-rate",
        type=int,
        default=22050,
        help="Sample rate for UDP output audio (default: 22050)",
    )
    #
    parser.add_argument(
        "--debug-recording-dir", help="Directory to store audio for debugging"
    )
    #
    parser.add_argument(
        "--debug", action="store_true", help="Print DEBUG messages to the console"
    )
    args = parser.parse_args()
    logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
    _LOGGER.debug(args)

    if not shutil.which("ffmpeg"):
        _LOGGER.fatal("Please install ffmpeg")
        sys.exit(1)

    if args.mic_device and (not args.mic_command):
        args.mic_command = ARECORD_WITH_DEVICE.format(device=args.mic_device)

    if not args.mic_command:
        args.mic_command = DEFAULT_ARECORD

    if args.snd_device and (not args.snd_command):
        args.snd_command = APLAY_WITH_DEVICE.format(
            device=args.snd_device, rate=args.snd_command_sample_rate
        )

    if not args.snd_command:
        args.snd_command = DEFAULT_APLAY.format(rate=args.snd_command_sample_rate)

    args.mic_command = shlex.split(args.mic_command)
    args.snd_command = shlex.split(args.snd_command)

    if args.debug_recording_dir:
        # Create directory for saving debug audio
        args.debug_recording_dir = Path(args.debug_recording_dir)
        args.debug_recording_dir.mkdir(parents=True, exist_ok=True)

    loop = asyncio.get_running_loop()
    audio_queue: "asyncio.Queue[Tuple[int, bytes]]" = asyncio.Queue()
    speech_detected = asyncio.Event()
    state = State(mic=MicState.WAIT_FOR_VAD)

    # Recording thread for microphone
    mic_thread = threading.Thread(
        target=_mic_proc,
        args=(args, loop, audio_queue, speech_detected, state),
        daemon=True,
    )
    mic_thread.start()

    # Audio output
    snd_socket: Optional[socket.socket] = None

    try:
        while state.is_running:
            try:
                if args.udp_snd is not None:
                    # UDP socket
                    if snd_socket is None:
                        snd_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                    play = functools.partial(
                        play_udp,
                        udp_socket=snd_socket,
                        udp_port=args.udp_snd,
                        state=state,
                        sample_rate=args.udp_snd_sample_rate,
                        volume=args.volume,
                    )
                else:
                    # External program
                    play = functools.partial(
                        play_subprocess,
                        command=args.snd_command,
                        sample_rate=args.snd_command_sample_rate,
                        volume=args.volume,
                    )

                if args.vad != VAD_DISABLED:
                    _LOGGER.debug("Waiting for speech")
                    await speech_detected.wait()
                    speech_detected.clear()

                    if not state.is_running:
                        # Error in mic thread
                        break

                    _LOGGER.debug("Speech detected")

                async for _timestamp, event_type, event_data in stream(
                    host=args.host,
                    protocol=args.protocol,
                    port=args.port,
                    token=args.token,
                    audio=audio_queue,
                    pipeline_name=args.pipeline,
                    audio_seconds_to_buffer=args.wake_buffer_seconds,
                ):
                    _LOGGER.debug("%s %s", event_type, event_data)

                    if event_type == "wake_word-end":
                        if args.awake_sound:
                            state.mic = MicState.NOT_RECORDING
                            play(media=args.awake_sound)
                            state.mic = MicState.RECORDING
                    elif event_type == "stt-end":
                        # Stop recording until run ends
                        state.mic = MicState.NOT_RECORDING
                        if args.done_sound:
                            play(media=args.done_sound)
                    elif event_type == "tts-end":
                        # Play TTS output
                        tts_url = event_data.get("tts_output", {}).get("url")
                        if tts_url:
                            play(
                                media=f"{args.protocol}://{args.host}:{args.port}{tts_url}"
                            )
                    elif event_type in ("run-end", "error"):
                        # Start recording for next wake word
                        state.mic = MicState.WAIT_FOR_VAD
            except Exception:
                _LOGGER.exception("Unexpected error")
                state.mic = MicState.WAIT_FOR_VAD
    finally:
        state.is_running = False
        mic_thread.join()


# -----------------------------------------------------------------------------


def _mic_proc(
    args: argparse.Namespace,
    loop: asyncio.AbstractEventLoop,
    audio_queue: "asyncio.Queue[Tuple[int, bytes]]",
    speech_detected: asyncio.Event,
    state: State,
) -> None:
    try:
        audio_processor: "Optional[AudioProcessor]" = None
        vad: Optional[VoiceActivityDetector] = None
        vad_activation: int = 0
        vad_chunk_buffer: Deque[Tuple[int, bytes]] = deque(
            maxlen=args.vad_buffer_chunks
        )
        sub_chunk_samples: Final = 160
        sub_chunk_bytes: Final = sub_chunk_samples * 2  # 16-bit
        wav_writer: Optional[wave.Wave_write] = None

        if (
            (args.vad == "webrtcvad")
            or (args.noise_suppression > 0)
            or (args.auto_gain > 0)
        ):
            from webrtc_noise_gain import AudioProcessor

            audio_processor = AudioProcessor(args.auto_gain, args.noise_suppression)

            # Required so we don't need an extra buffer
            assert (
                SAMPLES_PER_CHUNK % sub_chunk_samples
            ) == 0, "Audio chunks must be a multiple of 10ms"
            _LOGGER.debug("Using webrtc audio processing")

        if args.vad == "silero":
            vad = SileroVoiceActivityDetector(args.vad_model)
            _LOGGER.debug("Using silero VAD")

        if args.udp_mic is not None:
            # UDP socket
            mic_stream = record_udp(args.udp_mic, state)
        else:
            # External program
            mic_stream = record_subprocess(args.mic_command)

        for ts_chunk in mic_stream:
            if not state.is_running:
                break

            timestamp, chunk = ts_chunk
            vad_prob = 0.0

            if args.volume_multiplier != 1.0:
                chunk = multiply_volume(chunk, args.volume_multiplier)

            # Process in 10ms sub-chunks.
            if audio_processor is not None:
                clean_chunk = bytes()
                ap_sub_chunks = len(chunk) // sub_chunk_bytes
                if (len(chunk) % sub_chunk_bytes) != 0:
                    _LOGGER.warning("Mic chunk size is not a multiple of 10ms")

                for sub_chunk_idx in range(ap_sub_chunks):
                    sub_chunk_offset = sub_chunk_idx * sub_chunk_bytes
                    result = audio_processor.Process10ms(
                        chunk[sub_chunk_offset : (sub_chunk_offset + sub_chunk_bytes)]
                    )

                    clean_chunk += result.audio
                    if result.is_speech:
                        vad_prob = 1.0

                # Overwrite with clean audio
                chunk = clean_chunk
                ts_chunk = (timestamp, clean_chunk)

            if state.mic == MicState.WAIT_FOR_VAD:
                if wav_writer is not None:
                    wav_writer.close()
                    wav_writer = None

                if (vad is None) and (audio_processor is None):
                    # No VAD
                    state.mic = MicState.RECORDING
                else:
                    if vad is not None:
                        # silero
                        vad_prob = vad(chunk)

                    if vad_prob >= args.vad_threshold:
                        vad_activation += 1
                    else:
                        vad_activation = max(0, vad_activation - 1)

                    if vad_activation >= args.vad_trigger_level:
                        state.mic = MicState.RECORDING
                        speech_detected.set()

                        if vad is not None:
                            vad.reset()

                        vad_activation = 0
                    else:
                        vad_chunk_buffer.append(ts_chunk)

            if state.mic == MicState.RECORDING:
                if args.debug_recording_dir and (wav_writer is None):
                    # Save audio
                    wav_writer = wave.open(
                        str(args.debug_recording_dir / f"{time.monotonic_ns()}.wav"),
                        "wb",
                    )
                    wav_writer.setframerate(RATE)
                    wav_writer.setsampwidth(WIDTH)
                    wav_writer.setnchannels(CHANNELS)

                if vad_chunk_buffer:
                    for buffered_chunk in vad_chunk_buffer:
                        loop.call_soon_threadsafe(
                            audio_queue.put_nowait, buffered_chunk
                        )

                        if wav_writer is not None:
                            wav_writer.writeframes(buffered_chunk[1])

                    vad_chunk_buffer.clear()

                loop.call_soon_threadsafe(audio_queue.put_nowait, ts_chunk)
                if wav_writer is not None:
                    wav_writer.writeframes(ts_chunk[1])

    except Exception:
        _LOGGER.exception("Unexpected error in _mic_proc")
        os._exit(-1)  # pylint: disable=protected-access


# -----------------------------------------------------------------------------

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

homeassistant_satellite/__main__.py
synesthesiam/homeassistant-satellite
import logging
import socket
import subprocess
import time
from typing import Final, Iterable, List, Tuple

from .state import State

DEFAULT_ARECORD: Final = "arecord -r 16000 -c 1 -f S16_LE -t raw"
ARECORD_WITH_DEVICE: Final = "arecord -D {device} -r 16000 -c 1 -f S16_LE -t raw"

RATE: Final = 16000
WIDTH: Final = 2
CHANNELS: Final = 1
SAMPLES_PER_CHUNK = int(0.03 * RATE)  # 30ms

_LOGGER = logging.getLogger()


def record_udp(
    port: int,
    state: State,
    host: str = "0.0.0.0",
    samples_per_chunk: int = SAMPLES_PER_CHUNK,
) -> Iterable[Tuple[int, bytes]]:
    bytes_per_chunk = samples_per_chunk * WIDTH * CHANNELS

    udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    udp_socket.bind((host, port))
    _LOGGER.debug("Listening for UDP audio at %s:%s", host, port)

    audio_buffer = bytes()
    is_first_chunk = True

    with udp_socket:
        while True:
            chunk, addr = udp_socket.recvfrom(bytes_per_chunk)
            if state.mic_host is None:
                state.mic_host = addr[0]

            if is_first_chunk:
                _LOGGER.debug("Receiving audio from client")
                is_first_chunk = False

            if audio_buffer or (len(chunk) < bytes_per_chunk):
                # Buffer audio if chunks are too small
                audio_buffer += chunk
                if len(audio_buffer) < bytes_per_chunk:
                    continue

                chunk = audio_buffer[:bytes_per_chunk]
                audio_buffer = audio_buffer[bytes_per_chunk:]

            yield time.monotonic_ns(), chunk


def record_subprocess(
    command: List[str],
    samples_per_chunk: int = SAMPLES_PER_CHUNK,
) -> Iterable[Tuple[int, bytes]]:
    """Yield mic samples from a subprocess with a timestamp."""
    _LOGGER.debug("Microphone command: %s", command)
    bytes_per_chunk = samples_per_chunk * WIDTH * CHANNELS
    with subprocess.Popen(command, stdout=subprocess.PIPE) as proc:
        assert proc.stdout is not None
        while True:
            chunk = proc.stdout.read(bytes_per_chunk)
            if not chunk:
                break

            yield time.monotonic_ns(), chunk

homeassistant_satellite/mic.py
synesthesiam/homeassistant-satellite
import asyncio
import json
import logging
import time
from typing import Any, AsyncGenerator, Dict, Optional, Tuple

import aiohttp

_LOGGER = logging.getLogger(__name__)


async def stream(
    host: str,
    protocol: str,
    token: str,
    audio: "asyncio.Queue[Tuple[int, bytes]]",
    pipeline_name: Optional[str] = None,
    port: int = 8123,
    api_path: str = "/api",
    audio_seconds_to_buffer: float = 0,
) -> AsyncGenerator[Tuple[int, str, Dict[str, Any]], None]:
    """Streams audio to an Assist pipeline and yields events as (timestamp, type, data)."""
    ws_protocol = "wss" if protocol == "https" else "ws"
    url = f"{ws_protocol}://{host}:{port}{api_path}/websocket"
    message_id = 1

    async with aiohttp.ClientSession() as session:
        async with session.ws_connect(url) as websocket:
            await _authenticate(websocket, token)

            pipeline_id: Optional[str] = None
            if pipeline_name:
                message_id, pipeline_id = await _get_pipeline_id(
                    websocket, message_id, pipeline_name
                )

            message_id, handler_id = await _start_pipeline(
                websocket,
                message_id,
                pipeline_id,
                audio_seconds_to_buffer=audio_seconds_to_buffer,
            )

            async for timestamp, event_type, event_data in _audio_to_events(
                websocket,
                handler_id,
                audio,
            ):
                yield timestamp, event_type, event_data


async def _authenticate(websocket, token: str):
    """Authenticates with HA using a long-lived access token."""
    msg = await websocket.receive_json()
    _LOGGER.debug(msg)
    assert msg["type"] == "auth_required", msg
    await websocket.send_json(
        {
            "type": "auth",
            "access_token": token,
        }
    )

    msg = await websocket.receive_json()
    _LOGGER.debug(msg)
    assert msg["type"] == "auth_ok", msg


async def _get_pipeline_id(
    websocket, message_id: int, pipeline_name: str
) -> Tuple[int, Optional[str]]:
    """Resolves pipeline id by name."""
    await websocket.send_json(
        {
            "type": "assist_pipeline/pipeline/list",
            "id": message_id,
        }
    )
    msg = await websocket.receive_json()
    _LOGGER.debug(msg)
    message_id += 1

    pipelines = msg["result"]["pipelines"]
    pipeline_id = _find_pipeline_by_name(
        pipeline_name, {p["name"]: p for p in pipelines}
    )

    if not pipeline_id:
        _LOGGER.warning("No pipeline named %s in %s", pipeline_name, pipelines)

    return message_id, pipeline_id


async def _start_pipeline(
    websocket,
    message_id: int,
    pipeline_id: Optional[str],
    audio_seconds_to_buffer: float = 0.0,
) -> Tuple[int, int]:
    """Starts Assist pipeline and returns (message id, handler id)"""
    pipeline_args = {
        "type": "assist_pipeline/run",
        "id": message_id,
        "start_stage": "wake_word",
        "end_stage": "tts",
        "input": {
            "sample_rate": 16000,
            "timeout": 3,
            "audio_seconds_to_buffer": audio_seconds_to_buffer,
        },
    }
    if pipeline_id:
        pipeline_args["pipeline"] = pipeline_id
    await websocket.send_json(pipeline_args)
    message_id += 1

    msg = await websocket.receive_json()
    _LOGGER.debug(msg)
    assert msg["success"], "Pipeline failed to run"

    # Get handler id.
    # This is a single byte prefix that needs to be in every binary payload.
    msg = await websocket.receive_json()
    _LOGGER.debug(msg)
    handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]

    return message_id, handler_id


async def _audio_to_events(
    websocket,
    handler_id: int,
    audio: "asyncio.Queue[Tuple[int, bytes]]",
) -> AsyncGenerator[Tuple[int, str, Dict[str, Any]], None]:
    """Streams audio into pipeline and yields events."""
    prefix_bytes = bytes([handler_id])

    audio_task = asyncio.create_task(audio.get())
    event_task = asyncio.create_task(websocket.receive())
    pending = {audio_task, event_task}

    while True:
        done, pending = await asyncio.wait(
            pending,
            return_when=asyncio.FIRST_COMPLETED,
        )

        if audio_task in done:
            # Forward to websocket
            _timestamp, audio_chunk = audio_task.result()
            pending.add(
                asyncio.create_task(websocket.send_bytes(prefix_bytes + audio_chunk))
            )

            # Next audio chunk
            audio_task = asyncio.create_task(audio.get())
            pending.add(audio_task)

        if event_task in done:
            msg = event_task.result()
            if msg.type != aiohttp.WSMsgType.TEXT:
                _LOGGER.warning("Unexpected message: %s", msg)
                continue

            event = json.loads(msg.data)

            if event.get("type") != "event":
                continue

            _LOGGER.debug(event)
            event_type = event["event"]["type"]
            event_data = event["event"]["data"]
            yield time.monotonic_ns(), event_type, event_data

            if event_type == "run-end":
                _LOGGER.debug("Pipeline finished")
                break

            if (event_type == "error") and (
                event_data.get("code") != "wake-word-timeout"
            ):
                _LOGGER.error(event_data["message"])
                break

            # Next event
            event_task = asyncio.create_task(websocket.receive())
            pending.add(event_task)

    for task in pending:
        task.cancel()


def _find_pipeline_by_name(name: str, pipelines: Dict[str, Any]) -> Optional[str]:
    """Return pipeline id for a name. Try exact match first, following by normalized match."""
    pipeline_info = pipelines.get(name)
    if pipeline_info is not None:
        # Exact match
        return pipeline_info["id"]

    # Normalize and check again
    name_norm = _normalize_pipeline_name(name)
    for pipeline_name, pipeline_info in pipelines.items():
        pipeline_name_norm = _normalize_pipeline_name(pipeline_name)
        if name_norm == pipeline_name_norm:
            return pipeline_info["id"]

    return None


def _normalize_pipeline_name(name: str) -> str:
    return name.strip().casefold()

homeassistant_satellite/remote.py
synesthesiam/homeassistant-satellite
import logging
import socket
import subprocess
import wave
from typing import Final, List

from .state import State

DEFAULT_APLAY: Final = "aplay -r {rate} -c 1 -f S16_LE -t raw"
APLAY_WITH_DEVICE: Final = "aplay -D {device} -r {rate} -c 1 -f S16_LE -t raw"

_LOGGER = logging.getLogger()


def play_udp(
    media: str,
    udp_socket: socket.socket,
    udp_port: int,
    state: State,
    sample_rate: int,
    samples_per_chunk: int = 1024,
    volume: float = 1.0,
) -> None:
    """Uses ffmpeg to stream raw audio to a UDP port."""
    assert state.mic_host is not None

    cmd = [
        "ffmpeg",
        "-i",
        media,
        "-f",
        "wav",
        "-ar",
        str(sample_rate),
        "-ac",
        "1",
        "-filter:a",
        f"volume={volume}",
        "-",
    ]
    _LOGGER.debug("play: %s", cmd)

    with subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
    ) as proc:
        assert proc.stdout is not None
        with wave.open(proc.stdout, "rb") as wav_file:
            assert wav_file.getsampwidth() == 2
            chunk = wav_file.readframes(samples_per_chunk)
            while chunk:
                udp_socket.sendto(chunk, (state.mic_host, udp_port))
                chunk = wav_file.readframes(samples_per_chunk)


def play_subprocess(
    media: str,
    command: List[str],
    sample_rate: int,
    samples_per_chunk: int = 1024,
    volume: float = 1.0,
) -> None:
    """Uses ffmpeg and a subprocess to play a URL to an audio output device."""
    ffmpeg_cmd = [
        "ffmpeg",
        "-i",
        media,
        "-f",
        "wav",
        "-ar",
        str(sample_rate),
        "-ac",
        "1",
        "-filter:a",
        f"volume={volume}",
        "-",
    ]
    _LOGGER.debug("play ffmpeg: %s", ffmpeg_cmd)
    _LOGGER.debug("play: %s", command)

    with subprocess.Popen(
        ffmpeg_cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
    ) as ffmpeg_proc, subprocess.Popen(command, stdin=subprocess.PIPE) as snd_proc:
        assert ffmpeg_proc.stdout is not None
        assert snd_proc.stdin is not None

        with wave.open(ffmpeg_proc.stdout, "rb") as wav_file:
            assert wav_file.getsampwidth() == 2
            chunk = wav_file.readframes(samples_per_chunk)
            while chunk:
                snd_proc.stdin.write(chunk)
                chunk = wav_file.readframes(samples_per_chunk)

homeassistant_satellite/snd.py
synesthesiam/homeassistant-satellite
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional


class MicState(str, Enum):
    NOT_RECORDING = auto()
    WAIT_FOR_VAD = auto()
    RECORDING = auto()


@dataclass
class State:
    is_running: bool = True
    mic: MicState = MicState.NOT_RECORDING
    mic_host: Optional[str] = None

homeassistant_satellite/state.py
synesthesiam/homeassistant-satellite
#!/usr/bin/env python3
import array


def multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
    """Multiplies 16-bit PCM samples by a constant."""

    def _clamp(val: float) -> float:
        """Clamp to signed 16-bit."""
        return max(-32768, min(32767, val))

    return array.array(
        "h",
        (int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)),
    ).tobytes()

homeassistant_satellite/util.py
synesthesiam/homeassistant-satellite
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Union

_RATE = 16000
_LOGGER = logging.getLogger()


class VoiceActivityDetector(ABC):
    @abstractmethod
    def __call__(self, audio: bytes) -> float:
        pass

    def reset(self) -> None:
        pass


class SileroVoiceActivityDetector(VoiceActivityDetector):
    """Detects speech/silence using Silero VAD.

    https://github.com/snakers4/silero-vad
    """

    def __init__(self, onnx_path: Union[str, Path]):
        try:
            import numpy as np
            import onnxruntime
        except ImportError:
            _LOGGER.fatal("Please pip install homeassistant_satellite[silerovad]")
            raise

        onnx_path = str(onnx_path)

        opts = onnxruntime.SessionOptions()
        opts.inter_op_num_threads = 1
        opts.intra_op_num_threads = 1

        self.session = onnxruntime.InferenceSession(
            onnx_path, providers=["CPUExecutionProvider"], sess_options=opts
        )

        self._h = np.zeros((2, 1, 64)).astype("float32")
        self._c = np.zeros((2, 1, 64)).astype("float32")

    def reset(self) -> None:
        try:
            import numpy as np
        except ImportError:
            _LOGGER.fatal("Please pip install homeassistant_satellite[silerovad]")
            raise

        self._h = np.zeros((2, 1, 64)).astype("float32")
        self._c = np.zeros((2, 1, 64)).astype("float32")

    def __call__(self, audio: bytes):
        """Return probability of speech in audio [0-1].

        Audio must be 16Khz 16-bit mono PCM.
        """
        try:
            import numpy as np
        except ImportError:
            _LOGGER.fatal("Please pip install homeassistant_satellite[silerovad]")
            raise

        audio_array = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32767.0

        if len(audio_array.shape) == 1:
            # Add batch dimension
            audio_array = np.expand_dims(audio_array, 0)

        ort_inputs = {
            "input": audio_array.astype(np.float32),
            "h": self._h,
            "c": self._c,
            "sr": np.array(_RATE, dtype=np.int64),
        }
        ort_outs = self.session.run(None, ort_inputs)
        out, self._h, self._c = ort_outs

        return out.squeeze()

homeassistant_satellite/vad.py
synesthesiam/homeassistant-satellite
#!/usr/bin/env python3
from pathlib import Path

import setuptools
from setuptools import setup

this_dir = Path(__file__).parent
module_dir = this_dir / "homeassistant_satellite"

# -----------------------------------------------------------------------------

# Load README in as long description
long_description: str = ""
readme_path = this_dir / "README.md"
if readme_path.is_file():
    long_description = readme_path.read_text(encoding="utf-8")

requirements = []
requirements_path = this_dir / "requirements.txt"
if requirements_path.is_file():
    with open(requirements_path, "r", encoding="utf-8") as requirements_file:
        requirements = requirements_file.read().splitlines()

version_path = module_dir / "VERSION"
with open(version_path, "r", encoding="utf-8") as version_file:
    version = version_file.read().strip()

# -----------------------------------------------------------------------------

setup(
    name="homeassistant_satellite",
    version=version,
    description="Voice satellite for Home Assistant",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="http://github.com/synesthesiam/homeassistant-satellite",
    author="Michael Hansen",
    author_email="mike@rhasspy.org",
    license="MIT",
    packages=setuptools.find_packages(),
    package_data={
        "homeassistant_satellite": ["VERSION", "py.typed", "models/silero_vad.onnx"],
    },
    install_requires=requirements,
    extras_require={
        "silerovad": ["onnxruntime>=1.10.0,<2", "numpy<1.26"],
        "webrtc": ["webrtc-noise-gain==1.2.3"],
    },
    classifiers=[
        "Development Status :: 3 - Alpha",
        "Intended Audience :: Developers",
        "Topic :: Text Processing :: Linguistic",
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python :: 3.7",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: 3.10",
        "Programming Language :: Python :: 3.11",
    ],
    keywords="voice satellite home assistant",
)

setup.py
synesthesiam/homeassistant-satellite