Merge PR #6724: feat(live): add selectable voice setting (@roboomp)

This commit is contained in:
can1357
2026-07-27 05:26:35 +02:00
6 changed files with 97 additions and 6 deletions
+1
View File
@@ -81,6 +81,7 @@
- Fixed Escape waiting for an in-flight `session_stop` extension handler to exhaust its timeout; abort now cancels the active stop pass without reporting a false timeout or applying stale continuation context ([#6489](https://github.com/can1357/oh-my-pi/issues/6489)).
- Fixed the agent not resuming after re-answering a past `ask` from the session tree. Committing a new answer via `/tree` branched a fresh sibling `toolResult` and rebuilt context, but nothing ever continued the agent — unlike a live `ask`, whose continuation is intrinsic to the streaming run loop — so the model never consumed the new answer and the session sat idle until a manual prompt. `navigateTree` now reports the commit (`askReanswerCommitted`) and the interactive `/tree` handler resumes the agent via `resumeAfterAskReanswer()` *after* its transcript rebuild, so the resumed turn never renders against the stale pre-rebuild UI. Plain leaf moves and the read-only `reopenAsk` probe stay idle ([#6483](https://github.com/can1357/oh-my-pi/issues/6483)).
- Fixed Ctrl+C and fatal shutdown entering an `ExtensionExitError` rejection loop while an extension or hook was still loading ([#6488](https://github.com/can1357/oh-my-pi/issues/6488)).
- Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)).
## [17.1.3] - 2026-07-24
@@ -2,6 +2,7 @@ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
import { DEFAULT_RELAY_URL } from "../collab/protocol";
import { DEFAULT_LIVE_VOICE, LIVE_VOICE_OPTIONS, LIVE_VOICE_VALUES } from "../live/voices";
import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS, STT_MODEL_VALUES } from "../stt/models";
import { STT_SUBMIT_TRIGGER_OPTIONS, STT_SUBMIT_TRIGGER_VALUES } from "../stt/submit-trigger";
import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
@@ -4845,6 +4846,18 @@ export const SETTINGS_SCHEMA = {
],
},
},
"live.voice": {
type: "enum",
values: LIVE_VOICE_VALUES,
default: DEFAULT_LIVE_VOICE,
ui: {
tab: "providers",
group: "Services",
label: "Live Voice",
description: "Voice used by Codex-backed realtime voice sessions",
options: LIVE_VOICE_OPTIONS,
},
},
"providers.tts": {
type: "enum",
values: ["auto", "local", "xai"] as const,
+2 -2
View File
@@ -17,8 +17,8 @@ import {
} from "./protocol";
import { CodexLiveTransport } from "./transport";
import type { LivePhase } from "./visualizer";
import { DEFAULT_LIVE_VOICE } from "./voices";
const DEFAULT_VOICE = "sol";
const OUTPUT_ACTIVE_LEVEL = 0.015;
const MIN_BARGE_IN_LEVEL = 0.04;
const OUTPUT_ECHO_RATIO = 0.65;
@@ -120,7 +120,7 @@ export class LiveSessionController {
this.#session = options.session;
this.#callbacks = options.callbacks;
this.#extractAssistantText = options.extractAssistantText;
this.#voice = options.voice?.trim() || DEFAULT_VOICE;
this.#voice = options.voice?.trim() || DEFAULT_LIVE_VOICE;
}
/** Current realtime call phase. */
+18
View File
@@ -0,0 +1,18 @@
/** Voices accepted by Codex-backed realtime sessions and exposed in settings. */
export const LIVE_VOICE_OPTIONS = [
{ value: "arbor", label: "Arbor" },
{ value: "breeze", label: "Breeze" },
{ value: "cove", label: "Cove" },
{ value: "ember", label: "Ember" },
{ value: "juniper", label: "Juniper" },
{ value: "maple", label: "Maple" },
{ value: "sol", label: "Sol" },
{ value: "spruce", label: "Spruce" },
{ value: "vale", label: "Vale" },
] as const;
/** Accepted values for the live voice setting. */
export const LIVE_VOICE_VALUES = LIVE_VOICE_OPTIONS.map(({ value }) => value);
/** Voice used when no live voice preference is configured. */
export const DEFAULT_LIVE_VOICE = "sol";
@@ -1,6 +1,6 @@
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { LiveSessionController, type LiveTranscript } from "../../live/controller";
import { LiveSessionController, type LiveSessionControllerOptions, type LiveTranscript } from "../../live/controller";
import { LIVE_MODEL } from "../../live/protocol";
import { LiveVisualizer } from "../../live/visualizer";
import { vocalizer } from "../../tts/vocalizer";
@@ -11,6 +11,7 @@ import type { InteractiveModeContext } from "../types";
import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
const ANIMATION_INTERVAL_MS = 80;
type LiveSessionFactory = (options: LiveSessionControllerOptions) => LiveSessionController;
const LIVE_MESSAGE_USAGE: AssistantMessage["usage"] = {
input: 0,
@@ -27,6 +28,7 @@ function errorFrom(cause: unknown): Error {
/** Owns the editor-replacing visualizer and realtime session lifecycle for `/live`. */
export class LiveCommandController {
readonly #ctx: InteractiveModeContext;
readonly #createSession: LiveSessionFactory | undefined;
#session: LiveSessionController | undefined;
#settling: Promise<void> | undefined;
@@ -40,8 +42,9 @@ export class LiveCommandController {
#assistantTranscriptTurn = 0;
#assistantTranscriptStartedAt = 0;
constructor(ctx: InteractiveModeContext) {
constructor(ctx: InteractiveModeContext, createSession?: LiveSessionFactory) {
this.#ctx = ctx;
this.#createSession = createSession;
}
/** Whether a live session is connected, connecting, or closing. */
@@ -100,9 +103,10 @@ export class LiveCommandController {
this.#mountVisualizer(visualizer);
let session: LiveSessionController;
session = new LiveSessionController({
const options: LiveSessionControllerOptions = {
session: this.#ctx.session,
extractAssistantText: message => this.#ctx.extractAssistantText(message),
voice: this.#ctx.settings.get("live.voice"),
callbacks: {
onPhase: phase => {
if (this.#visualizer !== visualizer) return;
@@ -128,7 +132,8 @@ export class LiveCommandController {
},
onTerminal: error => this.#finish(session, error),
},
});
};
session = this.#createSession ? this.#createSession(options) : new LiveSessionController(options);
this.#session = session;
try {
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { LiveSessionController } from "@oh-my-pi/pi-coding-agent/live/controller";
import { LiveCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/live-command-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
function createContext(): InteractiveModeContext {
const editor = {
getUseTerminalCursor: vi.fn(() => true),
setUseTerminalCursor: vi.fn(),
};
return {
settings: Settings.isolated({ "live.voice": "vale" }),
session: {},
extractAssistantText: vi.fn(() => ""),
editor,
editorContainer: { clear: vi.fn(), addChild: vi.fn() },
ui: {
getShowHardwareCursor: vi.fn(() => true),
setShowHardwareCursor: vi.fn(),
setFocus: vi.fn(),
requestRender: vi.fn(),
requestComponentRender: vi.fn(),
},
showError: vi.fn(),
chatContainer: { children: [] },
present: vi.fn(),
} as unknown as InteractiveModeContext;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("LiveCommandController", () => {
it("forwards the selected voice across the live-session boundary", async () => {
const ctx = createContext();
let receivedVoice: string | undefined;
const controller = new LiveCommandController(ctx, options => {
receivedVoice = options.voice;
const session = new LiveSessionController(options);
vi.spyOn(session, "start").mockResolvedValue();
vi.spyOn(session, "stop").mockResolvedValue();
return session;
});
try {
await controller.handleCommand();
expect(receivedVoice).toBe("vale");
} finally {
await controller.stop();
}
});
});