Merge PR #6724: feat(live): add selectable voice setting (@roboomp)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user