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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user