fix(tts): stopped queued TTS playback on Esc after stream end

Once the assistant reply stops streaming, `vocalizer.clear()` was only invoked from the aborted-stream cascade in EventController. Escaping after the model finished fell through InputController to the empty-editor double-Esc gesture while StreamingAudioPlayer kept draining buffered Kokoro PCM.

Add `Vocalizer.isSpeaking()` (true while any live player, stream handle, or in-flight abort is around) and consult it in the Esc handler before the double-Esc branch: if speech is still audible, a single Esc calls `vocalizer.clear()` and resets `lastEscapeTime` so tree/branch stays reachable via the next press.

Fixes #4521
This commit is contained in:
roboomp
2026-07-04 14:42:24 +00:00
parent 38565c58cb
commit 8cce6f637c
4 changed files with 43 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Esc no longer stopping TTS audio playback once the assistant reply had finished streaming; queued Kokoro (and remote) audio kept reading past the response's end because `vocalizer.clear()` only fired from the aborted-stream cascade. Esc now silences a still-audible vocalizer as its first action (ahead of the `tree`/`branch` double-Esc gesture); a second Esc keeps its previous behavior. ([#4521](https://github.com/can1357/oh-my-pi/issues/4521))
## [16.3.6] - 2026-07-04
### Changed
@@ -23,6 +23,7 @@ import { isLowSignalTitleInput } from "../../tiny/text";
import { tinyTitleClient } from "../../tiny/title-client";
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { vocalizer } from "../../tts/vocalizer";
import {
copyToClipboard,
readImageFromClipboard,
@@ -390,6 +391,13 @@ export class InputController {
// Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc.
this.ctx.lastEscapeTime = 0;
this.#clearStreamingEscapeArm();
} else if (vocalizer.isSpeaking()) {
// TTS buffers seconds of PCM past the streaming abort, so an Esc
// arriving after the model stopped would otherwise fall through to
// the double-Esc gesture while Kokoro reads on. Silence first;
// tree/branch stays reachable via a second Esc.
vocalizer.clear();
this.ctx.lastEscapeTime = 0;
} else {
// Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
const action = settings.get("doubleEscapeAction");
@@ -195,6 +195,15 @@ export class Vocalizer {
this.#livePlayers.clear();
}
/**
* True while any utterance is still audible or synthesizing — a live
* player, an unfinished stream handle, or an in-flight rewrite is enough.
* Callers (Esc handler) treat this as the "silence me" signal.
*/
isSpeaking(): boolean {
return this.#livePlayers.size > 0 || this.#liveAborts.size > 0 || this.#handle !== null;
}
/** Lower the volume while the user is speaking (push-to-talk), so speech doesn't drown them out. */
duck(): void {
this.#ducked = true;
@@ -4,6 +4,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext, SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer";
import * as logger from "@oh-my-pi/pi-utils/logger";
type Spy = Mock<(...args: unknown[]) => unknown>;
@@ -702,6 +703,27 @@ describe("InputController escape behavior", () => {
expect(ctx.showTreeSelector).not.toHaveBeenCalled();
expect(ctx.showUserMessageSelector).not.toHaveBeenCalled();
});
it("silences a still-audible vocalizer on Esc instead of opening the tree selector (#4521)", () => {
const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {});
const isSpeaking = vi.spyOn(vocalizer, "isSpeaking").mockReturnValue(true);
const { ctx, editor, spies } = createContext();
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(clear).toHaveBeenCalledTimes(1);
expect(ctx.showTreeSelector).not.toHaveBeenCalled();
expect(ctx.showUserMessageSelector).not.toHaveBeenCalled();
expect(spies.resetDisplay).not.toHaveBeenCalled();
// A second Esc after silence must NOT immediately fire the double-Esc
// gesture — the first press consumed the arm.
isSpeaking.mockReturnValue(false);
editor.onEscape?.();
expect(ctx.showTreeSelector).not.toHaveBeenCalled();
});
});
describe("InputController Ctrl+C behavior", () => {