From eb29a003867fe1cba3e98865700f8416c5a9f9d3 Mon Sep 17 00:00:00 2001 From: Omar Amin <65174210+omaramin-2000@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:46:02 +0300 Subject: [PATCH] Enable dual audio input channel (#334) This PR adds the ability to choose the microphone stream that works best with a particular STT engine using the new dual audio input channel feature. --- .env.example | 3 +++ README.md | 1 + docker-entrypoint.sh | 4 ++++ linux_voice_assistant/__main__.py | 32 +++++++++++++++++++++++++----- linux_voice_assistant/models.py | 1 + linux_voice_assistant/satellite.py | 15 ++++++++++---- 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 8951d6f..a971aa6 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,9 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie" ### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant: # MIC_NOISE_SUPPRESSION="0" +### Number of mic channels to stream +# AUDIO_INPUT_CHANNELS=2 + ### Enable thinking sound (optional): # ENABLE_THINKING_SOUND="1" diff --git a/README.md b/README.md index 5ab5e18..3f8389c 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] | `--mic-volume` | Control microphone volume | 100 | | `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 | | `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 | +| `--audio-input-channels` | Number of microphone audio channels to stream | 2 | | `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` | | `--wake-model` | ID of active wake word model | `okay_nabu` | | `--stop-model` | ID of stop model | `stop` | diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2eae229..b96c972 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -52,6 +52,10 @@ if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" ) fi +if [ -n "${AUDIO_INPUT_CHANNELS}" ]; then + EXTRA_ARGS+=( "--audio-input-channels" "$AUDIO_INPUT_CHANNELS" ) +fi + if [ "$ENABLE_THINKING_SOUND" = "1" ]; then EXTRA_ARGS+=( "--enable-thinking-sound" ) fi diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 4b41489..88b3083 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -74,6 +74,13 @@ async def main() -> None: parser.add_argument("--mic-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)") 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( + "--audio-input-channels", + type=int, + default=1, + choices=(1, 2), + help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)", + ) parser.add_argument( "--wake-word-dir", default=[_WAKEWORDS_DIR], @@ -328,6 +335,7 @@ async def main() -> None: mic_volume=preferences.mic_volume, mic_auto_gain=preferences.mic_auto_gain, mic_noise_suppression=preferences.mic_noise_suppression, + audio_input_channels=args.audio_input_channels, timer_max_ring_seconds=args.timer_max_ring_seconds, ) @@ -413,6 +421,7 @@ async def main() -> None: def process_audio(state: ServerState, mic, block_size: int): """Process audio chunks from the microphone.""" + n_channels = state.audio_input_channels wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] micro_features: Optional[MicroWakeWordFeatures] = None @@ -427,12 +436,23 @@ def process_audio(state: ServerState, mic, block_size: int): try: _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=n_channels, blocksize=block_size) as mic_in: while True: - audio_chunk_array = mic_in.record(block_size).reshape(-1) - # little-endian 16-bit signed + # Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono. + raw = mic_in.record(block_size) # float32, range [-1, 1] mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0)) - audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype(" 1 else raw.reshape(-1) + chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("= 2 else None + state.satellite.handle_audio(audio_chunk, audio_chunk_2) assert micro_features is not None micro_inputs.clear() diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 7319ee3..5fd0569 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -125,6 +125,7 @@ class ServerState: mic_auto_gain: int = 0 mic_noise_suppression: int = 0 mic_volume: int = 100 # 1–100, default maximum + audio_input_channels: int = 2 # number of mic channels to stream timer_max_ring_seconds: float = 900.0 def save_preferences(self) -> None: diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 05184d0..139b26f 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -63,6 +63,8 @@ _LOGGER = logging.getLogger(__name__) PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} +_HAS_AUDIO_DATA2 = "data2" in {f.name for f in VoiceAssistantAudio.DESCRIPTOR.fields} + class VoiceSatelliteProtocol(APIServer): @@ -82,6 +84,9 @@ class VoiceSatelliteProtocol(APIServer): self.supported_features = ( VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS ) + # Channel 1 carries echo-reference audio; advertise SPEAKER so HA knows to use it for server-side AEC. + if state.audio_input_channels >= 2: + self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)] @@ -607,12 +612,14 @@ class VoiceSatelliteProtocol(APIServer): self.state.save_preferences() self.state.wake_words_changed = True - def handle_audio(self, audio_chunk: bytes) -> None: - + # handle_audio — both channels in ONE message + def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None: if not self._is_streaming_audio or self.state.muted: return - - self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) + if _HAS_AUDIO_DATA2 and audio_chunk_2 is not None: + self.send_messages([VoiceAssistantAudio(data=audio_chunk, data2=audio_chunk_2)]) + else: + self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: if self._timer_finished: