Skip to content

Commit 3188f34

Browse files
authored
Merge branch 'dev' into codex/issue-1623-workspace-camera-animation
Signed-off-by: Hendrik D. <144935017+HendrikD2005@users.noreply.github.com>
2 parents dd5afbf + b28276e commit 3188f34

53 files changed

Lines changed: 4007 additions & 391 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/keiko-editor/vscode-parity-roadmap.md

Lines changed: 103 additions & 62 deletions
Large diffs are not rendered by default.

docs/voice/capability-configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ Server-side, `selectVoicePersonaVoice(config, persona)` resolves the cheapest co
145145
provider that maps the requested persona to its `voiceId`; the resolver result carries the `voiceId` and
146146
therefore stays server-side. It is the seam the assistant speech-output feature (Issue #1558) consumes.
147147

148+
> **Realtime voice ids are a narrower set than text-to-speech voice ids.** The realtime models accept
149+
> `alloy`, `ash`, `ballad`, `coral`, `echo`, `sage`, `shimmer`, and `verse`; some text-to-speech-only voices
150+
> (for example `nova` and `onyx`) are **rejected** by the realtime model and would break realtime session
151+
> creation. For this reason a `keiko-realtime` provider's `voiceProfiles` must map each persona to a
152+
> realtime-valid voice id. The realtime negotiation applies `resolveRealtimeVoice` as a defense-in-depth
153+
> guard: a configured voice id outside the realtime set falls back to `alloy` rather than failing the
154+
> session. A `keiko-tts` provider keeps the broader text-to-speech voice set.
155+
148156
## 3. Reading the capability: the BFF endpoint
149157

150158
The UI reads the resolved voice capability before rendering any voice affordance:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Voice pipeline fluidity audit and optimization
2+
3+
This note records a focused audit of the shipped voice dialogue pipeline (Epics #491 and #1556)
4+
prompted by user reports that the human↔assistant dialogue did not feel fluid (latency, choppiness,
5+
unconvincing quality), the fixes applied, and the prioritized follow-ups.
6+
7+
## Method
8+
9+
The pipeline hot path was audited statically across the Model Gateway voice adapters, the BFF voice
10+
handlers, and the browser dialogue/transport/playback hooks. In addition, the **live** Azure Foundry
11+
voice deployments were exercised directly to obtain real latency numbers and to verify provider
12+
request/response schemas (no mocks). Audio quality (timbre/naturalness) is a property of the
13+
configured provider model and is out of scope for pipeline changes; perceived fluidity (start
14+
latency, choppiness, turn-taking, robustness) is what this work targets.
15+
16+
## Live baseline (real Azure Foundry calls)
17+
18+
| Path | Observation |
19+
| ---------------------------------------- | --------------------------------------------------------------------------------------------------- |
20+
| TTS `keiko-tts`, short reply, mp3 | ttfb ~0.78s, total ~1.4s, ~72KB |
21+
| TTS `keiko-tts`, short reply, opus | ttfb ~0.90s, total ~1.1s, ~18KB |
22+
| TTS `keiko-tts`, long reply, mp3 | total ~3.0s, ~303KB |
23+
| TTS `keiko-tts`, long reply, opus | total ~1.95s, ~75KB |
24+
| STT `keiko-stt` | ~1.2s round-trip; rejects audio without a recognized file extension |
25+
| Realtime `keiko-realtime` session create | HTTP 200 ~0.3s; **default session ran the provider demo persona**; voice `nova` rejected (HTTP 400) |
26+
27+
## Fixed in this change
28+
29+
### Realtime dialogue (Wave A) — `fix(voice): make realtime dialogue audible and grounded`
30+
31+
- **Silent assistant (critical):** the negotiated remote audio track was never attached to an output
32+
sink (`useRealtimeVoice` wired `onConnectionStateChange` but never `onRemoteTrack`). The remote
33+
stream is now attached to an autoplaying audio sink.
34+
- **Ungrounded persona (critical):** the realtime session was created with no `instructions`, so it
35+
ran the provider default demo persona ("helpful, witty, friendly AI … talk quickly … knowledge
36+
cutoff 2023-10"). It now uses the same system persona as the text chat, set via the GA nested
37+
`audio.{input,output}` session schema (verified against the live endpoint; the older top-level
38+
shape returns HTTP 500).
39+
- **Voice + transcription:** a realtime-valid output voice and `input_audio_transcription` +
40+
`server_vad` turn detection are now configured.
41+
- **Invalid-voice guard:** `resolveRealtimeVoice` maps a TTS-only voice id (e.g. `nova`, rejected by
42+
the realtime model with HTTP 400) to a safe default so a misconfigured persona mapping cannot break
43+
session creation.
44+
- **Transient ICE `disconnected`** no longer tears the session down immediately; a bounded grace
45+
window allows recovery.
46+
- **Full-duplex capture:** `getUserMedia` now requests echo cancellation / noise suppression / AGC /
47+
mono, preventing the assistant echoing into the microphone.
48+
- **Negotiation timeout** clamped to 8s (was the generic 30s provider timeout).
49+
50+
### Turn-based speech latency (Wave B) — `perf(voice): request opus for interactive assistant speech`
51+
52+
- The assistant speak path now requests **opus (audio/ogg)** instead of mp3: measured ~25–35% faster
53+
end-to-end and ~4x smaller for the same utterance, lowering both the synth-to-first-audio wait and
54+
the base64 inflation of the JSON envelope. opus output is a valid, browser-playable Ogg/Opus
55+
container, and the MIME stays inside the existing server allowlist.
56+
57+
## Prioritized follow-ups (identified, deliberately deferred)
58+
59+
These were confirmed real but are deferred to keep this change contained, well-tested, and within the
60+
shipped subsystem's invariants. They are ordered by perceived-fluidity value.
61+
62+
1. **Turn-based VAD end-of-turn + voice-activated barge-in.** The shipped dialogue mode (STT+TTS)
63+
ends a user turn only when the user manually taps the mic again, and barge-in is button-driven.
64+
A WebAudio `AnalyserNode` on the dictation `MediaStream` (trailing-silence end-of-turn + energy-
65+
onset barge-in) would make turns feel natural. Deferred because it requires exposing the shared
66+
dictation recorder's stream and WebAudio test infrastructure (regression surface on composer
67+
dictation, #495). _Files: `useVoiceDialogueSession.ts`, `voice-dialogue-session.ts`,
68+
`dictation-recorder.ts`._ Note: realtime mode already gets natural turn-taking via `server_vad`.
69+
2. **Streaming assistant-speech playout (MSE/Web Audio).** Start playback on the first audio chunk
70+
instead of buffering the whole clip. With opus the whole-clip wait is already ~1.1s versus a
71+
~0.9s time-to-first-audio floor, so the remaining benefit is ~0.2s; it is a larger architectural
72+
change to the audible path and warrants its own change with perceptual sign-off. _Files:
73+
`useAssistantSpeech.ts`, `voice-handlers.ts` (reuse the existing SSE seam), `lib/api.ts`._
74+
3. **Control-plane WS heartbeat + client negotiate() timeout.** Ping/pong liveness and a browser-side
75+
handshake timeout so a half-open connection cannot stall on "negotiating". _Files:
76+
`voice-realtime.ts`, `voice-realtime-client.ts`._
77+
4. **Per-user persona selection for realtime.** Plumb the selected `male`/`female`/`neutral` persona
78+
through the control protocol so realtime honors it (today it uses the configured neutral default).
79+
5. **Live interrupt offset.** Source the barge-in offset from the live media position rather than the
80+
previous interrupt's stored offset. _File: `useVoiceDialogueSession.ts`._
81+
6. **STT interim/verbose_json.** Request `verbose_json` for real duration (the current `json` path
82+
parses `confidence`/`duration` that are never returned) and surface interim transcripts. _File:
83+
`speech-to-text-adapter.ts`._
84+
85+
## Invariants preserved
86+
87+
Model Gateway boundary intact; no new runtime media dependencies; no raw audio persisted; the browser
88+
never receives the provider key; spoken Keiko derives from the same persona as written Keiko; voice
89+
remains capability-gated and fully optional.

packages/keiko-contracts/src/bff-wire.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,12 +1140,26 @@ export interface FilesRenameRequest {
11401140
readonly path: string;
11411141
// Root-relative POSIX destination path; its parent must exist and it must not already exist.
11421142
readonly newPath: string;
1143+
// Issue 2.6: optional version-aware precondition for a FILE move — the BFF rejects with
1144+
// STALE_SESSION (409) if the on-disk file changed since this revision. Only a caller holding an open
1145+
// buffer (editor/agent) has it; the metadata-only tree omits it.
1146+
readonly baseVersion?: EditorDocumentVersion;
11431147
}
11441148

11451149
export interface FilesDeleteRequest {
11461150
readonly root: string;
11471151
// Root-relative POSIX path of the entry to delete. A directory is removed recursively.
11481152
readonly path: string;
1153+
// Issue 2.6: optional version-aware precondition for a FILE delete (see FilesRenameRequest).
1154+
readonly baseVersion?: EditorDocumentVersion;
1155+
}
1156+
1157+
export interface FilesCopyRequest {
1158+
readonly root: string;
1159+
// Root-relative POSIX path of the existing entry to copy (a directory is copied recursively).
1160+
readonly sourcePath: string;
1161+
// Root-relative POSIX destination path; its parent must exist and it must not already exist.
1162+
readonly destPath: string;
11491163
}
11501164

11511165
// Shared response for create / rename / delete: the affected entry's canonical root-relative path

packages/keiko-contracts/src/voice-protocol.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
// realized on the existing loopback HTTP + Server-Sent Events seam; a bidirectional WebSocket upgrade
1616
// is an explicit, ADR-gated transport decision owned by Issue #497 (ADR-0058 D3 / ADR-0059).
1717

18-
import type { VoiceProfile, VoiceProviderLocality, VoiceUnavailableReason } from "./gateway.js";
18+
import type {
19+
VoicePersona,
20+
VoiceProfile,
21+
VoiceProviderLocality,
22+
VoiceUnavailableReason,
23+
} from "./gateway.js";
1924

2025
// ─── Protocol version ─────────────────────────────────────────────────────────
2126
export const VOICE_PROTOCOL_VERSION = "1" as const;
@@ -285,6 +290,10 @@ export interface VoiceSessionCreateMessage extends VoiceControlEnvelope<"session
285290
readonly idempotencyKey: string;
286291
readonly requestedProfile: VoiceProfile;
287292
readonly negotiationMode: VoiceNegotiationMode;
293+
// Optional product voice persona ("male" | "female" | "neutral") the client selected. Content-free
294+
// (an enum, never a provider voice id); the host resolves it server-side to a realtime-valid voice so
295+
// the spoken voice matches the user's choice. Absent ⇒ the host uses its configured default voice.
296+
readonly persona?: VoicePersona | undefined;
288297
}
289298

290299
export interface VoiceSessionCreatedMessage extends VoiceControlEnvelope<"session.created"> {

packages/keiko-editor/src/components/KeikoCodeEditor.test.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ vi.mock("@monaco-editor/react", () => {
157157
focus: vi.fn(),
158158
setSelection: vi.fn(),
159159
revealRangeInCenterIfOutsideViewport: vi.fn(),
160-
deltaDecorations: vi.fn((_oldDecorations: readonly string[], newDecorations: readonly unknown[]) =>
161-
newDecorations.length > 0 ? ["reference-decoration"] : [],
160+
deltaDecorations: vi.fn(
161+
(_oldDecorations: readonly string[], newDecorations: readonly unknown[]) =>
162+
newDecorations.length > 0 ? ["reference-decoration"] : [],
162163
),
163164
mounted: false,
164165
fakeEditor: null as unknown as FakeEditorShape,
@@ -456,6 +457,25 @@ describe("KeikoCodeEditor — runtime errors", () => {
456457
expect(onRuntimeError).toHaveBeenCalled();
457458
expect(screen.getByLabelText("Editor: src/a.ts")).toBeInTheDocument();
458459
});
460+
461+
it("re-applies the theme on a theme-variant change without remounting (Issue 2.2)", async () => {
462+
// A light/dark toggle must re-theme the SAME live editor (preserving the undo stack + view state),
463+
// not remount it. The re-apply re-attempts theme registration; in jsdom that surfaces again
464+
// through onRuntimeError — the observable that the re-theme path ran on the live instance.
465+
const onRuntimeError = vi.fn();
466+
const { rerender } = render(
467+
<KeikoCodeEditor {...baseProps({ onRuntimeError, themeVariant: "dark" })} />,
468+
);
469+
await flushMount();
470+
expect(onRuntimeError).toHaveBeenCalled();
471+
onRuntimeError.mockClear();
472+
473+
rerender(<KeikoCodeEditor {...baseProps({ onRuntimeError, themeVariant: "light" })} />);
474+
await flushMount();
475+
expect(onRuntimeError).toHaveBeenCalled();
476+
// Still the same mounted editor — no remount.
477+
expect(screen.getByLabelText("Editor: src/a.ts")).toBeInTheDocument();
478+
});
459479
});
460480

461481
describe("KeikoCodeEditor — Monaco language", () => {
@@ -529,12 +549,15 @@ describe("KeikoCodeEditor — reference reveal", () => {
529549
expect(captured.editor?.revealRangeInCenterIfOutsideViewport).toHaveBeenCalledWith(
530550
expectedRange,
531551
);
532-
expect(captured.editor?.deltaDecorations).toHaveBeenCalledWith([], [
533-
{
534-
range: expectedRange,
535-
options: { isWholeLine: true, className: "keiko-editor-reference-target" },
536-
},
537-
]);
552+
expect(captured.editor?.deltaDecorations).toHaveBeenCalledWith(
553+
[],
554+
[
555+
{
556+
range: expectedRange,
557+
options: { isWholeLine: true, className: "keiko-editor-reference-target" },
558+
},
559+
],
560+
);
538561
});
539562

540563
it("replays the same range when the host sends a new reveal request id", async () => {
@@ -547,9 +570,7 @@ describe("KeikoCodeEditor — reference reveal", () => {
547570
const firstRevealCount = captured.editor?.setSelection.mock.calls.length ?? 0;
548571

549572
rerender(
550-
<KeikoCodeEditor
551-
{...baseProps({ revealRequest: { ...revealRequest, id: "ref-2" } })}
552-
/>,
573+
<KeikoCodeEditor {...baseProps({ revealRequest: { ...revealRequest, id: "ref-2" } })} />,
553574
);
554575

555576
expect(captured.editor?.setSelection).toHaveBeenCalledTimes(firstRevealCount + 1);

packages/keiko-editor/src/components/on-mount.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,18 @@ export function isSaveChord(event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrl
220220
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
221221
}
222222

223-
function registerTheme(args: WireEditorOnMountArgs): void {
223+
/**
224+
* (Re)define the Keiko Monaco theme for `themeVariant` from the live DOM tokens and apply it to the
225+
* running editor — `registerKeikoEditorTheme` calls both `defineTheme` and `setTheme`. Used at mount
226+
* and again when the app theme switches (Issue 2.2), so a light/dark toggle re-themes the SAME editor
227+
* instance instead of forcing a remount that would discard the undo stack and view state.
228+
*/
229+
export function reapplyEditorTheme(args: {
230+
readonly monaco: MountMonaco;
231+
readonly container: HTMLElement;
232+
readonly themeVariant: EditorThemeVariant;
233+
readonly onThemeError?: ((message: string) => void) | undefined;
234+
}): void {
224235
try {
225236
const tokens = resolveEditorThemeTokensFromDom(args.container);
226237
registerKeikoEditorTheme(args.monaco.editor, args.themeVariant, tokens);
@@ -230,6 +241,15 @@ function registerTheme(args: WireEditorOnMountArgs): void {
230241
}
231242
}
232243

244+
function registerTheme(args: WireEditorOnMountArgs): void {
245+
reapplyEditorTheme({
246+
monaco: args.monaco,
247+
container: args.container,
248+
themeVariant: args.themeVariant,
249+
onThemeError: args.onThemeError,
250+
});
251+
}
252+
233253
function installSaveAction(args: WireEditorOnMountArgs): monaco.IDisposable {
234254
return args.editor.addAction(
235255
buildSaveActionDescriptor({

0 commit comments

Comments
 (0)