```
refactor: improve session management, audio handling, and MCP configuration - Change default session name from 'voice-dev' to '__voice-dev' - Change default server port from 3000 to 6767 - Add conversation history persistence to .conversations directory - Defer stream abort until after transcription completes - Return empty content arrays from MCP tool handlers (use structuredContent only) - Add pause/resume support to audio player for handling false positive speech detection - Add .conversations to .gitignore ```
This commit is contained in:
@@ -3,3 +3,4 @@ dist
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
.conversations
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function getTerminalMcpClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create Terminal MCP server
|
// Create Terminal MCP server
|
||||||
const server = await createTerminalMcpServer({ sessionName: "voice-dev" });
|
const server = await createTerminalMcpServer({ sessionName: "__voice-dev" });
|
||||||
|
|
||||||
// Create linked transport pair
|
// Create linked transport pair
|
||||||
const [clientTransport, serverTransport] =
|
const [clientTransport, serverTransport] =
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function createServer(httpServer: HttpServer, config: ServerConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const port = parseInt(process.env.PORT || "3000", 10);
|
const port = parseInt(process.env.PORT || "6767", 10);
|
||||||
const isDev = process.env.NODE_ENV !== "production";
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
const config: ServerConfig = { port, isDev };
|
const config: ServerConfig = { port, isDev };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, mkdir, writeFile } from "fs/promises";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import { join } from "path";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { streamText, stepCountIs } from "ai";
|
import { streamText, stepCountIs } from "ai";
|
||||||
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
||||||
@@ -53,6 +54,7 @@ export class Session {
|
|||||||
|
|
||||||
// Conversation history
|
// Conversation history
|
||||||
private messages: ModelMessage[] = [];
|
private messages: ModelMessage[] = [];
|
||||||
|
private turnIndex = 0;
|
||||||
|
|
||||||
// Per-session managers
|
// Per-session managers
|
||||||
private readonly ttsManager: TTSManager;
|
private readonly ttsManager: TTSManager;
|
||||||
@@ -236,17 +238,6 @@ export class Session {
|
|||||||
* Process audio through STT and then LLM
|
* Process audio through STT and then LLM
|
||||||
*/
|
*/
|
||||||
private async processAudio(audio: Buffer, format: string): Promise<void> {
|
private async processAudio(audio: Buffer, format: string): Promise<void> {
|
||||||
// Abort any in-progress stream immediately
|
|
||||||
this.createAbortController();
|
|
||||||
|
|
||||||
// Wait for aborted stream to finish cleanup (save partial response)
|
|
||||||
if (this.currentStreamPromise) {
|
|
||||||
console.log(
|
|
||||||
`[Session ${this.clientId}] Waiting for aborted stream to finish cleanup`
|
|
||||||
);
|
|
||||||
await this.currentStreamPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setPhase("transcribing");
|
this.setPhase("transcribing");
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -265,12 +256,23 @@ export class Session {
|
|||||||
const transcriptText = result.text.trim();
|
const transcriptText = result.text.trim();
|
||||||
if (!transcriptText) {
|
if (!transcriptText) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Empty transcription, skipping LLM`
|
`[Session ${this.clientId}] Empty transcription (false positive), not aborting`
|
||||||
);
|
);
|
||||||
this.setPhase("idle");
|
this.setPhase("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Has content - abort any in-progress stream now
|
||||||
|
this.createAbortController();
|
||||||
|
|
||||||
|
// Wait for aborted stream to finish cleanup (save partial response)
|
||||||
|
if (this.currentStreamPromise) {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Waiting for aborted stream to finish cleanup`
|
||||||
|
);
|
||||||
|
await this.currentStreamPromise;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit transcription result
|
// Emit transcription result
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "transcription_result",
|
type: "transcription_result",
|
||||||
@@ -353,6 +355,9 @@ export class Session {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Debug: dump conversation before LLM call
|
||||||
|
await this.dumpConversation();
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
process.env.OPENROUTER_API_KEY,
|
process.env.OPENROUTER_API_KEY,
|
||||||
"OPENROUTER_API_KEY is required"
|
"OPENROUTER_API_KEY is required"
|
||||||
@@ -371,20 +376,13 @@ export class Session {
|
|||||||
tools: allTools,
|
tools: allTools,
|
||||||
abortSignal: this.abortController.signal,
|
abortSignal: this.abortController.signal,
|
||||||
onFinish: async (event) => {
|
onFinish: async (event) => {
|
||||||
for (const step of event.steps) {
|
const newMessages = event.response.messages;
|
||||||
this.messages.push(...step.response.messages);
|
if (newMessages.length > 0) {
|
||||||
|
this.messages.push(...newMessages);
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] onFinish - saved message with ${newMessages.length} steps`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`[Session ${this.clientId}] onFinish - saved message with ${event.steps.length} steps`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onAbort: async (event) => {
|
|
||||||
for (const step of event.steps) {
|
|
||||||
this.messages.push(...step.response.messages);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[Session ${this.clientId}] onAbort - saved message with ${event.steps.length} steps`
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onChunk: async ({ chunk }) => {
|
onChunk: async ({ chunk }) => {
|
||||||
if (chunk.type === "text-delta") {
|
if (chunk.type === "text-delta") {
|
||||||
@@ -451,6 +449,8 @@ export class Session {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "activity_log",
|
type: "activity_log",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -539,6 +539,9 @@ export class Session {
|
|||||||
// Re-throw unexpected errors
|
// Re-throw unexpected errors
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Increment turn index for next LLM call
|
||||||
|
this.turnIndex++;
|
||||||
|
|
||||||
// Clear the stream promise tracker
|
// Clear the stream promise tracker
|
||||||
this.currentStreamPromise = null;
|
this.currentStreamPromise = null;
|
||||||
}
|
}
|
||||||
@@ -708,6 +711,36 @@ export class Session {
|
|||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug helper: dump conversation to disk
|
||||||
|
*/
|
||||||
|
private async dumpConversation(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dumpDir = join(process.cwd(), ".conversations");
|
||||||
|
await mkdir(dumpDir, { recursive: true });
|
||||||
|
|
||||||
|
const filename = `${this.conversationId}-${this.turnIndex}.json`;
|
||||||
|
const filepath = join(dumpDir, filename);
|
||||||
|
|
||||||
|
const dump = {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
turnIndex: this.turnIndex,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
messages: this.messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(filepath, JSON.stringify(dump, null, 2), "utf-8");
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Dumped conversation to ${filepath}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to dump conversation:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up session resources
|
* Clean up session resources
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function createTerminalMcpServer(
|
|||||||
const terminals = await terminalManager.listTerminals();
|
const terminals = await terminalManager.listTerminals();
|
||||||
const output = { terminals };
|
const output = { terminals };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
content: [],
|
||||||
structuredContent: output,
|
structuredContent: output,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export async function createTerminalMcpServer(
|
|||||||
});
|
});
|
||||||
const output = { terminal };
|
const output = { terminal };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
content: [],
|
||||||
structuredContent: output,
|
structuredContent: output,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ export async function createTerminalMcpServer(
|
|||||||
);
|
);
|
||||||
const result = { output };
|
const result = { output };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
content: [],
|
||||||
structuredContent: result,
|
structuredContent: result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ export async function createTerminalMcpServer(
|
|||||||
);
|
);
|
||||||
const result = { output: output || undefined };
|
const result = { output: output || undefined };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
content: [],
|
||||||
structuredContent: result,
|
structuredContent: result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ export async function createTerminalMcpServer(
|
|||||||
);
|
);
|
||||||
const result = { output: output || undefined };
|
const result = { output: output || undefined };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
content: [],
|
||||||
structuredContent: result,
|
structuredContent: result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ export async function createTerminalMcpServer(
|
|||||||
await terminalManager.renameTerminal(terminalName, newName);
|
await terminalManager.renameTerminal(terminalName, newName);
|
||||||
const output = { success: true };
|
const output = { success: true };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
content: [],
|
||||||
structuredContent: output,
|
structuredContent: output,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ export async function createTerminalMcpServer(
|
|||||||
await terminalManager.killTerminal(terminalName);
|
await terminalManager.killTerminal(terminalName);
|
||||||
const output = { success: true };
|
const output = { success: true };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
content: [],
|
||||||
structuredContent: output,
|
structuredContent: output,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { Mic, Send, Radio } from "lucide-react";
|
|||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { createAudioPlayer } from "./lib/audio-playback";
|
import { createAudioPlayer } from "./lib/audio-playback";
|
||||||
import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture";
|
import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture";
|
||||||
import { createRealtimeVAD, float32ArrayToBlob, type RealtimeVAD } from "./lib/audio-realtime";
|
import {
|
||||||
|
createRealtimeVAD,
|
||||||
|
float32ArrayToBlob,
|
||||||
|
type RealtimeVAD,
|
||||||
|
} from "./lib/audio-realtime";
|
||||||
import { ToolCallCard } from "./components/ToolCallCard";
|
import { ToolCallCard } from "./components/ToolCallCard";
|
||||||
import { ArtifactDrawer, type Artifact } from "./components/ArtifactDrawer";
|
import { ArtifactDrawer, type Artifact } from "./components/ArtifactDrawer";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -80,7 +84,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = "auto";
|
||||||
const newHeight = Math.max(textareaRef.current.scrollHeight, 44);
|
const newHeight = Math.max(textareaRef.current.scrollHeight, 44);
|
||||||
textareaRef.current.style.height = `${newHeight}px`;
|
textareaRef.current.style.height = `${newHeight}px`;
|
||||||
}
|
}
|
||||||
@@ -106,19 +110,26 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Listen for status messages
|
// Listen for status messages
|
||||||
const unsubStatus = ws.on("status", (message) => {
|
const unsubStatus = ws.on("status", (message) => {
|
||||||
if (message.type !== 'status') return;
|
if (message.type !== "status") return;
|
||||||
const msg = 'message' in message.payload ? String(message.payload.message) : undefined;
|
const msg =
|
||||||
|
"message" in message.payload
|
||||||
|
? String(message.payload.message)
|
||||||
|
: undefined;
|
||||||
addLog("info", msg || `Status: ${message.payload.status}`);
|
addLog("info", msg || `Status: ${message.payload.status}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for activity log messages
|
// Listen for activity log messages
|
||||||
const unsubActivity = ws.on("activity_log", (message) => {
|
const unsubActivity = ws.on("activity_log", (message) => {
|
||||||
if (message.type !== 'activity_log') return;
|
if (message.type !== "activity_log") return;
|
||||||
const data = message.payload;
|
const data = message.payload;
|
||||||
|
|
||||||
// Handle tool calls
|
// Handle tool calls
|
||||||
if (data.type === "tool_call" && data.metadata) {
|
if (data.type === "tool_call" && data.metadata) {
|
||||||
const { toolCallId, toolName, arguments: args } = data.metadata as {
|
const {
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
arguments: args,
|
||||||
|
} = data.metadata as {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
arguments: unknown;
|
arguments: unknown;
|
||||||
@@ -155,7 +166,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle tool errors - update tool call status to failed
|
// Handle tool errors - update tool call status to failed
|
||||||
if (data.type === "error" && data.metadata && 'toolCallId' in data.metadata) {
|
if (
|
||||||
|
data.type === "error" &&
|
||||||
|
data.metadata &&
|
||||||
|
"toolCallId" in data.metadata
|
||||||
|
) {
|
||||||
const { toolCallId, error } = data.metadata as {
|
const { toolCallId, error } = data.metadata as {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
@@ -172,7 +187,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map activity log types to UI log types
|
// Map activity log types to UI log types
|
||||||
let logType: "system" | "info" | "success" | "error" | "user" | "assistant" = "info";
|
let logType:
|
||||||
|
| "system"
|
||||||
|
| "info"
|
||||||
|
| "success"
|
||||||
|
| "error"
|
||||||
|
| "user"
|
||||||
|
| "assistant" = "info";
|
||||||
if (data.type === "transcript") logType = "user";
|
if (data.type === "transcript") logType = "user";
|
||||||
else if (data.type === "assistant") logType = "assistant";
|
else if (data.type === "assistant") logType = "assistant";
|
||||||
else if (data.type === "error") logType = "error";
|
else if (data.type === "error") logType = "error";
|
||||||
@@ -187,20 +208,37 @@ function App() {
|
|||||||
|
|
||||||
// Listen for streaming assistant chunks
|
// Listen for streaming assistant chunks
|
||||||
const unsubChunk = ws.on("assistant_chunk", (message) => {
|
const unsubChunk = ws.on("assistant_chunk", (message) => {
|
||||||
if (message.type !== 'assistant_chunk') return;
|
if (message.type !== "assistant_chunk") return;
|
||||||
setCurrentAssistantMessage((prev) => prev + message.payload.chunk);
|
setCurrentAssistantMessage((prev) => prev + message.payload.chunk);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for transcription results
|
// Listen for transcription results
|
||||||
const unsubTranscription = ws.on("transcription_result", () => {
|
const unsubTranscription = ws.on("transcription_result", (message) => {
|
||||||
// Note: Transcription is already broadcast as activity_log with type "transcript"
|
if (message.type !== "transcription_result") return;
|
||||||
// No need to log it again here to avoid duplication
|
|
||||||
setIsProcessingAudio(false);
|
setIsProcessingAudio(false);
|
||||||
|
|
||||||
|
const transcriptText = message.payload.text.trim();
|
||||||
|
|
||||||
|
if (!transcriptText) {
|
||||||
|
// Empty transcription - false positive, resume playback
|
||||||
|
console.log(
|
||||||
|
"[App] Empty transcription (false positive) - resuming playback"
|
||||||
|
);
|
||||||
|
audioPlayerRef.current.resume();
|
||||||
|
} else {
|
||||||
|
// Has content - real speech detected
|
||||||
|
// Server will handle abort, just stop playback on client
|
||||||
|
console.log("[App] Transcription received - stopping playback");
|
||||||
|
audioPlayerRef.current.stop();
|
||||||
|
setIsPlayingAudio(false);
|
||||||
|
setCurrentAssistantMessage("");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for artifacts
|
// Listen for artifacts
|
||||||
const unsubArtifact = ws.on("artifact", (message) => {
|
const unsubArtifact = ws.on("artifact", (message) => {
|
||||||
if (message.type !== 'artifact') return;
|
if (message.type !== "artifact") return;
|
||||||
const artifact = message.payload;
|
const artifact = message.payload;
|
||||||
|
|
||||||
// Add to artifacts map
|
// Add to artifacts map
|
||||||
@@ -229,7 +267,7 @@ function App() {
|
|||||||
|
|
||||||
// Listen for audio output (TTS)
|
// Listen for audio output (TTS)
|
||||||
const unsubAudioOutput = ws.on("audio_output", async (message) => {
|
const unsubAudioOutput = ws.on("audio_output", async (message) => {
|
||||||
if (message.type !== 'audio_output') return;
|
if (message.type !== "audio_output") return;
|
||||||
const data = message.payload;
|
const data = message.payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -311,12 +349,14 @@ function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
console.log('[App] Stopping recording...');
|
console.log("[App] Stopping recording...");
|
||||||
const audioBlob = await recorder.stop();
|
const audioBlob = await recorder.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
|
||||||
const format = audioBlob.type || 'audio/webm';
|
const format = audioBlob.type || "audio/webm";
|
||||||
console.log(`[App] Recording complete: ${audioBlob.size} bytes, format: ${format}`);
|
console.log(
|
||||||
|
`[App] Recording complete: ${audioBlob.size} bytes, format: ${format}`
|
||||||
|
);
|
||||||
|
|
||||||
setIsProcessingAudio(true);
|
setIsProcessingAudio(true);
|
||||||
addLog("info", "Sending audio to server...");
|
addLog("info", "Sending audio to server...");
|
||||||
@@ -339,9 +379,11 @@ function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`);
|
console.log(
|
||||||
|
`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('[App] Starting recording...');
|
console.log("[App] Starting recording...");
|
||||||
|
|
||||||
// Stop any currently playing audio when starting to record
|
// Stop any currently playing audio when starting to record
|
||||||
audioPlayerRef.current.stop();
|
audioPlayerRef.current.stop();
|
||||||
@@ -351,7 +393,7 @@ function App() {
|
|||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[App] Recording error:', error);
|
console.error("[App] Recording error:", error);
|
||||||
addLog("error", `Failed to record audio: ${error.message}`);
|
addLog("error", `Failed to record audio: ${error.message}`);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
}
|
}
|
||||||
@@ -369,9 +411,9 @@ function App() {
|
|||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Detect if device is desktop (not mobile/tablet)
|
// Detect if device is desktop (not mobile/tablet)
|
||||||
const isDesktop = window.matchMedia('(pointer: fine)').matches;
|
const isDesktop = window.matchMedia("(pointer: fine)").matches;
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
if (isDesktop && !e.shiftKey && userInput.trim()) {
|
if (isDesktop && !e.shiftKey && userInput.trim()) {
|
||||||
// Desktop: plain Enter sends message
|
// Desktop: plain Enter sends message
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -425,18 +467,9 @@ function App() {
|
|||||||
console.log("[App] Speech detected!");
|
console.log("[App] Speech detected!");
|
||||||
setIsSpeechDetected(true);
|
setIsSpeechDetected(true);
|
||||||
|
|
||||||
// Interrupt playback when speech is detected
|
// Pause playback when speech is detected
|
||||||
audioPlayerRef.current.stop();
|
// Will resume if false positive, or stop completely if real speech
|
||||||
setIsPlayingAudio(false);
|
audioPlayerRef.current.pause();
|
||||||
setCurrentAssistantMessage("");
|
|
||||||
|
|
||||||
// Send abort request to server immediately
|
|
||||||
ws.send({
|
|
||||||
type: "session",
|
|
||||||
message: {
|
|
||||||
type: "abort_request",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSpeechEnd: async (audioData: Float32Array) => {
|
onSpeechEnd: async (audioData: Float32Array) => {
|
||||||
console.log("[App] Speech ended, processing...");
|
console.log("[App] Speech ended, processing...");
|
||||||
@@ -475,8 +508,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onVADMisfire: () => {
|
onVADMisfire: () => {
|
||||||
console.log("[App] VAD misfire");
|
console.log("[App] VAD misfire - resuming playback");
|
||||||
setIsSpeechDetected(false);
|
setIsSpeechDetected(false);
|
||||||
|
|
||||||
|
// Resume playback since it was a false positive
|
||||||
|
audioPlayerRef.current.resume();
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.error("[App] VAD error:", error);
|
console.error("[App] VAD error:", error);
|
||||||
@@ -506,7 +542,9 @@ function App() {
|
|||||||
<header className="header">
|
<header className="header">
|
||||||
<h1>Voice Assistant</h1>
|
<h1>Voice Assistant</h1>
|
||||||
<div className="header-status">
|
<div className="header-status">
|
||||||
{isPlayingAudio && <div className="audio-playing-indicator" title="Playing audio" />}
|
{isPlayingAudio && (
|
||||||
|
<div className="audio-playing-indicator" title="Playing audio" />
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`status-indicator ${
|
className={`status-indicator ${
|
||||||
ws.isConnected ? "connected" : "disconnected"
|
ws.isConnected ? "connected" : "disconnected"
|
||||||
@@ -584,7 +622,7 @@ function App() {
|
|||||||
value={userInput}
|
value={userInput}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message or tap mic to talk..."
|
placeholder="Type a message"
|
||||||
disabled={!ws.isConnected || isRecording || isRealtimeMode}
|
disabled={!ws.isConnected || isRecording || isRealtimeMode}
|
||||||
className="message-input"
|
className="message-input"
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -593,8 +631,14 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggleRealtimeMode}
|
onClick={handleToggleRealtimeMode}
|
||||||
disabled={!ws.isConnected || isRecording || isVADLoading}
|
disabled={!ws.isConnected || isRecording || isVADLoading}
|
||||||
className={`send-button realtime-button ${isRealtimeMode ? 'active' : ''} ${isSpeechDetected ? 'speech-detected' : ''} ${isVADLoading ? 'loading' : ''}`}
|
className={`send-button realtime-button ${
|
||||||
title={isRealtimeMode ? "Stop realtime mode" : "Start realtime mode"}
|
isRealtimeMode ? "active" : ""
|
||||||
|
} ${isSpeechDetected ? "speech-detected" : ""} ${
|
||||||
|
isVADLoading ? "loading" : ""
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isRealtimeMode ? "Stop realtime mode" : "Start realtime mode"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isVADLoading ? (
|
{isVADLoading ? (
|
||||||
<span className="loading-indicator" />
|
<span className="loading-indicator" />
|
||||||
@@ -606,7 +650,9 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={!ws.isConnected || isProcessingAudio || isRealtimeMode}
|
disabled={!ws.isConnected || isProcessingAudio || isRealtimeMode}
|
||||||
className={`send-button ${isRecording ? 'recording' : ''} ${isProcessingAudio ? 'processing' : ''} ${userInput.trim() ? 'has-text' : ''}`}
|
className={`send-button ${isRecording ? "recording" : ""} ${
|
||||||
|
isProcessingAudio ? "processing" : ""
|
||||||
|
} ${userInput.trim() ? "has-text" : ""}`}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<span className="recording-indicator" />
|
<span className="recording-indicator" />
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export interface AudioPlayer {
|
export interface AudioPlayer {
|
||||||
play(audioData: Blob): Promise<number>;
|
play(audioData: Blob): Promise<number>;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
|
pause(): void;
|
||||||
|
resume(): void;
|
||||||
isPlaying(): boolean;
|
isPlaying(): boolean;
|
||||||
|
isPaused(): boolean;
|
||||||
clearQueue(): void;
|
clearQueue(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ interface QueuedAudio {
|
|||||||
export function createAudioPlayer(): AudioPlayer {
|
export function createAudioPlayer(): AudioPlayer {
|
||||||
let currentAudio: HTMLAudioElement | null = null;
|
let currentAudio: HTMLAudioElement | null = null;
|
||||||
let playing = false;
|
let playing = false;
|
||||||
|
let paused = false;
|
||||||
let queue: QueuedAudio[] = [];
|
let queue: QueuedAudio[] = [];
|
||||||
let isProcessingQueue = false;
|
let isProcessingQueue = false;
|
||||||
let currentReject: ((error: Error) => void) | null = null;
|
let currentReject: ((error: Error) => void) | null = null;
|
||||||
@@ -124,6 +128,7 @@ export function createAudioPlayer(): AudioPlayer {
|
|||||||
currentAudio = null;
|
currentAudio = null;
|
||||||
}
|
}
|
||||||
playing = false;
|
playing = false;
|
||||||
|
paused = false;
|
||||||
|
|
||||||
// Reject the current playing promise if it exists
|
// Reject the current playing promise if it exists
|
||||||
if (currentReject) {
|
if (currentReject) {
|
||||||
@@ -140,10 +145,36 @@ export function createAudioPlayer(): AudioPlayer {
|
|||||||
isProcessingQueue = false;
|
isProcessingQueue = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pausePlayback(): void {
|
||||||
|
if (currentAudio && playing && !paused) {
|
||||||
|
currentAudio.pause();
|
||||||
|
paused = true;
|
||||||
|
console.log("[AudioPlayer] Playback paused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePlayback(): void {
|
||||||
|
if (currentAudio && paused) {
|
||||||
|
currentAudio.play().catch((error) => {
|
||||||
|
console.error("[AudioPlayer] Failed to resume playback:", error);
|
||||||
|
if (currentReject) {
|
||||||
|
currentReject(error);
|
||||||
|
currentReject = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
paused = false;
|
||||||
|
console.log("[AudioPlayer] Playback resumed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPlayingFunc(): boolean {
|
function isPlayingFunc(): boolean {
|
||||||
return playing;
|
return playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPausedFunc(): boolean {
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
function clearQueue(): void {
|
function clearQueue(): void {
|
||||||
// Reject all pending promises in the queue
|
// Reject all pending promises in the queue
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -155,7 +186,10 @@ export function createAudioPlayer(): AudioPlayer {
|
|||||||
return {
|
return {
|
||||||
play,
|
play,
|
||||||
stop,
|
stop,
|
||||||
|
pause: pausePlayback,
|
||||||
|
resume: resumePlayback,
|
||||||
isPlaying: isPlayingFunc,
|
isPlaying: isPlayingFunc,
|
||||||
|
isPaused: isPausedFunc,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user