Загрузка исходника
This commit is contained in:
Executable
+36
@@ -0,0 +1,36 @@
|
||||
"""Voice satellite using the Wyoming protocol."""
|
||||
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
|
||||
from .satellite import (
|
||||
AlwaysStreamingSatellite,
|
||||
SatelliteBase,
|
||||
VadStreamingSatellite,
|
||||
WakeStreamingSatellite,
|
||||
)
|
||||
from .settings import (
|
||||
EventSettings,
|
||||
MicSettings,
|
||||
SatelliteSettings,
|
||||
SndSettings,
|
||||
VadSettings,
|
||||
WakeSettings,
|
||||
)
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
__version__ = importlib.metadata.version("wyoming-satellite")
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AlwaysStreamingSatellite",
|
||||
"EventSettings",
|
||||
"MicSettings",
|
||||
"SatelliteBase",
|
||||
"SatelliteSettings",
|
||||
"SndSettings",
|
||||
"VadSettings",
|
||||
"VadStreamingSatellite",
|
||||
"WakeStreamingSatellite",
|
||||
"WakeSettings",
|
||||
]
|
||||
Executable
+504
@@ -0,0 +1,504 @@
|
||||
"""Main entry point for Wyoming satellite."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from wyoming.info import Attribution, Info, Satellite
|
||||
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||
|
||||
from . import __version__
|
||||
from .event_handler import SatelliteEventHandler
|
||||
from .satellite import (
|
||||
AlwaysStreamingSatellite,
|
||||
SatelliteBase,
|
||||
VadStreamingSatellite,
|
||||
WakeStreamingSatellite,
|
||||
)
|
||||
from .settings import (
|
||||
EventSettings,
|
||||
MicSettings,
|
||||
SatelliteSettings,
|
||||
SndSettings,
|
||||
TimerSettings,
|
||||
VadSettings,
|
||||
WakeSettings,
|
||||
WakeWordAndPipeline,
|
||||
)
|
||||
from .utils import (
|
||||
get_mac_address,
|
||||
needs_silero,
|
||||
needs_webrtc,
|
||||
run_event_command,
|
||||
split_command,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
# Microphone input
|
||||
parser.add_argument("--mic-uri", help="URI of Wyoming microphone service")
|
||||
parser.add_argument("--mic-command", help="Program to run for microphone input")
|
||||
parser.add_argument(
|
||||
"--mic-command-rate",
|
||||
type=int,
|
||||
default=16000,
|
||||
help="Sample rate of mic-command (hertz, default: 16000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-command-width",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sample width of mic-command (bytes, default: 2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-command-channels",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Sample channels of mic-command (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-command-samples-per-chunk",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Sample per chunk for mic-command (default: 1024)",
|
||||
)
|
||||
parser.add_argument("--mic-volume-multiplier", type=float, default=1.0)
|
||||
parser.add_argument(
|
||||
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
||||
)
|
||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||
parser.add_argument(
|
||||
"--mic-seconds-to-mute-after-awake-wav",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-no-mute-during-awake-wav",
|
||||
action="store_true",
|
||||
help="Don't mute the microphone while awake wav is being played",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-channel-index",
|
||||
type=int,
|
||||
help="Take microphone input from a specific channel (first channel is 0)",
|
||||
)
|
||||
|
||||
# Sound output
|
||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
||||
parser.add_argument("--snd-command", help="Program to run for sound output")
|
||||
parser.add_argument(
|
||||
"--snd-command-rate",
|
||||
type=int,
|
||||
default=22050,
|
||||
help="Sample rate of snd-command (hertz, default: 22050)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--snd-command-width",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sample width of snd-command (bytes, default: 2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--snd-command-channels",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Sample channels of snd-command (default: 1)",
|
||||
)
|
||||
parser.add_argument("--snd-volume-multiplier", type=float, default=1.0)
|
||||
|
||||
# Local wake word detection
|
||||
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
|
||||
parser.add_argument(
|
||||
"--wake-word-name",
|
||||
action="append",
|
||||
default=[],
|
||||
nargs="+",
|
||||
metavar=("name", "pipeline"),
|
||||
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
|
||||
)
|
||||
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
||||
parser.add_argument(
|
||||
"--wake-command-rate",
|
||||
type=int,
|
||||
default=16000,
|
||||
help="Sample rate of wake-command (hertz, default: 16000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wake-command-width",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sample width of wake-command (bytes, default: 2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wake-command-channels",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Sample channels of wake-command (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wake-refractory-seconds",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Seconds after a wake word detection before another detection is handled (default: 5)",
|
||||
)
|
||||
|
||||
# Voice activity detector
|
||||
parser.add_argument(
|
||||
"--vad", action="store_true", help="Wait for speech before streaming audio"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vad-system",
|
||||
dest="vad_system",
|
||||
choices=("silero", "webrtc"),
|
||||
default="silero",
|
||||
help="VAD implementation to use (default: silero)",
|
||||
)
|
||||
parser.add_argument("--vad-threshold", type=float, default=0.5)
|
||||
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
||||
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
||||
parser.add_argument(
|
||||
"--vad-wake-word-timeout",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Seconds before going back to waiting for speech when wake word isn't detected",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vad-energy-threshold",
|
||||
type=float,
|
||||
help="Optional RMS energy threshold for 'webrtc' VAD (int16 scale)",
|
||||
)
|
||||
|
||||
# External event handlers
|
||||
parser.add_argument(
|
||||
"--event-uri", help="URI of Wyoming service to forward events to"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--startup-command", help="Command run when the satellite starts"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect-command", help="Command to run when wake word detection starts"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detection-command", help="Command to run when wake word is detected"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transcript-command",
|
||||
help="Command to run when speech to text transcript is returned",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stt-start-command",
|
||||
help="Command to run when the user starts speaking",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stt-stop-command",
|
||||
help="Command to run when the user stops speaking",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--synthesize-command",
|
||||
help="Command to run when text to speech text is returned",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tts-start-command",
|
||||
help="Command to run when text to speech response starts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tts-stop-command",
|
||||
help="Command to run when text to speech response stops",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tts-played-command",
|
||||
help="Command to run when text-to-speech audio stopped playing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-start-command",
|
||||
help="Command to run when audio streaming starts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-stop-command",
|
||||
help="Command to run when audio streaming stops",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--error-command",
|
||||
help="Command to run when an error occurs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--connected-command",
|
||||
help="Command to run when connected to the server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disconnected-command",
|
||||
help="Command to run when disconnected from the server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-started-command",
|
||||
help="Command to run when a timer starts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-updated-command",
|
||||
help="Command to run when a timer is paused, resumed, or has time added or removed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-cancelled-command",
|
||||
"--timer-canceled-command",
|
||||
help="Command to run when a timer is cancelled",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-command",
|
||||
help="Command to run when a timer finishes",
|
||||
)
|
||||
|
||||
# Sounds
|
||||
parser.add_argument(
|
||||
"--awake-wav", help="WAV file to play when wake word is detected"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--done-wav", help="WAV file to play when voice command is done"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-wav", help="WAV file to play when a timer finishes"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-wav-repeat",
|
||||
nargs=2,
|
||||
metavar=("repeat", "delay"),
|
||||
type=float,
|
||||
default=(1, 0),
|
||||
help="Number of times to play timer finished WAV and delay between repeats in seconds",
|
||||
)
|
||||
|
||||
# Satellite details
|
||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||
parser.add_argument(
|
||||
"--name", default="Wyoming Satellite", help="Name of the satellite"
|
||||
)
|
||||
parser.add_argument("--area", help="Area name of the satellite")
|
||||
|
||||
# Zeroconf
|
||||
parser.add_argument(
|
||||
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zeroconf-name",
|
||||
help="Name used for zeroconf discovery (default: MAC from uuid.getnode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zeroconf-host",
|
||||
help="Host address for zeroconf discovery (default: detect)",
|
||||
)
|
||||
#
|
||||
parser.add_argument(
|
||||
"--debug-recording-dir", help="Directory to store audio for debugging"
|
||||
)
|
||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||
parser.add_argument(
|
||||
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=__version__,
|
||||
help="Print version and exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate args
|
||||
if (not args.mic_uri) and (not args.mic_command):
|
||||
_LOGGER.fatal("Either --mic-uri or --mic-command is required")
|
||||
sys.exit(1)
|
||||
|
||||
if needs_webrtc(args):
|
||||
try:
|
||||
import webrtc_noise_gain # noqa: F401
|
||||
except ImportError:
|
||||
_LOGGER.exception("Extras for webrtc are not installed")
|
||||
sys.exit(1)
|
||||
|
||||
if needs_silero(args):
|
||||
try:
|
||||
import pysilero_vad # noqa: F401
|
||||
except ImportError:
|
||||
_LOGGER.exception("Extras for silerovad are not installed")
|
||||
sys.exit(1)
|
||||
|
||||
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
||||
_LOGGER.fatal("%s does not exist", args.awake_wav)
|
||||
sys.exit(1)
|
||||
|
||||
if args.done_wav and (not Path(args.done_wav).is_file()):
|
||||
_LOGGER.fatal("%s does not exist", args.done_wav)
|
||||
sys.exit(1)
|
||||
|
||||
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
|
||||
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
||||
sys.exit(1)
|
||||
|
||||
if args.vad and (args.wake_uri or args.wake_command):
|
||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
||||
)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
if args.debug_recording_dir:
|
||||
args.debug_recording_dir = Path(args.debug_recording_dir)
|
||||
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
|
||||
|
||||
wyoming_info = Info(
|
||||
satellite=Satellite(
|
||||
name=args.name,
|
||||
area=args.area,
|
||||
description=args.name,
|
||||
attribution=Attribution(name="", url=""),
|
||||
installed=True,
|
||||
version=__version__,
|
||||
)
|
||||
)
|
||||
|
||||
settings = SatelliteSettings(
|
||||
mic=MicSettings(
|
||||
uri=args.mic_uri,
|
||||
command=split_command(args.mic_command),
|
||||
rate=args.mic_command_rate,
|
||||
width=args.mic_command_width,
|
||||
channels=args.mic_command_channels,
|
||||
samples_per_chunk=args.mic_command_samples_per_chunk,
|
||||
volume_multiplier=args.mic_volume_multiplier,
|
||||
auto_gain=args.mic_auto_gain,
|
||||
noise_suppression=args.mic_noise_suppression,
|
||||
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
|
||||
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
|
||||
channel_index=args.mic_channel_index,
|
||||
),
|
||||
vad=VadSettings(
|
||||
enabled=args.vad,
|
||||
system=args.vad_system,
|
||||
threshold=args.vad_threshold,
|
||||
trigger_level=args.vad_trigger_level,
|
||||
buffer_seconds=args.vad_buffer_seconds,
|
||||
wake_word_timeout=args.vad_wake_word_timeout,
|
||||
energy_threshold=args.vad_energy_threshold,
|
||||
),
|
||||
wake=WakeSettings(
|
||||
uri=args.wake_uri,
|
||||
command=split_command(args.wake_command),
|
||||
names=[
|
||||
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
|
||||
],
|
||||
refractory_seconds=(
|
||||
args.wake_refractory_seconds
|
||||
if args.wake_refractory_seconds > 0
|
||||
else None
|
||||
),
|
||||
),
|
||||
snd=SndSettings(
|
||||
uri=args.snd_uri,
|
||||
command=split_command(args.snd_command),
|
||||
rate=args.snd_command_rate,
|
||||
width=args.snd_command_width,
|
||||
channels=args.snd_command_channels,
|
||||
volume_multiplier=args.snd_volume_multiplier,
|
||||
awake_wav=args.awake_wav,
|
||||
done_wav=args.done_wav,
|
||||
),
|
||||
event=EventSettings(
|
||||
uri=args.event_uri,
|
||||
startup=split_command(args.startup_command),
|
||||
streaming_start=split_command(args.streaming_start_command),
|
||||
streaming_stop=split_command(args.streaming_stop_command),
|
||||
detect=split_command(args.detect_command),
|
||||
detection=split_command(args.detection_command),
|
||||
played=split_command(args.tts_played_command),
|
||||
transcript=split_command(args.transcript_command),
|
||||
stt_start=split_command(args.stt_start_command),
|
||||
stt_stop=split_command(args.stt_stop_command),
|
||||
synthesize=split_command(args.synthesize_command),
|
||||
tts_start=split_command(args.tts_start_command),
|
||||
tts_stop=split_command(args.tts_stop_command),
|
||||
error=split_command(args.error_command),
|
||||
connected=split_command(args.connected_command),
|
||||
disconnected=split_command(args.disconnected_command),
|
||||
),
|
||||
timer=TimerSettings(
|
||||
started=split_command(args.timer_started_command),
|
||||
updated=split_command(args.timer_updated_command),
|
||||
cancelled=split_command(args.timer_cancelled_command),
|
||||
finished=split_command(args.timer_finished_command),
|
||||
finished_wav=args.timer_finished_wav,
|
||||
finished_wav_plays=int(args.timer_finished_wav_repeat[0]),
|
||||
finished_wav_delay=args.timer_finished_wav_repeat[1],
|
||||
),
|
||||
debug_recording_dir=args.debug_recording_dir,
|
||||
)
|
||||
|
||||
satellite: SatelliteBase
|
||||
|
||||
if settings.wake.enabled:
|
||||
# Local wake word detection
|
||||
satellite = WakeStreamingSatellite(settings)
|
||||
elif settings.vad.enabled:
|
||||
# Stream after speech
|
||||
satellite = VadStreamingSatellite(settings)
|
||||
else:
|
||||
# Stream all the time
|
||||
satellite = AlwaysStreamingSatellite(settings)
|
||||
|
||||
if args.startup_command:
|
||||
await run_event_command(split_command(args.startup_command))
|
||||
|
||||
_LOGGER.info("Ready")
|
||||
|
||||
# Start server
|
||||
server = AsyncServer.from_uri(args.uri)
|
||||
|
||||
if (not args.no_zeroconf) and isinstance(server, AsyncTcpServer):
|
||||
from wyoming.zeroconf import register_server
|
||||
|
||||
if not args.zeroconf_name:
|
||||
args.zeroconf_name = get_mac_address()
|
||||
|
||||
tcp_server: AsyncTcpServer = server
|
||||
await register_server(
|
||||
name=args.zeroconf_name,
|
||||
port=tcp_server.port,
|
||||
host=args.zeroconf_host,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Zeroconf discovery enabled (name=%s, host=%s)",
|
||||
args.zeroconf_name,
|
||||
args.zeroconf_host,
|
||||
)
|
||||
|
||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
|
||||
|
||||
try:
|
||||
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
await satellite.stop()
|
||||
await satellite_task
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run():
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
"""Wyoming event handler for satellites."""
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
|
||||
from wyoming.event import Event
|
||||
from wyoming.info import Describe, Info
|
||||
from wyoming.server import AsyncEventHandler
|
||||
|
||||
from .satellite import SatelliteBase
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
|
||||
class SatelliteEventHandler(AsyncEventHandler):
|
||||
"""Event handler for clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wyoming_info: Info,
|
||||
satellite: SatelliteBase,
|
||||
cli_args: argparse.Namespace,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cli_args = cli_args
|
||||
self.wyoming_info = wyoming_info
|
||||
self.client_id = str(time.monotonic_ns())
|
||||
self.satellite = satellite
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def handle_event(self, event: Event) -> bool:
|
||||
"""Handle events from the server."""
|
||||
if Describe.is_type(event.type):
|
||||
await self.satellite.update_info(self.wyoming_info)
|
||||
await self.write_event(self.wyoming_info.event())
|
||||
return True
|
||||
|
||||
if self.satellite.server_id is None:
|
||||
# Take over after a problem occurred
|
||||
await self.satellite.set_server(self.client_id, self.writer)
|
||||
elif self.satellite.server_id != self.client_id:
|
||||
# New connection
|
||||
_LOGGER.debug("Connection cancelled: %s", self.client_id)
|
||||
return False
|
||||
|
||||
await self.satellite.event_from_server(event)
|
||||
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Server disconnect."""
|
||||
if self.satellite.server_id == self.client_id:
|
||||
await self.satellite.clear_server()
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
from wyoming.event import Event
|
||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://")
|
||||
#
|
||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
_LOGGER.info("Ready")
|
||||
|
||||
# Start server
|
||||
server = AsyncServer.from_uri(args.uri)
|
||||
|
||||
try:
|
||||
await server.run(partial(ExampleEventHandler, args))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExampleEventHandler(AsyncEventHandler):
|
||||
"""Event handler for clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cli_args: argparse.Namespace,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cli_args = cli_args
|
||||
self.client_id = str(time.monotonic_ns())
|
||||
|
||||
_LOGGER.debug("Client connected: %s", self.client_id)
|
||||
|
||||
async def handle_event(self, event: Event) -> bool:
|
||||
_LOGGER.info(event)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Executable
BIN
Binary file not shown.
Executable
+1512
File diff suppressed because it is too large
Load Diff
Executable
+222
@@ -0,0 +1,222 @@
|
||||
"""Satellite settings."""
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceSettings(ABC):
|
||||
"""Base class for service settings."""
|
||||
|
||||
uri: Optional[str] = None
|
||||
"""tcp://ip-address:port"""
|
||||
|
||||
command: Optional[List[str]] = None
|
||||
"""program + args"""
|
||||
|
||||
reconnect_seconds: float = 3.0
|
||||
"""Seconds before reconnection attempt is made."""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True if service is enabled."""
|
||||
return bool(self.uri or self.command)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MicSettings(ServiceSettings):
|
||||
"""Microphone service settings."""
|
||||
|
||||
volume_multiplier: float = 1.0
|
||||
"""Multiplier for raw audio data (1.0 = no change)"""
|
||||
|
||||
auto_gain: int = 0
|
||||
"""Automatic gain control (0-31 dbFS)"""
|
||||
|
||||
noise_suppression: int = 0
|
||||
"""Level of noise suppression (0-4, 4 is max)"""
|
||||
|
||||
rate: int = 16000
|
||||
"""Sample rate of mic audio (hertz)"""
|
||||
|
||||
width: int = 2
|
||||
"""Sample width of mic audio (bytes)"""
|
||||
|
||||
channels: int = 1
|
||||
"""Sample channels in mic audio"""
|
||||
|
||||
samples_per_chunk: int = 1024
|
||||
"""Samples to read at a time from mic command"""
|
||||
|
||||
mute_during_awake_wav: bool = True
|
||||
"""True if microphone audio should be muted while the awake WAV is playing."""
|
||||
|
||||
seconds_to_mute_after_awake_wav: float = 0.5
|
||||
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
|
||||
|
||||
channel_index: Optional[int] = None
|
||||
"""Index of microphone channel to use. When not set, all input channels are used as-is."""
|
||||
|
||||
@property
|
||||
def needs_webrtc(self) -> bool:
|
||||
"""True if webrtc audio enhancements are needed."""
|
||||
return self.enabled and ((self.auto_gain > 0) or (self.noise_suppression > 0))
|
||||
|
||||
@property
|
||||
def needs_processing(self) -> bool:
|
||||
"""True if some audio pre-processing is needed for mic audio."""
|
||||
return self.enabled and ((self.volume_multiplier != 1.0) or self.needs_webrtc)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SndSettings(ServiceSettings):
|
||||
"""Sound output service settings."""
|
||||
|
||||
volume_multiplier: float = 1.0
|
||||
"""Multiplier for raw audio data (1.0 = no change)"""
|
||||
|
||||
awake_wav: Optional[str] = None
|
||||
"""Path to WAV file played after wake word detection."""
|
||||
|
||||
done_wav: Optional[str] = None
|
||||
"""Path to WAV file played after voice command is recognized."""
|
||||
|
||||
rate: int = 22050
|
||||
"""Sample rate of output audio (hertz)"""
|
||||
|
||||
width: int = 2
|
||||
"""Sample width of output audio (bytes)"""
|
||||
|
||||
channels: int = 1
|
||||
"""Sample channels in output audio"""
|
||||
|
||||
samples_per_chunk: int = 1024
|
||||
"""Samples to write at a time to snd command"""
|
||||
|
||||
disconnect_after_stop: bool = True
|
||||
"""True if snd service should be disconnected after AudioStop."""
|
||||
|
||||
@property
|
||||
def needs_processing(self) -> bool:
|
||||
"""True if some pre-processing is needed for output audio."""
|
||||
return self.enabled and (self.volume_multiplier != 1.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeWordAndPipeline:
|
||||
"""Wake word name + optional pipeline name."""
|
||||
|
||||
name: str
|
||||
pipeline: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeSettings(ServiceSettings):
|
||||
"""Wake word service settings."""
|
||||
|
||||
names: Optional[List[WakeWordAndPipeline]] = None
|
||||
"""List of wake word names to listen for."""
|
||||
|
||||
rate: int = 16000
|
||||
"""Sample rate of wake word audio (hertz)"""
|
||||
|
||||
width: int = 2
|
||||
"""Sample width of wake word audio (bytes)"""
|
||||
|
||||
channels: int = 1
|
||||
"""Sample channels in wake word audio"""
|
||||
|
||||
refractory_seconds: Optional[float] = 5.0
|
||||
"""Seconds after a wake word detection before another detection is handled."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VadSettings:
|
||||
"""Voice activity detector settings."""
|
||||
|
||||
enabled: bool = False
|
||||
"""True if VAD should be used."""
|
||||
|
||||
system: str = "silero"
|
||||
"""VAD implementation to use: 'silero' or 'webrtc'"""
|
||||
|
||||
threshold: float = 0.5
|
||||
"""Silence threshold (0-1, 1 is speech)"""
|
||||
|
||||
trigger_level: int = 1
|
||||
"""Number of chunks to cross threshold before activation."""
|
||||
|
||||
energy_threshold: Optional[float] = None
|
||||
"""Optional RMS energy threshold for 'webrtc' VAD (int16 scale)."""
|
||||
|
||||
buffer_seconds: float = 2.0
|
||||
"""Seconds of audio before activation to keep/stream"""
|
||||
|
||||
wake_word_timeout: Optional[float] = 5.0
|
||||
"""Seconds before going back to sleep if wake word is not detected."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventSettings(ServiceSettings):
|
||||
"""External event service settings."""
|
||||
|
||||
startup: Optional[List[str]] = None
|
||||
streaming_start: Optional[List[str]] = None
|
||||
streaming_stop: Optional[List[str]] = None
|
||||
detect: Optional[List[str]] = None
|
||||
detection: Optional[List[str]] = None
|
||||
played: Optional[List[str]] = None
|
||||
transcript: Optional[List[str]] = None
|
||||
stt_start: Optional[List[str]] = None
|
||||
stt_stop: Optional[List[str]] = None
|
||||
synthesize: Optional[List[str]] = None
|
||||
tts_start: Optional[List[str]] = None
|
||||
tts_stop: Optional[List[str]] = None
|
||||
error: Optional[List[str]] = None
|
||||
connected: Optional[List[str]] = None
|
||||
disconnected: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimerSettings:
|
||||
"""Voice timer settings."""
|
||||
|
||||
started: Optional[List[str]] = None
|
||||
"""Command to run when a timer starts."""
|
||||
|
||||
updated: Optional[List[str]] = None
|
||||
"""Command to run when a timer is paused, resumed, or has time added or removed."""
|
||||
|
||||
cancelled: Optional[List[str]] = None
|
||||
"""Command to run when a timer is cancelled."""
|
||||
|
||||
finished: Optional[List[str]] = None
|
||||
"""Command to run when a timer finishes."""
|
||||
|
||||
finished_wav: Optional[str] = None
|
||||
"""WAV file to play when a timer finishes."""
|
||||
|
||||
finished_wav_plays: int = 1
|
||||
"""Number of times to play finished WAV."""
|
||||
|
||||
finished_wav_delay: float = 0
|
||||
"""Delay in seconds between repeats of finished WAV."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SatelliteSettings:
|
||||
"""Wyoming satellite settings."""
|
||||
|
||||
mic: MicSettings
|
||||
vad: VadSettings = field(default_factory=VadSettings)
|
||||
wake: WakeSettings = field(default_factory=WakeSettings)
|
||||
snd: SndSettings = field(default_factory=SndSettings)
|
||||
event: EventSettings = field(default_factory=EventSettings)
|
||||
timer: TimerSettings = field(default_factory=TimerSettings)
|
||||
|
||||
restart_timeout: float = 5.0
|
||||
|
||||
debug_recording_dir: Optional[Path] = None
|
||||
"""Path to directory where debug audio is written."""
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
"""Utility methods."""
|
||||
from .audio import (
|
||||
AudioBuffer,
|
||||
DebugAudioWriter,
|
||||
chunk_samples,
|
||||
multiply_volume,
|
||||
wav_to_events,
|
||||
)
|
||||
from .misc import (
|
||||
get_mac_address,
|
||||
needs_silero,
|
||||
needs_webrtc,
|
||||
normalize_wake_word,
|
||||
run_event_command,
|
||||
split_command,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AudioBuffer",
|
||||
"chunk_samples",
|
||||
"DebugAudioWriter",
|
||||
"get_mac_address",
|
||||
"multiply_volume",
|
||||
"needs_silero",
|
||||
"needs_webrtc",
|
||||
"normalize_wake_word",
|
||||
"run_event_command",
|
||||
"split_command",
|
||||
"wav_to_events",
|
||||
]
|
||||
Executable
+205
@@ -0,0 +1,205 @@
|
||||
"""Audio utilities."""
|
||||
import array
|
||||
import logging
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, Optional, Union
|
||||
|
||||
from pyring_buffer import RingBuffer
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.event import Event
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
|
||||
class AudioBuffer:
|
||||
"""Fixed-sized audio buffer with variable internal length."""
|
||||
|
||||
def __init__(self, maxlen: int) -> None:
|
||||
"""Initialize buffer."""
|
||||
self._buffer = bytearray(maxlen)
|
||||
self._length = 0
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
"""Get number of bytes currently in the buffer."""
|
||||
return self._length
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the buffer."""
|
||||
self._length = 0
|
||||
|
||||
def append(self, data: bytes) -> None:
|
||||
"""Append bytes to the buffer, increasing the internal length."""
|
||||
data_len = len(data)
|
||||
if (self._length + data_len) > len(self._buffer):
|
||||
raise ValueError("Length cannot be greater than buffer size")
|
||||
|
||||
self._buffer[self._length : self._length + data_len] = data
|
||||
self._length += data_len
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Convert written portion of buffer to bytes."""
|
||||
return bytes(self._buffer[: self._length])
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get the number of bytes currently in the buffer."""
|
||||
return self._length
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Return True if there are bytes in the buffer."""
|
||||
return self._length > 0
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def chunk_samples(
|
||||
samples: bytes,
|
||||
bytes_per_chunk: int,
|
||||
leftover_chunk_buffer: AudioBuffer,
|
||||
) -> Iterable[bytes]:
|
||||
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
|
||||
|
||||
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
|
||||
# Extend leftover chunk, but not enough samples to complete it
|
||||
leftover_chunk_buffer.append(samples)
|
||||
return
|
||||
|
||||
next_chunk_idx = 0
|
||||
|
||||
if leftover_chunk_buffer:
|
||||
# Add to leftover chunk from previous call(s).
|
||||
bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer)
|
||||
leftover_chunk_buffer.append(samples[:bytes_to_copy])
|
||||
next_chunk_idx = bytes_to_copy
|
||||
|
||||
# Process full chunk in buffer
|
||||
yield leftover_chunk_buffer.to_bytes()
|
||||
leftover_chunk_buffer.clear()
|
||||
|
||||
while next_chunk_idx < len(samples) - bytes_per_chunk + 1:
|
||||
# Process full chunk
|
||||
yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk]
|
||||
next_chunk_idx += bytes_per_chunk
|
||||
|
||||
# Capture leftover chunks
|
||||
if rest_samples := samples[next_chunk_idx:]:
|
||||
leftover_chunk_buffer.append(rest_samples)
|
||||
|
||||
|
||||
def wav_to_events(
|
||||
wav_path: Union[str, Path],
|
||||
samples_per_chunk: int = 1024,
|
||||
volume_multiplier: float = 1.0,
|
||||
) -> Iterator[Event]:
|
||||
"""Load WAV audio for playback on an event (wake/done)."""
|
||||
with wave.open(str(wav_path), "rb") as wav_file:
|
||||
rate = wav_file.getframerate()
|
||||
width = wav_file.getsampwidth()
|
||||
channels = wav_file.getnchannels()
|
||||
|
||||
timestamp = 0
|
||||
yield AudioStart(
|
||||
rate=rate, width=width, channels=channels, timestamp=timestamp
|
||||
).event()
|
||||
|
||||
audio_bytes = wav_file.readframes(samples_per_chunk)
|
||||
while audio_bytes:
|
||||
if volume_multiplier != 1.0:
|
||||
audio_bytes = multiply_volume(audio_bytes, volume_multiplier)
|
||||
|
||||
chunk = AudioChunk(
|
||||
rate=rate,
|
||||
width=width,
|
||||
channels=channels,
|
||||
audio=audio_bytes,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
yield chunk.event()
|
||||
timestamp += int(chunk.seconds * 1000)
|
||||
audio_bytes = wav_file.readframes(samples_per_chunk)
|
||||
|
||||
yield AudioStop(timestamp=timestamp).event()
|
||||
|
||||
|
||||
class DebugAudioWriter:
|
||||
def __init__(
|
||||
self,
|
||||
dir_path: Union[str, Path],
|
||||
suffix: str,
|
||||
rate: int = 16000,
|
||||
width: int = 2,
|
||||
channels: int = 1,
|
||||
ring_buffer_size: Optional[int] = None,
|
||||
) -> None:
|
||||
self.dir_path = Path(dir_path)
|
||||
self.suffix = suffix
|
||||
self.rate = rate
|
||||
self.width = width
|
||||
self.channels = channels
|
||||
|
||||
self._wav_path: Optional[Path] = None
|
||||
self._wav_writer: Optional[wave.Wave_write] = None
|
||||
|
||||
# If ring buffer size is set, we will hold audio in a ring buffer
|
||||
# instead of directly writing it to disk.
|
||||
#
|
||||
# This allows only the last few seconds of wake word audio to be stored.
|
||||
self._ring_buffer: Optional[RingBuffer] = None
|
||||
if ring_buffer_size is not None:
|
||||
# Hold audio in a ring buffer before writing
|
||||
self._ring_buffer = RingBuffer(ring_buffer_size)
|
||||
|
||||
def start(self, timestamp: Optional[int] = None) -> None:
|
||||
self.stop()
|
||||
|
||||
if timestamp is None:
|
||||
timestamp = time.monotonic_ns()
|
||||
|
||||
self._wav_path = self.dir_path / f"{timestamp}-{self.suffix}.wav"
|
||||
self._wav_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._wav_writer = wave.open(str(self._wav_path), "wb")
|
||||
self._wav_writer.setframerate(self.rate)
|
||||
self._wav_writer.setsampwidth(self.width)
|
||||
self._wav_writer.setnchannels(self.channels)
|
||||
|
||||
_LOGGER.debug("Started recording to %s", self._wav_path)
|
||||
|
||||
def write(self, audio: bytes) -> None:
|
||||
if self._wav_writer is None:
|
||||
return
|
||||
|
||||
if self._ring_buffer is not None:
|
||||
# Hold audio in a ring buffer before writing
|
||||
self._ring_buffer.put(audio)
|
||||
else:
|
||||
# Write directly to disk
|
||||
self._wav_writer.writeframes(audio)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._wav_writer is None:
|
||||
return
|
||||
|
||||
if self._ring_buffer is not None:
|
||||
# Write all audio now and reset buffer
|
||||
self._wav_writer.writeframes(self._ring_buffer.getvalue())
|
||||
self._ring_buffer = RingBuffer(self._ring_buffer.maxlen)
|
||||
|
||||
self._wav_writer.close()
|
||||
self._wav_writer = None
|
||||
|
||||
_LOGGER.debug("Stopped recording to %s", self._wav_path)
|
||||
self._wav_path = None
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
"""Miscellaneous utilities."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import unicodedata
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from wyoming.event import Eventable
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
|
||||
async def run_event_command(
|
||||
command: Optional[List[str]], command_input: Optional[Union[str, Eventable]] = None
|
||||
) -> None:
|
||||
"""Run a custom event command with optional input."""
|
||||
if not command:
|
||||
return
|
||||
|
||||
if isinstance(command_input, Eventable):
|
||||
# Convert event to JSON
|
||||
event_dict = command_input.event().to_dict()
|
||||
command_input = json.dumps(event_dict, ensure_ascii=False)
|
||||
|
||||
_LOGGER.debug("Running %s", command)
|
||||
program, *program_args = command
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
program, *program_args, stdin=asyncio.subprocess.PIPE
|
||||
)
|
||||
assert proc.stdin is not None
|
||||
|
||||
if command_input:
|
||||
await proc.communicate(input=command_input.encode("utf-8"))
|
||||
else:
|
||||
proc.stdin.close()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def get_mac_address() -> str:
|
||||
"""Return MAC address formatted as hex with no colons."""
|
||||
return "".join(
|
||||
# pylint: disable=consider-using-f-string
|
||||
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
||||
::-1
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def needs_webrtc(args: argparse.Namespace) -> bool:
|
||||
"""Return True if webrtc must be used."""
|
||||
return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
|
||||
|
||||
|
||||
def needs_silero(args: argparse.Namespace) -> bool:
|
||||
"""Return True if silero-vad must be used."""
|
||||
return args.vad and (getattr(args, "vad_system", "silero") == "silero")
|
||||
|
||||
|
||||
def split_command(command: Optional[str]) -> Optional[List[str]]:
|
||||
"""Split command line program/args if not empty."""
|
||||
if not command:
|
||||
return None
|
||||
|
||||
return shlex.split(command)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def normalize_wake_word(wake_word: str) -> str:
|
||||
"""Normalizes a wake word name for comparison."""
|
||||
|
||||
# Lower case
|
||||
wake_word = wake_word.strip().casefold()
|
||||
|
||||
# Remove version numbers like v1.0
|
||||
wake_word = re.sub(r"v[0-9]+(\.[0-9])+", "", wake_word)
|
||||
|
||||
# Replace anything besides letters/numbers with whitespace
|
||||
wake_word = "".join(
|
||||
c if unicodedata.category(c) in ("Ll", "Nd") else " " for c in wake_word
|
||||
)
|
||||
|
||||
# Normalize whitespace
|
||||
wake_word = " ".join(wake_word.strip().split())
|
||||
|
||||
return wake_word
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
"""Voice activity detection."""
|
||||
from typing import Optional
|
||||
import math
|
||||
import array
|
||||
|
||||
|
||||
class SileroVad:
|
||||
"""Voice activity detection with silero VAD."""
|
||||
|
||||
def __init__(self, threshold: float, trigger_level: int) -> None:
|
||||
from pysilero_vad import SileroVoiceActivityDetector
|
||||
|
||||
self.detector = SileroVoiceActivityDetector()
|
||||
self.threshold = threshold
|
||||
self.trigger_level = trigger_level
|
||||
self._activation = 0
|
||||
|
||||
def __call__(self, audio_bytes: Optional[bytes]) -> bool:
|
||||
if audio_bytes is None:
|
||||
# Reset
|
||||
self._activation = 0
|
||||
self.detector.reset()
|
||||
return False
|
||||
|
||||
if self.detector(audio_bytes) >= self.threshold:
|
||||
# Speech detected
|
||||
self._activation += 1
|
||||
if self._activation >= self.trigger_level:
|
||||
self._activation = 0
|
||||
return True
|
||||
else:
|
||||
# Silence detected
|
||||
self._activation = max(0, self._activation - 1)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class WebRtcVadEnergy:
|
||||
"""Voice activity detection using webrtcvad + RMS energy threshold.
|
||||
|
||||
Expects 16-bit mono PCM at 16Khz. Chunks are internally processed in 20ms windows.
|
||||
"""
|
||||
|
||||
def __init__(self, trigger_level: int, energy_threshold: Optional[float] = None) -> None:
|
||||
import webrtcvad # type: ignore
|
||||
|
||||
self.vad = webrtcvad.Vad(3)
|
||||
self.trigger_level = trigger_level
|
||||
self.energy_threshold = energy_threshold if energy_threshold is not None else 200.0
|
||||
self._activation = 0
|
||||
|
||||
# 20ms @16kHz, 16-bit mono
|
||||
self._samples_per_window = 320
|
||||
self._bytes_per_window = self._samples_per_window * 2
|
||||
|
||||
def __call__(self, audio_bytes: Optional[bytes]) -> bool:
|
||||
if audio_bytes is None:
|
||||
# Reset
|
||||
self._activation = 0
|
||||
return False
|
||||
|
||||
# Process in 20ms windows as required by webrtcvad
|
||||
for i in range(0, len(audio_bytes) - (len(audio_bytes) % self._bytes_per_window), self._bytes_per_window):
|
||||
window = audio_bytes[i : i + self._bytes_per_window]
|
||||
|
||||
# RMS energy
|
||||
pcm = array.array("h")
|
||||
pcm.frombytes(window)
|
||||
if len(pcm) == 0:
|
||||
continue
|
||||
rms = math.sqrt(sum(int(s) * int(s) for s in pcm) / len(pcm))
|
||||
|
||||
is_speech = False
|
||||
try:
|
||||
is_speech = self.vad.is_speech(window, 16000) and (rms >= float(self.energy_threshold))
|
||||
except Exception:
|
||||
# If VAD errors on malformed window, skip
|
||||
is_speech = False
|
||||
|
||||
if is_speech:
|
||||
self._activation += 1
|
||||
if self._activation >= self.trigger_level:
|
||||
self._activation = 0
|
||||
return True
|
||||
else:
|
||||
self._activation = max(0, self._activation - 1)
|
||||
|
||||
return False
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
"""webrtc audio processing."""
|
||||
from typing import Final
|
||||
|
||||
from .utils import AudioBuffer, chunk_samples
|
||||
|
||||
|
||||
class WebRtcAudio:
|
||||
"""Audio processing using webrtc."""
|
||||
|
||||
_sub_chunk_samples: Final = 160 # 10ms @ 16Khz
|
||||
_sub_chunk_bytes: Final = _sub_chunk_samples * 2 # 16-bit
|
||||
|
||||
def __init__(self, auto_gain: int, noise_suppression: int) -> None:
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
self.audio_processor = AudioProcessor(auto_gain, noise_suppression)
|
||||
self.audio_buffer = AudioBuffer(self._sub_chunk_bytes)
|
||||
|
||||
def __call__(self, audio_bytes: bytes) -> bytes:
|
||||
"""Process in 10ms chunks."""
|
||||
clean_chunk = bytes()
|
||||
for sub_chunk in chunk_samples(
|
||||
audio_bytes, self._sub_chunk_bytes, self.audio_buffer
|
||||
):
|
||||
result = self.audio_processor.Process10ms(sub_chunk)
|
||||
clean_chunk += result.audio
|
||||
|
||||
return clean_chunk
|
||||
Reference in New Issue
Block a user