feat(voice): implement turn-detection and real-time audio streaming
This commit is contained in:
@@ -109,6 +109,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
stackBehavior="replace"
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
|
||||
@@ -676,7 +676,7 @@ function SessionProviderInternal({
|
||||
return;
|
||||
}
|
||||
|
||||
return voiceRuntime.registerSession({
|
||||
return voiceRuntime.registerSession({
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
@@ -684,11 +684,17 @@ function SessionProviderInternal({
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType, isLast) => {
|
||||
sendVoiceAudioChunk: async (audioData, mimeType) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType, isLast);
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType);
|
||||
},
|
||||
audioPlayed: async (chunkId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.audioPlayed(chunkId);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
if (!client) {
|
||||
@@ -1236,6 +1242,11 @@ function SessionProviderInternal({
|
||||
}
|
||||
|
||||
const payload: AudioOutputPayload = message.payload;
|
||||
if (payload.isVoiceMode && voiceRuntime) {
|
||||
voiceRuntime.handleAudioOutput(serverId, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackGroupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
const isFinalChunk = payload.isLastChunk ?? true;
|
||||
@@ -1421,14 +1432,22 @@ function SessionProviderInternal({
|
||||
if (message.type !== "transcription_result") return;
|
||||
|
||||
const transcriptText = message.payload.text.trim();
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
if (!transcriptText) {
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentAssistantMessage(serverId, "");
|
||||
});
|
||||
|
||||
const unsubVoiceInputState = client.on("voice_input_state", (message) => {
|
||||
if (message.type !== "voice_input_state") return;
|
||||
voiceRuntime?.onServerSpeechStateChanged(
|
||||
serverId,
|
||||
message.payload.isSpeaking
|
||||
);
|
||||
});
|
||||
|
||||
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
|
||||
if (message.type !== "agent_deleted") {
|
||||
return;
|
||||
@@ -1537,6 +1556,7 @@ function SessionProviderInternal({
|
||||
unsubActivity();
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubVoiceInputState();
|
||||
unsubAgentDeleted();
|
||||
unsubAgentArchived();
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -163,9 +164,19 @@ export function WorkspaceDesktopTabsRow({
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabs.length - 1;
|
||||
const isOnlyTab = tabs.length <= 1;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab,
|
||||
index,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsToLeft,
|
||||
onCloseTabsAfter: onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
@@ -285,64 +296,21 @@ export function WorkspaceDesktopTabsRow({
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{tab.kind === "agent" ? (
|
||||
<>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-resume-command`}
|
||||
onSelect={() => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
}}
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
Copy resume command
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-agent-id`}
|
||||
onSelect={() => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
}}
|
||||
>
|
||||
Copy agent id
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-left`}
|
||||
disabled={isFirstTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToLeft(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the left
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-right`}
|
||||
disabled={isLastTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToRight(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-others`}
|
||||
disabled={isOnlyTab}
|
||||
onSelect={() => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close other tabs
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close`}
|
||||
onSelect={() => {
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
@@ -143,6 +145,7 @@ function buildOpenIntentKey(input: {
|
||||
}
|
||||
|
||||
type MobileWorkspaceTabSwitcherProps = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
activeTabLabel: string;
|
||||
activeTabPresentation: WorkspaceTabPresentation | null;
|
||||
@@ -151,9 +154,16 @@ type MobileWorkspaceTabSwitcherProps = {
|
||||
tabPresentationsByKey: Map<string, WorkspaceTabPresentation>;
|
||||
onSelectSwitcherTab: (key: string) => void;
|
||||
onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabs,
|
||||
activeTabKey,
|
||||
activeTabLabel,
|
||||
activeTabPresentation,
|
||||
@@ -162,15 +172,29 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabPresentationsByKey,
|
||||
onSelectSwitcherTab,
|
||||
onSelectNewTabOption,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
}: MobileWorkspaceTabSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const tabIndexByKey = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
tabs.forEach((tab, index) => {
|
||||
map.set(tab.key, index);
|
||||
});
|
||||
return map;
|
||||
}, [tabs]);
|
||||
|
||||
return (
|
||||
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
testID="workspace-tab-switcher-trigger"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.switcherTrigger,
|
||||
(hovered || pressed || isOpen) && styles.switcherTriggerActive,
|
||||
@@ -242,12 +266,71 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
const presentation =
|
||||
tabPresentationsByKey.get(option.id) ??
|
||||
deriveWorkspaceTabPresentation({ tab });
|
||||
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries =
|
||||
tabIndex < 0
|
||||
? []
|
||||
: buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab,
|
||||
index: tabIndex,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
return (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
trailingAccessory={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open menu for ${presentation.label}`}
|
||||
hitSlop={8}
|
||||
style={({ open, pressed }) => [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(open || pressed) && styles.mobileTabMenuTriggerActive,
|
||||
]}
|
||||
>
|
||||
<Ellipsis
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -587,6 +670,29 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId, openIntent]
|
||||
);
|
||||
|
||||
const openWorkspaceDraftTab = useCallback(
|
||||
(input?: { draftId?: string; focus?: boolean }) => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
||||
});
|
||||
if (tabId && input?.focus !== false) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
[focusTab, normalizedServerId, normalizedWorkspaceId, openDraftTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOpenIntentKey) {
|
||||
if (resolvedOpenIntentKey !== null) {
|
||||
@@ -619,24 +725,11 @@ function WorkspaceScreenContent({
|
||||
|
||||
if (openIntent.kind === "draft") {
|
||||
const draftId = openIntent.draftId.trim();
|
||||
const tabId =
|
||||
draftId === "new"
|
||||
? openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: generateDraftId(),
|
||||
})
|
||||
: openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
const tabId = openWorkspaceDraftTab({
|
||||
...(draftId === "new" ? {} : { draftId }),
|
||||
focus: true,
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
@@ -653,18 +746,12 @@ function WorkspaceScreenContent({
|
||||
: { kind: "file", path: openIntent.path },
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusTab,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
openOrFocusTab,
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
@@ -821,6 +908,10 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (openIntent) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (workspaceAgents.length > 0 || terminals.length > 0) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
@@ -834,20 +925,12 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
emptyWorkspaceSeedRef.current = workspaceKey;
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
openWorkspaceDraftTab();
|
||||
}, [
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
persistenceKey,
|
||||
terminals.length,
|
||||
tabs.length,
|
||||
@@ -924,19 +1007,8 @@ function WorkspaceScreenContent({
|
||||
}, [activeTabPresentation]);
|
||||
|
||||
const handleCreateDraftTab = useCallback(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
}, [navigateToTabId, normalizedServerId, normalizedWorkspaceId, openDraftTab]);
|
||||
openWorkspaceDraftTab();
|
||||
}, [openWorkspaceDraftTab]);
|
||||
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
if (createTerminalMutation.isPending) {
|
||||
@@ -1375,29 +1447,23 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
if (target.kind === "draft") {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
key={`${normalizedServerId}:${normalizedWorkspaceId}:${tabId}`}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
tabId={activeTabId ?? target.draftId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={handleOpenFileFromChat}
|
||||
onCreated={(agentSnapshot) => {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
|
||||
const nextTabId = retargetWorkspaceTab({
|
||||
retargetWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||
});
|
||||
if (nextTabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: nextTabId,
|
||||
});
|
||||
}
|
||||
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentSnapshot.id, normalized);
|
||||
@@ -1635,6 +1701,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTabLabel={activeTabLabel}
|
||||
activeTabPresentation={activeTabPresentation}
|
||||
@@ -1643,6 +1710,12 @@ function WorkspaceScreenContent({
|
||||
tabPresentationsByKey={tabPresentationsByKey}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceDesktopTabsRow
|
||||
@@ -1868,6 +1941,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
mobileTabMenuTrigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tabsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function createAgentTab(): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
agentId: "agent-123",
|
||||
provider: "codex",
|
||||
label: "Agent 123",
|
||||
subtitle: "/repo",
|
||||
titleState: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabMenuEntries", () => {
|
||||
it("uses desktop tab ordering labels for desktop menus", () => {
|
||||
const onCopyResumeCommand = vi.fn();
|
||||
const onCopyAgentId = vi.fn();
|
||||
const onCloseTab = vi.fn();
|
||||
const onCloseTabsBefore = vi.fn();
|
||||
const onCloseTabsAfter = vi.fn();
|
||||
const onCloseOtherTabs = vi.fn();
|
||||
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-context-agent_123",
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses stacked ordering labels for mobile menus", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-menu-agent_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits agent copy actions for non-agent tabs", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: {
|
||||
key: "draft_123",
|
||||
tabId: "draft_123",
|
||||
kind: "draft",
|
||||
draftId: "draft_123",
|
||||
label: "New agent",
|
||||
subtitle: "/repo",
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase: "workspace-tab-menu-draft_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
|
||||
false
|
||||
);
|
||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
export type WorkspaceTabMenuEntry =
|
||||
| {
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
testID: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| {
|
||||
kind: "separator";
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface BuildWorkspaceTabMenuEntriesInput {
|
||||
surface: WorkspaceTabMenuSurface;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
menuTestIDBase: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||
}
|
||||
|
||||
function buildCloseAfterLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs below" : "Close to the right";
|
||||
}
|
||||
|
||||
function buildCloseBeforeTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-above" : "close-left";
|
||||
}
|
||||
|
||||
function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-below" : "close-right";
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabMenuEntries(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
const {
|
||||
surface,
|
||||
tab,
|
||||
index,
|
||||
tabCount,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
} = input;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabCount - 1;
|
||||
const isOnlyTab = tabCount <= 1;
|
||||
const entries: WorkspaceTabMenuEntry[] = [];
|
||||
|
||||
if (tab.kind === "agent") {
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-agent-id",
|
||||
label: "Copy agent id",
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
});
|
||||
}
|
||||
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-before",
|
||||
label: buildCloseBeforeLabel(surface),
|
||||
disabled: isFirstTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsBefore(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-after",
|
||||
label: buildCloseAfterLabel(surface),
|
||||
disabled: isLastTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsAfter(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-others",
|
||||
label: "Close other tabs",
|
||||
disabled: isOnlyTab,
|
||||
testID: `${menuTestIDBase}-close-others`,
|
||||
onSelect: () => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
label: "Close",
|
||||
testID: `${menuTestIDBase}-close`,
|
||||
onSelect: () => {
|
||||
void onCloseTab(tab.tabId);
|
||||
},
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||
expect(model.tabs[0]?.descriptor.label).toBe("New Agent");
|
||||
});
|
||||
|
||||
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||
|
||||
@@ -169,8 +169,8 @@ export function deriveWorkspaceTabModel(input: {
|
||||
tabId: tab.tabId,
|
||||
kind: "draft",
|
||||
draftId: tab.target.draftId,
|
||||
label: "Draft",
|
||||
subtitle: "Draft",
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -124,6 +124,7 @@ type WorkspaceTabOptionRowProps = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
trailingAccessory?: ReactNode;
|
||||
};
|
||||
|
||||
export function WorkspaceTabOptionRow({
|
||||
@@ -131,30 +132,36 @@ export function WorkspaceTabOptionRow({
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionRow,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.optionRow, active && styles.optionRowActive]}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionMainPressable,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{trailingAccessory ? (
|
||||
<View style={styles.optionTrailingAccessorySlot}>{trailingAccessory}</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,12 +185,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: 0,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
optionMainPressable: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
optionRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
@@ -204,6 +220,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
optionTrailingAccessorySlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -137,6 +137,35 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
const focusedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
});
|
||||
|
||||
useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
|
||||
focusedFileTabId
|
||||
);
|
||||
});
|
||||
|
||||
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import {
|
||||
addExpoTwoWayAudioEventListener,
|
||||
getMicrophonePermissionsAsync,
|
||||
initialize,
|
||||
playPCMData,
|
||||
requestMicrophonePermissionsAsync,
|
||||
resumePlayback,
|
||||
stopPlayback,
|
||||
tearDown,
|
||||
toggleRecording,
|
||||
} from "@boudra/expo-two-way-audio";
|
||||
import { Audio } from "expo-av";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import {
|
||||
THINKING_TONE_REPEAT_GAP_MS,
|
||||
} from "@/utils/thinking-tone";
|
||||
import {
|
||||
THINKING_TONE_NATIVE_PCM_BASE64,
|
||||
THINKING_TONE_NATIVE_PCM_DURATION_MS,
|
||||
} from "@/utils/thinking-tone.native-pcm";
|
||||
import type {
|
||||
@@ -21,7 +11,9 @@ import type {
|
||||
AudioEngineCallbacks,
|
||||
AudioPlaybackSource,
|
||||
} from "@/voice/audio-engine-types";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// Use expo-av for playback instead of expo-two-way-audio (temporary test)
|
||||
const USE_EXPO_AV_PLAYBACK = true;
|
||||
|
||||
interface QueuedAudio {
|
||||
audio: AudioPlaybackSource;
|
||||
@@ -38,53 +30,6 @@ interface AudioEngineTraceOptions {
|
||||
traceLabel?: string;
|
||||
}
|
||||
|
||||
function resamplePcm16(
|
||||
pcm: Uint8Array,
|
||||
fromRate: number,
|
||||
toRate: number
|
||||
): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const output = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const offset = sampleIndex * 2;
|
||||
if (offset + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[offset]!;
|
||||
const hi = pcm[offset + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value -= 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const offset = sampleIndex * 2;
|
||||
output[offset] = clamped & 0xff;
|
||||
output[offset + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i += 1) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const frac = sourceIndex - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
@@ -117,6 +62,7 @@ export function createAudioEngine(
|
||||
};
|
||||
thinkingTone: CuePcm | null;
|
||||
destroyed: boolean;
|
||||
mockVolumeInterval: ReturnType<typeof setInterval> | null;
|
||||
} = {
|
||||
initialized: false,
|
||||
captureActive: false,
|
||||
@@ -132,41 +78,58 @@ export function createAudioEngine(
|
||||
},
|
||||
thinkingTone: null,
|
||||
destroyed: false,
|
||||
mockVolumeInterval: null,
|
||||
};
|
||||
|
||||
const microphoneSubscription = addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
callbacks.onCaptureData(event.data);
|
||||
}
|
||||
);
|
||||
// Only subscribe to native events if not mocking
|
||||
let microphoneSubscription: { remove(): void };
|
||||
let volumeSubscription: { remove(): void };
|
||||
|
||||
const volumeSubscription = addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
if (MOCK_MODE) {
|
||||
microphoneSubscription = { remove() {} };
|
||||
volumeSubscription = { remove() {} };
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
callbacks.onCaptureData(event.data);
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
}
|
||||
);
|
||||
);
|
||||
volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
return;
|
||||
}
|
||||
await initialize();
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
await native.initialize();
|
||||
}
|
||||
refs.initialized = true;
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission(): Promise<void> {
|
||||
let permission = await getMicrophonePermissionsAsync().catch(() => null);
|
||||
if (MOCK_MODE) {
|
||||
return;
|
||||
}
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||
if (!permission?.granted) {
|
||||
permission = await requestMicrophonePermissionsAsync().catch(() => null);
|
||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||
}
|
||||
if (!permission?.granted) {
|
||||
throw new Error(
|
||||
@@ -179,9 +142,8 @@ export function createAudioEngine(
|
||||
if (refs.thinkingTone) {
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
const pcm16k = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64");
|
||||
const durationMs = THINKING_TONE_NATIVE_PCM_DURATION_MS;
|
||||
refs.thinkingTone = { pcm16k, durationMs };
|
||||
refs.thinkingTone = { pcm16k: new Uint8Array(0), durationMs };
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
|
||||
@@ -194,7 +156,6 @@ export function createAudioEngine(
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
await ensureInitialized();
|
||||
resumePlayback();
|
||||
|
||||
return await new Promise<number>(async (resolve, reject) => {
|
||||
refs.activePlayback = { resolve, reject, settled: false };
|
||||
@@ -203,10 +164,16 @@ export function createAudioEngine(
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const pcm = new Uint8Array(arrayBuffer);
|
||||
const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000;
|
||||
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
|
||||
const durationSec = pcm16k.length / 2 / 16000;
|
||||
const durationSec = pcm.length / 2 / inputRate;
|
||||
|
||||
console.log(`[AudioEngine.native] playPCMData: MOCK_MODE=${MOCK_MODE} inputRate=${inputRate} inputBytes=${pcm.length} durationSec=${durationSec.toFixed(3)}`);
|
||||
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.resumePlayback();
|
||||
native.playPCMData(pcm);
|
||||
}
|
||||
|
||||
playPCMData(pcm16k);
|
||||
clearPlaybackTimeout();
|
||||
refs.playbackTimeout = setTimeout(() => {
|
||||
clearPlaybackTimeout();
|
||||
@@ -248,6 +215,13 @@ export function createAudioEngine(
|
||||
refs.processingQueue = false;
|
||||
}
|
||||
|
||||
function nativeStopPlayback(): void {
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.stopPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function stopLooping(): void {
|
||||
refs.looping.active = false;
|
||||
refs.looping.token += 1;
|
||||
@@ -255,7 +229,7 @@ export function createAudioEngine(
|
||||
clearTimeout(refs.looping.timeout);
|
||||
refs.looping.timeout = null;
|
||||
}
|
||||
stopPlayback();
|
||||
nativeStopPlayback();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -273,14 +247,22 @@ export function createAudioEngine(
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
if (refs.captureActive) {
|
||||
toggleRecording(false);
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
if (refs.mockVolumeInterval) {
|
||||
clearInterval(refs.mockVolumeInterval);
|
||||
refs.mockVolumeInterval = null;
|
||||
}
|
||||
refs.captureActive = false;
|
||||
}
|
||||
clearPlaybackTimeout();
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (refs.initialized) {
|
||||
tearDown();
|
||||
if (refs.initialized && !MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.tearDown();
|
||||
refs.initialized = false;
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
@@ -295,7 +277,22 @@ export function createAudioEngine(
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
toggleRecording(true);
|
||||
|
||||
if (MOCK_MODE) {
|
||||
// Emit fake volume levels and empty PCM data periodically
|
||||
refs.mockVolumeInterval = setInterval(() => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const fakeVolume = refs.muted ? 0 : 0.15 + Math.random() * 0.5;
|
||||
callbacks.onVolumeLevel(fakeVolume);
|
||||
// Send empty PCM chunk to keep the pipeline alive
|
||||
callbacks.onCaptureData(new Uint8Array(3200));
|
||||
}, 100);
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(true);
|
||||
}
|
||||
refs.captureActive = true;
|
||||
} catch (error) {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -306,7 +303,15 @@ export function createAudioEngine(
|
||||
|
||||
async stopCapture() {
|
||||
if (refs.captureActive) {
|
||||
toggleRecording(false);
|
||||
if (MOCK_MODE) {
|
||||
if (refs.mockVolumeInterval) {
|
||||
clearInterval(refs.mockVolumeInterval);
|
||||
refs.mockVolumeInterval = null;
|
||||
}
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
}
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
@@ -335,7 +340,7 @@ export function createAudioEngine(
|
||||
},
|
||||
|
||||
stop() {
|
||||
stopPlayback();
|
||||
nativeStopPlayback();
|
||||
clearPlaybackTimeout();
|
||||
const active = refs.activePlayback;
|
||||
refs.activePlayback = null;
|
||||
@@ -368,20 +373,17 @@ export function createAudioEngine(
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const cue =
|
||||
audio.byteLength > 0
|
||||
? {
|
||||
pcm16k: audio,
|
||||
durationMs: (audio.byteLength / 2 / 16000) * 1000,
|
||||
}
|
||||
: await ensureThinkingTone();
|
||||
const cue = await ensureThinkingTone();
|
||||
|
||||
const loop = () => {
|
||||
if (!refs.looping.active || refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
resumePlayback();
|
||||
playPCMData(cue.pcm16k);
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.resumePlayback();
|
||||
native.playPCMData(cue.pcm16k);
|
||||
}
|
||||
refs.looping.timeout = setTimeout(
|
||||
loop,
|
||||
cue.durationMs + (gapMs || THINKING_TONE_REPEAT_GAP_MS)
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SpeechSegmenter } from "./speech-segmenter";
|
||||
|
||||
const mkPcmBytes = (length: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) bytes[i] = i % 255;
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("SpeechSegmenter", () => {
|
||||
it("streams continuously and flushes at stop", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: true,
|
||||
volumeThreshold: 0.3,
|
||||
silenceDurationMs: 2000,
|
||||
speechConfirmationMs: 300,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 100, // ensure we only flush on stop
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{ onAudioSegment }
|
||||
);
|
||||
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onAudioSegment).not.toHaveBeenCalled();
|
||||
|
||||
segmenter.stop(Date.now());
|
||||
|
||||
expect(onAudioSegment).toHaveBeenCalledTimes(1);
|
||||
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
||||
expect(lastCall.isLast).toBe(true);
|
||||
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects speech, calls onSpeechStart, and flushes on speech end", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const detectingChanges: boolean[] = [];
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.3,
|
||||
silenceDurationMs: 2000,
|
||||
speechConfirmationMs: 300,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 100,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onAudioSegment,
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onDetectingChange: (v) => detectingChanges.push(v),
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 10_000;
|
||||
|
||||
// Start detection.
|
||||
segmenter.pushVolumeLevel(0.5, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(detectingChanges).toEqual([true]);
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
expect(onSpeechStart).not.toHaveBeenCalled();
|
||||
|
||||
// Confirm speech.
|
||||
segmenter.pushVolumeLevel(0.5, t0 + 300);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
expect(detectingChanges).toEqual([true, false]);
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
|
||||
// End speech after silence.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000 + 2000);
|
||||
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
expect(detectingChanges).toEqual([true, false, true, false]);
|
||||
expect(speakingChanges).toEqual([true, false]);
|
||||
expect(onAudioSegment).toHaveBeenCalled();
|
||||
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
||||
expect(lastCall.isLast).toBe(true);
|
||||
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps detection alive across a brief pause and confirms short utterances", () => {
|
||||
const onSpeechStart = vi.fn();
|
||||
const detectingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.18,
|
||||
silenceDurationMs: 1400,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 700,
|
||||
minChunkDurationMs: 100,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeechStart,
|
||||
onDetectingChange: (v) => detectingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 20_000;
|
||||
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 80);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 140);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
|
||||
expect(detectingChanges).toContain(true);
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps utterance open after fade-out and resumes speaking during silence grace", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.2,
|
||||
silenceDurationMs: 1500,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 400,
|
||||
minChunkDurationMs: 1000, // keep everything buffered until final flush
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onAudioSegment,
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 30_000;
|
||||
|
||||
// Start + confirm speaking.
|
||||
segmenter.pushVolumeLevel(0.6, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.6, t0 + 130);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Drop below threshold: meter should fade, but turn remains open.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 200);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 500);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
|
||||
// Speak again before silence timeout expires: continue same utterance.
|
||||
segmenter.pushVolumeLevel(0.6, t0 + 900);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
expect(onSpeechEnd).not.toHaveBeenCalled();
|
||||
|
||||
// End for real.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 1000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 2600);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 4200);
|
||||
|
||||
expect(speakingChanges).toEqual([true, false, true, false]);
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
const finalCalls = onAudioSegment.mock.calls.filter((call) => call[0].isLast);
|
||||
expect(finalCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps confirmed speech active above release threshold even when below start threshold", () => {
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.3,
|
||||
volumeReleaseThreshold: 0.1,
|
||||
silenceDurationMs: 1200,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 1000,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 40_000;
|
||||
|
||||
// Confirm speech.
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 130);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Stay between start and release thresholds for longer than silence timeout:
|
||||
// should still be considered speaking.
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 400);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 1000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 2000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
|
||||
expect(onSpeechEnd).not.toHaveBeenCalled();
|
||||
expect(speakingChanges.at(-1)).toBe(true);
|
||||
|
||||
// Now actually drop below release threshold and allow silence timeout.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 2001);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 3405);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 4706);
|
||||
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
expect(speakingChanges.at(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores brief confirmed dips below release threshold without flip-flopping speaking", () => {
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.2,
|
||||
volumeReleaseThreshold: 0.08,
|
||||
confirmedDropGracePeriodMs: 250,
|
||||
silenceDurationMs: 1500,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 1000,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 50_000;
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 130); // confirm speaking
|
||||
|
||||
// Two short dips below release threshold; both shorter than debounce.
|
||||
segmenter.pushVolumeLevel(0.05, t0 + 300);
|
||||
segmenter.pushVolumeLevel(0.09, t0 + 500);
|
||||
segmenter.pushVolumeLevel(0.05, t0 + 620);
|
||||
segmenter.pushVolumeLevel(0.1, t0 + 760);
|
||||
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export type SpeechSegment = { audioData: string; isLast: boolean };
|
||||
|
||||
export type SpeechSegmenterConfig = {
|
||||
enableContinuousStreaming: boolean;
|
||||
volumeThreshold: number; // 0..1
|
||||
volumeReleaseThreshold?: number; // 0..1; lower threshold used to keep active speech from dropping out
|
||||
confirmedDropGracePeriodMs?: number; // ms dip debounce before leaving confirmed speaking
|
||||
silenceDurationMs: number;
|
||||
speechConfirmationMs: number;
|
||||
detectionGracePeriodMs: number;
|
||||
minChunkDurationMs?: number;
|
||||
pcmSampleRate?: number;
|
||||
pcmChannels?: number;
|
||||
pcmBitsPerSample?: number;
|
||||
};
|
||||
|
||||
export type SpeechSegmenterCallbacks = {
|
||||
onAudioSegment?: (segment: SpeechSegment) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onDetectingChange?: (isDetecting: boolean) => void;
|
||||
onSpeakingChange?: (isSpeaking: boolean) => void;
|
||||
};
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
// NOTE: This is performance-sensitive during continuous streaming.
|
||||
// Buffer-backed base64 is significantly faster than manual string building.
|
||||
try {
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
||||
} catch {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SpeechSegmenter {
|
||||
private config: Required<Omit<SpeechSegmenterConfig, "minChunkDurationMs" | "pcmSampleRate" | "pcmChannels" | "pcmBitsPerSample">> &
|
||||
Required<Pick<SpeechSegmenterConfig, "minChunkDurationMs" | "pcmSampleRate" | "pcmChannels" | "pcmBitsPerSample">>;
|
||||
private callbacks: SpeechSegmenterCallbacks;
|
||||
|
||||
private audioBuffer: Uint8Array[] = [];
|
||||
private bufferedBytes = 0;
|
||||
|
||||
private speechDetectionStartMs: number | null = null;
|
||||
private detectionSilenceStartMs: number | null = null;
|
||||
private confirmedDropStartMs: number | null = null;
|
||||
private silenceStartMs: number | null = null;
|
||||
private isSpeaking = false;
|
||||
private isDetecting = false;
|
||||
private speechConfirmed = false;
|
||||
|
||||
private pcmBytesPerMs: number;
|
||||
private minChunkBytes: number;
|
||||
|
||||
constructor(config: SpeechSegmenterConfig, callbacks: SpeechSegmenterCallbacks = {}) {
|
||||
const volumeReleaseThreshold = this.resolveVolumeReleaseThreshold(
|
||||
config.volumeThreshold,
|
||||
config.volumeReleaseThreshold
|
||||
);
|
||||
this.config = {
|
||||
...config,
|
||||
volumeReleaseThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriodMs ?? 250,
|
||||
minChunkDurationMs: config.minChunkDurationMs ?? 1000,
|
||||
pcmSampleRate: config.pcmSampleRate ?? 16000,
|
||||
pcmChannels: config.pcmChannels ?? 1,
|
||||
pcmBitsPerSample: config.pcmBitsPerSample ?? 16,
|
||||
};
|
||||
this.callbacks = callbacks;
|
||||
this.pcmBytesPerMs = 0;
|
||||
this.minChunkBytes = 0;
|
||||
this.recomputeDerived();
|
||||
}
|
||||
|
||||
updateConfig(next: SpeechSegmenterConfig): void {
|
||||
const volumeReleaseThreshold = this.resolveVolumeReleaseThreshold(
|
||||
next.volumeThreshold,
|
||||
next.volumeReleaseThreshold
|
||||
);
|
||||
this.config = {
|
||||
...next,
|
||||
volumeReleaseThreshold,
|
||||
confirmedDropGracePeriodMs: next.confirmedDropGracePeriodMs ?? this.config.confirmedDropGracePeriodMs,
|
||||
minChunkDurationMs: next.minChunkDurationMs ?? this.config.minChunkDurationMs,
|
||||
pcmSampleRate: next.pcmSampleRate ?? this.config.pcmSampleRate,
|
||||
pcmChannels: next.pcmChannels ?? this.config.pcmChannels,
|
||||
pcmBitsPerSample: next.pcmBitsPerSample ?? this.config.pcmBitsPerSample,
|
||||
};
|
||||
this.recomputeDerived();
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: SpeechSegmenterCallbacks): void {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
getSpeechDetectionStartMs(): number | null {
|
||||
return this.speechDetectionStartMs;
|
||||
}
|
||||
|
||||
getIsSpeaking(): boolean {
|
||||
return this.isSpeaking;
|
||||
}
|
||||
|
||||
getIsDetecting(): boolean {
|
||||
return this.isDetecting;
|
||||
}
|
||||
|
||||
getVolumeReleaseThreshold(): number {
|
||||
return this.config.volumeReleaseThreshold;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.isSpeaking = false;
|
||||
this.isDetecting = false;
|
||||
this.speechConfirmed = false;
|
||||
}
|
||||
|
||||
flush(isLast: boolean): void {
|
||||
if (this.audioBuffer.length === 0) {
|
||||
this.bufferedBytes = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBinary = concatenateUint8Arrays(this.audioBuffer);
|
||||
const pcmBase64 = uint8ArrayToBase64(combinedBinary);
|
||||
this.callbacks.onAudioSegment?.({ audioData: pcmBase64, isLast });
|
||||
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the input stream emits a PCM16 chunk (Uint8Array of bytes).
|
||||
* In non-continuous mode, chunk buffering is gated by the VAD state.
|
||||
*/
|
||||
pushPcmChunk(chunk: Uint8Array): void {
|
||||
if (chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
this.audioBuffer.push(chunk);
|
||||
this.bufferedBytes += chunk.length;
|
||||
if (this.bufferedBytes >= this.minChunkBytes) {
|
||||
this.flush(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer if we're detecting or speaking. When speech is confirmed, we also
|
||||
// stream partial chunks to reduce latency.
|
||||
if (this.speechDetectionStartMs !== null || this.isSpeaking) {
|
||||
this.audioBuffer.push(chunk);
|
||||
this.bufferedBytes += chunk.length;
|
||||
|
||||
if (this.speechConfirmed && this.bufferedBytes >= this.minChunkBytes) {
|
||||
this.flush(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called with a normalized volume level (0..1). Drives VAD transitions.
|
||||
*/
|
||||
pushVolumeLevel(volume: number, nowMs: number): void {
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speechDetectedForStart = volume > this.config.volumeThreshold;
|
||||
const speechDetectedForContinue = volume > this.config.volumeReleaseThreshold;
|
||||
|
||||
if (!this.speechConfirmed) {
|
||||
if (speechDetectedForStart) {
|
||||
// Initial detection phase.
|
||||
this.confirmedDropStartMs = null;
|
||||
if (this.speechDetectionStartMs === null) {
|
||||
this.speechDetectionStartMs = nowMs;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.setDetecting(true);
|
||||
this.setSpeaking(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Volume is back above threshold - reset grace period.
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.setSpeaking(true);
|
||||
|
||||
const speechDuration = nowMs - this.speechDetectionStartMs;
|
||||
if (speechDuration >= this.config.speechConfirmationMs) {
|
||||
this.speechConfirmed = true;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.callbacks.onSpeechStart?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.speechDetectionStartMs !== null) {
|
||||
// Volume dropped during detection phase - apply grace period.
|
||||
if (this.detectionSilenceStartMs === null) {
|
||||
this.detectionSilenceStartMs = nowMs;
|
||||
this.setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const graceDuration = nowMs - this.detectionSilenceStartMs;
|
||||
if (graceDuration >= this.config.detectionGracePeriodMs) {
|
||||
// Cancel detection.
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (speechDetectedForContinue) {
|
||||
// Continuing confirmed speech (or resuming during silence grace).
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.confirmedDropStartMs === null) {
|
||||
this.confirmedDropStartMs = nowMs;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedDropDuration = nowMs - this.confirmedDropStartMs;
|
||||
if (confirmedDropDuration < this.config.confirmedDropGracePeriodMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Potential speech END while utterance remains open.
|
||||
if (this.silenceStartMs === null) {
|
||||
this.silenceStartMs = nowMs;
|
||||
this.setDetecting(true);
|
||||
this.setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const silenceDuration = nowMs - this.silenceStartMs;
|
||||
if (silenceDuration >= this.config.silenceDurationMs) {
|
||||
// Speech END confirmed.
|
||||
this.speechConfirmed = false;
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
this.callbacks.onSpeechEnd?.();
|
||||
if (this.audioBuffer.length === 0) {
|
||||
// Important: downstream expects an explicit segment boundary marker.
|
||||
this.callbacks.onAudioSegment?.({ audioData: "", isLast: true });
|
||||
} else {
|
||||
this.flush(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(nowMs: number): void {
|
||||
void nowMs;
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
this.flush(true);
|
||||
}
|
||||
// Ensure observers see state clear transitions.
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private setDetecting(next: boolean): void {
|
||||
if (this.isDetecting === next) {
|
||||
return;
|
||||
}
|
||||
this.isDetecting = next;
|
||||
this.callbacks.onDetectingChange?.(next);
|
||||
}
|
||||
|
||||
private setSpeaking(next: boolean): void {
|
||||
if (this.isSpeaking === next) {
|
||||
return;
|
||||
}
|
||||
this.isSpeaking = next;
|
||||
this.callbacks.onSpeakingChange?.(next);
|
||||
}
|
||||
|
||||
private recomputeDerived(): void {
|
||||
this.pcmBytesPerMs =
|
||||
(this.config.pcmSampleRate *
|
||||
this.config.pcmChannels *
|
||||
(this.config.pcmBitsPerSample / 8)) /
|
||||
1000;
|
||||
this.minChunkBytes = Math.round(this.pcmBytesPerMs * this.config.minChunkDurationMs);
|
||||
}
|
||||
|
||||
private resolveVolumeReleaseThreshold(
|
||||
volumeThreshold: number,
|
||||
configured?: number
|
||||
): number {
|
||||
const defaultReleaseThreshold = volumeThreshold * 0.4;
|
||||
const raw = configured ?? defaultReleaseThreshold;
|
||||
return Math.max(0, Math.min(raw, volumeThreshold));
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,30 @@ function createSessionAdapter(serverId = "server-1"): VoiceSessionAdapter {
|
||||
serverId,
|
||||
setVoiceMode: vi.fn().mockResolvedValue(undefined),
|
||||
sendVoiceAudioChunk: vi.fn().mockResolvedValue(undefined),
|
||||
audioPlayed: vi.fn().mockResolvedValue(undefined),
|
||||
abortRequest: vi.fn().mockResolvedValue(undefined),
|
||||
setAssistantAudioPlaying: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAudioPayload(args: {
|
||||
id: string;
|
||||
groupId: string;
|
||||
chunkIndex: number;
|
||||
isLastChunk: boolean;
|
||||
isVoiceMode?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: args.id,
|
||||
groupId: args.groupId,
|
||||
chunkIndex: args.chunkIndex,
|
||||
isLastChunk: args.isLastChunk,
|
||||
isVoiceMode: args.isVoiceMode ?? true,
|
||||
format: "pcm",
|
||||
audio: Buffer.from(`chunk-${args.chunkIndex}`).toString("base64"),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createServerInfo(): DaemonServerInfo {
|
||||
return {
|
||||
serverId: "server-1",
|
||||
@@ -91,37 +110,21 @@ describe("voice runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions from capturing to submitting to waiting on the final chunk", async () => {
|
||||
it("streams continuous PCM chunks and waits after receiving a transcript", 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(runtime.getSnapshot().phase).toBe("listening");
|
||||
|
||||
expect(adapter.sendVoiceAudioChunk).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
true
|
||||
"audio/pcm;rate=16000;bits=16"
|
||||
);
|
||||
|
||||
runtime.onTranscriptionResult("server-1", "hello");
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
});
|
||||
|
||||
@@ -138,6 +141,110 @@ describe("voice runtime", () => {
|
||||
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("starts playback on the first chunk before isLastChunk arrives", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
let resolvePlay!: (duration: number) => void;
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.play).mockImplementation(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolvePlay = resolve;
|
||||
})
|
||||
);
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-0",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: false,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getSnapshot().phase).toBe("playing");
|
||||
expect(adapter.audioPlayed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-1",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 1,
|
||||
isLastChunk: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
resolvePlay(0.1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
|
||||
expect(engine.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("acknowledges chunks only after they are consumed and finishes after the final chunk", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const playResolvers: Array<(duration: number) => void> = [];
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.play).mockImplementation(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
playResolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-0",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: false,
|
||||
})
|
||||
);
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-1",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 1,
|
||||
isLastChunk: true,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(adapter.audioPlayed).not.toHaveBeenCalled();
|
||||
|
||||
playResolvers.shift()!(0.1);
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
|
||||
expect(engine.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
playResolvers.shift()!(0.1);
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-1");
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to waiting after assistant playback when the turn is still active", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
@@ -152,6 +259,18 @@ describe("voice runtime", () => {
|
||||
expect(engine.playLooping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the thinking tone when an agent turn begins before playback", 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");
|
||||
|
||||
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();
|
||||
@@ -164,10 +283,11 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("listening");
|
||||
expect(engine.playLooping).not.toHaveBeenCalled();
|
||||
expect(engine.playLooping).toHaveBeenCalledTimes(1);
|
||||
expect(engine.stopLooping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts the active turn on barge-in after the grace period", async () => {
|
||||
it("keeps local volume alone non-authoritative for playback interruption", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
@@ -177,16 +297,46 @@ describe("voice runtime", () => {
|
||||
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(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
|
||||
expect(adapter.abortRequest).not.toHaveBeenCalled();
|
||||
expect(engine.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the meter white state driven by server speech detection", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.handleCaptureVolume(0.7);
|
||||
|
||||
expect(runtime.getTelemetrySnapshot()).toMatchObject({
|
||||
volume: expect.any(Number),
|
||||
isDetecting: true,
|
||||
isSpeaking: false,
|
||||
});
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", true);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", false);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
|
||||
});
|
||||
|
||||
it("stops assistant playback immediately when server speech starts", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", true);
|
||||
|
||||
expect(adapter.abortRequest).toHaveBeenCalled();
|
||||
expect(engine.stop).toHaveBeenCalled();
|
||||
expect(runtime.getSnapshot().phase).toBe("capturing");
|
||||
expect(engine.clearQueue).toHaveBeenCalled();
|
||||
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(false);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
|
||||
});
|
||||
|
||||
it("authoritatively stops and suppresses later voice audio", async () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import { Buffer } from "buffer";
|
||||
import type {
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import type { DaemonServerInfo } from "@/stores/session-store";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
||||
|
||||
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
|
||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||
@@ -22,7 +25,6 @@ export type VoiceRuntimePhase =
|
||||
| "disabled"
|
||||
| "starting"
|
||||
| "listening"
|
||||
| "capturing"
|
||||
| "submitting"
|
||||
| "waiting"
|
||||
| "playing"
|
||||
@@ -47,11 +49,8 @@ export interface VoiceRuntimeTelemetrySnapshot {
|
||||
export interface VoiceSessionAdapter {
|
||||
serverId: string;
|
||||
setVoiceMode(enabled: boolean, agentId?: string): Promise<void>;
|
||||
sendVoiceAudioChunk(
|
||||
audioData: string,
|
||||
mimeType: string,
|
||||
isLast: boolean
|
||||
): Promise<void>;
|
||||
sendVoiceAudioChunk(audioData: string, mimeType: string): Promise<void>;
|
||||
audioPlayed(chunkId: string): Promise<void>;
|
||||
abortRequest(): Promise<void>;
|
||||
setAssistantAudioPlaying(isPlaying: boolean): void;
|
||||
}
|
||||
@@ -68,16 +67,51 @@ interface RuntimeSessionState {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
interface ContinuousVoiceUploader {
|
||||
reset(): void;
|
||||
pushPcmChunk(chunk: Uint8Array): void;
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
snapshot: VoiceRuntimeSnapshot;
|
||||
telemetry: VoiceRuntimeTelemetrySnapshot;
|
||||
turnInProgress: boolean;
|
||||
serverSpeechDetected: boolean;
|
||||
transportReady: boolean;
|
||||
generation: number;
|
||||
speechInterruptTimer: ReturnType<typeof setTimeout> | null;
|
||||
speechInterruptSent: boolean;
|
||||
segmentDurationTimer: ReturnType<typeof setInterval> | null;
|
||||
lastDisplayVolumePublishMs: number;
|
||||
localSpeechStartedAt: number | null;
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "audio_output" }
|
||||
>["payload"];
|
||||
|
||||
type StreamingPlaybackChunk = {
|
||||
id: string;
|
||||
chunkIndex: number;
|
||||
source: { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string };
|
||||
};
|
||||
|
||||
type StreamingPlaybackGroup = {
|
||||
groupId: string;
|
||||
isVoiceMode: boolean;
|
||||
shouldPlay: boolean;
|
||||
chunks: Map<number, StreamingPlaybackChunk>;
|
||||
nextChunkToPlay: number;
|
||||
finalChunkIndex: number | null;
|
||||
started: boolean;
|
||||
ackedChunkIds: Set<string>;
|
||||
};
|
||||
|
||||
interface RuntimePlaybackState {
|
||||
groups: Map<string, StreamingPlaybackGroup>;
|
||||
orderedGroupIds: string[];
|
||||
activeGroupId: string | null;
|
||||
processing: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
@@ -131,6 +165,7 @@ export interface VoiceRuntime {
|
||||
updateSessionConnection(serverId: string, connected: boolean): void;
|
||||
handleCapturePcm(chunk: Uint8Array): void;
|
||||
handleCaptureVolume(level: number): void;
|
||||
handleAudioOutput(serverId: string, payload: AudioOutputPayload): void;
|
||||
startVoice(serverId: string, agentId: string): Promise<void>;
|
||||
stopVoice(): Promise<void>;
|
||||
destroy(): Promise<void>;
|
||||
@@ -140,6 +175,7 @@ export interface VoiceRuntime {
|
||||
onAssistantAudioStarted(serverId: string): void;
|
||||
onAssistantAudioFinished(serverId: string): void;
|
||||
onTranscriptionResult(serverId: string, text: string): void;
|
||||
onServerSpeechStateChanged(serverId: string, isSpeaking: boolean): void;
|
||||
onTurnEvent(serverId: string, agentId: string, eventType: TurnEventType): void;
|
||||
}
|
||||
|
||||
@@ -151,12 +187,19 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
snapshot: INITIAL_SNAPSHOT,
|
||||
telemetry: INITIAL_TELEMETRY,
|
||||
turnInProgress: false,
|
||||
serverSpeechDetected: false,
|
||||
transportReady: false,
|
||||
generation: 0,
|
||||
speechInterruptTimer: null,
|
||||
speechInterruptSent: false,
|
||||
segmentDurationTimer: null,
|
||||
lastDisplayVolumePublishMs: 0,
|
||||
localSpeechStartedAt: null,
|
||||
};
|
||||
const playback: RuntimePlaybackState = {
|
||||
groups: new Map(),
|
||||
orderedGroupIds: [],
|
||||
activeGroupId: null,
|
||||
processing: false,
|
||||
generation: 0,
|
||||
};
|
||||
|
||||
function emit(): void {
|
||||
@@ -213,10 +256,151 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
return sessions.get(state.snapshot.activeServerId) ?? null;
|
||||
}
|
||||
|
||||
function clearSpeechInterruptTimer(): void {
|
||||
if (state.speechInterruptTimer) {
|
||||
clearTimeout(state.speechInterruptTimer);
|
||||
state.speechInterruptTimer = null;
|
||||
function decodeAudioChunk(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
function toPlaybackSource(
|
||||
bytes: Uint8Array,
|
||||
format: string
|
||||
): { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string } {
|
||||
const mimeType =
|
||||
format === "pcm"
|
||||
? "audio/pcm;rate=24000;bits=16"
|
||||
: format === "mp3"
|
||||
? "audio/mpeg"
|
||||
: `audio/${format}`;
|
||||
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
type: mimeType,
|
||||
async arrayBuffer() {
|
||||
return Uint8Array.from(bytes).buffer;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetPlaybackState(): void {
|
||||
const hadGroups = playback.groups.size;
|
||||
playback.generation += 1;
|
||||
playback.groups.clear();
|
||||
playback.orderedGroupIds = [];
|
||||
playback.activeGroupId = null;
|
||||
playback.processing = false;
|
||||
if (hadGroups > 0) {
|
||||
console.log(`[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`);
|
||||
}
|
||||
}
|
||||
|
||||
function activateNextPlaybackGroup(): void {
|
||||
while (playback.orderedGroupIds.length > 0) {
|
||||
const groupId = playback.orderedGroupIds[0]!;
|
||||
if (playback.groups.has(groupId)) {
|
||||
playback.activeGroupId = groupId;
|
||||
return;
|
||||
}
|
||||
playback.orderedGroupIds.shift();
|
||||
}
|
||||
playback.activeGroupId = null;
|
||||
}
|
||||
|
||||
async function acknowledgeChunk(chunkId: string): Promise<void> {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
await activeSession.adapter.audioPlayed(chunkId);
|
||||
}
|
||||
|
||||
async function processPlaybackQueue(serverId: string): Promise<void> {
|
||||
if (playback.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
playback.processing = true;
|
||||
const generation = playback.generation;
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`);
|
||||
try {
|
||||
while (playback.activeGroupId) {
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = playback.groups.get(playback.activeGroupId);
|
||||
if (!group) {
|
||||
activateNextPlaybackGroup();
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChunk = group.chunks.get(group.nextChunkToPlay);
|
||||
if (!nextChunk) {
|
||||
if (
|
||||
group.finalChunkIndex !== null &&
|
||||
group.nextChunkToPlay > group.finalChunkIndex
|
||||
) {
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`);
|
||||
playback.groups.delete(group.groupId);
|
||||
if (playback.orderedGroupIds[0] === group.groupId) {
|
||||
playback.orderedGroupIds.shift();
|
||||
} else {
|
||||
playback.orderedGroupIds = playback.orderedGroupIds.filter(
|
||||
(value) => value !== group.groupId
|
||||
);
|
||||
}
|
||||
if (group.started && group.isVoiceMode) {
|
||||
api.onAssistantAudioFinished(serverId);
|
||||
}
|
||||
activateNextPlaybackGroup();
|
||||
continue;
|
||||
}
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`);
|
||||
return;
|
||||
}
|
||||
|
||||
group.chunks.delete(group.nextChunkToPlay);
|
||||
|
||||
if (group.shouldPlay && !group.started && group.isVoiceMode) {
|
||||
group.started = true;
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`);
|
||||
api.onAssistantAudioStarted(serverId);
|
||||
}
|
||||
|
||||
const playStart = Date.now();
|
||||
try {
|
||||
if (group.shouldPlay) {
|
||||
await deps.engine.play(nextChunk.source);
|
||||
console.log(`[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`);
|
||||
} else {
|
||||
console.log(`[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] play error + generation changed, aborting`);
|
||||
return;
|
||||
}
|
||||
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`, error);
|
||||
}
|
||||
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!group.ackedChunkIds.has(nextChunk.id)) {
|
||||
group.ackedChunkIds.add(nextChunk.id);
|
||||
void acknowledgeChunk(nextChunk.id).catch((error) => {
|
||||
console.warn("[VoiceRuntime] Failed to confirm audio playback:", error);
|
||||
});
|
||||
}
|
||||
|
||||
group.nextChunkToPlay += 1;
|
||||
}
|
||||
} finally {
|
||||
if (generation === playback.generation) {
|
||||
playback.processing = false;
|
||||
}
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,9 +411,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileSegmentDurationTimer(
|
||||
segmenter: SpeechSegmenter
|
||||
): void {
|
||||
function reconcileSegmentDurationTimer(): void {
|
||||
if (!state.telemetry.isDetecting && !state.telemetry.isSpeaking) {
|
||||
clearSegmentDurationTimer();
|
||||
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
||||
@@ -241,7 +423,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
state.segmentDurationTimer = setInterval(() => {
|
||||
const startedAt = segmenter.getSpeechDetectionStartMs();
|
||||
const startedAt = state.localSpeechStartedAt;
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
segmentDuration: startedAt ? Date.now() - startedAt : 0,
|
||||
@@ -262,10 +444,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
deps.engine.stopLooping();
|
||||
}
|
||||
|
||||
function resetSegmenter(segmenter: SpeechSegmenter): void {
|
||||
segmenter.reset();
|
||||
clearSpeechInterruptTimer();
|
||||
function resetCaptureTelemetry(): void {
|
||||
clearSegmentDurationTimer();
|
||||
state.localSpeechStartedAt = null;
|
||||
patchTelemetry({ ...INITIAL_TELEMETRY });
|
||||
}
|
||||
|
||||
@@ -277,134 +458,34 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
deps.engine.playLooping(new Uint8Array(0), THINKING_TONE_REPEAT_GAP_MS);
|
||||
}
|
||||
|
||||
async function interruptActiveTurn(): Promise<void> {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
const uploader: ContinuousVoiceUploader = {
|
||||
reset() {},
|
||||
pushPcmChunk(chunk) {
|
||||
const activeSession = getActiveSession();
|
||||
if (
|
||||
!activeSession ||
|
||||
!state.transportReady ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
chunk.byteLength === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopCue();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
activeSession.adapter.setAssistantAudioPlaying(false);
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "capturing" }));
|
||||
|
||||
try {
|
||||
await activeSession.adapter.abortRequest();
|
||||
} catch (error) {
|
||||
console.error("[VoiceRuntime] Failed to abort active turn:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold,
|
||||
confirmedDropGracePeriodMs:
|
||||
REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs,
|
||||
silenceDurationMs: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs,
|
||||
speechConfirmationMs: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs,
|
||||
detectionGracePeriodMs: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs,
|
||||
void activeSession.adapter
|
||||
.sendVoiceAudioChunk(Buffer.from(chunk).toString("base64"), PCM_MIME_TYPE)
|
||||
.catch((error) => {
|
||||
console.error("[VoiceRuntime] Failed to send audio chunk:", error);
|
||||
});
|
||||
},
|
||||
{
|
||||
onAudioSegment: ({ audioData, isLast }) => {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession || !state.transportReady || !state.snapshot.isVoiceMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = state.generation;
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
phase: isLast ? "submitting" : "capturing",
|
||||
}));
|
||||
if (isLast) {
|
||||
state.turnInProgress = true;
|
||||
}
|
||||
|
||||
void activeSession.adapter
|
||||
.sendVoiceAudioChunk(audioData, PCM_MIME_TYPE, isLast)
|
||||
.then(() => {
|
||||
if (
|
||||
!isLast ||
|
||||
generation !== state.generation ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
state.snapshot.activeServerId !== activeSession.adapter.serverId ||
|
||||
state.snapshot.phase !== "submitting"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[VoiceRuntime] Failed to send audio segment:", error);
|
||||
});
|
||||
},
|
||||
onSpeechStart: () => {
|
||||
stopCue();
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptSent = false;
|
||||
reconcileCue();
|
||||
},
|
||||
onDetectingChange: (isDetecting) => {
|
||||
const previous = state.telemetry;
|
||||
patchTelemetry((prev) => ({ ...prev, isDetecting }));
|
||||
if (previous.isDetecting !== isDetecting) {
|
||||
}
|
||||
if (!previous.isDetecting && isDetecting) {
|
||||
stopCue();
|
||||
}
|
||||
reconcileSegmentDurationTimer(segmenter);
|
||||
reconcileCue();
|
||||
},
|
||||
onSpeakingChange: (isSpeaking) => {
|
||||
const previous = state.telemetry;
|
||||
patchTelemetry((prev) => ({ ...prev, isSpeaking }));
|
||||
if (previous.isSpeaking !== isSpeaking) {
|
||||
}
|
||||
|
||||
if (!previous.isSpeaking && isSpeaking) {
|
||||
stopCue();
|
||||
if (
|
||||
state.snapshot.phase === "waiting" ||
|
||||
state.snapshot.phase === "playing"
|
||||
) {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptTimer = setTimeout(() => {
|
||||
state.speechInterruptTimer = null;
|
||||
if (
|
||||
state.speechInterruptSent ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
!state.telemetry.isSpeaking
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.speechInterruptSent = true;
|
||||
void interruptActiveTurn();
|
||||
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSpeaking) {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptSent = false;
|
||||
}
|
||||
|
||||
reconcileSegmentDurationTimer(segmenter);
|
||||
reconcileCue();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function resetToDisabledState(): void {
|
||||
state.transportReady = false;
|
||||
state.turnInProgress = false;
|
||||
state.speechInterruptSent = false;
|
||||
state.serverSpeechDetected = false;
|
||||
state.lastDisplayVolumePublishMs = 0;
|
||||
resetSegmenter(segmenter);
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot({ ...INITIAL_SNAPSHOT });
|
||||
}
|
||||
|
||||
@@ -435,6 +516,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
async function performLocalStop(): Promise<void> {
|
||||
stopCue();
|
||||
uploader.reset();
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
await deps.engine.stopCapture().catch(() => undefined);
|
||||
@@ -466,7 +549,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const api: VoiceRuntime = {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -524,7 +607,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||
return;
|
||||
}
|
||||
segmenter.pushPcmChunk(chunk);
|
||||
uploader.pushPcmChunk(chunk);
|
||||
},
|
||||
|
||||
handleCaptureVolume(level) {
|
||||
@@ -532,9 +615,81 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
const displayLevel = state.snapshot.isMuted ? 0 : level;
|
||||
publishDisplayVolume(displayLevel, nowMs);
|
||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||
state.localSpeechStartedAt = null;
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
segmentDuration: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
segmenter.pushVolumeLevel(level, nowMs);
|
||||
|
||||
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
|
||||
if (isActive && state.localSpeechStartedAt === null) {
|
||||
state.localSpeechStartedAt = nowMs;
|
||||
}
|
||||
if (!isActive) {
|
||||
state.localSpeechStartedAt = null;
|
||||
}
|
||||
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isDetecting: isActive,
|
||||
isSpeaking: state.serverSpeechDetected,
|
||||
}));
|
||||
reconcileSegmentDurationTimer();
|
||||
if (!isActive) {
|
||||
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
||||
}
|
||||
reconcileCue();
|
||||
},
|
||||
|
||||
handleAudioOutput(serverId, payload) {
|
||||
if (
|
||||
serverId !== state.snapshot.activeServerId ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
!payload.isVoiceMode
|
||||
) {
|
||||
console.log(`[VoiceRuntime] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
console.log(`[VoiceRuntime] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} size=${payload.audio.length} format=${payload.format}`);
|
||||
|
||||
let group = playback.groups.get(groupId);
|
||||
if (!group) {
|
||||
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
|
||||
console.log(`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isDetecting=${state.telemetry.isDetecting} isSpeaking=${state.telemetry.isSpeaking}`);
|
||||
group = {
|
||||
groupId,
|
||||
isVoiceMode: payload.isVoiceMode,
|
||||
shouldPlay: api.shouldPlayVoiceAudio(serverId),
|
||||
chunks: new Map(),
|
||||
nextChunkToPlay: 0,
|
||||
finalChunkIndex: null,
|
||||
started: false,
|
||||
ackedChunkIds: new Set(),
|
||||
};
|
||||
playback.groups.set(groupId, group);
|
||||
playback.orderedGroupIds.push(groupId);
|
||||
if (!playback.activeGroupId) {
|
||||
playback.activeGroupId = groupId;
|
||||
}
|
||||
}
|
||||
|
||||
group.chunks.set(chunkIndex, {
|
||||
id: payload.id,
|
||||
chunkIndex,
|
||||
source: toPlaybackSource(decodeAudioChunk(payload.audio), payload.format),
|
||||
});
|
||||
if (payload.isLastChunk) {
|
||||
group.finalChunkIndex = chunkIndex;
|
||||
}
|
||||
|
||||
void processPlaybackQueue(serverId);
|
||||
},
|
||||
|
||||
async startVoice(serverId, agentId) {
|
||||
@@ -594,7 +749,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
state.transportReady = true;
|
||||
state.turnInProgress = false;
|
||||
resetSegmenter(segmenter);
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
isVoiceMode: true,
|
||||
@@ -619,8 +775,10 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}));
|
||||
|
||||
try {
|
||||
state.transportReady = false;
|
||||
stopCue();
|
||||
uploader.reset();
|
||||
state.transportReady = false;
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
activeSession?.adapter.setAssistantAudioPlaying(false);
|
||||
@@ -647,7 +805,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
toggleMute() {
|
||||
const nextMuted = deps.engine.toggleMute();
|
||||
if (nextMuted) {
|
||||
resetSegmenter(segmenter);
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
isMuted: true,
|
||||
@@ -672,9 +831,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
state.snapshot.isVoiceMode &&
|
||||
state.snapshot.activeServerId === serverId &&
|
||||
state.snapshot.phase !== "stopping" &&
|
||||
state.snapshot.phase !== "disabled" &&
|
||||
!state.telemetry.isDetecting &&
|
||||
!state.telemetry.isSpeaking
|
||||
state.snapshot.phase !== "disabled"
|
||||
);
|
||||
},
|
||||
|
||||
@@ -701,8 +858,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
if (state.turnInProgress) {
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,6 +874,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
if (text.trim()) {
|
||||
state.turnInProgress = true;
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -727,6 +885,30 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
},
|
||||
|
||||
onServerSpeechStateChanged(serverId, isSpeaking) {
|
||||
if (
|
||||
serverId !== state.snapshot.activeServerId ||
|
||||
!state.snapshot.isVoiceMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[VoiceRuntime] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase}`);
|
||||
state.serverSpeechDetected = isSpeaking;
|
||||
if (isSpeaking) {
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
getActiveSession()?.adapter.setAssistantAudioPlaying(false);
|
||||
}
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isSpeaking,
|
||||
}));
|
||||
reconcileSegmentDurationTimer();
|
||||
reconcileCue();
|
||||
},
|
||||
|
||||
onTurnEvent(serverId, agentId, eventType) {
|
||||
if (
|
||||
!state.snapshot.isVoiceMode ||
|
||||
@@ -738,6 +920,10 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
if (eventType === "turn_started") {
|
||||
state.turnInProgress = true;
|
||||
if (state.snapshot.phase !== "playing") {
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -748,4 +934,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user