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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -32,6 +32,7 @@ dependencies = [
|
|||||||
"zeroconf<1",
|
"zeroconf<1",
|
||||||
"getmac<1",
|
"getmac<1",
|
||||||
"types-protobuf",
|
"types-protobuf",
|
||||||
|
"webrtc_noise_gain",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user