refactor(app): replace voice hooks with voice runtime and audio engine architecture

This commit is contained in:
Mohamed Boudra
2026-03-13 20:53:55 +07:00
parent 730f34753a
commit 80a2f8052c
51 changed files with 4308 additions and 2380 deletions
@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import {
createVoiceRuntime,
type VoiceRuntime,
type VoiceSessionAdapter,
} from "@/voice/voice-runtime";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
function createAudioEngineMock(): AudioEngine {
return {
initialize: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
startCapture: vi.fn().mockResolvedValue(undefined),
stopCapture: vi.fn().mockResolvedValue(undefined),
toggleMute: vi.fn().mockReturnValue(true),
isMuted: vi.fn().mockReturnValue(false),
play: vi.fn().mockResolvedValue(0.1),
stop: vi.fn(),
clearQueue: vi.fn(),
isPlaying: vi.fn().mockReturnValue(false),
playLooping: vi.fn(),
stopLooping: vi.fn(),
};
}
function createSessionAdapter(serverId = "server-1"): VoiceSessionAdapter {
return {
serverId,
setVoiceMode: vi.fn().mockResolvedValue(undefined),
sendVoiceAudioChunk: vi.fn().mockResolvedValue(undefined),
abortRequest: vi.fn().mockResolvedValue(undefined),
setAssistantAudioPlaying: vi.fn(),
};
}
function createServerInfo(): DaemonServerInfo {
return {
serverId: "server-1",
hostname: "host",
version: "1.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
};
}
function createRuntime(options?: {
engine?: AudioEngine;
getServerInfo?: (serverId: string) => DaemonServerInfo | null;
}) {
const engine = options?.engine ?? createAudioEngineMock();
const runtime = createVoiceRuntime({
engine,
getServerInfo: options?.getServerInfo ?? (() => createServerInfo()),
activateKeepAwake: vi.fn().mockResolvedValue(undefined),
deactivateKeepAwake: vi.fn().mockResolvedValue(undefined),
});
return { runtime, engine };
}
describe("voice runtime", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts voice when adapter is ready", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
expect(engine.initialize).toHaveBeenCalled();
expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1");
expect(engine.startCapture).toHaveBeenCalled();
expect(runtime.getSnapshot()).toMatchObject({
phase: "listening",
isVoiceMode: true,
activeServerId: "server-1",
activeAgentId: "agent-1",
});
});
it("transitions from capturing to submitting to waiting on the final chunk", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.handleCaptureVolume(0.4);
await vi.advanceTimersByTimeAsync(
REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10
);
runtime.handleCaptureVolume(0.4);
runtime.handleCapturePcm(new Uint8Array(32000));
expect(runtime.getSnapshot().phase).toBe("capturing");
runtime.handleCapturePcm(new Uint8Array(4000));
runtime.handleCaptureVolume(0);
await vi.advanceTimersByTimeAsync(
REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs + 10
);
runtime.handleCaptureVolume(0);
await vi.advanceTimersByTimeAsync(
REALTIME_VOICE_VAD_CONFIG.silenceDurationMs + 10
);
runtime.handleCaptureVolume(0);
await Promise.resolve();
expect(adapter.sendVoiceAudioChunk).toHaveBeenLastCalledWith(
expect.any(String),
"audio/pcm;rate=16000;bits=16",
true
);
expect(runtime.getSnapshot().phase).toBe("waiting");
});
it("moves from waiting to playing on the first assistant audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onAssistantAudioStarted("server-1");
expect(runtime.getSnapshot().phase).toBe("playing");
expect(engine.stopLooping).toHaveBeenCalled();
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true);
});
it("returns to waiting after assistant playback when the turn is still active", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
runtime.onAssistantAudioFinished("server-1");
expect(runtime.getSnapshot().phase).toBe("waiting");
expect(engine.playLooping).toHaveBeenCalled();
});
it("returns to listening after assistant playback once the turn is complete", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_completed");
runtime.onAssistantAudioFinished("server-1");
expect(runtime.getSnapshot().phase).toBe("listening");
expect(engine.playLooping).not.toHaveBeenCalled();
});
it("interrupts the active turn on barge-in after the grace period", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
runtime.handleCaptureVolume(0.5);
await vi.advanceTimersByTimeAsync(
REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10
);
await vi.advanceTimersByTimeAsync(
REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs
);
expect(adapter.abortRequest).toHaveBeenCalled();
expect(engine.stop).toHaveBeenCalled();
expect(runtime.getSnapshot().phase).toBe("capturing");
});
it("authoritatively stops and suppresses later voice audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
await runtime.stopVoice();
expect(adapter.setVoiceMode).toHaveBeenLastCalledWith(false);
expect(engine.stopCapture).toHaveBeenCalled();
expect(runtime.getSnapshot().phase).toBe("disabled");
expect(runtime.shouldPlayVoiceAudio("server-1")).toBe(false);
});
it("returns an explicit not-ready error when the adapter is missing", async () => {
const { runtime } = createRuntime();
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
"Voice runtime is not ready for host server-1"
);
});
it("resyncs voice mode after connection recovers", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
vi.mocked(adapter.setVoiceMode).mockClear();
runtime.updateSessionConnection("server-1", false);
runtime.updateSessionConnection("server-1", true);
await Promise.resolve();
expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1");
});
it("does not emit when the snapshot is unchanged", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
const listener = vi.fn();
const unsubscribe = runtime.subscribe(listener);
runtime.handleCaptureVolume(0);
runtime.handleCaptureVolume(0);
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
});