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:
1 parent
06f7256ebf
commit
eb29a00386
6 files changed
+47
-9
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user