Add support for WebRTC Auto Gain and Noise Suppression (#250)

## WebRTC Auto Gain & Noise Suppression
 
This PR adds support for WebRTC Auto Gain and Noise Suppression as previously implemented in the Wyoming Satellite project.
 
### What's New
 
Two new entities have been created a number slider for gain and another one for noise suppression. The processing happens inside of the `webrtc.py` file and is implemented in the `__main__.py` file's `process_audio` function. The slider for gain has a limit of 0-31 and noise suppression has a limit of 0-4.
 
The Auto Gain and Noise Suppression persist through restarts and are saved as preferences. The values are dynamically chnged without needing a restart.
 
CLI flags called `--mic-auto-gain` and `--mic-noise-suppression` have also been added.
 
>  When both values are 0, WebRTC processing is bypassed entirely with no performance overhead.

Closes #25 
 
### Testing
 
When saving the audio recorded by LVA there is a noticeable increase. The following two files have been recorded with similar audio being played through them with these results (Gain-31 and NS-4):
 
| Metric | With AGC+NS | Without |
|---|---|---|
| Max amplitude | 0.329 | 0.054 |
| RMS amplitude | 0.008905 | 0.002862 |
| Volume adjustment needed | 3.034x | 13.479x |
| Rough frequency | 916 Hz | 705 Hz |
This commit is contained in:
aryanhasgithub authored and GitHub committed 2026-04-09 00:43:11 +02:00
1 parent 341e3dcb9c
commit 4ee7bb04bc
11 files changed
+330 -11

No files matched your search

+6
View File
@@ -41,6 +41,12 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
### Audio output device (optional): ### Audio output device (optional):
# AUDIO_OUTPUT_DEVICE="default" # AUDIO_OUTPUT_DEVICE="default"
### Mic Gain (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
# MIC_AUTO_GAIN="0"
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
# MIC_NOISE_SUPPRESSION="0"
### Enable thinking sound (optional): ### Enable thinking sound (optional):
# ENABLE_THINKING_SOUND="1" # ENABLE_THINKING_SOUND="1"
+5 -1
View File
@@ -51,7 +51,7 @@ For all other users we have different installation methods available (Docker, sy
💡 **Note:** There is a [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup. 💡 **Note:** There is a [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
``` sh ``` sh
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND] [--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug] [--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
``` ```
@@ -62,6 +62,8 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--audio-input-device` | Soundcard name for input device | Autodetected | | `--audio-input-device` | Soundcard name for input device | Autodetected |
| `--audio-input-block-size` | Audio input block size in samples | 1024 | | `--audio-input-block-size` | Audio input block size in samples | 1024 |
| `--audio-output-device` | mpv name for output device | Autodetected | | `--audio-output-device` | mpv name for output device | Autodetected |
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` | | `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
| `--wake-model` | ID of active wake word model | `okay_nabu` | | `--wake-model` | ID of active wake word model | `okay_nabu` |
| `--stop-model` | ID of stop model | `stop` | | `--stop-model` | ID of stop model | `stop` |
@@ -81,6 +83,8 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--debug` | Print DEBUG messages to console | False | | `--debug` | Print DEBUG messages to console | False |
| `--output-only` | Enable output only mode | False | | `--output-only` | Enable output only mode | False |
💡 **Note:** There is a detailed explanation on the gain and noise suppression flags in the [audio options](docs/audio_options.md) file.
## Build Information ## Build Information
Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance. Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance.
+8
View File
@@ -40,6 +40,14 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" ) EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
fi fi
if [ -n "${MIC_AUTO_GAIN}" ]; then
EXTRA_ARGS+=( "--mic-auto-gain" "$MIC_AUTO_GAIN" )
fi
if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then
EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" )
fi
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
EXTRA_ARGS+=( "--enable-thinking-sound" ) EXTRA_ARGS+=( "--enable-thinking-sound" )
fi fi
+49
View File
@@ -0,0 +1,49 @@
# Audio Options: Gain and Noise Suppression
## What do Gain and Noise Suppression do
### Gain
When you change gain, LVA(using the webrtc library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
- Microphones that are too quiet by default
- Environments where you move around relative to the mic
- Keeping wake word detection consistent regardless of speaking volume
### Noise Suppression
When you change noise suppression, LVA(using the webrtc library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
- Noisy environments
- Improving STT accuracy by sending cleaner audio to Home Assistant
- Reducing false wake word triggers from background noise
## Using Gain and Noise Suppression with LVA
LVA implements gain and Noise Suppression in two ways:
- [CLI Argument/ENV file](#cli-argumentenv-file)
- [Home Assistant Entity](#home-assistant-entity)
### CLI Argument/ENV file
#### CLI Argument
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
- `--mic-auto-gain`(ranges from 0-31)
- `--mic-noise-suppression`(ranges from 0-4)
#### ENV file
You can add/edit these variables in the .env file to set gain and noise suppression:
- `MIC_AUTO_GAIN="0"`
- `MIC_NOISE_SUPPRESSION="0"`
### Home Assistant Entity
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
+4
View File
@@ -176,6 +176,8 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
# Environment=PORT="6053" # Environment=PORT="6053"
# Environment=AUDIO_INPUT_DEVICE="default" # Environment=AUDIO_INPUT_DEVICE="default"
# Environment=AUDIO_OUTPUT_DEVICE="default" # Environment=AUDIO_OUTPUT_DEVICE="default"
# Environment=MIC_AUTO_GAIN="0"
# Environment=MIC_NOISE_SUPPRESSION="0"
# Environment=ENABLE_THINKING_SOUND="1" # Environment=ENABLE_THINKING_SOUND="1"
# Environment=WAKE_WORD_DIR="app/wakewords" # Environment=WAKE_WORD_DIR="app/wakewords"
# Environment=WAKE-MODEL="okay_nabu" # Environment=WAKE-MODEL="okay_nabu"
@@ -255,6 +257,8 @@ The following variables can be configured in the `.env` or in the service file:
| `PORT` | `6053` | API server port | | `PORT` | `6053` | API server port |
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name | | `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name | | `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound | | `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory | | `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use | | `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
+34 -3
View File
@@ -26,6 +26,7 @@ from .util import (
get_esphome_version, get_esphome_version,
get_version, get_version,
) )
from .webrtc import WebRTCProcessor
from .zeroconf import HomeAssistantZeroconf from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -68,6 +69,8 @@ async def main() -> None:
action="store_true", action="store_true",
help="List audio output devices and exit", help="List audio output devices and exit",
) )
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
parser.add_argument( parser.add_argument(
"--wake-word-dir", "--wake-word-dir",
default=[_WAKEWORDS_DIR], default=[_WAKEWORDS_DIR],
@@ -295,6 +298,19 @@ async def main() -> None:
if args.enable_thinking_sound: if args.enable_thinking_sound:
preferences.thinking_sound = 1 preferences.thinking_sound = 1
if args.mic_auto_gain or args.mic_noise_suppression:
try:
import webrtc_noise_gain # type: ignore[import-untyped] # noqa: F401
except ImportError:
_LOGGER.exception("Extras for webrtc are not installed")
sys.exit(1)
if args.mic_auto_gain > 0:
preferences.mic_auto_gain = args.mic_auto_gain
if args.mic_noise_suppression > 0:
preferences.mic_noise_suppression = args.mic_noise_suppression
# Load wake/stop models # Load wake/stop models
active_wake_words: Set[str] = set() active_wake_words: Set[str] = set()
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
@@ -359,10 +375,12 @@ async def main() -> None:
output_only=args.output_only, output_only=args.output_only,
download_dir=args.download_dir, download_dir=args.download_dir,
volume=initial_volume, volume=initial_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
timer_max_ring_seconds=args.timer_max_ring_seconds, timer_max_ring_seconds=args.timer_max_ring_seconds,
) )
if args.enable_thinking_sound: if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
state.save_preferences() state.save_preferences()
initial_volume_percent = int(round(initial_volume * 100)) initial_volume_percent = int(round(initial_volume * 100))
@@ -429,13 +447,15 @@ def process_audio(state: ServerState, mic, block_size: int):
has_oww = False has_oww = False
last_active: Optional[float] = None last_active: Optional[float] = None
webrtc: Optional[WebRTCProcessor] = None
try: try:
_LOGGER.debug("Opening audio input device: %s", mic.name) _LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in: with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
while True: while True:
audio_chunk_array = mic_in.record(block_size).reshape(-1) 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").tobytes() # little-endian 16-bit signed # little-endian 16-bit signed
audio_chunk = (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
if state.satellite is None: if state.satellite is None:
continue continue
@@ -456,6 +476,18 @@ def process_audio(state: ServerState, mic, block_size: int):
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()
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
if agc > 0 or ns > 0:
if webrtc is None:
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
else:
webrtc.update_settings(agc, ns)
audio_chunk = webrtc.process(audio_chunk)
if not audio_chunk:
continue
try: try:
state.satellite.handle_audio(audio_chunk) state.satellite.handle_audio(audio_chunk)
@@ -486,7 +518,6 @@ def process_audio(state: ServerState, mic, block_size: int):
if (last_active is None) or ((now - last_active) > state.refractory_seconds): if (last_active is None) or ((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:
+93
View File
@@ -6,10 +6,16 @@ from typing import Callable, List, Optional, Union
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesMediaPlayerResponse, ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest, ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSwitchResponse, ListEntitiesSwitchResponse,
MediaPlayerCommandRequest, MediaPlayerCommandRequest,
MediaPlayerStateResponse, MediaPlayerStateResponse,
NumberCommandRequest,
NumberStateResponse,
SelectCommandRequest,
SelectStateResponse,
SubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest, SwitchCommandRequest,
SwitchStateResponse, SwitchStateResponse,
@@ -116,6 +122,10 @@ class MediaPlayerEntity(ESPHomeEntity):
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
self._log.debug("handle_message called with msg: %s", msg) self._log.debug("handle_message called with msg: %s", msg)
# Suppress warning for irrelevant NumberCommandRequest
if isinstance(msg, (NumberCommandRequest, SelectCommandRequest)):
return
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key): if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
self._log.debug("MediaPlayerCommandRequest matched for this key") self._log.debug("MediaPlayerCommandRequest matched for this key")
@@ -347,3 +357,86 @@ class ThinkingSoundEntity(ESPHomeEntity):
# Always return our internal switch state # Always return our internal switch state
self.sync_with_state() self.sync_with_state()
yield SwitchStateResponse(key=self.key, state=self._switch_state) yield SwitchStateResponse(key=self.key, state=self._switch_state)
class MicSettingEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_value: Callable[[], Union[float, str]],
set_value: Callable[[Union[float, str]], None],
min_value: float = 0.0,
max_value: float = 1.0,
options: Optional[List[str]] = None,
icon: str = "mdi:microphone",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.options = options # If present, this behaves as a Dropdown
self.min_value = min_value
self.max_value = max_value
self._get_value = get_value
self._set_value = set_value
self._state = self._get_value()
self.icon = icon
def sync_with_state(self) -> None:
"""Sync internal state with the actual value."""
self._state = self._get_value()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
# --- 1. HANDLE COMMANDS FROM HOME ASSISTANT ---
if self.options:
if isinstance(msg, SelectCommandRequest) and (msg.key == self.key):
new_val = msg.state
self._state = new_val
self._set_value(new_val)
yield SelectStateResponse(key=self.key, state=new_val)
else:
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
new_val = msg.state
self._state = new_val
self._set_value(new_val)
yield NumberStateResponse(key=self.key, state=new_val)
# --- 2. DISCOVERY (TELL HA WHAT TYPE TO SHOW) ---
if isinstance(msg, ListEntitiesRequest):
if self.options:
yield ListEntitiesSelectResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
options=self.options,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)
else:
yield ListEntitiesNumberResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
min_value=self.min_value,
max_value=self.max_value,
step=1.0,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)
# --- 3. INITIAL SYNC / STATE UPDATES ---
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
if self.options:
yield SelectStateResponse(key=self.key, state=str(self._state))
else:
yield NumberStateResponse(key=self.key, state=float(self._state))
def update_get_value(self, get_value: Callable[[], Union[float, str]]) -> None:
self._get_value = get_value
def update_set_value(self, set_value: Callable[[Union[float, str]], None]) -> None:
self._set_value = set_value
+27
View File
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from .entity import ( from .entity import (
ESPHomeEntity, ESPHomeEntity,
MediaPlayerEntity, MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity, MuteSwitchEntity,
ThinkingSoundEntity, ThinkingSoundEntity,
) )
@@ -59,6 +60,8 @@ class Preferences:
active_wake_words: List[str] = field(default_factory=list) active_wake_words: List[str] = field(default_factory=list)
volume: Optional[float] = None volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled thinking_sound: int = 0 # 0 = disabled, 1 = enabled
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
@dataclass @dataclass
@@ -91,6 +94,8 @@ class ServerState:
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
mute_switch_entity: "Optional[MuteSwitchEntity]" = None mute_switch_entity: "Optional[MuteSwitchEntity]" = None
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
mic_gain_entity: "Optional[MicSettingEntity]" = None
mic_noise_suppression_entity: "Optional[MicSettingEntity]" = None
wake_words_changed: bool = False wake_words_changed: bool = False
refractory_seconds: float = 2.0 refractory_seconds: float = 2.0
thinking_sound_enabled: bool = False thinking_sound_enabled: bool = False
@@ -98,6 +103,8 @@ class ServerState:
muted: bool = False muted: bool = False
connected: bool = False connected: bool = False
volume: float = 1.0 volume: float = 1.0
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
timer_max_ring_seconds: float = 900.0 timer_max_ring_seconds: float = 900.0
def save_preferences(self) -> None: def save_preferences(self) -> None:
@@ -131,3 +138,23 @@ class ServerState:
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path) _LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
self.save_preferences() self.save_preferences()
_LOGGER.info("Volume saved successfully") _LOGGER.info("Volume saved successfully")
def persist_mic_gain(self, gain: float) -> None:
"""Persist the microphone auto gain value."""
gain_int = int(gain)
if self.mic_auto_gain == gain_int and self.preferences.mic_auto_gain == gain_int:
return
self.mic_auto_gain = gain_int
self.preferences.mic_auto_gain = gain_int
self.save_preferences()
def persist_mic_noise(self, noise: float) -> None:
"""Persist the microphone noise suppression value."""
noise_int = int(noise)
if self.mic_noise_suppression == noise_int and self.preferences.mic_noise_suppression == noise_int:
return
self.mic_noise_suppression = noise_int
self.preferences.mic_noise_suppression = noise_int
self.save_preferences()
+62 -7
View File
@@ -20,6 +20,8 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesDoneResponse, ListEntitiesDoneResponse,
ListEntitiesRequest, ListEntitiesRequest,
MediaPlayerCommandRequest, MediaPlayerCommandRequest,
NumberCommandRequest,
SelectCommandRequest,
SubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest, SwitchCommandRequest,
VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceFinished,
@@ -45,7 +47,7 @@ from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer from .api_server import APIServer
from .entity import MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity from .entity import MediaPlayerEntity, MicSettingEntity, MuteSwitchEntity, ThinkingSoundEntity
from .models import AvailableWakeWord, ServerState, WakeWordType from .models import AvailableWakeWord, ServerState, WakeWordType
from .util import call_all from .util import call_all
@@ -159,6 +161,58 @@ class VoiceSatelliteProtocol(APIServer):
thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled) thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled)
thinking_sound_switch.sync_with_state() thinking_sound_switch.sync_with_state()
# Mic Gain
if self.state.mic_gain_entity is None:
self.state.mic_gain_entity = MicSettingEntity(
server=self,
key=len(self.state.entities),
name="Mic Auto Gain",
object_id="mic_gain",
min_value=0.0,
max_value=31.0,
get_value=lambda: float(self.state.mic_auto_gain),
set_value=lambda val: self.state.persist_mic_gain(float(val)),
icon="mdi:microphone-plus",
)
self.state.entities.append(self.state.mic_gain_entity)
elif self.state.mic_gain_entity not in self.state.entities:
self.state.entities.append(self.state.mic_gain_entity)
self.state.mic_gain_entity.server = self
self.state.mic_gain_entity.update_get_value(lambda: float(self.state.mic_auto_gain))
self.state.mic_gain_entity.update_set_value(lambda val: self.state.persist_mic_gain(float(val))) # type: ignore[arg-type]
self.state.mic_gain_entity.sync_with_state()
# Mic Noise Suppression
_NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"]
_NOISE_TO_INT = {label: i for i, label in enumerate(_NOISE_OPTIONS)}
def _get_noise_label() -> str:
return _NOISE_OPTIONS[max(0, min(4, self.state.mic_noise_suppression))]
def _set_noise_label(label: Union[float, str]) -> None:
self.state.persist_mic_noise(float(_NOISE_TO_INT.get(str(label), 0)))
if self.state.mic_noise_suppression_entity is None:
self.state.mic_noise_suppression_entity = MicSettingEntity(
server=self,
key=len(self.state.entities),
name="Mic Noise Suppression",
object_id="mic_noise",
options=_NOISE_OPTIONS,
get_value=_get_noise_label,
set_value=_set_noise_label,
icon="mdi:waveform",
)
self.state.entities.append(self.state.mic_noise_suppression_entity)
elif self.state.mic_noise_suppression_entity not in self.state.entities:
self.state.entities.append(self.state.mic_noise_suppression_entity)
self.state.mic_noise_suppression_entity.server = self
self.state.mic_noise_suppression_entity.update_get_value(_get_noise_label)
self.state.mic_noise_suppression_entity.update_set_value(_set_noise_label)
self.state.mic_noise_suppression_entity.sync_with_state()
self._is_streaming_audio = False self._is_streaming_audio = False
self._tts_url: Optional[str] = None self._tts_url: Optional[str] = None
self._tts_played = False self._tts_played = False
@@ -302,12 +356,7 @@ class VoiceSatelliteProtocol(APIServer):
) )
elif isinstance( elif isinstance(
msg, msg,
( (ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
ListEntitiesRequest,
SubscribeHomeAssistantStatesRequest,
MediaPlayerCommandRequest,
SwitchCommandRequest,
),
): ):
for entity in self.state.entities: for entity in self.state.entities:
yield from entity.handle_message(msg) yield from entity.handle_message(msg)
@@ -550,6 +599,12 @@ class VoiceSatelliteProtocol(APIServer):
if self.state.mute_switch_entity is not None: if self.state.mute_switch_entity is not None:
self.state.mute_switch_entity.sync_with_state() self.state.mute_switch_entity.sync_with_state()
if self.state.mic_gain_entity is not None:
self.state.mic_gain_entity.sync_with_state()
if self.state.mic_noise_suppression_entity is not None:
self.state.mic_noise_suppression_entity.sync_with_state()
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection") _LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
def process_packet(self, msg_type: int, packet_data: bytes) -> None: def process_packet(self, msg_type: int, packet_data: bytes) -> None:
+41
View File
@@ -0,0 +1,41 @@
import logging
_LOGGER = logging.getLogger(__name__)
class WebRTCProcessor:
def __init__(self, agc_level: int = 0, ns_level: int = 0):
from webrtc_noise_gain import AudioProcessor # type: ignore[import-untyped]
self.apm = AudioProcessor(agc_level, ns_level)
self.agc_level = agc_level
self.ns_level = ns_level
self._buffer = bytearray()
self.FRAME_SIZE_BYTES = 320 # 160 samples * 2 bytes (16-bit PCM)
def update_settings(self, agc_level: int, ns_level: int):
"""Re-initialize processor if settings changed."""
if self.agc_level != agc_level or self.ns_level != ns_level:
from webrtc_noise_gain import AudioProcessor
_LOGGER.debug("Updating WebRTC settings: Gain=%s, NS=%s", agc_level, ns_level)
self.apm = AudioProcessor(agc_level, ns_level)
self.agc_level = agc_level
self.ns_level = ns_level
def process(self, raw_bytes: bytes) -> bytes:
"""
Buffer and process audio.
Returns processed bytes (may be shorter than input if buffering).
"""
self._buffer.extend(raw_bytes)
processed_chunks: list[bytes] = []
while len(self._buffer) >= self.FRAME_SIZE_BYTES:
frame = bytes(self._buffer[: self.FRAME_SIZE_BYTES])
del self._buffer[: self.FRAME_SIZE_BYTES] # drain in-place
result = self.apm.Process10ms(frame)
processed_chunks.append(result.audio)
return b"".join(processed_chunks)
+1
View File
@@ -32,6 +32,7 @@ dependencies = [
"zeroconf<1", "zeroconf<1",
"getmac<1", "getmac<1",
"types-protobuf", "types-protobuf",
"webrtc_noise_gain",
] ]
dynamic = ["version"] dynamic = ["version"]