Загрузка исходника
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user