Ante / src /ante /conversation_handler.py
Ransom070's picture
Add 0G Compute Network speech backend
1445a0e
Raw History Blame Contribute Delete
5.86 kB
from __future__ import annotations
import time
import asyncio
import logging
from abc import ABC, abstractmethod
from typing import ClassVar, TypeAlias
from collections.abc import Callable
import numpy as np
from numpy.typing import NDArray
from ante.streaming import AdditionalOutputs, AsyncStreamHandler, wait_for_item
from ante.idle_policy import start_idle_tool_call
from ante.tools.core_tools import ToolDependencies, get_tool_specs
from ante.tools.background_tool_manager import BackgroundToolManager
logger = logging.getLogger(__name__)
AudioFrame: TypeAlias = tuple[int, NDArray[np.int16]]
HandlerOutput: TypeAlias = AudioFrame | AdditionalOutputs | None
QueueItem: TypeAlias = AudioFrame | AdditionalOutputs
class ConversationHandler(AsyncStreamHandler, ABC):
"""Shared app handler contract and idle behavior for realtime conversation backends."""
IDLE_BEHAVIOR_THRESHOLD_S: ClassVar[float] = 180.0
deps: ToolDependencies
tool_manager: BackgroundToolManager
output_queue: asyncio.Queue[QueueItem]
last_activity_time: float
last_idle_behavior_time: float
_activity_observer: Callable[[str], None] | None = None
_transcript_observer: Callable[[str, str, bool], None] | None = None
def __init__(self) -> None:
"""Initialize the stream handler and shared idle/activity tracking."""
super().__init__()
self.last_activity_time = time.monotonic()
self.last_idle_behavior_time = self.last_activity_time
def set_activity_observer(self, observer: Callable[[str], None] | None) -> None:
"""Attach or detach an activity observer. Pass None to clear."""
self._activity_observer = observer
def set_transcript_observer(self, observer: Callable[[str, str, bool], None] | None) -> None:
"""Attach/detach a transcript observer, called (role, text, final)."""
self._transcript_observer = observer
def _emit_transcript(self, role: str, text: str, final: bool = True) -> None:
"""Forward one transcript chunk to the observer, if attached."""
observer = self._transcript_observer
if observer is not None and text:
try:
observer(role, text, final)
except Exception:
logger.debug("transcript observer raised (ignored)", exc_info=True)
def _mark_activity(self, reason: str) -> None:
"""Record non-idle conversation activity for the idle timer."""
self.last_activity_time = time.monotonic()
logger.debug("last activity time updated to %s (%s)", self.last_activity_time, reason)
if self._activity_observer is not None:
try:
self._activity_observer(reason)
except Exception:
logger.debug("activity observer raised (ignored)", exc_info=True)
def _idle_behavior_ready(self) -> bool:
"""Return whether idle behavior may run now. Backends can add guards."""
return True
async def emit(self) -> HandlerOutput:
"""Emit the next queued output, triggering local idle behavior when due."""
now = time.monotonic()
idle_duration = now - self.last_activity_time
idle_behavior_duration = now - self.last_idle_behavior_time
if (
idle_duration > self.IDLE_BEHAVIOR_THRESHOLD_S
and idle_behavior_duration > self.IDLE_BEHAVIOR_THRESHOLD_S
and self._idle_behavior_ready()
and self.deps.movement_manager.is_idle()
):
try:
await self.send_idle_signal(idle_duration)
except Exception as e:
logger.warning("Idle tool skipped (connection closed?): %s", e)
return None
self.last_idle_behavior_time = now
handler_output = await wait_for_item(self.output_queue)
return handler_output
async def send_idle_signal(self, idle_duration: float) -> None:
"""Run a locally selected idle tool without sending an idle turn to the model."""
if not self._is_connected():
logger.debug("No active session; cannot run idle tool")
return
available_tool_names = {spec["name"] for spec in get_tool_specs()}
await start_idle_tool_call(
deps=self.deps,
tool_manager=self.tool_manager,
output_queue=self.output_queue,
available_tool_names=available_tool_names,
idle_duration=idle_duration,
)
@abstractmethod
def _is_connected(self) -> bool:
"""Return whether the backend session/connection is currently open."""
...
@abstractmethod
async def start_up(self) -> None:
"""Start the realtime handler."""
...
@abstractmethod
async def shutdown(self) -> None:
"""Shut down the realtime handler."""
...
@abstractmethod
async def receive(self, frame: AudioFrame) -> None:
"""Receive an input audio frame."""
...
@abstractmethod
async def apply_personality(self, profile: str | None) -> str:
"""Apply a personality profile."""
...
@abstractmethod
async def get_available_voices(self) -> list[str]:
"""Return voices available for the active backend."""
...
@abstractmethod
def get_current_voice(self) -> str:
"""Return the current voice."""
...
@abstractmethod
async def change_voice(self, voice: str) -> str:
"""Change the current voice."""
...
@abstractmethod
async def say(self, text: str) -> None:
"""Make the robot speak ``text`` now (injected turn; not verbatim TTS).
The backend is speech-to-speech, so ``text`` is an instruction the
model voices, not a guaranteed-literal string. Raises if no session is
open.
"""
...