Files
voiceagent/src/voice_assistant/turn_manager.py

146 lines
3.7 KiB
Python

from dataclasses import dataclass
from enum import StrEnum
class TurnState(StrEnum):
LISTENING = "listening"
PROCESSING = "processing"
SPEAKING = "speaking"
COMPLETED = "completed"
INTERRUPTED = "interrupted"
@dataclass(slots=True)
class Turn:
turn_id: int
state: TurnState = TurnState.LISTENING
partial_text: str = ""
final_text: str | None = None
class TurnManager:
def __init__(self) -> None:
self._active_turn: Turn | None = None
@property
def active_turn(self) -> Turn | None:
return self._active_turn
def start(self, turn_id: int) -> Turn:
if self._active_turn is not None:
raise RuntimeError(
"A turn is already active."
)
self._active_turn = Turn(
turn_id=turn_id,
state=TurnState.LISTENING,
)
return self._active_turn
def on_partial(
self,
turn_id: int,
text: str,
) -> None:
turn = self._require_active_turn(turn_id)
if turn.state != TurnState.LISTENING:
raise RuntimeError(
f"Cannot receive partial transcript while "
f"turn is {turn.state.value}."
)
turn.partial_text = text
def on_final(
self,
turn_id: int,
text: str,
) -> None:
turn = self._require_active_turn(turn_id)
if turn.state != TurnState.LISTENING:
raise RuntimeError(
f"Cannot receive final transcript while "
f"turn is {turn.state.value}."
)
turn.final_text = text
turn.partial_text = text
turn.state = TurnState.PROCESSING
def on_agent_response(
self,
turn_id: int,
) -> None:
turn = self._require_active_turn(turn_id)
if turn.state != TurnState.PROCESSING:
raise RuntimeError(
f"Cannot start speaking while "
f"turn is {turn.state.value}."
)
turn.state = TurnState.SPEAKING
def on_tts_complete(
self,
turn_id: int,
) -> None:
turn = self._require_active_turn(turn_id)
if turn.state != TurnState.SPEAKING:
raise RuntimeError(
f"Cannot complete TTS while "
f"turn is {turn.state.value}."
)
turn.state = TurnState.COMPLETED
def stop(self, turn_id: int) -> Turn:
"""
Return the active turn when its complete lifecycle has finished.
A turn may only be stopped after the TTS stage has completed and
the semantic state has reached COMPLETED.
"""
turn = self._require_active_turn(turn_id)
if turn.state != TurnState.COMPLETED:
raise RuntimeError(
f"Cannot stop turn while turn is {turn.state.value}."
)
return turn
def interrupt(self, turn_id: int) -> Turn:
turn = self._require_active_turn(turn_id)
turn.state = TurnState.INTERRUPTED
return turn
def clear(self, turn_id: int) -> None:
"""
Clear the active turn after its lifecycle has been handled.
Turn identity validation is delegated to _require_active_turn().
"""
self._require_active_turn(turn_id)
self._active_turn = None
def _require_active_turn(self, turn_id: int) -> Turn:
if self._active_turn is None:
raise RuntimeError(
"There is no active turn."
)
if self._active_turn.turn_id != turn_id:
raise ValueError(
f"Unexpected turn_id={turn_id}; "
f"active_turn_id={self._active_turn.turn_id}."
)
return self._active_turn