Ctrl K
ComunidadGuías…

rex/voice_loop.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Async voice assistant loop orchestrating wake word, STT, LLM, and TTS.

RELATIONSHIP NOTE — two voice_loop files exist in this repo:
- ``rex/voice_loop.py`` (this file, package): canonical implementation.
  ``rex_loop.py`` imports ``build_voice_loop`` from here and this is the
  authoritative voice loop used when Rex starts.
- ``voice_loop.py`` (repo root): legacy implementation containing
  ``AsyncRexAssistant``. Kept for backward compatibility only. Changes here
  do NOT affect the ``rex_loop.py`` startup path.

Implementation lives in ``rex/voice/`` (one module per concern, see
US-REM-028). This module is the stable facade: every public class, helper,
and module-level object keeps its ``rex.voice_loop.<name>`` import path, and
tests monkeypatch names here (the voice modules resolve patchable names
through this module at call time).
"""

from __future__ import annotations

# The facade intentionally binds settings/logger before late re-export imports so
# voice submodules and tests can resolve the stable patch points at runtime.
# ruff: noqa: E402, I001

import asyncio  # noqa: F401  (re-export: tests patch rex.voice_loop.asyncio.*)
import logging
import os  # noqa: F401  (re-export: tests patch rex.voice_loop.os.*)
import warnings
from importlib import import_module  # noqa: F401  (re-export: patched in tests)
from typing import Any

from rex.assistant_errors import (  # noqa: F401  (re-export)
    AudioDeviceError,
    AudioFormatError,
    SpeechToTextError,
    TextToSpeechError,
)
from rex.wake_acknowledgment import ensure_wake_acknowledgment_sound  # noqa: F401

from .config import settings  # noqa: F401  (re-export: patched in tests)

# Suppress torio FFmpeg extension warnings — FFmpeg is not required for audio
# capture/playback (sounddevice handles that). It is only used internally by
# Coqui XTTS; the XTTS fallback path handles the case where it is absent.
warnings.filterwarnings("ignore", message=".*FFmpeg extension.*")
warnings.filterwarnings("ignore", message=".*libtorio.*")
warnings.filterwarnings("ignore", category=RuntimeWarning, module="torio")

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Re-exports (US-REM-028). Order matters: the voice modules below resolve
# patchable names (settings, logger, sa, lazy importers, classes) through
# this module at call time, so the bindings above must exist first.
# ---------------------------------------------------------------------------
from rex.voice._types import (  # noqa: F401
    _USE_CONFIG_LANGUAGE,
    AudioArray,
    IdentifySpeakerCallable,
    RecorderCallable,
)
from rex.voice.acknowledgement import WakeAcknowledgement  # noqa: F401
from rex.voice.audio_utils import (  # noqa: F401
    _VOICE_INTERACTION_ID,
    _apply_stt_auto_gain,
    _audio_level,
    _audio_quality_summary,
    _available_input_devices,
    _detect_audio_format,
    _device_name,
    _max_input_channels,
    _prepare_audio_for_stt,
    _to_wav_buffer,
    _validate_input_device_index,
    _voice_log_extra,
)
from rex.voice.builder import (  # noqa: F401
    _build_voice_id_callback,
    _resolve_voice_reference,
    build_voice_loop as _build_voice_loop_impl,
)
from rex.voice.loop import VoiceLoop  # noqa: F401
from rex.voice.microphone import AsyncMicrophone  # noqa: F401
from rex.voice.optional_imports import (  # noqa: F401
    _import_optional,
    _lazy_import_numpy,
    _lazy_import_simpleaudio,
    _lazy_import_soundfile,
    _lazy_import_tts,
    _lazy_import_whisper,
    _load_sounddevice,
    _require_numpy,
    _require_sounddevice,
)
from rex.voice.stt import SpeechToText  # noqa: F401
from rex.voice.transcripts import (  # noqa: F401
    _WARMUP_PHRASE,
    _combine_followup_transcript,
    _extract_completed_sentences,
    _is_low_value_transcript,
    _is_suspicious_voice_transcript,
    _is_weak_transcript_fragment,
    _looks_like_clarification_reply,
    _normalize_transcript_for_guard,
    _protect_abbreviations,
    _sentence_buffer_stream,
    _sentence_stream,
    _split_into_sentences,
    _strip_wake_prefix,
)
from rex.voice.tts import SynthesizedAudio, TextToSpeech  # noqa: F401

np = _lazy_import_numpy()
sa = _lazy_import_simpleaudio()
sd = None
_NDArray = np.ndarray if np is not None else Any


def _tts_engine_from_callback(callback: Any) -> Any | None:
    """Find the TTS engine captured by the canonical builder speak callback."""
    for cell in getattr(callback, "__closure__", None) or ():
        try:
            candidate = cell.cell_contents
        except ValueError:
            continue
        if isinstance(getattr(candidate, "_provider", None), str) and isinstance(
            getattr(candidate, "_language", None), str
        ):
            return candidate
    return None


async def _direct_smart_speaker_speak(
    target_id: str,
    text: str,
    local_speak: Any,
) -> bool:
    """Use the existing XTTS->WAV smart-speaker path without shared mutation.

    A dedicated TextToSpeech facade shares Rex's warm XTTS runtime/model but
    owns its route override for this call. The route hook is strict: if Sonos
    or Bose delivery fails, AudioDeviceError prevents the TTS implementation
    from silently falling back to the computer speaker.
    """
    provider, separator, ip = target_id.partition(":")
    if separator != ":" or provider not in {"sonos", "bose"} or not ip:
        return False

    source_tts = _tts_engine_from_callback(local_speak)
    if source_tts is None or getattr(source_tts, "_provider", None) != "xtts":
        return False

    try:
        from rex.audio.speaker_discovery import get_speaker_discovery

        speakers = get_speaker_discovery().get_cached_speakers()
        speaker = next(
            (
                item
                for item in speakers
                if getattr(item, "provider", None) == provider and getattr(item, "ip", None) == ip
            ),
            None,
        )
    except Exception as exc:
        logger.warning("Direct smart-speaker route discovery failed: %s", exc)
        return False
    if speaker is None:
        return False

    try:
        dedicated_tts = TextToSpeech(
            language=source_tts._language,
            default_speaker=getattr(source_tts, "_default_speaker", None),
        )
    except Exception as exc:
        logger.warning("Direct smart-speaker TTS setup failed: %s", exc)
        return False
    if getattr(dedicated_tts, "_provider", None) != "xtts":
        return False

    dedicated_tts._tts_output_device = speaker.name
    original_route = dedicated_tts._try_smart_speaker
    route_attempts: list[bool] = []

    def strict_route(wav_path: str) -> bool:
        routed = bool(original_route(wav_path))
        route_attempts.append(routed)
        if not routed:
            raise AudioDeviceError(f"Smart-speaker output {target_id!r} is unavailable")
        return True

    # TextToSpeech intentionally supports this runtime route hook. setattr keeps
    # the dynamic override explicit for mypy; B010 is intentionally waived here.
    setattr(dedicated_tts, "_try_smart_speaker", strict_route)  # noqa: B010
    try:
        metrics = await dedicated_tts.speak(
            text,
            speaker_wav=getattr(source_tts, "_default_speaker", None),
        )
    except Exception as exc:
        logger.warning("Direct smart-speaker spoken delivery failed: %s", exc)
        return False

    path_used = metrics.get("path_used") if isinstance(metrics, dict) else None
    return bool(route_attempts) and all(route_attempts) and path_used != "stdout_fallback"


def build_voice_loop(assistant, *args: Any, **kwargs: Any) -> VoiceLoop:
    """Build the canonical loop and apply user-scoped spoken-output routing."""
    origin_device_id = kwargs.pop("origin_device_id", None)
    loop = _build_voice_loop_impl(assistant, *args, **kwargs)
    user_id = getattr(assistant, "user_id", None)
    if not isinstance(user_id, str) or not user_id:
        return loop

    original_speak = loop._speak
    original_streaming = loop._speak_streaming

    async def routed_speak(text: str) -> None:
        from rex.output_routing.runtime import get_output_routing_service, user_local_now
        from rex.output_routing.spoken import deliver_spoken_response, send_remote_spoken_text

        try:
            routing = get_output_routing_service()
        except RuntimeError:
            await original_speak(text)
            return

        async def remote_sender(target_id: str, spoken_text: str) -> bool:
            if target_id.startswith("ha:"):
                return await send_remote_spoken_text(target_id, spoken_text)
            return await _direct_smart_speaker_speak(target_id, spoken_text, original_speak)

        result = await deliver_spoken_response(
            text,
            routing=routing,
            user_id=user_id,
            origin_device_id=origin_device_id,
            at=user_local_now(user_id),
            remote_sender=remote_sender,
            local_speak=original_speak,
        )
        if not result.delivered:
            raise TextToSpeechError(f"Spoken response target delivery failed: {result.reason}")

    async def routed_streaming(sentences) -> None:  # noqa: ANN001
        if original_streaming is None:
            parts = [part async for part in sentences]
            if parts:
                await routed_speak(" ".join(parts))
            return
        from rex.output_routing.execution import resolve_spoken_response
        from rex.output_routing.runtime import get_output_routing_service, user_local_now

        try:
            routing = get_output_routing_service()
            route = resolve_spoken_response(
                routing,
                user_id=user_id,
                origin_device_id=origin_device_id,
                at=user_local_now(user_id),
            )
        except RuntimeError:
            await original_streaming(sentences)
            return
        if route.target_id is None or route.suppressed or route.target_id.startswith("local:"):
            await original_streaming(sentences)
            return
        parts = [part async for part in sentences]
        if parts:
            await routed_speak(" ".join(parts))

    loop._speak = routed_speak
    if original_streaming is not None:
        loop._speak_streaming = routed_streaming
    return loop


__all__ = [
    "AsyncMicrophone",
    "WakeAcknowledgement",
    "SpeechToText",
    "SynthesizedAudio",
    "TextToSpeech",
    "VoiceLoop",
    "build_voice_loop",
    "_resolve_voice_reference",
]