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.
This commit is contained in:
Omar Amin authored and GitHub committed 2026-06-19 02:46:02 +03:00
1 parent 06f7256ebf
commit eb29a00386
6 files changed
+47 -9

No files matched your search

+27 -5
View File
@@ -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("<i2").tobytes()
# Build per-channel byte arrays. Channel 0 is the primary
# microphone; channel 1 (when present) is the reference/speaker
# feed used for server-side AEC.
channel_chunks: list[bytes] = []
for ch in range(n_channels):
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
channel_chunks.append(chunk)
# Primary channel drives WebRTC and wake-word detection.
audio_chunk = channel_chunks[0]
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
@@ -532,7 +552,9 @@ def process_audio(state: ServerState, mic, block_size: int):
oww_features = OpenWakeWordFeatures.from_builtin()
try:
state.satellite.handle_audio(audio_chunk)
# Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
assert micro_features is not None
micro_inputs.clear()
+1
View File
@@ -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:
+11 -4
View File
@@ -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: