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

+34 -3
View File
@@ -26,6 +26,7 @@ from .util import (
get_esphome_version,
get_version,
)
from .webrtc import WebRTCProcessor
from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__)
@@ -68,6 +69,8 @@ async def main() -> None:
action="store_true",
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(
"--wake-word-dir",
default=[_WAKEWORDS_DIR],
@@ -295,6 +298,19 @@ async def main() -> None:
if args.enable_thinking_sound:
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
active_wake_words: Set[str] = set()
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
@@ -359,10 +375,12 @@ async def main() -> None:
output_only=args.output_only,
download_dir=args.download_dir,
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,
)
if args.enable_thinking_sound:
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
state.save_preferences()
initial_volume_percent = int(round(initial_volume * 100))
@@ -429,13 +447,15 @@ def process_audio(state: ServerState, mic, block_size: int):
has_oww = False
last_active: Optional[float] = None
webrtc: Optional[WebRTCProcessor] = None
try:
_LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
while True:
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:
continue
@@ -456,6 +476,18 @@ def process_audio(state: ServerState, mic, block_size: int):
if has_oww and (oww_features is None):
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:
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):
state.satellite.wakeup(wake_word)
last_active = now
# Always process to keep state correct
stopped = False
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
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSwitchResponse,
MediaPlayerCommandRequest,
MediaPlayerStateResponse,
NumberCommandRequest,
NumberStateResponse,
SelectCommandRequest,
SelectStateResponse,
SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest,
SwitchStateResponse,
@@ -116,6 +122,10 @@ class MediaPlayerEntity(ESPHomeEntity):
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
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):
self._log.debug("MediaPlayerCommandRequest matched for this key")
@@ -347,3 +357,86 @@ class ThinkingSoundEntity(ESPHomeEntity):
# Always return our internal switch state
self.sync_with_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 (
ESPHomeEntity,
MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity,
ThinkingSoundEntity,
)
@@ -59,6 +60,8 @@ class Preferences:
active_wake_words: List[str] = field(default_factory=list)
volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
@dataclass
@@ -91,6 +94,8 @@ class ServerState:
satellite: "Optional[VoiceSatelliteProtocol]" = None
mute_switch_entity: "Optional[MuteSwitchEntity]" = 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
refractory_seconds: float = 2.0
thinking_sound_enabled: bool = False
@@ -98,6 +103,8 @@ class ServerState:
muted: bool = False
connected: bool = False
volume: float = 1.0
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
timer_max_ring_seconds: float = 900.0
def save_preferences(self) -> None:
@@ -131,3 +138,23 @@ class ServerState:
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
self.save_preferences()
_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,
ListEntitiesRequest,
MediaPlayerCommandRequest,
NumberCommandRequest,
SelectCommandRequest,
SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest,
VoiceAssistantAnnounceFinished,
@@ -45,7 +47,7 @@ from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
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 .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.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._tts_url: Optional[str] = None
self._tts_played = False
@@ -302,12 +356,7 @@ class VoiceSatelliteProtocol(APIServer):
)
elif isinstance(
msg,
(
ListEntitiesRequest,
SubscribeHomeAssistantStatesRequest,
MediaPlayerCommandRequest,
SwitchCommandRequest,
),
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
):
for entity in self.state.entities:
yield from entity.handle_message(msg)
@@ -550,6 +599,12 @@ class VoiceSatelliteProtocol(APIServer):
if self.state.mute_switch_entity is not None:
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")
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)