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:
1 parent
341e3dcb9c
commit
4ee7bb04bc
11 files changed
+330
-11
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user