reachy-tales / src /story-bridge.ts
Nicolas Rabault
feat(app): story-bridge orchestrating engine + realtime + voice swap
4a1c549
Raw
History Blame Contribute Delete
7.12 kB
import { StoryEngine, type TransitionResult, type LoadedContent } from "story-engine";
import { OpenaiRealtimeClient, type RealtimeTool } from "./openai-realtime.js";
import { SummaryBuilder } from "./summary-builder.js";
import { ROBOT_TOOLS, RobotToolDispatcher } from "./robot-tools.js";
import { toRealtimeTools, isStoryTool } from "./story-tools.js";
import type { ReachyMiniInstance } from "./globals.d.ts";
export interface StoryBridgeOptions {
content: LoadedContent;
robot: ReachyMiniInstance;
apiKey: string;
realtimeModel: string;
summaryModel: string;
defaultVoice: string;
getRobotMicTrack: () => MediaStreamTrack | null;
onOpenAiOutputTrack: (track: MediaStreamTrack) => void;
onAppStateChange: (state: BridgeAppState) => void;
}
export type BridgeAppState =
| "starting"
| "listening"
| "user-speaking"
| "processing"
| "ai-speaking"
| "swapping-character"
| "swapping-story"
| "error";
export class StoryBridge {
private engine: StoryEngine;
private realtime: OpenaiRealtimeClient | null = null;
private summary: SummaryBuilder;
private robotDispatcher: RobotToolDispatcher;
private currentVoice: string | null = null;
private opts: StoryBridgeOptions;
constructor(opts: StoryBridgeOptions) {
this.opts = opts;
this.engine = new StoryEngine(opts.content);
this.summary = new SummaryBuilder({ apiKey: opts.apiKey, model: opts.summaryModel });
this.robotDispatcher = new RobotToolDispatcher(opts.robot);
}
async start(): Promise<void> {
this.opts.onAppStateChange("starting");
const narrator = this.engine.getCurrentCharacter();
const voice = narrator?.voice ?? this.opts.defaultVoice;
await this.openSession(voice);
this.opts.onAppStateChange("listening");
}
async stop(): Promise<void> {
if (this.realtime) {
await this.realtime.close();
this.realtime = null;
}
}
private buildTools(): RealtimeTool[] {
return [...toRealtimeTools(this.engine.buildToolDefinitions()), ...ROBOT_TOOLS];
}
private attachListeners(client: OpenaiRealtimeClient): void {
client.on("status", ({ status }) => this.translateRealtimeState(status));
client.on("outputTrack", ({ track }) => this.opts.onOpenAiOutputTrack(track));
client.on("toolCall", ({ callId, name, arguments: args }) => {
this.handleToolCall(name, args)
.then((result) => client.sendToolResponse(callId, result))
.catch((err) => {
console.error("[story-bridge] tool call error:", err);
client.sendToolResponse(callId, `Error: ${String(err)}`);
});
});
client.on("error", ({ error }) => {
console.error("[story-bridge] realtime error:", error);
this.opts.onAppStateChange("error");
});
}
private async openSession(voice: string): Promise<void> {
const inputTrack = this.opts.getRobotMicTrack();
if (!inputTrack) throw new Error("No mic track available");
this.realtime = new OpenaiRealtimeClient({
apiKey: this.opts.apiKey,
model: this.opts.realtimeModel,
voice,
instructions: this.engine.buildSystemPrompt(),
tools: this.buildTools(),
inputTrack,
});
this.attachListeners(this.realtime);
await this.realtime.connect();
this.currentVoice = voice;
}
private translateRealtimeState(rtState: string): void {
switch (rtState) {
case "connected":
this.opts.onAppStateChange("listening");
break;
case "user-speaking":
case "processing":
case "ai-speaking":
this.opts.onAppStateChange(rtState as BridgeAppState);
break;
case "error":
this.opts.onAppStateChange("error");
break;
}
}
async handleToolCall(name: string, args: Record<string, unknown>): Promise<string> {
if (!isStoryTool(name)) {
return this.robotDispatcher.handle(this.opts.robot, name, args);
}
let result: TransitionResult;
switch (name) {
case "select_story":
result = this.engine.enterStoryMode(args.storyId as string);
return this.applyTransition(result, "swapping-story");
case "advance_storyline":
result = this.engine.advanceStoryline(args.actionId as string);
return this.applyTransition(result);
case "navigate_scene":
result = this.engine.navigateScene(args.sceneId as string);
return this.applyTransition(result);
case "perform_scene_action":
result = this.engine.performSceneAction(args.actionId as string);
return this.applyTransition(result);
case "request_options":
return this.engine.formatOptionsRecap();
case "exit_story":
result = this.engine.exitStoryMode();
return this.applyTransition(result, "swapping-story");
default:
return `Unknown tool: ${name}`;
}
}
private async applyTransition(
result: TransitionResult,
transientState: "swapping-character" | "swapping-story" = "swapping-character",
): Promise<string> {
const blocked = result.events.find((e) => e.type === "blocked");
if (blocked)
return `Cannot do that: ${(blocked as Extract<typeof blocked, { type: "blocked" }>).reason}`;
const speakerChange = result.events.find((e) => e.type === "speakerChanged") as
| Extract<TransitionResult["events"][number], { type: "speakerChanged" }>
| undefined;
if (speakerChange) {
const newVoice = speakerChange.newCharacter.voice ?? this.opts.defaultVoice;
if (newVoice !== this.currentVoice) {
this.opts.onAppStateChange(transientState);
await this.swapSession(newVoice);
this.opts.onAppStateChange("ai-speaking");
return "";
}
}
// Same voice — session already has current state; no updateSession available,
// so we close and reopen to push fresh instructions + tools.
await this.swapSession(this.currentVoice ?? this.opts.defaultVoice);
return this.engine.getCurrentNode()?.text ?? "";
}
private async swapSession(newVoice: string): Promise<void> {
let summaryText = "(no summary)";
try {
summaryText = await this.summary.summarize(this.engine.getRecentTransitionLog());
} catch (e) {
console.warn("[story-bridge] Summary failed, continuing with no summary:", e);
}
if (this.realtime) {
await this.realtime.close();
this.realtime = null;
}
const inputTrack = this.opts.getRobotMicTrack();
if (!inputTrack) {
console.error("[story-bridge] No mic track available for new session");
this.opts.onAppStateChange("error");
return;
}
const instructions =
this.engine.buildSystemPrompt() +
`\n\n# RECENT STORY CONTEXT\n${summaryText}\n\nSpeak the next scripted line now, in character.`;
this.realtime = new OpenaiRealtimeClient({
apiKey: this.opts.apiKey,
model: this.opts.realtimeModel,
voice: newVoice,
instructions,
tools: this.buildTools(),
inputTrack,
});
this.attachListeners(this.realtime);
await this.realtime.connect();
this.currentVoice = newVoice;
}
}