ai-time-machine / src /time_machine /application /encounter_service.py
manikandanj's picture
Prepare AI Time Machine hackathon Space
5862322 verified
Raw
History Blame Contribute Delete
20.2 kB
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import contextmanager
from collections.abc import Callable, Iterator
from time import perf_counter
from typing import Any
from time_machine.application.session_state import InMemorySessionRepository
from time_machine.domain.events import (
ConversationTextEvent,
DestinationEvent,
MachineStateEvent,
NarrationEvent,
PersonaEvent,
PortraitEvent,
SceneEvent,
SouvenirEvent,
TemporalEvent,
TranscriptEvent,
VisualArtifactEvent,
)
from time_machine.domain.models import (
AudioResult,
ConversationResponse,
ConversationTurn,
EncounterSession,
SavedEncounter,
Transcript,
VoiceProfile,
)
from time_machine.ports.conversation import ConversationEngine
from time_machine.ports.immersion import ImmersiveExperienceGenerator
from time_machine.ports.persona import PersonaGenerator
from time_machine.ports.souvenir import SouvenirGenerator
from time_machine.ports.speech import TTSAdapter
from time_machine.ports.storage import EncounterStore
from time_machine.ports.trace import TraceSink
from time_machine.ports.world import DestinationGenerator
class EncounterService:
def __init__(
self,
sessions: InMemorySessionRepository,
destination_generator: DestinationGenerator,
persona_generator: PersonaGenerator,
conversation_engine: ConversationEngine,
tts: TTSAdapter,
souvenir_generator: SouvenirGenerator,
store: EncounterStore,
trace_sink: TraceSink,
immersive_generator: ImmersiveExperienceGenerator | None = None,
) -> None:
self.sessions = sessions
self.destination_generator = destination_generator
self.persona_generator = persona_generator
self.conversation_engine = conversation_engine
self.tts = tts
self.souvenir_generator = souvenir_generator
self.store = store
self.trace_sink = trace_sink
self.immersive_generator = immersive_generator
def start_encounter(
self,
mode: str,
coordinate_prompt: str | None,
session_id: str,
seed: int | None = None,
) -> Iterator[TemporalEvent]:
encounter = EncounterSession(session_id=session_id)
yield from self._emit(
MachineStateEvent(
state="launching",
message="Temporal engines charging.",
metadata=self._metadata(encounter),
)
)
with _stage_timer("destination.generate") as destination_timing:
encounter.destination = self.destination_generator.generate_destination(
mode=mode,
coordinate_prompt=coordinate_prompt,
seed=seed,
)
encounter.touch()
yield from self._emit(
DestinationEvent(
destination=encounter.destination,
message="Destination resolved.",
metadata=self._metadata(encounter, destination_timing),
)
)
with _stage_timer("persona.generate") as persona_timing:
encounter.persona = self.persona_generator.generate_persona(
destination=encounter.destination,
seed=seed,
)
with _stage_timer("tts.prepare_voice") as voice_timing:
encounter.voice_profile = self.tts.prepare_voice(encounter.persona)
warm_up = getattr(self.tts, "warm_up", None)
if callable(warm_up):
warm_up(encounter.voice_profile)
immersive_timing = {"stage": "immersion.generate", "elapsed_ms": None}
if self.immersive_generator is not None:
with _stage_timer("immersion.generate") as immersive_timing:
(
encounter.scene,
encounter.portrait,
encounter.narration,
) = self._generate_launch_immersion(encounter)
if encounter.narration is not None:
encounter.narration = encounter.narration.model_copy(
update={
"voice_profile": _narrator_voice_profile(),
"audio": self._generate_narration_audio(encounter.narration.text),
}
)
encounter.history.append(
ConversationTurn(
role="machine",
text=(
"Signal locked onto "
f"{encounter.persona.name}: {encounter.persona.immediate_situation}"
),
)
)
encounter.touch()
self.sessions.save(encounter)
yield from self._emit(
PersonaEvent(
persona=encounter.persona,
message="Ordinary-person signal locked.",
metadata=self._metadata(encounter, persona_timing, voice_timing),
)
)
if encounter.scene is not None:
yield from self._emit(
SceneEvent(
scene=encounter.scene,
message="World image stabilized.",
metadata=self._metadata(encounter, immersive_timing),
)
)
if encounter.portrait is not None:
yield from self._emit(
PortraitEvent(
portrait=encounter.portrait,
message="Character image resolved.",
metadata=self._metadata(encounter, immersive_timing),
)
)
if encounter.narration is not None:
yield from self._emit(
NarrationEvent(
narration=encounter.narration,
message="Narrator signal prepared.",
metadata=self._metadata(encounter, immersive_timing),
)
)
yield from self._emit(
MachineStateEvent(
state="conversation_ready",
message="The radio channel is open.",
metadata=self._metadata(encounter),
)
)
def handle_text_turn(
self,
encounter_id: str,
text: str,
) -> Iterator[TemporalEvent]:
encounter = self.sessions.get(encounter_id)
if encounter.destination is None or encounter.persona is None:
raise ValueError("Encounter must have a destination and persona before turns.")
user_turn = ConversationTurn(role="user", text=text)
encounter.history.append(user_turn)
yield from self._emit(
TranscriptEvent(
transcript=Transcript(text=text, confidence=1.0, language="en"),
message="User signal decoded.",
metadata=self._metadata(encounter),
)
)
conversation_started = perf_counter()
tts_started = perf_counter()
response = ConversationResponse(text="")
conversation_timing = {"stage": "conversation.respond", "elapsed_ms": None}
spoken_audio_until = 0
pending_tts = _PendingTTS()
try:
for text_event in self.conversation_engine.stream_response(
destination=encounter.destination,
persona=encounter.persona,
history=encounter.history,
latest_user_text=text,
):
conversation_timing = {
"stage": "conversation.respond",
"elapsed_ms": round((perf_counter() - conversation_started) * 1000, 1),
}
response = ConversationResponse(
text=text_event.text,
prosody_hint=_optional_string(text_event.metadata.get("prosody_hint")),
ui_signal_event=text_event.message,
)
yield from self._emit(
text_event.model_copy(
update={
"metadata": self._metadata(encounter, conversation_timing),
}
)
)
yield from self._emit_pending_tts(pending_tts.drain_ready())
if encounter.voice_profile is not None:
segment, spoken_audio_until = _next_tts_segment(
text_event.text,
consumed_chars=spoken_audio_until,
is_final=text_event.is_final,
)
if segment:
pending_tts.submit(
lambda segment=segment, prosody_hint=response.prosody_hint, conversation_timing=conversation_timing: self._build_tts_segment_events(
encounter=encounter,
text=segment,
prosody_hint=prosody_hint,
conversation_timing=conversation_timing,
tts_started=tts_started,
)
)
yield from self._emit_pending_tts(pending_tts.drain_ready())
yield from self._emit_pending_tts(pending_tts.drain_ready())
if not response.text.strip():
with _stage_timer("conversation.respond") as conversation_timing:
response = self.conversation_engine.respond(
destination=encounter.destination,
persona=encounter.persona,
history=encounter.history,
latest_user_text=text,
)
yield from self._emit(
ConversationTextEvent(
text=response.text,
is_final=True,
message=response.ui_signal_event,
metadata=self._metadata(encounter, conversation_timing),
)
)
if encounter.voice_profile is not None:
segment, spoken_audio_until = _next_tts_segment(
response.text,
consumed_chars=spoken_audio_until,
is_final=True,
)
if segment:
pending_tts.submit(
lambda segment=segment, prosody_hint=response.prosody_hint, conversation_timing=conversation_timing: self._build_tts_segment_events(
encounter=encounter,
text=segment,
prosody_hint=prosody_hint,
conversation_timing=conversation_timing,
tts_started=tts_started,
)
)
yield from self._emit_pending_tts(pending_tts.drain_all())
except Exception:
pending_tts.close(cancel=True)
raise
else:
pending_tts.close()
response = ConversationResponse(
text=response.text.strip(),
prosody_hint=response.prosody_hint,
ui_signal_event=response.ui_signal_event,
)
encounter.history.append(ConversationTurn(role="character", text=response.text))
encounter.touch()
self.sessions.save(encounter)
def _build_tts_segment_events(
self,
encounter: EncounterSession,
text: str,
prosody_hint: str | None,
conversation_timing: dict[str, Any],
tts_started: float,
) -> list[TemporalEvent]:
if encounter.voice_profile is None:
return []
events: list[TemporalEvent] = []
for audio_event in self.tts.stream_audio(
text=text,
voice_profile=encounter.voice_profile,
prosody_hint=prosody_hint,
):
tts_timing = {
"stage": "tts.synthesize",
"elapsed_ms": round((perf_counter() - tts_started) * 1000, 1),
}
events.append(
audio_event.model_copy(
update={
"message": audio_event.message or "Character audio prepared.",
"metadata": self._metadata(encounter, conversation_timing, tts_timing),
}
)
)
return events
def _stream_tts_segment(
self,
encounter: EncounterSession,
text: str,
prosody_hint: str | None,
conversation_timing: dict[str, Any],
tts_started: float,
) -> Iterator[TemporalEvent]:
for event in self._build_tts_segment_events(
encounter=encounter,
text=text,
prosody_hint=prosody_hint,
conversation_timing=conversation_timing,
tts_started=tts_started,
):
yield from self._emit(event)
def _emit_pending_tts(self, events: Iterator[TemporalEvent]) -> Iterator[TemporalEvent]:
for event in events:
yield from self._emit(event)
def generate_souvenir(self, encounter_id: str) -> Iterator[TemporalEvent]:
encounter = self.sessions.get(encounter_id)
if encounter.destination is None or encounter.persona is None:
raise ValueError("Encounter must have a destination and persona.")
encounter.souvenir = self.souvenir_generator.generate_souvenir(
destination=encounter.destination,
persona=encounter.persona,
history=encounter.history,
)
if self.immersive_generator is not None:
encounter.artifact = self.immersive_generator.generate_artifact(
destination=encounter.destination,
persona=encounter.persona,
history=encounter.history,
souvenir=encounter.souvenir,
)
encounter.touch()
self.sessions.save(encounter)
if encounter.artifact is not None:
yield from self._emit(
VisualArtifactEvent(
artifact=encounter.artifact,
message="Temporal artifact materialized.",
metadata=self._metadata(encounter),
)
)
yield from self._emit(
SouvenirEvent(
souvenir=encounter.souvenir,
message="Temporal souvenir stabilized.",
metadata=self._metadata(encounter),
)
)
def save_encounter(self, encounter_id: str) -> SavedEncounter:
encounter = self.sessions.get(encounter_id)
return self.store.save_encounter(encounter)
def _emit(self, event: TemporalEvent) -> Iterator[TemporalEvent]:
self.trace_sink.record_event(event)
yield event
def _generate_launch_immersion(
self,
encounter: EncounterSession,
) -> tuple[Any, Any, Any]:
if self.immersive_generator is None or encounter.destination is None or encounter.persona is None:
return None, None, None
with ThreadPoolExecutor(max_workers=3) as executor:
scene_future = executor.submit(
self.immersive_generator.generate_scene,
encounter.destination,
encounter.persona,
)
portrait_future = executor.submit(
self.immersive_generator.generate_portrait,
encounter.destination,
encounter.persona,
)
narration_future = executor.submit(
self.immersive_generator.generate_narration,
encounter.destination,
encounter.persona,
)
return scene_future.result(), portrait_future.result(), narration_future.result()
def _generate_narration_audio(self, text: str) -> AudioResult | None:
if not getattr(self.tts, "supports_narration_audio", False):
return None
try:
return self.tts.synthesize(
text=text,
voice_profile=_narrator_voice_profile(),
prosody_hint="cinematic narrator, calm, spacious, not the character voice",
)
except Exception:
return None
def _metadata(self, encounter: EncounterSession, *timings: dict[str, Any]) -> dict[str, Any]:
metadata: dict[str, Any] = {
"session_id": encounter.session_id,
"encounter_id": encounter.encounter_id,
}
completed_timings = [timing for timing in timings if timing.get("elapsed_ms") is not None]
if completed_timings:
metadata["timings"] = completed_timings
metadata["elapsed_ms"] = round(sum(timing["elapsed_ms"] for timing in completed_timings), 1)
return metadata
@contextmanager
def _stage_timer(stage: str) -> Iterator[dict[str, Any]]:
timing: dict[str, Any] = {"stage": stage, "elapsed_ms": None}
started = perf_counter()
try:
yield timing
finally:
timing["elapsed_ms"] = round((perf_counter() - started) * 1000, 1)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _narrator_voice_profile() -> VoiceProfile:
return VoiceProfile(
voice_id="cinematic-temporal-narrator",
description=(
"A distinct cinematic narrator voice: measured, spacious, resonant, "
"documentary tone, clearly not the encountered character."
),
pace="slow",
emotion="awe",
accent_hint="neutral English narrator",
)
class _PendingTTS:
def __init__(self) -> None:
self.executor = ThreadPoolExecutor(max_workers=1)
self.futures: list[Future[list[TemporalEvent]]] = []
self.closed = False
def submit(self, events: Callable[[], list[TemporalEvent]]) -> None:
if self.closed:
return
self.futures.append(self.executor.submit(lambda: list(events())))
def drain_ready(self) -> Iterator[TemporalEvent]:
while self.futures and self.futures[0].done():
future = self.futures.pop(0)
yield from future.result()
def drain_all(self) -> Iterator[TemporalEvent]:
try:
while self.futures:
future = self.futures.pop(0)
yield from future.result()
finally:
self.close()
def close(self, *, cancel: bool = False) -> None:
if self.closed:
return
self.closed = True
self.executor.shutdown(wait=not cancel, cancel_futures=cancel)
def _next_tts_segment(
text: str,
consumed_chars: int,
is_final: bool,
) -> tuple[str, int]:
clean_text = text.strip()
if not clean_text or consumed_chars >= len(clean_text):
return "", consumed_chars
start = consumed_chars
while start < len(clean_text) and clean_text[start].isspace():
start += 1
if start >= len(clean_text):
return "", start
if is_final:
return clean_text[start:].strip(), len(clean_text)
boundary = _last_sentence_boundary(clean_text, start)
if boundary <= start:
return "", consumed_chars
return clean_text[start:boundary].strip(), boundary
def _last_sentence_boundary(text: str, start: int) -> int:
boundary = -1
for mark in ".!?":
boundary = max(boundary, text.rfind(mark))
if boundary < start:
return -1
return boundary + 1