From ea60fc1df3755781fd3b3068f172dde539142a9b Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 26 Jul 2026 18:41:06 +0000 Subject: [PATCH 1/2] feat(live): added selectable voice setting Added the supported realtime voice catalog to settings and passed the selected value into each new live session. Covered the voice list, default, UI options, and persisted override. Fixes #6566 --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../coding-agent/src/config/settings-schema.ts | 13 +++++++++++++ packages/coding-agent/src/live/controller.ts | 4 ++-- packages/coding-agent/src/live/voices.ts | 18 ++++++++++++++++++ .../controllers/live-command-controller.ts | 1 + .../coding-agent/test/settings-manager.test.ts | 11 +++++++++++ 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/live/voices.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7c9fcf50c..b4e08eb4d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- 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 ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 8d808ad55..371049e4d 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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"; @@ -4812,6 +4813,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, diff --git a/packages/coding-agent/src/live/controller.ts b/packages/coding-agent/src/live/controller.ts index d44568a80..61699bc9a 100644 --- a/packages/coding-agent/src/live/controller.ts +++ b/packages/coding-agent/src/live/controller.ts @@ -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. */ diff --git a/packages/coding-agent/src/live/voices.ts b/packages/coding-agent/src/live/voices.ts new file mode 100644 index 000000000..d2e581627 --- /dev/null +++ b/packages/coding-agent/src/live/voices.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/controllers/live-command-controller.ts b/packages/coding-agent/src/modes/controllers/live-command-controller.ts index 859241202..9ab84c811 100644 --- a/packages/coding-agent/src/modes/controllers/live-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/live-command-controller.ts @@ -103,6 +103,7 @@ export class LiveCommandController { session = new LiveSessionController({ 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; diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 080f8d2e2..8b1b65254 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -9,12 +9,14 @@ import type { Context } from "@oh-my-pi/pi-ai/types"; import { getDefault, getEnumValues, + getUi, onAppendOnlyModeChanged, onStatusLineSessionAccentChanged, resetSettingsForTest, type SettingPath, Settings, } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { DEFAULT_LIVE_VOICE, LIVE_VOICE_OPTIONS, LIVE_VOICE_VALUES } from "@oh-my-pi/pi-coding-agent/live/voices"; import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { AUTO_IMAGE_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/tools/image-providers"; import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; @@ -143,6 +145,15 @@ describe("Settings", () => { expect(getDefault("providers.maxInFlightRequests")).toEqual({}); }); + it("exposes the supported live voices with sol as the default", () => { + expect(getDefault("live.voice")).toBe(DEFAULT_LIVE_VOICE); + expect(getEnumValues("live.voice")).toEqual(LIVE_VOICE_VALUES); + expect(getUi("live.voice")?.options).toEqual(LIVE_VOICE_OPTIONS); + + const settings = Settings.isolated({ "live.voice": "vale" }); + expect(settings.get("live.voice")).toBe("vale"); + }); + it("exposes all tool calling mode options", () => { const values = getEnumValues("tools.format"); expect(values).toEqual([ From bb8c0f12d328a7cfd2db5ab600efea62dda73b40 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 26 Jul 2026 18:52:11 +0000 Subject: [PATCH 2/2] test(live): covered selected voice session boundary Replaced schema self-comparisons with a controller contract test that selects vale and captures the options used to construct the live session. --- .../controllers/live-command-controller.ts | 12 +++-- .../live-command-controller.test.ts | 54 +++++++++++++++++++ .../test/settings-manager.test.ts | 11 ---- 3 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 packages/coding-agent/test/modes/controllers/live-command-controller.test.ts diff --git a/packages/coding-agent/src/modes/controllers/live-command-controller.ts b/packages/coding-agent/src/modes/controllers/live-command-controller.ts index 9ab84c811..a32620a97 100644 --- a/packages/coding-agent/src/modes/controllers/live-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/live-command-controller.ts @@ -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 | 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,7 +103,7 @@ 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"), @@ -129,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 { diff --git a/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts new file mode 100644 index 000000000..0ac00e838 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts @@ -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(); + } + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 8b1b65254..080f8d2e2 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -9,14 +9,12 @@ import type { Context } from "@oh-my-pi/pi-ai/types"; import { getDefault, getEnumValues, - getUi, onAppendOnlyModeChanged, onStatusLineSessionAccentChanged, resetSettingsForTest, type SettingPath, Settings, } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { DEFAULT_LIVE_VOICE, LIVE_VOICE_OPTIONS, LIVE_VOICE_VALUES } from "@oh-my-pi/pi-coding-agent/live/voices"; import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { AUTO_IMAGE_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/tools/image-providers"; import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; @@ -145,15 +143,6 @@ describe("Settings", () => { expect(getDefault("providers.maxInFlightRequests")).toEqual({}); }); - it("exposes the supported live voices with sol as the default", () => { - expect(getDefault("live.voice")).toBe(DEFAULT_LIVE_VOICE); - expect(getEnumValues("live.voice")).toEqual(LIVE_VOICE_VALUES); - expect(getUi("live.voice")?.options).toEqual(LIVE_VOICE_OPTIONS); - - const settings = Settings.isolated({ "live.voice": "vale" }); - expect(settings.get("live.voice")).toBe("vale"); - }); - it("exposes all tool calling mode options", () => { const values = getEnumValues("tools.format"); expect(values).toEqual([