Prompt is too long

This commit is contained in:
Mohamed Boudra
2025-10-19 16:23:13 +02:00
parent 40b199e329
commit c3049b8fa0
67 changed files with 504 additions and 17776 deletions

Binary file not shown.

3265
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,10 +0,0 @@
# LiveKit Configuration
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your-livekit-api-key
LIVEKIT_API_SECRET=your-livekit-api-secret
# MCP Server (Optional)
MCP_SERVER_URL=https://your-mcp-server.example.com
# Note: With LiveKit Inference, you don't need individual provider API keys
# LiveKit handles authentication to OpenAI, Cartesia, AssemblyAI, etc.

View File

@@ -1,18 +0,0 @@
repos:
- repo: local
hooks:
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: false
args: [agent.py]
- id: ruff-check
name: ruff check
entry: uv run ruff check
language: system
types: [python]
pass_filenames: false
args: [.]

View File

@@ -1,24 +0,0 @@
.PHONY: typecheck test lint format dev
# Type checking
typecheck:
uv run mypy agent.py
# Linting
lint:
uv run ruff check .
# Format code
format:
uv run ruff format .
# Run all checks
check: typecheck lint
# Run the agent in dev mode
dev:
uv run python agent.py dev
# Install dev dependencies
install-dev:
uv pip install -e ".[dev]"

File diff suppressed because it is too large Load Diff

View File

@@ -1,195 +0,0 @@
"""
LiveKit Voice Agent with MCP Support (Python)
Migrated from Node.js version with added MCP integration
"""
import asyncio
import os
from pathlib import Path
from typing import Any, AsyncIterable, Coroutine
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
AutoSubscribe,
JobContext,
JobProcess,
WorkerOptions,
cli,
llm,
voice,
inference,
)
from livekit.agents.llm.mcp import MCPServerHTTP
from livekit.agents.llm.tool_context import FunctionTool, RawFunctionTool
from livekit.agents.voice import ModelSettings
from livekit.plugins import anthropic, openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
# Load environment variables
load_dotenv()
def load_system_prompt() -> str:
"""Load system prompt from agent-prompt.md file."""
prompt_path = Path(__file__).parent / "agent-prompt.md"
return prompt_path.read_text()
# Load system prompt from external file for easier editing
SYSTEM_PROMPT = load_system_prompt()
class TimedAgent(voice.Agent):
"""Agent that waits for TTS to complete before executing tool calls."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._current_speech_handle: Any = None
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool | RawFunctionTool],
model_settings: ModelSettings,
) -> AsyncIterable[llm.ChatChunk | str]:
"""Override llm_node to buffer tool calls until after text."""
tool_chunk_buffer: list[llm.ChatChunk] = []
text_chunks: list[llm.ChatChunk] = []
print(f"[TimedAgent] llm_node called")
# Get result from parent - might be coroutine or async iterable
parent_result = super().llm_node(chat_ctx, tools, model_settings)
# Handle coroutine case
if isinstance(parent_result, Coroutine):
resolved = await parent_result
# The resolved value should be an async iterable
if resolved is None:
print(f"[TimedAgent] parent_result resolved to None")
return
parent_result = resolved # type: ignore[assignment]
# Now iterate
async for chunk in parent_result: # type: ignore[union-attr]
if isinstance(chunk, llm.ChatChunk) and chunk.delta:
# Collect text chunks
if chunk.delta.content:
print(f"[TimedAgent] Text chunk: {chunk.delta.content[:50]}...")
text_chunks.append(chunk)
yield chunk # Let TTS start immediately
# Buffer tool calls
if chunk.delta.tool_calls:
print(f"[TimedAgent] Tool call chunk: {chunk.delta.tool_calls}")
tool_chunk_buffer.append(chunk)
else:
print(f"[TimedAgent] Other chunk type: {type(chunk)}")
yield chunk
print(f"[TimedAgent] Done iterating. text_chunks={len(text_chunks)}, tool_chunks={len(tool_chunk_buffer)}")
# If we have tool calls, wait for speech to complete
if tool_chunk_buffer:
if text_chunks:
# Estimate speech duration: ~150 words per minute = 2.5 words/sec = 0.4 sec/word
total_words = sum(
len(chunk.delta.content.split())
for chunk in text_chunks
if chunk.delta and chunk.delta.content
)
# Add extra buffer time for TTS processing and network
estimated_duration = (total_words * 0.4) + 1.0
print(f"[TimedAgent] Waiting {estimated_duration}s for {total_words} words to be spoken")
await asyncio.sleep(estimated_duration)
else:
# No text but we have tool calls - wait a default amount
print(f"[TimedAgent] No text chunks but have tool calls - waiting 2s default")
await asyncio.sleep(2.0)
# Now yield the tool calls
print(f"[TimedAgent] Yielding {len(tool_chunk_buffer)} tool calls")
for tool_chunk in tool_chunk_buffer:
yield tool_chunk
async def entrypoint(ctx: JobContext) -> None:
"""Main entry point for the voice agent."""
# Get API keys from environment
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
if not openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY environment variable is required")
# Get MCP server URL from environment
mcp_server_url = os.getenv("MCP_SERVER_URL")
# Prepare MCP servers list
mcp_servers: list[MCPServerHTTP] = []
if mcp_server_url:
print(f"✓ MCP Server configured: {mcp_server_url}")
server = MCPServerHTTP(
url=mcp_server_url,
timeout=10
)
mcp_servers.append(server)
else:
print("⚠ No MCP_SERVER_URL found in environment")
# Connect to the room
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# Create the voice agent with MCP tools
agent = TimedAgent(
instructions=SYSTEM_PROMPT,
mcp_servers=mcp_servers, # Native MCP support!
)
# Create the agent session with BYOK (Bring Your Own Key)
# Using Claude via OpenRouter for redundancy against downtime
session: Any = voice.AgentSession(
stt=openai.STT(
model="gpt-4o-transcribe",
),
vad=silero.VAD.load(
min_silence_duration=1.2, # Wait 1.2s after speech ends before considering turn complete (default: 0.55s)
),
turn_detection=EnglishModel(), # Context-aware turn detector prevents premature turn-taking
llm=openai.LLM(
model="anthropic/claude-sonnet-4.5",
# model="z-ai/glm-4.6",
base_url="https://openrouter.ai/api/v1",
api_key=openrouter_api_key,
),
tts=inference.TTS(
model="inworld/inworld-tts-1",
voice="Olivia",
language="en"
),
# Tool execution limits
max_tool_steps=10, # Max consecutive tool calls per turn (default: 3)
# Turn detection configuration - less aggressive (give user more time)
min_endpointing_delay=1.0, # Minimum 1s before responding (default: 0.5s)
# Interruption configuration - more aggressive (allow faster interruption)
allow_interruptions=True, # Allow user to interrupt agent mid-speech
min_interruption_duration=0.2, # Detect interruption after 0.2s of speech (default: 0.5s)
min_interruption_words=0, # Don't require full word to interrupt (default: 0)
# Generation configuration
preemptive_generation=True, # Disable preemptive generation for more accurate responses
)
# Start the session
await session.start(agent=agent, room=ctx.room)
print(f"✓ Agent started successfully in room: {ctx.room.name}")
print(f"✓ MCP servers: {len(mcp_servers)} configured")
if __name__ == "__main__":
# Run the agent worker
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
)
)

View File

@@ -1,38 +0,0 @@
[project]
name = "voice-dev-agent"
version = "1.0.0"
description = "LiveKit voice agent with MCP support"
requires-python = ">=3.10"
dependencies = [
"livekit-agents[mcp,anthropic,openai,silero,turn-detector]>=1.0.0",
"python-dotenv>=1.0.0",
"torch>=2.0.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.1.0",
"mypy>=1.8.0",
]
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true
[[tool.mypy.overrides]]
module = "livekit.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "torch.*"
ignore_missing_imports = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
node_modules/
build/
.DS_Store
*.log

View File

@@ -1,8 +0,0 @@
Copyright 2025 Nicolò Gnudi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
{
"name": "@voice-dev/mcp-server",
"version": "0.3.0",
"description": "MCP Server for voice-controlled terminal development",
"type": "module",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsx watch src/index.ts --http --password dev-password",
"dev:stdio": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"check-release": "npm run build && npm publish --dry-run",
"release": "npm run build && npm publish"
},
"bin": {
"voice-dev-mcp": "build/index.js"
},
"files": [
"build"
],
"keywords": [
"mcp",
"tmux",
"claude"
],
"author": "nickgnd",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.0",
"@types/express": "^5.0.3",
"express": "^5.1.0",
"uuid": "^11.1.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@types/uuid": "^10.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.3"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nickgnd/tmux-mcp.git"
},
"bugs": {
"url": "https://github.com/nickgnd/tmux-mcp/issues"
},
"homepage": "https://github.com/nickgnd/tmux-mcp#readme"
}

View File

@@ -1,86 +0,0 @@
import express, { Request, Response, NextFunction } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
interface HttpServerOptions {
port: number;
password: string;
server: McpServer;
}
interface AuthenticatedRequest extends Request {
isAuthenticated?: boolean;
}
export function startHttpServer({ port, password, server }: HttpServerOptions): void {
const app = express();
app.use(express.json());
// Password authentication middleware
function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const providedPassword = req.query.password as string;
if (!providedPassword || providedPassword !== password) {
res.status(401).json({ error: 'Unauthorized: Invalid or missing password' });
return;
}
req.isAuthenticated = true;
next();
}
// Health check endpoint
app.get('/', (req: Request, res: Response) => {
res.json({
status: 'ok',
service: 'voice-dev-mcp',
transport: 'streamable-http'
});
});
// Streamable HTTP endpoint (modern MCP transport)
app.post('/mcp', authenticate, async (req: AuthenticatedRequest, res: Response) => {
try {
// Create a new transport for each request to prevent request ID collisions
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true
});
res.on('close', () => {
transport.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
});
const httpServer = app.listen(port, () => {
console.error(`MCP HTTP server listening on port ${port}`);
console.error(`Streamable HTTP endpoint: http://localhost:${port}/mcp?password=****`);
});
httpServer.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
console.error(`Error: Port ${port} is already in use`);
process.exit(1);
} else {
console.error(`Server error:`, error);
process.exit(1);
}
});
}

View File

@@ -1,760 +0,0 @@
#!/usr/bin/env node
import { parseArgs } from "node:util";
import {
McpServer,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as tmux from "./tmux.js";
import { startHttpServer } from "./http-server.js";
import os from "node:os";
const DEFAULT_SESSION = "__voice-dev";
// Create MCP server
const server = new McpServer(
{
name: "voice-dev-mcp",
version: "0.4.0",
},
{
capabilities: {
resources: {
subscribe: true,
listChanged: true,
},
tools: {
listChanged: true,
},
logging: {},
},
}
);
/**
* Ensure the default session exists
*/
async function ensureDefaultSession(): Promise<void> {
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
console.log(`Creating default session: ${DEFAULT_SESSION}`);
await tmux.createSession(DEFAULT_SESSION);
}
}
// List terminals - Tool
server.tool(
"list-terminals",
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, and currently running command.",
{},
async () => {
try {
await ensureDefaultSession();
// Get all windows in the default session
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
const windows = await tmux.listWindows(session.id);
// For each window, get the pane and its info
const terminals = await Promise.all(
windows.map(async (window) => {
const panes = await tmux.listPanes(window.id);
const pane = panes[0]; // Always use first pane
const workingDirectory = pane
? await tmux.getCurrentWorkingDirectory(pane.id)
: "unknown";
const currentCommand = pane
? await tmux.getCurrentCommand(pane.id)
: "unknown";
return {
name: window.name,
active: window.active,
workingDirectory,
currentCommand,
};
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(terminals, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error listing terminals: ${error}`,
},
],
isError: true,
};
}
}
);
// Create terminal - Tool
server.tool(
"create-terminal",
"Create a new terminal at a specific working directory. Always specify workingDirectory based on context - use project paths when working on projects, or the same directory as current terminal when user says 'another terminal here'. Defaults to ~ only if no context.",
{
name: z.string().describe("Name for the new terminal"),
workingDirectory: z
.string()
.default(os.homedir())
.describe(
"Working directory for the terminal. Required parameter - set contextually based on what the user is working on. Use project paths when working on projects. Defaults to home directory (~) only if no context."
),
initialCommand: z
.string()
.optional()
.describe(
"Optional command to execute after creating the terminal. The command runs after changing to the working directory."
),
},
async ({ name, workingDirectory, initialCommand }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Create window in default session with working directory and optional command
const window = await tmux.createWindow(session.id, name, {
workingDirectory,
command: initialCommand,
});
if (!window) {
return {
content: [
{
type: "text",
text: `Failed to create terminal: ${name}`,
},
],
};
}
const commandOutput = window.output;
let text = `Terminal created: ${JSON.stringify(
{
name: window.name,
workingDirectory,
},
null,
2
)}`;
if (initialCommand && commandOutput) {
text += `\n\nInitial command executed: ${initialCommand}\n\n--- Output ---\n${commandOutput}`;
} else if (initialCommand) {
text += `\n\nInitial command sent: ${initialCommand}`;
}
return {
content: [
{
type: "text",
text,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error creating terminal: ${error}`,
},
],
isError: true,
};
}
}
);
// Capture terminal - Tool
server.tool(
"capture-terminal",
"Capture the last N lines of output from a terminal. Use this to see command results, check status, or debug issues.",
{
terminalName: z.string().describe("Name of the terminal"),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
wait: z
.number()
.optional()
.describe(
"Milliseconds to wait before capturing output. Useful for slow commands."
),
},
async ({ terminalName, lines, wait }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Wait if specified
if (wait) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
// Get the pane for this terminal
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
const content = await tmux.capturePaneContent(
pane.id,
lines || 200,
false
);
return {
content: [
{
type: "text",
text: content || "No content captured",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error capturing terminal content: ${error}`,
},
],
isError: true,
};
}
}
);
// Send text - Tool
server.tool(
"send-text",
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.",
{
terminalName: z.string().describe("Name of the terminal"),
text: z
.string()
.describe(
"Text to type into the terminal. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
),
pressEnter: z
.boolean()
.optional()
.describe(
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
wait: z
.number()
.optional()
.describe("Milliseconds to wait before capturing output"),
})
.optional()
.describe(
"Capture terminal output after sending text. Specify 'wait' for slow commands."
),
},
async ({ terminalName, text, pressEnter, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
if (return_output && output) {
return {
content: [
{
type: "text",
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\n--- Output ---\n${output}`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\nUse capture-terminal to verify the result.`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error sending text: ${error}`,
},
],
isError: true,
};
}
}
);
// Send keys - Tool
server.tool(
"send-keys",
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.",
{
terminalName: z.string().describe("Name of the terminal"),
keys: z
.string()
.describe(
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
),
repeat: z
.number()
.min(1)
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
wait: z
.number()
.optional()
.describe("Milliseconds to wait before capturing output"),
})
.optional()
.describe(
"Capture terminal output after sending keys. Specify 'wait' for slow commands."
),
},
async ({ terminalName, keys, repeat, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
if (return_output && output) {
return {
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\n--- Output ---\n${output}`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\nUse capture-terminal to verify the result.`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error sending keys: ${error}`,
},
],
isError: true,
};
}
}
);
// Rename terminal - Tool
server.tool(
"rename-terminal",
"Rename a terminal to a more descriptive name",
{
terminalName: z.string().describe("Current name of the terminal"),
newName: z.string().describe("New name for the terminal"),
},
async ({ terminalName, newName }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
await tmux.renameWindow(session.id, terminalName, newName);
return {
content: [
{
type: "text",
text: `Terminal "${terminalName}" renamed to "${newName}"`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error renaming terminal: ${error}`,
},
],
isError: true,
};
}
}
);
// Kill terminal - Tool
server.tool(
"kill-terminal",
"Close a terminal and end its shell session",
{
terminalName: z.string().describe("Name of the terminal"),
},
async ({ terminalName }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await tmux.killWindow(window.id);
return {
content: [
{
type: "text",
text: `Terminal "${terminalName}" has been closed`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error killing terminal: ${error}`,
},
],
isError: true,
};
}
}
);
// Expose terminals as a resource
server.resource("Terminals", "tmux://terminals", async () => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
const windows = await tmux.listWindows(session.id);
const terminals = await Promise.all(
windows.map(async (window) => {
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
const workingDirectory = pane
? await tmux.getCurrentWorkingDirectory(pane.id)
: "unknown";
const currentCommand = pane
? await tmux.getCurrentCommand(pane.id)
: "unknown";
return {
name: window.name,
active: window.active,
workingDirectory,
currentCommand,
};
})
);
return {
contents: [
{
uri: "tmux://terminals",
text: JSON.stringify(terminals, null, 2),
},
],
};
} catch (error) {
return {
contents: [
{
uri: "tmux://terminals",
text: `Error listing terminals: ${error}`,
},
],
};
}
});
// Expose terminal content as a resource
server.resource(
"Terminal Content",
new ResourceTemplate("tmux://terminal/{terminalName}", {
list: async () => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
return { resources: [] };
}
const windows = await tmux.listWindows(session.id);
const terminalResources = windows.map((window) => ({
name: `Terminal: ${window.name} ${window.active ? "(active)" : ""}`,
uri: `tmux://terminal/${encodeURIComponent(window.name)}`,
description: `Content from terminal ${window.name}`,
}));
return {
resources: terminalResources,
};
} catch (error) {
server.server.sendLoggingMessage({
level: "error",
data: `Error listing terminals: ${error}`,
});
return { resources: [] };
}
},
}),
async (uri, { terminalName }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Ensure terminalName is a string
const terminalNameStr = Array.isArray(terminalName)
? terminalName[0]
: terminalName;
// Decode URI component in case terminal name has special characters
const decodedTerminalName = decodeURIComponent(terminalNameStr);
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, decodedTerminalName);
if (!window) {
throw new Error(`Terminal '${decodedTerminalName}' not found`);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${decodedTerminalName}`);
}
const content = await tmux.capturePaneContent(pane.id, 200, false);
return {
contents: [
{
uri: uri.href,
text: content || "No content captured",
},
],
};
} catch (error) {
return {
contents: [
{
uri: uri.href,
text: `Error capturing terminal content: ${error}`,
},
],
};
}
}
);
async function main() {
try {
const { values } = parseArgs({
options: {
"shell-type": { type: "string", default: "bash", short: "s" },
http: { type: "boolean", default: false },
port: { type: "string" },
password: { type: "string" },
},
allowPositionals: true,
});
// Set shell configuration
tmux.setShellConfig({
type: values["shell-type"] as string,
});
// Ensure default session exists
await ensureDefaultSession();
console.log(values, process.argv);
// Check if HTTP mode is enabled
if (values.http) {
if (!values.password) {
console.error("Error: --password is required when using --http mode");
console.error(
"\nUsage: tmux-mcp --http --password your-secret-password"
);
console.error(
"Set PORT environment variable to change port (default: 3000)"
);
process.exit(1);
}
const port = Number(values.port || process.env.PORT || "6767");
// Start HTTP server
startHttpServer({
port,
password: values.password,
server,
});
} else {
// Start stdio server (default)
const transport = new StdioServerTransport();
await server.connect(transport);
}
} catch (error) {
console.error("Failed to start MCP server:", error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -1,852 +0,0 @@
import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import os from "node:os";
const exec = promisify(execCallback);
// Basic interfaces for tmux objects
export interface TmuxSession {
id: string;
name: string;
attached: boolean;
windows: number;
}
export interface TmuxWindow {
id: string;
name: string;
active: boolean;
sessionId: string;
}
export interface TmuxPane {
id: string;
windowId: string;
active: boolean;
title: string;
}
interface CommandExecution {
id: string;
paneId: string;
command: string;
status: "pending" | "completed" | "error";
startTime: Date;
result?: string;
exitCode?: number;
rawMode?: boolean;
}
export type ShellType = "bash" | "zsh" | "fish";
let shellConfig: { type: ShellType } = { type: "bash" };
export function setShellConfig(config: { type: string }): void {
// Validate shell type
const validShells: ShellType[] = ["bash", "zsh", "fish"];
if (validShells.includes(config.type as ShellType)) {
shellConfig = { type: config.type as ShellType };
} else {
shellConfig = { type: "bash" };
}
}
/**
* Execute a tmux command and return the result
*/
export async function executeTmux(tmuxCommand: string): Promise<string> {
try {
const { stdout } = await exec(`tmux ${tmuxCommand}`);
return stdout.trim();
} catch (error: any) {
throw new Error(`Failed to execute tmux command: ${error.message}`);
}
}
/**
* Check if tmux server is running
*/
export async function isTmuxRunning(): Promise<boolean> {
try {
await executeTmux("list-sessions -F '#{session_name}'");
return true;
} catch (error) {
return false;
}
}
/**
* List all tmux sessions
*/
export async function listSessions(): Promise<TmuxSession[]> {
const format =
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
const output = await executeTmux(`list-sessions -F '${format}'`);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, attached, windows] = line.split(":");
return {
id,
name,
attached: attached === "1",
windows: parseInt(windows, 10),
};
});
}
/**
* Find a session by name
*/
export async function findSessionByName(
name: string
): Promise<TmuxSession | null> {
try {
const sessions = await listSessions();
return sessions.find((session) => session.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === null;
}
/**
* List windows in a session
*/
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
const output = await executeTmux(
`list-windows -t '${sessionId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, active] = line.split(":");
return {
id,
name,
active: active === "1",
sessionId,
};
});
}
/**
* List panes in a window
*/
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
const output = await executeTmux(
`list-panes -t '${windowId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, title, active] = line.split(":");
return {
id,
windowId,
title: title,
active: active === "1",
};
});
}
/**
* Capture content from a specific pane, by default the latest 200 lines.
*/
export async function capturePaneContent(
paneId: string,
lines: number = 200,
includeColors: boolean = false
): Promise<string> {
const colorFlag = includeColors ? "-e" : "";
return executeTmux(
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`
);
}
/**
* Get the current working directory of a pane
*/
export async function getCurrentWorkingDirectory(
paneId: string
): Promise<string> {
try {
const tmuxPath = await executeTmux(`display-message -p -t '${paneId}' '#{pane_current_path}'`);
// If tmux returns a valid path, use it
if (tmuxPath && tmuxPath.trim()) {
return tmuxPath;
}
// Fallback: get the PID and use lsof to find the actual CWD
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`);
const { stdout } = await exec(`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`);
return stdout.trim() || tmuxPath;
} catch (error) {
// If all else fails, return empty string
return "";
}
}
/**
* Get the current command running in a pane (full command line with arguments)
* Gets the immediate child process of the shell, not the shell itself
*/
export async function getCurrentCommand(paneId: string): Promise<string> {
try {
// Get the shell PID (the pane's main process)
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`);
// First, check if there's a child process using comm= (works for all programs including top)
// Use 'ax' flags to see all processes
const { stdout: childPid } = await exec(`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`);
if (childPid.trim()) {
// Found a child process, get its full command with args
const { stdout: fullCmd } = await exec(`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`);
const command = fullCmd.trim();
if (command) {
return command;
}
}
// No child process, just return the shell name
const { stdout: shellCmd } = await exec(`ps -p ${shellPid} -o comm=`);
return shellCmd.trim();
} catch (error) {
// Fallback to just the command name if ps fails
return executeTmux(`display-message -p -t '${paneId}' '#{pane_current_command}'`);
}
}
/**
* Create a new tmux session with a default window named "default" in home directory
*/
export async function createSession(name: string): Promise<TmuxSession | null> {
const homeDir = process.env.HOME || "~";
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
// Disable automatic window renaming for all windows in the session
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
return findSessionByName(name);
}
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~/')) {
const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir);
}
if (path === '~') {
return process.env.HOME || os.homedir();
}
return path;
}
/**
* Create a new window in a session with optional working directory and initial command
*/
export async function createWindow(
sessionId: string,
name: string,
options?: {
workingDirectory?: string;
command?: string;
}
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) {
// Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
}
await executeTmux(newWindowCmd);
const windows = await listWindows(sessionId);
const window = windows.find((window) => window.name === name);
if (!window) return null;
// Disable automatic window renaming
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
// Get the default pane created with the window
const panes = await listPanes(window.id);
const defaultPane = panes[0];
let commandOutput: string | undefined;
// If command is provided, execute it in the new pane
if (options?.command && defaultPane) {
await sendText({
paneId: defaultPane.id,
text: options.command,
pressEnter: true,
});
// Sleep for 1 second to allow command to execute
await new Promise((resolve) => setTimeout(resolve, 1000));
// Capture the pane content
commandOutput = await capturePaneContent(defaultPane.id, 200, false);
}
return {
...window,
paneId: defaultPane?.id || "",
output: commandOutput,
};
}
/**
* Kill a tmux session by ID
*/
export async function killSession(sessionId: string): Promise<void> {
await executeTmux(`kill-session -t '${sessionId}'`);
}
/**
* Kill a tmux window by ID
*/
export async function killWindow(windowId: string): Promise<void> {
await executeTmux(`kill-window -t '${windowId}'`);
}
/**
* Kill a tmux pane by ID
*/
export async function killPane(paneId: string): Promise<void> {
await executeTmux(`kill-pane -t '${paneId}'`);
}
/**
* Rename a tmux window by name or ID
*/
export async function renameWindow(
sessionId: string,
windowNameOrId: string,
newName: string
): Promise<void> {
// Validate new name is unique
const isUnique = await isWindowNameUnique(sessionId, newName);
if (!isUnique) {
throw new Error(
`Terminal with name '${newName}' already exists. Please choose a unique name.`
);
}
// Check if windowNameOrId is a window ID (starts with @) or a name
let windowId: string;
if (windowNameOrId.startsWith("@")) {
windowId = windowNameOrId;
} else {
// Resolve name to ID
const window = await findWindowByName(sessionId, windowNameOrId);
if (!window) {
throw new Error(`Terminal '${windowNameOrId}' not found.`);
}
windowId = window.id;
}
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
// Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
}
/**
* Split a tmux pane horizontally or vertically
*/
export async function splitPane(
targetPaneId: string,
direction: "horizontal" | "vertical" = "vertical",
size?: number
): Promise<TmuxPane | null> {
// Build the split-window command
let splitCommand = "split-window";
// Add direction flag (-h for horizontal, -v for vertical)
if (direction === "horizontal") {
splitCommand += " -h";
} else {
splitCommand += " -v";
}
// Add target pane
splitCommand += ` -t '${targetPaneId}'`;
// Add size if specified (as percentage)
if (size !== undefined && size > 0 && size < 100) {
splitCommand += ` -p ${size}`;
}
// Execute the split command
await executeTmux(splitCommand);
// Get the window ID from the target pane to list all panes
const windowInfo = await executeTmux(
`display-message -p -t '${targetPaneId}' '#{window_id}'`
);
// List all panes in the window to find the newly created one
const panes = await listPanes(windowInfo);
// The newest pane is typically the last one in the list
return panes.length > 0 ? panes[panes.length - 1] : null;
}
// Map to track ongoing command executions
const activeCommands = new Map<string, CommandExecution>();
const startMarkerText = "TMUX_MCP_START";
const endMarkerPrefix = "TMUX_MCP_DONE_";
// Execute a command in a tmux pane and track its execution
export async function executeCommand(
paneId: string,
command: string,
rawMode?: boolean,
noEnter?: boolean
): Promise<string> {
// Generate unique ID for this command execution
const commandId = uuidv4();
let fullCommand: string;
if (rawMode || noEnter) {
fullCommand = command;
} else {
const endMarkerText = getEndMarkerText();
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
}
// Store command in tracking map
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: rawMode || noEnter,
});
// Send the command to the tmux pane
if (noEnter) {
// Check if this is a special key or key combination
// Special keys in tmux are typically capitalized or have special names
const specialKeys = [
"Up",
"Down",
"Left",
"Right",
"Escape",
"Tab",
"Enter",
"Space",
"BSpace",
"Delete",
"Home",
"End",
"PageUp",
"PageDown",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"BTab",
];
// Split the command into parts to handle combinations like "C-b" or "M-x"
const parts = fullCommand.split("-");
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
const isKeyCombo =
parts.length > 1 &&
(parts[0] === "C" || // Control
parts[0] === "M" || // Meta/Alt
parts[0] === "S"); // Shift
if (isSpecialKey || isKeyCombo) {
// Send special key or key combination as-is
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
} else {
// For regular text, send each character individually to ensure proper processing
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
for (const char of fullCommand) {
await executeTmux(
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
}
} else {
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
}
return commandId;
}
export async function checkCommandStatus(
commandId: string
): Promise<CommandExecution | null> {
const command = activeCommands.get(commandId);
if (!command) return null;
if (command.status !== "pending") return command;
const content = await capturePaneContent(command.paneId, 1000);
if (command.rawMode) {
command.result =
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
return command;
}
// Find the last occurrence of the markers
const startIndex = content.lastIndexOf(startMarkerText);
const endIndex = content.lastIndexOf(endMarkerPrefix);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
command.result = "Command output could not be captured properly";
return command;
}
// Extract exit code from the end marker line
const endLine = content.substring(endIndex).split("\n")[0];
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
const exitCodeMatch = endLine.match(endMarkerRegex);
if (exitCodeMatch) {
const exitCode = parseInt(exitCodeMatch[1], 10);
command.status = exitCode === 0 ? "completed" : "error";
command.exitCode = exitCode;
// Extract output between the start and end markers
const outputStart = startIndex + startMarkerText.length;
const outputContent = content.substring(outputStart, endIndex).trim();
command.result = outputContent
.substring(outputContent.indexOf("\n") + 1)
.trim();
// Update in map
activeCommands.set(commandId, command);
}
return command;
}
// Get command by ID
export function getCommand(commandId: string): CommandExecution | null {
return activeCommands.get(commandId) || null;
}
// Get all active command IDs
export function getActiveCommandIds(): string[] {
return Array.from(activeCommands.keys());
}
// Clean up completed commands older than a certain time
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
const now = new Date();
for (const [id, command] of activeCommands.entries()) {
const ageMinutes =
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
activeCommands.delete(id);
}
}
}
function getEndMarkerText(): string {
return shellConfig.type === "fish"
? `${endMarkerPrefix}$status`
: `${endMarkerPrefix}$?`;
}
// New consolidated API functions
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
interface SessionWithWindows extends TmuxSession {
windowDetails?: WindowWithPanes[];
}
interface WindowWithPanes extends TmuxWindow {
paneDetails?: TmuxPane[];
}
export async function list({
scope,
target,
}: {
scope: ListScope;
target?: string;
}): Promise<
| SessionWithWindows[]
| TmuxSession[]
| TmuxWindow[]
| TmuxPane[]
| TmuxSession
| TmuxWindow
| TmuxPane
> {
if (scope === "all") {
const sessions = await listSessions();
const sessionsWithDetails: SessionWithWindows[] = [];
for (const session of sessions) {
const windows = await listWindows(session.id);
const windowsWithPanes: WindowWithPanes[] = [];
for (const window of windows) {
const panes = await listPanes(window.id);
windowsWithPanes.push({
...window,
paneDetails: panes,
});
}
sessionsWithDetails.push({
...session,
windowDetails: windowsWithPanes,
});
}
return sessionsWithDetails;
}
if (scope === "sessions") {
return listSessions();
}
if (scope === "session") {
if (!target) {
throw new Error("target is required for scope 'session'");
}
return listWindows(target);
}
if (scope === "window") {
if (!target) {
throw new Error("target is required for scope 'window'");
}
return listPanes(target);
}
if (scope === "pane") {
if (!target) {
throw new Error("target is required for scope 'pane'");
}
const windowId = await executeTmux(
`display-message -p -t '${target}' '#{window_id}'`
);
const panes = await listPanes(windowId);
const pane = panes.find((p) => p.id === target);
if (!pane) {
throw new Error(`Pane not found: ${target}`);
}
return pane;
}
throw new Error(`Invalid scope: ${scope}`);
}
export type KillScope = "session" | "window" | "pane";
export async function kill({
scope,
target,
}: {
scope: KillScope;
target: string;
}): Promise<void> {
if (scope === "session") {
return killSession(target);
}
if (scope === "window") {
return killWindow(target);
}
if (scope === "pane") {
return killPane(target);
}
throw new Error(`Invalid scope: ${scope}`);
}
export interface ShellCommandResult {
command: string;
status: "completed" | "error";
exitCode: number;
output: string;
}
export async function executeShellCommand({
paneId,
command,
timeout = 30000,
}: {
paneId: string;
command: string;
timeout?: number;
}): Promise<ShellCommandResult> {
const commandId = uuidv4();
const endMarkerText = getEndMarkerText();
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: false,
});
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
// Poll for completion
const startTime = Date.now();
const pollInterval = 100;
while (Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const result = await checkCommandStatus(commandId);
if (result && result.status !== "pending") {
// Cleanup
activeCommands.delete(commandId);
return {
command: result.command,
status: result.status,
exitCode: result.exitCode!,
output: result.result || "",
};
}
}
// Timeout
activeCommands.delete(commandId);
throw new Error(
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
);
}
export async function sendKeys({
paneId,
keys,
repeat = 1,
return_output,
}: {
paneId: string;
keys: string;
repeat?: number;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) {
// Raw pass-through, no validation or processing
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
await new Promise((resolve) =>
setTimeout(resolve, return_output.wait ?? 500)
);
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}
export async function sendText({
paneId,
text,
pressEnter = false,
return_output,
}: {
paneId: string;
text: string;
pressEnter?: boolean;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Send each character with -l flag for literal interpretation
for (const char of text) {
await executeTmux(
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
if (pressEnter) {
await new Promise((resolve) => setTimeout(resolve, 300));
await executeTmux(`send-keys -t '${paneId}' Enter`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
if (return_output.wait) {
await new Promise((resolve) => setTimeout(resolve, return_output.wait));
}
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}

View File

@@ -1,47 +0,0 @@
#!/bin/bash
# Test MCP HTTP server with curl
# Usage: ./test-http.sh
PASSWORD="dev-password"
BASE_URL="http://localhost:3000"
echo "Testing MCP HTTP server..."
echo
# Test 1: Health check
echo "1. Health check:"
curl -s "$BASE_URL/" | jq .
echo
# Test 2: MCP initialize request
echo "2. Sending MCP initialize request:"
curl -s -X POST "$BASE_URL/messages?password=$PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
}' | jq .
echo
# Test 3: List tools
echo "3. Listing available tools:"
curl -s -X POST "$BASE_URL/messages?password=$PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}' | jq .
echo
echo "Done!"

View File

@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build"]
}

View File

@@ -1,153 +0,0 @@
# Audio Processing Architecture
## Overview
This application uses **browser-side WebRTC audio processing** with **WebSocket transport**. We do NOT use WebRTC for peer-to-peer connections.
## Why This Architecture?
### Browser-Side WebRTC Processing
- ✅ Echo cancellation - Removes feedback from speakers
- ✅ Noise suppression - Filters background noise
- ✅ Auto gain control - Normalizes volume levels
- ✅ No server-side processing needed
### WebSocket Transport
- ✅ Simple and reliable
- ✅ Works with existing infrastructure
- ✅ No NAT traversal issues
- ✅ Easy to debug
### No Server-Side WebRTC
- ❌ Server doesn't need to be a WebRTC peer
- ❌ No complex signaling protocol needed
- ❌ No audio encoding/decoding on server
- ❌ Simpler architecture
## Audio Flow
### Voice Input (STT)
1. Browser: `getUserMedia()` with audio constraints
2. Browser: MediaRecorder captures processed audio
3. Browser → Server: WebSocket (base64-encoded audio)
4. Server: OpenAI Whisper transcription
5. Server: LLM processing
6. Server: Generate TTS response
### Voice Output (TTS)
1. Server: OpenAI TTS generates MP3
2. Server → Browser: WebSocket (base64-encoded MP3)
3. Browser: HTMLAudioElement playback
## Audio Constraints
The browser applies these constraints to audio capture:
```javascript
{
echoCancellation: true, // Remove echo
noiseSuppression: true, // Remove noise
autoGainControl: true, // Normalize volume
sampleRate: 16000, // Optimal for speech
channelCount: 1, // Mono
}
```
These constraints are applied via the Web Audio API in `getUserMedia()`, which uses the browser's built-in audio processing pipeline (same as WebRTC uses internally).
## Audio Format
- **Input Format**: WebM/Opus (preferred) or browser's best available codec
- **Sample Rate**: 16kHz (optimal for speech recognition)
- **Channels**: Mono (sufficient for voice)
- **Output Format**: MP3 (from OpenAI TTS)
## Browser Compatibility
- ✅ Chrome/Edge - Full support
- ✅ Firefox - Full support
- ✅ Safari - Full support (iOS requires https)
- ✅ Mobile browsers - Supported
## Testing Audio Processing
To verify echo cancellation and noise suppression work:
1. Play music or make noise near your microphone
2. Start recording
3. Speak while noise is present
4. Stop recording and check transcription
5. Background noise should be filtered out
## Performance
- **Latency**: 2-4 seconds total
- STT: 1-2s (Whisper API)
- LLM: 0.5-1s (OpenAI GPT)
- TTS: 0.5-1s (OpenAI TTS)
- **Bandwidth**: ~10-20 KB/s for audio (both directions)
- **Audio Quality**: Excellent for speech (16kHz mono)
## Implementation Details
### Browser Side (`src/ui/lib/audio-capture.ts`)
The `createAudioRecorder()` function:
1. Requests microphone access with WebRTC audio constraints
2. Creates a MediaRecorder to capture processed audio
3. Collects audio chunks during recording
4. Returns a Blob when recording stops
Key features:
- Logs actual applied audio settings for debugging
- Prefers WebM/Opus codec (best for speech)
- Falls back to other supported codecs if needed
- Stops all audio tracks on completion
### Server Side (`src/server/websocket-server.ts`)
The WebSocket server:
1. Receives base64-encoded audio chunks
2. Buffers chunks until recording completes
3. Passes complete audio to STT handler
4. Broadcasts transcription and TTS response
Key features:
- Simple message-based protocol
- No complex state management
- Broadcasts to all connected clients
## Troubleshooting
### Microphone Permission Denied
- Ensure HTTPS is used (required on mobile)
- Check browser permissions settings
- Try in a different browser
### Poor Audio Quality
- Check microphone settings in OS
- Verify audio constraints are applied (check console logs)
- Test microphone in other applications
### High Latency
- Check network connection
- Verify server is responding quickly
- Monitor OpenAI API response times
## Future Enhancements
Potential improvements to consider:
1. **Voice Activity Detection (VAD)**: Automatically start/stop recording based on speech detection
2. **Audio Preprocessing**: Additional client-side filtering before sending to server
3. **Compression**: Use more aggressive compression for lower bandwidth
4. **Streaming STT**: Stream audio to server for real-time transcription
5. **Audio Visualization**: Show waveform or volume meter during recording
## References
- [MDN: getUserMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
- [MDN: MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
- [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text)
- [OpenAI TTS API](https://platform.openai.com/docs/guides/text-to-speech)

View File

@@ -1,496 +0,0 @@
# Voice Assistant - Implementation Plan
## Project Overview
**Goal**: Build a voice-controlled terminal assistant that runs locally as a single Express service.
**Architecture**:
- Single Express process serving everything
- Vite React UI bundled and served from Express
- WebSocket for control messages (activity log, status updates)
- Agent logic (STT/TTS/LLM pipeline using APIs)
- Daemon logic (terminal control using tmux) - same process, direct function calls
- Audio streaming solution (Phase 8)
## Technology Stack
### Server
- **Runtime**: Node.js with TypeScript
- **Framework**: Express
- **WebSocket**: ws library
- **Terminal Control**: tmux (via child_process)
- **APIs**:
- OpenAI (GPT-4 Turbo for LLM, TTS)
- Deepgram (Streaming STT)
### Client
- **Framework**: React 18
- **Build Tool**: Vite
- **Styling**: CSS (custom)
- **WebSocket**: Native WebSocket API
## Package Structure
```
packages/voice-assistant/
├── package.json
├── tsconfig.json # UI TypeScript config
├── tsconfig.server.json # Server TypeScript config
├── tsconfig.node.json # Vite config TypeScript
├── vite.config.ts # Vite bundler config
├── .env.example
├── .gitignore
├── IMPLEMENTATION_PLAN.md # This file
├── src/
│ ├── server/
│ │ ├── index.ts # ✅ Express + HTTP server entry point
│ │ ├── types.ts # ✅ Shared server types
│ │ ├── websocket-server.ts # ✅ WebSocket server
│ │ │
│ │ ├── agent/
│ │ │ ├── orchestrator.ts # Agent pipeline coordinator
│ │ │ ├── stt-deepgram.ts # Deepgram streaming STT
│ │ │ ├── llm-openai.ts # OpenAI GPT-4 with tools
│ │ │ ├── tts-openai.ts # OpenAI TTS (stream)
│ │ │ └── system-prompt.ts # Load from agent-prompt.md
│ │ │
│ │ └── daemon/
│ │ ├── terminal-manager.ts # Terminal control API
│ │ ├── tmux.ts # tmux primitives (from mcp-server)
│ │ └── tool-definitions.ts # LLM tool schemas
│ │
│ └── ui/
│ ├── index.html # ✅ HTML entry point
│ ├── main.tsx # ✅ React root
│ ├── App.tsx # ✅ Main app component
│ ├── App.css # ✅ App styles
│ ├── index.css # ✅ Global styles
│ │
│ ├── components/
│ │ ├── VoiceInterface.tsx # Main voice UI
│ │ ├── ActivityLog.tsx # Conversation log
│ │ ├── ConnectionIndicator.tsx # Connection status
│ │ └── AudioVisualizer.tsx # Audio waveform
│ │
│ ├── hooks/
│ │ ├── useWebSocket.ts # ✅ WebSocket client
│ │ └── useWebRTC.ts # WebRTC client (Phase 8)
│ │
│ └── lib/
│ └── audio-utils.ts # Audio processing utilities
└── dist/
├── server/ # Compiled TypeScript
└── ui/ # Built Vite app
```
---
## Implementation Phases
### ✅ Phase 1: Foundation (COMPLETED)
**Tasks**:
1. ✅ Create package structure and configuration files
2. ✅ Set up package.json with dependencies and scripts
3. ✅ Create TypeScript configurations (tsconfig.json, tsconfig.server.json)
4. ✅ Create Vite configuration (vite.config.ts)
5. ✅ Create basic Express server (src/server/index.ts)
6. ✅ Create basic Vite React app (src/ui/)
7. ✅ Install dependencies and test dev servers
**Key Files Created**:
- `src/server/index.ts` - Express server with production static file serving
- `src/server/types.ts` - Shared TypeScript types
- `src/ui/App.tsx` - Main React component
- `src/ui/index.html` - HTML entry point
- `vite.config.ts` - Vite build configuration
**Development Workflow**:
```bash
npm run dev # Runs both servers (concurrently)
npm run dev:server # Express on port 3000
npm run dev:ui # Vite on port 5173
```
**Production Build**:
```bash
npm run build # Builds UI + server
npm start # Serves from dist/
```
---
### ✅ Phase 2: WebSocket Communication (COMPLETED)
**Tasks**:
1. ✅ Add WebSocket server (websocket-server.ts)
2. ✅ Add WebSocket client hook (useWebSocket.ts)
3. ✅ Test WebSocket ping/pong communication
**Key Features**:
- WebSocket server on `/ws` path
- Client-side `useWebSocket` hook with auto-reconnect
- Message handler registration system
- Ping/pong messaging for connection testing
- Activity log UI component
- Real-time connection status indicator
**Key Files Created**:
- `src/server/websocket-server.ts` - VoiceAssistantWebSocketServer class
- `src/ui/hooks/useWebSocket.ts` - Client WebSocket hook
- Updated `src/ui/App.tsx` - WebSocket integration & activity log
**WebSocket Message Types**:
```typescript
interface WebSocketMessage {
type: 'activity_log' | 'status' | 'webrtc_signal' | 'ping' | 'pong';
payload: unknown;
}
```
**Testing**:
1. Start both servers: `npm run dev`
2. Open http://localhost:5173
3. Click "Send Ping" button
4. See "Received pong from server" in activity log
---
### ⏳ Phase 3: Terminal Control (Daemon)
**Tasks**:
1. ⏳ Copy tmux.ts from mcp-server package
2. ⏳ Create terminal-manager.ts with tool functions
3. ⏳ Create tool-definitions.ts for LLM schemas
4. ⏳ Test terminal operations (list, create, capture)
**Objectives**:
- Reuse existing tmux primitives from mcp-server
- Create high-level terminal management API
- Define LLM tool schemas for terminal operations
- Test terminal creation, command execution, output capture
**Terminal Tools** (7 core operations):
```typescript
// Tool functions to implement in terminal-manager.ts
1. listTerminals() TerminalInfo[]
2. createTerminal(name, workingDirectory, initialCommand?)
3. captureTerminal(terminalId, lines?, wait?)
4. sendText(terminalId, text, pressEnter?, return_output?)
5. sendKeys(terminalId, keys, repeat?, return_output?)
6. renameTerminal(terminalId, name)
7. killTerminal(terminalId)
```
**Terminal Model**:
- Default tmux session: `voice-dev`
- Terminal = tmux window (single pane)
- Terminal ID = window ID (format: `@123`)
- Working directory set on creation
---
### ⏳ Phase 4: LLM Integration
**Tasks**:
1. ⏳ Copy agent-prompt.md from agent-python
2. ⏳ Create system-prompt.ts to load prompt file
3. ⏳ Create llm-openai.ts (GPT-4 with function calling)
4. ⏳ Test LLM integration with tool calls
**Objectives**:
- Reuse existing system prompt from Python agent
- Implement OpenAI GPT-4 Turbo with function calling
- Define terminal tools as OpenAI functions
- Test: "create a terminal called test" → executes createTerminal()
**LLM Configuration**:
```typescript
{
model: "gpt-4-turbo",
tools: terminalToolDefinitions,
tool_choice: "auto",
stream: true
}
```
---
### ⏳ Phase 5: Agent Orchestrator
**Tasks**:
1. ⏳ Create agent orchestrator.ts
2. ⏳ Wire text input → LLM → tool execution → response
3. ⏳ Emit activity log events to WebSocket
4. ⏳ Test end-to-end text-based commands
**Objectives**:
- Create main agent loop
- Handle: user input → LLM → tool calls → terminal → response
- Broadcast activity to WebSocket clients
- Test complete text-based interaction flow
**Agent Loop**:
```
1. Receive user input (text/transcript)
2. Add to conversation context
3. Call LLM with tools
4. If tool calls:
a. Execute each tool via terminal-manager
b. Add results to context
c. Call LLM again with results
5. Return final response
6. Broadcast all events to WebSocket
```
---
### ⏳ Phase 6: Speech-to-Text (Deepgram)
**Tasks**:
1. ⏳ Add Deepgram STT integration (stt-deepgram.ts)
2. ⏳ Test STT with audio input
**Objectives**:
- Implement Deepgram streaming STT
- Handle audio chunks from client
- Return transcript fragments in real-time
- Detect end-of-utterance
**Deepgram Configuration**:
```typescript
{
model: "nova-2",
language: "en",
smart_format: true,
punctuate: true,
interim_results: true
}
```
---
### ⏳ Phase 7: Text-to-Speech (OpenAI)
**Tasks**:
1. ⏳ Add OpenAI TTS integration (tts-openai.ts)
2. ⏳ Test TTS with text input
**Objectives**:
- Implement OpenAI TTS API
- Convert text responses to audio
- Stream audio chunks to client
- Handle playback in browser
**TTS Configuration**:
```typescript
{
model: "tts-1",
voice: "alloy",
response_format: "pcm" // or "opus" for streaming
}
```
---
### ⏳ Phase 8: Audio Streaming
**Tasks**:
1. ⏳ Research WebRTC alternatives for audio streaming
2. ⏳ Implement audio streaming solution
3. ⏳ Test end-to-end voice interaction
**Objectives**:
- Implement bidirectional audio streaming
- Browser → Server: Microphone input
- Server → Browser: TTS output
- Consider: WebRTC (browser native) or WebSocket audio
**Note**:
- `wrtc` package removed due to native compilation issues
- Alternative options:
1. **WebRTC (browser-to-browser via signaling)** - Most robust
2. **WebSocket audio streaming** - Simpler, less efficient
3. **simple-peer** - WebRTC wrapper library
4. **MediaRecorder API** - Send audio chunks via WebSocket
---
### ⏳ Phase 9: UI Polish
**Tasks**:
1. ⏳ Create ActivityLog component (proper component)
2. ⏳ Create ConnectionIndicator component
3. ⏳ Add mute/unmute controls
4. ⏳ Polish UI and test full application
**Objectives**:
- Extract activity log into reusable component
- Add microphone mute/unmute button
- Add visual audio level indicator
- Improve styling and UX
- End-to-end testing
---
## Environment Variables
`.env` file (copy from `.env.example`):
```bash
# OpenAI API Key (for GPT-4 and TTS)
OPENAI_API_KEY=sk-...
# Deepgram API Key (for streaming STT)
DEEPGRAM_API_KEY=...
# Server Configuration
PORT=3000
NODE_ENV=development
```
---
## Development Commands
```bash
# Install dependencies
npm install
# Development (runs both servers)
npm run dev
# Development (individual servers)
npm run dev:server # Express on port 3000
npm run dev:ui # Vite on port 5173
# Build
npm run build:ui # Build Vite UI → dist/ui/
npm run build:server # Compile TS → dist/server/
npm run build # Build both
# Production
npm start # Serve from dist/
# Type checking
npm run typecheck # Check both UI and server types
```
---
## Testing Strategy
### Phase 1-2 (Foundation + WebSocket)
- ✅ Manual: Start servers, check endpoints
- ✅ Manual: Open UI, test ping/pong
### Phase 3 (Terminal Control)
- Manual: Call terminal-manager functions from server
- Manual: Verify tmux windows created/destroyed
- Manual: Check command execution and output capture
### Phase 4 (LLM)
- Manual: Send text prompts, check tool calls
- Manual: Verify tool execution results in context
### Phase 5 (Orchestrator)
- Manual: Send "create a terminal called test"
- Verify: LLM calls createTerminal tool
- Verify: Activity log shows tool call and result
### Phase 6-7 (STT/TTS)
- Manual: Record audio, check transcript
- Manual: Send text, check audio playback
### Phase 8 (Audio Streaming)
- Manual: Speak into microphone
- Verify: Real-time transcription
- Verify: Agent response plays back
### Phase 9 (Full E2E)
- Manual: Complete voice interaction loop
- Test: Various terminal commands via voice
- Test: Error handling and edge cases
---
## Key Decisions & Trade-offs
### 1. **Single Process Architecture**
- **Decision**: Run agent + daemon in same process
- **Why**: Simpler V1, avoid IPC complexity
- **Future**: Can split into microservices later
### 2. **OpenAI All-in-One**
- **Decision**: Use OpenAI for both LLM and TTS, Deepgram for STT
- **Why**: Simpler integration, fewer providers
- **Note**: Deepgram required because OpenAI Whisper doesn't support streaming
### 3. **Removed wrtc Package**
- **Decision**: Defer WebRTC server implementation to Phase 8
- **Why**: `wrtc` has native compilation issues
- **Alternative**: Will use browser-native WebRTC or WebSocket audio
### 4. **Auto-Execute Terminal Commands**
- **Decision**: No permission prompts in V1
- **Why**: Faster development, simpler UX
- **Future**: Add permission system in V2
### 5. **Reuse Existing Code**
- **Decision**: Copy tmux.ts and agent-prompt.md from existing packages
- **Why**: Proven, tested code
- **Benefit**: Consistent terminal model across packages
---
## Current Progress
**✅ Completed**: 10/33 tasks (30%)
- Phase 1: Foundation (7 tasks)
- Phase 2: WebSocket (3 tasks)
**⏳ In Progress**: Phase 3 (Terminal Control)
**📋 Remaining**: 23 tasks
- Phase 3: 4 tasks
- Phase 4: 4 tasks
- Phase 5: 4 tasks
- Phase 6: 2 tasks
- Phase 7: 2 tasks
- Phase 8: 3 tasks
- Phase 9: 4 tasks
---
## Next Steps
1. **Phase 3**: Copy `tmux.ts` from mcp-server
2. **Phase 3**: Create `terminal-manager.ts` with 7 core tools
3. **Phase 3**: Define OpenAI function schemas for tools
4. **Phase 3**: Test terminal operations manually
5. **Phase 4**: Copy `agent-prompt.md` and set up LLM
---
## References
- **Existing Packages**:
- `packages/mcp-server/src/tmux.ts` - Terminal control primitives
- `packages/agent-python/agent-prompt.md` - System prompt
- `packages/web/app/hooks/use-livekit-voice.ts` - LiveKit reference
- **APIs**:
- [OpenAI API Docs](https://platform.openai.com/docs)
- [Deepgram API Docs](https://developers.deepgram.com/)
- [WebRTC API Docs](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
---
## Notes
- This is a **V1 implementation** focused on getting a working prototype
- Prioritize simplicity over optimization
- Each phase should be independently testable
- WebSocket infrastructure is ready for real-time updates
- Audio streaming is the most complex remaining piece

View File

@@ -1,438 +0,0 @@
# Voice Assistant Implementation Summary
## Phases 6 & 7: Speech-to-Text (STT) and Text-to-Speech (TTS)
### Implementation Complete ✅
All tasks have been successfully implemented. The voice assistant now supports full voice interaction using OpenAI's Whisper API for STT and OpenAI's TTS API for speech synthesis.
---
## Research Findings
### OpenAI Whisper API (STT)
- **Streaming**: NOT supported - requires complete audio files
- **Implementation Model**: Push-to-talk (record → stop → transcribe)
- **Supported Formats**: webm, ogg, mp3, wav, m4a, mp4, mpeg, mpga, oga, flac
- **File Size Limit**: 25 MB
- **Latency**: < 3 seconds for typical voice commands
### OpenAI TTS API
- **Streaming**: Supported via chunk transfer encoding
- **Implementation**: Buffer-and-play model (simpler, reliable)
- **Voices**: alloy, echo, fable, onyx, nova, shimmer
- **Models**: tts-1 (fast), tts-1-hd (high quality)
- **Formats**: mp3, opus, aac, flac, wav, pcm
- **Default**: mp3 format with "alloy" voice
### Browser Audio APIs
- **MediaRecorder API**: Capture audio from microphone (webm/opus preferred)
- **Web Audio API**: Playback using HTMLAudioElement with blob URLs
- **Browser Support**: Chrome, Firefox, Safari, Edge (2021+)
- **HTTPS Required**: Yes (for microphone access on non-localhost)
---
## Architecture
### STT Flow (Push-to-Talk)
```
1. User clicks microphone button in UI
2. Browser captures audio via MediaRecorder API (webm/opus format)
3. Audio buffered in browser
4. User clicks stop button
5. Complete audio sent to server via WebSocket (base64 encoded)
6. Server receives audio, saves to temp file
7. Server calls OpenAI Whisper API
8. Transcript returned to server
9. Server broadcasts transcript to client
10. Server feeds transcript to orchestrator (LLM processing)
11. Assistant response generated and displayed
```
### TTS Flow (Buffer-and-Play)
```
1. Orchestrator generates LLM response (streaming text)
2. Server buffers complete response text
3. Server calls OpenAI TTS API with complete text
4. TTS returns audio (MP3, streamed from OpenAI)
5. Server buffers complete audio
6. Server sends audio to browser via WebSocket (base64 encoded)
7. Browser decodes audio, creates Blob URL
8. Browser plays audio using HTMLAudioElement
9. Queue management: next audio plays when current finishes
```
---
## Files Created
### Server-Side
1. **`src/server/agent/stt-openai.ts`** - STT integration module
- `initializeSTT()` - Initialize OpenAI Whisper client
- `transcribeAudio()` - Transcribe audio buffer to text
- Handles temporary file creation/cleanup
- Format detection (webm, ogg, mp3, etc.)
2. **`src/server/agent/tts-openai.ts`** - TTS integration module
- `initializeTTS()` - Initialize OpenAI TTS client
- `synthesizeSpeech()` - Convert text to speech
- Configurable voice and model
- Streaming response to buffer conversion
### Client-Side
3. **`src/ui/lib/audio-capture.ts`** - Audio recording utility
- `createAudioRecorder()` - Factory for audio recorder
- Microphone permission handling
- Format detection (webm/opus, ogg/opus)
- MediaRecorder API integration
- Chunk buffering and blob creation
4. **`src/ui/lib/audio-playback.ts`** - Audio playback utility
- `createAudioPlayer()` - Factory for audio player
- Queue management (sequential playback)
- Blob URL creation and cleanup
- HTMLAudioElement wrapper
5. **`src/ui/components/VoiceControls.tsx`** - Voice UI component
- Microphone recording button
- Recording/processing/playing states
- Visual feedback (animations, indicators)
- Permission handling
- Error display
### Modified Files
6. **`src/server/types.ts`** - Added audio message types
- `AudioChunkPayload`
- `RecordingStatePayload`
- `TranscriptionResultPayload`
- `AudioOutputPayload`
7. **`src/server/websocket-server.ts`** - Audio message handling
- `setAudioHandler()` - Register audio processing handler
- `handleAudioChunk()` - Buffer and process audio chunks
- Audio buffering for multiple chunks
- Base64 decoding
8. **`src/server/index.ts`** - Wire STT/TTS to server
- Initialize STT and TTS clients
- Audio handler for voice input (STT orchestrator)
- TTS generation for voice responses
- Helper function `processMessageWithOptionalTTS()`
9. **`src/server/agent/orchestrator.ts`** - Added enableTTS param
- Optional TTS synthesis parameter
- (Not used directly, TTS handled in server index)
10. **`src/ui/App.tsx`** - Integrated voice controls
- Added VoiceControls component
- Audio recording handler
- Audio playback handler
- State management (processing, playing)
- WebSocket message listeners
11. **`src/ui/App.css`** - Voice control styles
- Voice button styles
- Recording indicator animation
- Processing spinner animation
- Error/permission messages
- Playing indicator
12. **`.env.example`** - Updated with TTS configuration
- `TTS_VOICE` (default: alloy)
- `TTS_MODEL` (default: tts-1)
---
## Configuration
### Environment Variables
```bash
# Required
OPENAI_API_KEY=sk-...
# Optional (with defaults)
TTS_VOICE=alloy # alloy, echo, fable, onyx, nova, shimmer
TTS_MODEL=tts-1 # tts-1 or tts-1-hd
PORT=3000
NODE_ENV=development
```
---
## Usage Instructions
### Setup
1. **Install dependencies** (already done):
```bash
npm install
```
2. **Create `.env` file**:
```bash
cp .env.example .env
# Add your OPENAI_API_KEY
```
3. **Start the server**:
```bash
npm run dev
```
4. **Open browser**:
- Dev mode: http://localhost:5173 (Vite UI)
- Production: http://localhost:3000
### Voice Interaction
1. **Grant microphone permission** when prompted
2. **Click "Record" button** to start recording
3. **Speak your command**: e.g., "list all terminals"
4. **Click "Recording..." button** to stop
5. **Wait for transcription** (< 3 seconds)
6. **LLM processes** your request (executes tools if needed)
7. **Response is spoken** via TTS automatically
8. **Text is also displayed** in activity log
### Text Interaction (Still Works)
- Type message in text input
- Press "Send"
- Response displayed as text only (no TTS)
---
## Testing Scenarios
### Test 1: Basic STT
**Steps:**
1. Click "Record"
2. Say: "list terminals"
3. Click stop
**Expected:**
- ✅ Transcription appears in log
- ✅ Tool execution (list-terminals)
- ✅ Response displayed
### Test 2: Basic TTS
**Steps:**
1. Click "Record"
2. Say: "what terminals are available?"
3. Click stop
**Expected:**
- ✅ Transcription appears
- ✅ Text response streams
- ✅ Audio playback starts
- ✅ Speech matches text
### Test 3: Full Voice Conversation
**Steps:**
1. Say: "create a terminal called voice-test in /tmp"
2. Wait for response
3. Say: "list all terminals"
**Expected:**
- ✅ First command transcribed
- ✅ Terminal created
- ✅ TTS response played
- ✅ Second command works
- ✅ Terminal appears in list
### Test 4: Error Handling
**Steps:**
1. Deny microphone permission
2. Try to record
**Expected:**
- ✅ Permission error shown
- ✅ Record button disabled
### Test 5: Format Compatibility
**Test in:**
- Chrome (webm/opus)
- Firefox (ogg/opus)
- Safari (if available)
**Expected:**
- ✅ Format detected correctly
- ✅ Recording works
- ✅ Transcription succeeds
---
## Technical Details
### WebSocket Message Types
**Client → Server:**
```typescript
{
type: 'audio_chunk',
payload: {
audio: string, // base64 encoded
format: string, // 'audio/webm;codecs=opus'
isLast: boolean // true when recording complete
}
}
```
**Server → Client:**
```typescript
{
type: 'transcription_result',
payload: {
text: string,
language?: string,
duration?: number
}
}
{
type: 'audio_output',
payload: {
id: string, // unique ID
audio: string, // base64 encoded MP3
format: string // 'mp3'
}
}
```
### Audio Processing
**STT:**
- Accepts: Buffer (audio data), string (format)
- Creates temporary file for OpenAI API
- Cleans up temp file after transcription
- Returns: `{ text, language, duration }`
**TTS:**
- Accepts: string (text to synthesize)
- Streams response from OpenAI
- Converts stream to buffer
- Returns: `{ audio: Buffer, format: string }`
### Browser Audio
**Recording:**
- MediaRecorder with 100ms chunk interval
- Echo cancellation, noise suppression enabled
- Auto-detects supported mime type
- Buffers all chunks until stop
**Playback:**
- Creates blob URL from audio data
- Uses HTMLAudioElement for playback
- Sequential queue (one at a time)
- Cleans up blob URLs after playback
---
## Performance
### Latency Measurements
- **STT**: ~1-3 seconds (depends on audio length)
- **TTS**: ~1-2 seconds (depends on text length)
- **Total Voice Round-Trip**: ~3-5 seconds
- **Browser Recording**: Real-time, no delay
- **Browser Playback**: Immediate after buffer
### Resource Usage
- **Server Memory**: +50MB for OpenAI clients
- **Temp Files**: ~500KB per recording (auto-cleaned)
- **Network**:
- Upload: ~50KB per second of audio
- Download: ~10KB per second of speech
- **Browser**: Minimal (MediaRecorder, HTMLAudioElement)
---
## Known Limitations
1. **No Real-time Streaming STT**: Must wait for complete recording
2. **No Voice Activity Detection**: Manual stop required
3. **No Interrupt Capability**: Cannot interrupt playback
4. **Single Conversation**: One conversation per server instance
5. **No Audio Visualization**: No waveform display
6. **HTTPS Required**: For production (microphone access)
---
## Future Enhancements (Out of Scope)
1. **OpenAI Realtime API**: True streaming STT+LLM+TTS
2. **Voice Activity Detection**: Auto-stop on silence
3. **Interrupt Playback**: Stop TTS to speak again
4. **Audio Visualization**: Waveform/spectrum display
5. **Custom Wake Words**: "Hey Assistant..."
6. **Speaker Diarization**: Multiple speakers
7. **Noise Cancellation**: Advanced audio processing
8. **Multi-language**: Language detection and switching
---
## Success Criteria ✅
- ✅ User can record voice and get accurate transcription
- ✅ Transcription feeds into existing text chat flow
- ✅ Assistant responses are spoken via TTS
- ✅ Audio quality is acceptable (clear, natural)
- ✅ Latency is acceptable (< 5 seconds total)
- ✅ Error handling is graceful
- ✅ Works in Chrome and Firefox
- ✅ No new dependencies required
- ✅ Code is maintainable and well-structured
- ✅ TypeScript typechecks pass
---
## Dependencies Used
**No new dependencies added!** All using existing packages:
- `openai`: ^4.20.0 (STT and TTS APIs)
- `ws`: ^8.14.2 (WebSocket communication)
- `uuid`: ^9.0.1 (Unique IDs)
**Browser APIs:**
- MediaRecorder API (recording)
- HTMLAudioElement (playback)
- Navigator.mediaDevices (microphone access)
---
## Conclusion
Phases 6 & 7 (STT and TTS) have been successfully implemented. The voice assistant now supports:
1. **Voice Input**: Push-to-talk recording with OpenAI Whisper transcription
2. **Voice Output**: OpenAI TTS synthesis with automatic playback
3. **Full Voice Conversations**: Speak → transcribe → process → synthesize → play
4. **Graceful Degradation**: Text chat still works alongside voice
5. **Error Handling**: Microphone permissions, transcription errors, playback failures
6. **Cross-Browser**: Chrome and Firefox support confirmed
The implementation follows the push-to-talk model due to Whisper API limitations (no streaming STT). This is a reliable, production-ready approach that provides excellent voice interaction with minimal latency.
**Ready for testing with a real OpenAI API key!**
To test:
1. Create `.env` file with `OPENAI_API_KEY`
2. Run `npm run dev`
3. Open http://localhost:5173
4. Grant microphone permission
5. Click "Record" and speak
---
**Implementation Date**: 2025-10-19
**Status**: Complete
**Next Phase**: Testing and refinement based on user feedback

View File

@@ -1,226 +0,0 @@
# LLM Integration Guide
This document describes Phase 4 of the voice-assistant package: LLM Integration with OpenAI GPT-4.
## Overview
The LLM integration enables the voice assistant to understand natural language commands and execute terminal operations using OpenAI's function calling capabilities.
## Components
### 1. System Prompt (`agent-prompt.md`)
The system prompt defines the agent's personality and behavior. It's loaded from the package root and instructs the agent to:
- Acknowledge requests verbally before acting
- Report tool execution results back to the user
- Handle voice-to-text errors gracefully
- Be concise for mobile users
- Use the simplified "terminals" model for all terminal interactions
**Location**: `/packages/voice-assistant/agent-prompt.md`
### 2. System Prompt Loader (`src/server/agent/system-prompt.ts`)
Provides functions to load and cache the system prompt:
- `loadSystemPrompt()`: Loads the prompt from disk
- `getSystemPrompt()`: Returns cached prompt (loads if not cached)
### 3. OpenAI Integration (`src/server/agent/llm-openai.ts`)
Main LLM integration module with the following functions:
#### `initializeOpenAI(apiKey: string)`
Initializes the OpenAI client with your API key.
#### `callLLM(params: CallLLMParams): Promise<LLMResponse>`
Calls OpenAI's chat completions API with:
- Messages (conversation history)
- Tools (terminal operations)
- Optional streaming callback
Returns the LLM's response including any tool calls.
#### `executeToolCall(toolCall: ToolCall): Promise<string>`
Executes a tool call by mapping to terminal manager functions:
- `list_terminals``listTerminals()`
- `create_terminal``createTerminal(args)`
- `capture_terminal``captureTerminal(args.terminalId, args.lines, args.wait)`
- `send_text``sendText(args.terminalId, args.text, args.pressEnter, args.return_output)`
- `rename_terminal``renameTerminal(args.terminalId, args.newName)`
- `kill_terminal``killTerminal(args.terminalId)`
Returns the result as a JSON string.
#### `getTerminalTools(): ChatCompletionTool[]`
Returns the terminal tools in OpenAI's function calling format.
## Usage
### Environment Setup
Create a `.env` file in the package root:
```bash
OPENAI_API_KEY=sk-...
PORT=3000
NODE_ENV=development
```
### Testing the Integration
The package includes a test endpoint at `/api/test-llm` that demonstrates the LLM integration.
#### Manual Testing with curl
```bash
# Test 1: Create a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called llm-test"}' | jq .
# Test 2: List all terminals
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
# Test 3: Run a command
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run echo hello in the llm-test terminal"}' | jq .
```
#### Automated Testing
Run the provided test script:
```bash
./test-llm.sh
```
## API Response Format
The `/api/test-llm` endpoint returns:
```typescript
{
success: boolean;
userMessage: string;
llmResponse: string; // The LLM's text response
toolCalls?: Array<{
tool: string; // Tool name (e.g., "create_terminal")
arguments: any; // Tool arguments
result: string; // JSON result from tool execution
}>;
}
```
## Example Flow
1. **User sends message**: "create a terminal called web-dev at ~/projects/web"
2. **LLM processes request**:
- System prompt provides context about terminal operations
- User message is analyzed
- LLM decides to call `create_terminal` tool
3. **Tool execution**:
- Arguments: `{name: "web-dev", workingDirectory: "~/projects/web"}`
- `createTerminal()` is called
- Result returned as JSON
4. **Response to user**:
```json
{
"success": true,
"userMessage": "create a terminal called web-dev at ~/projects/web",
"llmResponse": "I'll create a terminal for you...",
"toolCalls": [{
"tool": "create_terminal",
"arguments": {"name": "web-dev", "workingDirectory": "~/projects/web"},
"result": "{\"id\":\"@123\",\"name\":\"web-dev\",\"workingDirectory\":\"/Users/user/projects/web\",...}"
}]
}
```
## Integration with Voice Assistant
The LLM integration is designed to work with the voice assistant's WebSocket server. Future phases will:
1. Connect WebSocket messages to LLM processing
2. Stream LLM responses back to the client
3. Handle multi-turn conversations
4. Maintain conversation context
## Error Handling
The LLM integration includes comprehensive error handling:
- Missing API key: Returns 500 error with message
- Tool execution failures: Returns error JSON in tool result
- Network errors: Caught and returned as 500 errors
- Invalid tool arguments: Caught by terminal manager functions
## Configuration
### Model Selection
Currently using `gpt-4o` (specified in `llm-openai.ts`). You can change this to:
- `gpt-4-turbo`: Faster, cheaper GPT-4
- `gpt-4`: Standard GPT-4
- `gpt-3.5-turbo`: Cheaper option (may have reduced performance)
### Streaming
Streaming is supported but not enabled by default in the test endpoint. To enable:
```typescript
const response = await callLLM({
messages,
tools: getTerminalTools(),
onChunk: (chunk) => {
// Handle streaming chunks
console.log(chunk);
}
});
```
## Development
### Building
```bash
npm run build
```
### Running in Development
```bash
npm start
```
The server will start on port 3000 (or `PORT` from `.env`).
## Troubleshooting
### "OpenAI client not initialized" error
Make sure you call `initializeOpenAI(apiKey)` before `callLLM()`.
### "OPENAI_API_KEY not set" error
Ensure you have a `.env` file with a valid `OPENAI_API_KEY`.
### Tool execution errors
Check that:
1. The default tmux session is initialized
2. Terminal IDs are valid (get them from `list_terminals`)
3. Arguments match the tool schema
### TypeScript errors
Run `npm run build` to check for compilation errors.
## Next Steps
Phase 5 will integrate the LLM with the WebSocket server to enable real-time voice conversations with terminal control.

View File

@@ -1,106 +0,0 @@
# Quick Start: LLM Integration
## Setup
1. **Install dependencies** (if not already done):
```bash
npm install
```
2. **Create `.env` file**:
```bash
cp .env.example .env
# Edit .env and add your OPENAI_API_KEY
```
3. **Build the project**:
```bash
npm run build
```
4. **Start the server**:
```bash
npm start
```
## Testing
### Quick Test
```bash
# Make sure server is running on http://localhost:3000
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
```
### Run All Tests
```bash
./test-llm.sh
```
## Example Commands
Try these natural language commands:
```bash
# Create a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called web-dev at ~/projects/web"}' | jq .
# List terminals
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "show me all terminals"}' | jq .
# Run a command
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run npm install in the web-dev terminal"}' | jq .
# Get terminal output
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "show me the output from web-dev terminal"}' | jq .
# Kill a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "close the web-dev terminal"}' | jq .
```
## What's Next?
Phase 5 will integrate this LLM functionality with the WebSocket server to enable:
- Real-time voice conversations
- Streaming responses
- Multi-turn context
- Voice-to-terminal control
## Files Created
- `/agent-prompt.md` - System prompt defining agent behavior
- `/src/server/agent/system-prompt.ts` - Loads and caches system prompt
- `/src/server/agent/llm-openai.ts` - OpenAI integration with tool execution
- `/src/server/index.ts` - Added `/api/test-llm` endpoint
- `/LLM_INTEGRATION.md` - Comprehensive integration guide
- `/QUICK_START.md` - This file
- `/test-llm.sh` - Automated test script
## Troubleshooting
**Q: "OPENAI_API_KEY not set" error**
A: Create a `.env` file with `OPENAI_API_KEY=sk-...`
**Q: Server won't start**
A: Make sure tmux is installed and running
**Q: Tool calls failing**
A: Check that the default tmux session is initialized (happens automatically on server start)
**Q: Build errors**
A: Run `npm run build` and check the error messages
For more details, see `LLM_INTEGRATION.md`.

View File

@@ -1,382 +0,0 @@
# Voice Implementation Plan (Phases 6 & 7)
## Research Summary
### OpenAI Whisper API (STT)
**Key Findings:**
- **No Native Streaming Support**: OpenAI Whisper API does NOT support real-time streaming transcription
- **Batch Processing Only**: Must send complete audio files after recording stops
- **Supported Formats**: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
- **File Size Limit**: 25 MB maximum
- **API Endpoint**: `/v1/audio/transcriptions`
- **Strategy**: Push-to-talk (click to record, click to stop, then transcribe)
**Alternative Considered:**
- OpenAI Realtime API provides true streaming STT+LLM+TTS in one WebSocket connection
- However, it's a different architecture (conversational AI vs tool-calling assistant)
- For this phase, we'll use Whisper API with push-to-talk model
### OpenAI TTS API
**Key Findings:**
- **Streaming Supported**: Yes, via chunk transfer encoding
- **Supported Formats**: mp3, opus, aac, flac, wav, pcm
- **Available Voices**: alloy, echo, fable, onyx, nova, shimmer
- **Quality Models**:
- `tts-1`: Faster, lower latency, normal quality
- `tts-1-hd`: Higher quality, slower
- **API Endpoint**: `/v1/audio/speech`
- **Default Format**: MP3
### Browser Audio APIs
**MediaRecorder API (Audio Capture):**
- **Widely Supported**: Chrome, Firefox, Safari, Edge (since 2021)
- **Format Support Varies**:
- Chrome/Opera: `audio/webm;codecs=opus`
- Firefox: `audio/ogg;codecs=opus` (also supports webm since Firefox 63)
- Need to check `MediaRecorder.isTypeSupported()` at runtime
- **Recommended Approach**: Capture as WebM/Opus, convert if needed
- **Events**: `dataavailable` for chunks, `stop` for complete recording
**Web Audio API (Audio Playback):**
- **Challenge**: `decodeAudioData()` requires complete files, not chunks
- **Solution Options**:
1. Use `<audio>` element with blob URLs (simpler, works with MP3)
2. Use MediaSource Extensions for true streaming
3. Buffer complete audio before playback
- **Recommended**: Option 1 for simplicity - buffer complete TTS response, then play
## Architecture Decisions
### STT Flow (Push-to-Talk Model)
```
User clicks microphone button
Browser captures audio via MediaRecorder
Audio buffered in browser as WebM/Opus
User clicks stop button
Complete audio sent to server via WebSocket (base64 encoded)
Server saves as temporary file (WebM)
Server converts to MP3 if needed (using FFmpeg or accept WebM directly)
Server calls OpenAI Whisper API with audio file
Transcript returned and fed to orchestrator
processUserMessage() handles as normal text input
```
### TTS Flow (Buffer-and-Play Model)
```
Orchestrator generates LLM response (streaming text)
Server buffers complete response text
Server calls OpenAI TTS API with complete text
TTS returns audio (MP3 format, streamed from OpenAI)
Server buffers complete audio
Server sends complete audio to browser via WebSocket (base64 encoded)
Browser creates Blob URL from audio data
Browser plays using <audio> element
Queue management: only play next when current finishes
```
**Rationale for Buffer-and-Play:**
- Simpler implementation (no streaming audio chunks)
- Avoids "clicks" between chunks
- Works reliably across all browsers
- TTS API is fast enough that latency is acceptable
- Can be upgraded to true streaming later if needed
## Implementation Plan
### Phase 6A: STT Integration
#### 1. Server-side STT Module (`src/server/agent/stt-openai.ts`)
```typescript
export interface STTConfig {
apiKey: string;
model?: 'whisper-1';
}
export interface TranscriptionResult {
text: string;
language?: string;
duration?: number;
}
export function initializeSTT(config: STTConfig): void;
export async function transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult>;
```
**Implementation Notes:**
- Use `openai` package (already installed)
- Accept audio buffer and format (webm, mp3, etc.)
- Call `/v1/audio/transcriptions` endpoint
- Return transcript text
- Handle errors gracefully
#### 2. Update WebSocket Message Types (`src/server/types.ts`)
```typescript
export interface WebSocketMessage {
type: 'activity_log' | 'status' | 'webrtc_signal' | 'ping' | 'pong'
| 'user_message' | 'assistant_chunk'
| 'audio_chunk' | 'audio_output' | 'recording_state' | 'transcription_result';
payload: unknown;
}
export interface AudioChunkPayload {
audio: string; // base64 encoded audio data
format: string; // 'webm', 'ogg', etc.
isLast: boolean; // true when recording stopped
}
export interface RecordingStatePayload {
isRecording: boolean;
}
export interface TranscriptionResultPayload {
text: string;
duration?: number;
}
```
#### 3. Add Audio Handling to WebSocket Server (`src/server/websocket-server.ts`)
- Add handler for `audio_chunk` messages
- Buffer audio chunks until `isLast: true`
- Call STT transcription
- Send `transcription_result` back to client
- Feed transcript to orchestrator
#### 4. Wire STT to Orchestrator (`src/server/agent/orchestrator.ts`)
- Add `processAudioInput()` function that wraps `processUserMessage()`
- Add activity log entries for STT events
### Phase 6B: Browser Audio Capture
#### 1. Audio Capture Utility (`src/ui/lib/audio-capture.ts`)
```typescript
export interface AudioCaptureConfig {
mimeType?: string;
audioBitsPerSecond?: number;
}
export interface AudioRecorder {
start(): Promise<void>;
stop(): Promise<Blob>;
isRecording(): boolean;
getSupportedMimeType(): string | null;
}
export function createAudioRecorder(config?: AudioCaptureConfig): AudioRecorder;
```
**Implementation Notes:**
- Request microphone permission
- Detect supported mime type (prefer webm/opus)
- Use MediaRecorder API
- Buffer chunks in array
- Return complete Blob on stop
#### 2. Voice Controls Component (`src/ui/components/VoiceControls.tsx`)
```typescript
interface VoiceControlsProps {
onAudioRecorded: (audio: Blob, format: string) => void;
isProcessing: boolean;
}
```
**UI Elements:**
- Microphone button (toggle recording)
- Recording indicator (red dot animation)
- Processing state (spinning loader)
- Visual feedback (waveform or simple animation)
#### 3. Update Main App (`src/ui/App.tsx`)
- Add voice controls section
- Handle `audio_chunk` sending
- Display transcription results
- Show recording/processing states
### Phase 7A: TTS Integration
#### 1. Server-side TTS Module (`src/server/agent/tts-openai.ts`)
```typescript
export interface TTSConfig {
apiKey: string;
model?: 'tts-1' | 'tts-1-hd';
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
}
export interface SpeechResult {
audio: Buffer;
format: string;
}
export function initializeTTS(config: TTSConfig): void;
export async function synthesizeSpeech(text: string): Promise<SpeechResult>;
```
**Implementation Notes:**
- Use `openai` package streaming support
- Default to `tts-1` model with `alloy` voice
- Stream from OpenAI and buffer complete audio
- Return as Buffer with format info
#### 2. Update WebSocket Message Types
```typescript
export interface AudioOutputPayload {
audio: string; // base64 encoded audio data (complete)
format: string; // 'mp3'
id: string; // unique ID for queue management
}
```
#### 3. Wire TTS to Orchestrator (`src/server/agent/orchestrator.ts`)
- Modify `processUserMessage()` to optionally synthesize response
- Add `enableTTS: boolean` parameter
- After complete assistant response, call TTS
- Send `audio_output` message to WebSocket clients
- Keep text display alongside audio
### Phase 7B: Browser Audio Playback
#### 1. Audio Playback Utility (`src/ui/lib/audio-playback.ts`)
```typescript
export interface AudioPlayer {
play(audioData: Blob): Promise<void>;
stop(): void;
isPlaying(): boolean;
}
export function createAudioPlayer(): AudioPlayer;
```
**Implementation Notes:**
- Create blob URLs from audio data
- Use `<audio>` element for playback
- Clean up blob URLs after playback
- Simple queue: play next when current finishes
#### 2. Update Voice Controls
- Add speaker indicator (playing state)
- Add stop playback button
- Visual feedback (audio wave or icon animation)
#### 3. Update Main App
- Handle `audio_output` messages
- Queue audio playback
- Show playing state in UI
## Testing Strategy
### Unit Tests (Optional for now)
- Test audio format detection
- Test buffer encoding/decoding
- Test queue management
### Manual Integration Tests
**Test 1: Basic STT**
1. Open UI, ensure WebSocket connected
2. Click microphone button
3. Speak: "list terminals"
4. Click stop recording
5. Verify: Transcription appears in activity log
6. Verify: Tool execution happens
7. Verify: Response displayed
**Test 2: Basic TTS**
1. Send text message: "what terminals are available?"
2. Verify: Text response streams as normal
3. Verify: Audio playback starts automatically
4. Verify: Audio matches text response
5. Verify: Text remains visible during playback
**Test 3: Full Voice Conversation**
1. Record: "create a terminal called voice-test in /tmp"
2. Verify: Transcription → tool call → response → TTS playback
3. Record: "list all terminals"
4. Verify: Second interaction works correctly
5. Verify: Terminal was actually created in tmux
**Test 4: Error Handling**
1. Try recording without microphone permission
2. Try sending empty audio
3. Try sending very long audio (>1 minute)
4. Verify: Graceful error messages
**Test 5: Audio Format Compatibility**
1. Test in Chrome (webm/opus)
2. Test in Firefox (ogg/opus)
3. Test in Safari (if possible)
4. Verify: Format detection works correctly
## Environment Configuration
Add to `.env` (or `.env.example`):
```bash
# OpenAI API Configuration
OPENAI_API_KEY=sk-...
# STT Configuration (optional overrides)
WHISPER_MODEL=whisper-1
# TTS Configuration (optional overrides)
TTS_MODEL=tts-1
TTS_VOICE=alloy
TTS_FORMAT=mp3
```
## Dependencies (Already Installed)
- `openai`: ^4.20.0 ✓
- `ws`: ^8.14.2 ✓
- `uuid`: ^9.0.1 ✓
**No additional dependencies needed!**
## Implementation Order
1. **STT Server Module** - Core transcription logic
2. **Browser Audio Capture** - Get microphone working
3. **WebSocket Audio Messages** - Wire STT to UI
4. **STT Integration Test** - Verify speech-to-text works end-to-end
5. **TTS Server Module** - Core synthesis logic
6. **Browser Audio Playback** - Get speaker working
7. **TTS Integration Test** - Verify text-to-speech works end-to-end
8. **Polish & Error Handling** - Edge cases, loading states, errors
9. **End-to-End Voice Test** - Full conversation flow
## Future Enhancements (Out of Scope)
- Real-time streaming STT (requires different API or third-party service)
- Voice Activity Detection (VAD) for automatic recording stop
- Speaker diarization (multiple speakers)
- Custom wake words
- Interrupt playback to speak
- Noise cancellation
- Audio visualization (waveforms)
- OpenAI Realtime API migration (full duplex voice)
## Success Criteria
✅ User can record voice and get accurate transcription
✅ Transcription feeds into existing text chat flow
✅ Assistant responses are spoken via TTS
✅ Audio quality is acceptable (clear, natural)
✅ Latency is acceptable (< 3 seconds for STT, < 2 seconds for TTS)
Error handling is graceful
Works in Chrome and Firefox
No new dependencies required
Code is maintainable and well-structured

View File

@@ -1,840 +0,0 @@
# WebRTC Audio Streaming Implementation Plan
**Date:** 2025-10-19
**Phase:** 8 - WebRTC Migration
**Status:** ~~Planning Complete~~ **SUPERSEDED - See Note Below**
---
## ⚠️ ARCHITECTURAL PIVOT - OCTOBER 2025
**This implementation plan has been superseded by a simpler architecture.**
We initially planned to use server-side WebRTC (via werift) to handle bidirectional audio streaming. However, we realized that **server-side WebRTC is not necessary** for our use case.
### What We Actually Need
1. **Browser-side WebRTC audio processing** - Echo cancellation, noise suppression, auto gain control
2. **Simple WebSocket transport** - Send/receive audio between browser and server
### Why Server-Side WebRTC is Unnecessary
- The server doesn't need to be a WebRTC peer
- We only need the browser's audio processing features (available via `getUserMedia()`)
- WebSocket provides simple, reliable transport for audio data
- No need for complex signaling, ICE, STUN, or TURN servers
### Current Architecture (Implemented)
```
Browser Server
↓ ↓
[getUserMedia] → WebRTC Audio Processing → [MediaRecorder]
↓ ↓
[Base64 Encode] → WebSocket → [Decode] → STT → LLM → TTS
[Audio Element] ← WebSocket ← [Base64 Encode] ← [MP3]
```
**Key Benefits:**
- Simple architecture (no server-side WebRTC complexity)
- Browser handles all audio processing (echo cancel, noise suppress, AGC)
- WebSocket transport is reliable and easy to debug
- No NAT traversal issues
- Works perfectly for our use case
### See Updated Documentation
For current audio processing architecture, see:
- `/Users/moboudra/dev/voice-dev/packages/voice-assistant/AUDIO_PROCESSING.md`
---
## Original Plan (For Historical Reference)
## Executive Summary
This document outlines the plan to migrate from WebSocket-based audio transport to WebRTC for real-time bidirectional audio streaming. The implementation uses a **dual-connection architecture**:
1. **WebSocket** - Control channel for signaling, text messages, tool notifications, and status updates
2. **WebRTC** - Media channel for low-latency bidirectional audio streaming
---
## Research Findings
### Node.js WebRTC Library Evaluation
After extensive research (avoiding `wrtc` due to native compilation issues), here are the viable options:
#### Option 1: **werift-webrtc** (RECOMMENDED)
- **Repository**: https://github.com/shinyoshiaki/werift-webrtc
- **Status**: Actively maintained (latest: 0.22.2, 3 months ago)
- **Type**: Pure TypeScript/JavaScript implementation
- **Pros**:
- No native dependencies (no compilation issues)
- Full TypeScript support
- Includes ICE/DTLS/SCTP/RTP/SRTP
- Works in Node.js
- Active development
- **Cons**:
- Still maturing (some features incomplete)
- ICE restart not yet available
- TURN ICE TLS/TCP not supported
- No simulcast (send) support yet
- **Verdict**: Best choice for our use case (simple peer-to-peer audio)
#### Option 2: **simple-peer**
- **Repository**: https://github.com/feross/simple-peer
- **Status**: Popular but unmaintained (last update 2021)
- **Type**: Browser + Node.js wrapper around WebRTC
- **Pros**:
- Simple API
- Well-documented
- 312 projects using it
- **Cons**:
- Requires `wrtc` for Node.js (which we're avoiding)
- Not updated in 4 years
- Would need `@roamhq/wrtc` fork
- **Verdict**: Not suitable (requires wrtc)
#### Option 3: **Alternative Approach - Signaling Only**
- **Concept**: Browser handles both WebRTC peers
- **Architecture**: Server acts as signaling relay only
- **Pros**:
- No Node.js WebRTC library needed
- Server complexity stays low
- Browser WebRTC is mature and reliable
- **Cons**:
- Cannot process audio server-side (defeats our purpose)
- Cannot wire to STT/TTS on server
- **Verdict**: Not applicable to our use case
### Recommended Approach: **werift-webrtc**
We will use `werift-webrtc` for server-side WebRTC peer connection management. This gives us:
1. Pure JavaScript implementation (no native compilation)
2. Full control over audio streams on the server
3. Ability to wire WebRTC audio tracks to STT/TTS pipelines
4. TypeScript support for better development experience
---
## Architecture Design
### Current Architecture (WebSocket Audio)
```
Browser Server
↓ ↓
[MediaRecorder] → WebSocket → [Buffer] → STT → LLM → TTS → [Buffer] → WebSocket → [Audio Element]
↑ ↓
[Microphone] [Speakers]
```
**Issues:**
- High latency (base64 encoding/decoding)
- Buffering required (push-to-talk)
- Not real-time
- Inefficient for audio transport
### New Architecture (WebRTC + WebSocket)
```
Browser Server
↓ ↓
[RTCPeerConnection] ←─────────────→ [werift.RTCPeerConnection]
↑ WebRTC Audio ↓
↑ ↓
[Microphone] [Audio Track] → STT Stream → LLM
[Audio Track] ← TTS Stream
↓ ↓
[Speakers] ←─────────────────────┘
WebSocket (signaling only)
↓ ↓
[WebSocket] ←──── SDP/ICE ────→ [WebSocket Server]
←─── Messages ────→
←── Tool Logs ────→
```
**Benefits:**
- Real-time audio streaming (low latency)
- No base64 encoding overhead
- Native browser media handling
- Continuous streaming (no push-to-talk requirement)
- Efficient bandwidth usage
---
## Implementation Plan
### Phase 1: Backend Setup
#### Task 1.1: Install werift-webrtc
```bash
cd /Users/moboudra/dev/voice-dev/packages/voice-assistant
npm install werift
```
#### Task 1.2: Create WebRTC Server Module
**File**: `src/server/webrtc/peer-connection.ts`
```typescript
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'werift';
export interface WebRTCPeerConfig {
iceServers?: Array<{
urls: string | string[];
username?: string;
credential?: string;
}>;
}
export interface WebRTCPeer {
id: string;
connection: RTCPeerConnection;
onAudioTrack: (track: MediaStreamTrack) => void;
addAudioTrack: (track: MediaStreamTrack) => void;
close: () => void;
}
export function createWebRTCPeer(config?: WebRTCPeerConfig): WebRTCPeer;
```
**Responsibilities:**
- Create RTCPeerConnection with STUN/TURN configuration
- Handle ICE candidate gathering
- Manage audio track addition/reception
- Connection state management
- Clean shutdown
#### Task 1.3: Create WebRTC Manager
**File**: `src/server/webrtc/manager.ts`
```typescript
import type { VoiceAssistantWebSocketServer } from '../websocket-server.js';
import type { WebRTCPeer } from './peer-connection.js';
export interface WebRTCManager {
createPeerForClient(clientId: string): Promise<WebRTCPeer>;
handleOffer(clientId: string, offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit>;
handleAnswer(clientId: string, answer: RTCSessionDescriptionInit): Promise<void>;
handleIceCandidate(clientId: string, candidate: RTCIceCandidateInit): Promise<void>;
removePeer(clientId: string): void;
}
export function createWebRTCManager(wsServer: VoiceAssistantWebSocketServer): WebRTCManager;
```
**Responsibilities:**
- Manage multiple peer connections (one per client)
- Coordinate signaling via WebSocket
- Track connection state
- Clean up disconnected peers
#### Task 1.4: Integrate Signaling with WebSocket
**File**: `src/server/websocket-server.ts` (modifications)
Add handlers for:
- `webrtc_offer` - Client sends SDP offer
- `webrtc_answer` - Client sends SDP answer
- `webrtc_ice_candidate` - Client sends ICE candidate
Add methods:
- `sendWebRTCSignal(clientId, signal)` - Send signaling to specific client
#### Task 1.5: Wire Audio Input to STT
**File**: `src/server/audio/stream-processor.ts` (new)
```typescript
export interface AudioStreamProcessor {
processInputStream(track: MediaStreamTrack, onTranscript: (text: string) => void): void;
stop(): void;
}
export function createAudioStreamProcessor(): AudioStreamProcessor;
```
**Responsibilities:**
- Receive WebRTC audio track
- Convert to format suitable for streaming STT
- Buffer audio chunks
- Feed to STT API (may need streaming STT service)
- Emit transcripts as they arrive
**Challenge**: OpenAI Whisper doesn't support streaming. Options:
1. **Option A**: Buffer audio chunks (e.g., 3-second segments) and transcribe incrementally
2. **Option B**: Use streaming STT service (Deepgram, AssemblyAI, Google Speech-to-Text)
3. **Option C**: Keep hybrid model: WebRTC for TTS output, WebSocket for STT input
**Recommendation**: Start with **Option A** (buffered chunks), evaluate **Option B** if latency is too high.
#### Task 1.6: Wire TTS Output to WebRTC
**File**: `src/server/audio/tts-stream.ts` (new)
```typescript
export interface TTSAudioStream {
sendToTrack(audioBuffer: Buffer, track: MediaStreamTrack): Promise<void>;
close(): void;
}
export function createTTSAudioStream(): TTSAudioStream;
```
**Responsibilities:**
- Receive TTS audio buffer from OpenAI
- Convert to WebRTC audio frames (RTP packets)
- Send via WebRTC audio track to client
- Handle backpressure and buffering
---
### Phase 2: Frontend Setup
#### Task 2.1: Create WebRTC Client Module
**File**: `src/ui/lib/webrtc-client.ts`
```typescript
export interface WebRTCClient {
initialize(): Promise<void>;
createOffer(): Promise<RTCSessionDescriptionInit>;
handleAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
addLocalAudioTrack(stream: MediaStream): void;
onRemoteTrack(callback: (track: MediaStreamTrack) => void): void;
close(): void;
}
export function createWebRTCClient(
onIceCandidate: (candidate: RTCIceCandidate) => void,
onConnectionStateChange: (state: RTCPeerConnectionState) => void
): WebRTCClient;
```
**Responsibilities:**
- Create browser RTCPeerConnection
- Manage local media stream (microphone)
- Handle remote audio track (TTS output)
- Emit ICE candidates for signaling
- Track connection state
#### Task 2.2: Integrate Signaling with WebSocket Hook
**File**: `src/ui/hooks/useWebSocket.ts` (modifications)
Add message handlers:
- `webrtc_offer` - Server sends offer (if server initiates)
- `webrtc_answer` - Server sends answer
- `webrtc_ice_candidate` - Server sends ICE candidate
Add methods:
- `sendWebRTCSignal(type, data)` - Send signaling to server
#### Task 2.3: Create WebRTC Connection Hook
**File**: `src/ui/hooks/useWebRTC.ts` (new)
```typescript
export interface UseWebRTCResult {
connectionState: RTCPeerConnectionState;
localStream: MediaStream | null;
remoteStream: MediaStream | null;
startConnection: () => Promise<void>;
stopConnection: () => void;
error: string | null;
}
export function useWebRTC(wsClient: WebSocketClient): UseWebRTCResult;
```
**Responsibilities:**
- Manage WebRTC client lifecycle
- Coordinate with WebSocket for signaling
- Provide connection state to UI
- Handle errors and reconnection
#### Task 2.4: Update VoiceControls Component
**File**: `src/ui/components/VoiceControls.tsx` (modifications)
Changes:
- Remove `createAudioRecorder()` usage
- Remove push-to-talk button logic
- Add connection toggle: "Connect Voice" / "Disconnect Voice"
- Display connection state
- Use WebRTC local/remote streams instead of MediaRecorder
New UI states:
- Disconnected
- Connecting
- Connected (streaming)
- Error
#### Task 2.5: Handle Remote Audio Playback
**File**: `src/ui/lib/webrtc-playback.ts` (new)
```typescript
export function playRemoteAudioTrack(track: MediaStreamTrack): HTMLAudioElement;
```
**Responsibilities:**
- Create Audio element for remote track
- Attach MediaStream to Audio element
- Auto-play TTS audio as it arrives
- Clean up on disconnect
---
### Phase 3: Integration and Testing
#### Task 3.1: Connection Flow Testing
**Test Case 1: Establish WebRTC Connection**
1. Open browser UI
2. Click "Connect Voice"
3. Grant microphone permission
4. Verify: WebSocket signaling messages exchanged
5. Verify: ICE candidates gathered and exchanged
6. Verify: WebRTC connection state = "connected"
7. Verify: No errors in console
**Expected Latency**: < 2 seconds to establish connection
#### Task 3.2: Audio Input Testing
**Test Case 2: Microphone to Server**
1. Establish WebRTC connection
2. Speak into microphone: "list terminals"
3. Verify: Audio track received on server
4. Verify: Audio data flowing (check logs)
5. Verify: STT processing triggered
6. Verify: Transcript appears in activity log
7. Verify: LLM response generated
**Expected Latency**: < 1 second for STT (with buffered chunks)
#### Task 3.3: Audio Output Testing
**Test Case 3: TTS to Browser**
1. Establish WebRTC connection
2. Speak: "what terminals are available?"
3. Verify: LLM response generated
4. Verify: TTS audio synthesized
5. Verify: Audio sent via WebRTC track
6. Verify: Audio plays in browser automatically
7. Verify: Clear, natural speech quality
**Expected Latency**: < 1 second for TTS
#### Task 3.4: Full Conversation Testing
**Test Case 4: Continuous Voice Conversation**
1. Establish connection
2. Speak: "create a terminal called test in /tmp"
3. Wait for response (spoken)
4. Speak: "list all terminals"
5. Verify: Both interactions work smoothly
6. Verify: No audio cutoff or overlap
7. Verify: Terminal actually created in tmux
**Expected Total Round-Trip**: < 3 seconds
#### Task 3.5: Error Handling Testing
**Test Case 5: Connection Failures**
1. Deny microphone permission graceful error
2. Kill WebSocket connection WebRTC reconnect attempt
3. ICE gathering fails show connection error
4. Send invalid SDP handle error gracefully
**Test Case 6: Audio Processing Failures**
1. STT API error show error, allow retry
2. TTS API error text-only fallback
3. Audio codec mismatch log warning, attempt recovery
---
## WebRTC Message Types
### Updated `src/server/types.ts`
```typescript
export interface WebSocketMessage {
type: 'activity_log' | 'status' | 'ping' | 'pong' | 'user_message' | 'assistant_chunk'
| 'audio_chunk' | 'audio_output' | 'recording_state' | 'transcription_result'
// New WebRTC signaling types:
| 'webrtc_offer' | 'webrtc_answer' | 'webrtc_ice_candidate';
payload: unknown;
}
export interface WebRTCOfferPayload {
sdp: string;
type: 'offer';
}
export interface WebRTCAnswerPayload {
sdp: string;
type: 'answer';
}
export interface WebRTCIceCandidatePayload {
candidate: string;
sdpMLineIndex: number | null;
sdpMid: string | null;
}
```
---
## Signaling Flow Diagram
```
Browser Server
| |
|--- Connect to WebSocket --------------->|
|<-- WebSocket connected -----------------|
| |
|--- Click "Connect Voice" --------------->|
| |
|--- getUserMedia (microphone) ---------->|
|<-- MediaStream acquired -----------------|
| |
|--- Create RTCPeerConnection ------------>|
|--- Add audio track --------------------->|
|--- createOffer() ----------------------->|
| |
|--- WS: webrtc_offer -------------------->|
| SDP offer |
| |--- Create RTCPeerConnection
| |--- setRemoteDescription(offer)
| |--- createAnswer()
| |
|<-- WS: webrtc_answer --------------------|
| SDP answer |
| |
|--- setRemoteDescription(answer) -------->|
| |
|<-- WS: webrtc_ice_candidate -------------|
|--- WS: webrtc_ice_candidate ------------>|
|<-- WS: webrtc_ice_candidate -------------|
|--- WS: webrtc_ice_candidate ------------>|
| (multiple ICE exchanges) |
| |
|========= WebRTC Connection Established ==|
| |
|--- Audio streaming ---------------------->|--- Audio track received
| |--- STT processing
| |--- LLM response
| |--- TTS synthesis
|<-- Audio streaming ----------------------|--- Send via audio track
| |
```
---
## Configuration
### Environment Variables
```bash
# Existing
OPENAI_API_KEY=sk-...
TTS_VOICE=alloy
TTS_MODEL=tts-1
# New (optional)
WEBRTC_STUN_SERVER=stun:stun.l.google.com:19302
WEBRTC_TURN_SERVER= # Optional TURN server
WEBRTC_TURN_USERNAME= # Optional
WEBRTC_TURN_CREDENTIAL= # Optional
```
### Default STUN/TURN Configuration
```typescript
const defaultIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
```
For most peer-to-peer scenarios, STUN is sufficient. TURN is only needed for restrictive NAT/firewall environments.
---
## Migration Strategy
### Hybrid Approach (Recommended)
To minimize risk, implement WebRTC in parallel with existing WebSocket audio:
**Phase 8A: WebRTC for TTS Only**
- Keep WebSocket for STT input (existing push-to-talk)
- Use WebRTC for TTS output (lower latency playback)
- Test and validate
**Phase 8B: WebRTC for STT (Buffered Chunks)**
- Add WebRTC audio input
- Buffer 2-3 second chunks
- Send chunks to Whisper API
- Compare latency with WebSocket approach
**Phase 8C: Full WebRTC (Optional Streaming STT)**
- Evaluate streaming STT services (Deepgram, AssemblyAI)
- If beneficial, replace Whisper with streaming STT
- Remove WebSocket audio code
**Phase 8D: Cleanup**
- Remove old WebSocket audio handlers
- Remove MediaRecorder code
- Remove audio-capture.ts and audio-playback.ts
- Update documentation
---
## STT Streaming Strategy
### Challenge: OpenAI Whisper is Not Streaming
**Current State**: Whisper requires complete audio files.
**Options for WebRTC Audio Input**:
1. **Option A: Buffered Chunks (Simple)**
- Buffer WebRTC audio track for N seconds (e.g., 3 seconds)
- Send buffer to Whisper API
- Repeat for continuous transcription
- **Pros**: Works with existing Whisper API, simple
- **Cons**: Still has buffering delay, not true real-time
2. **Option B: Streaming STT Service (Advanced)**
- Replace Whisper with streaming-capable service:
- **Deepgram**: WebSocket streaming, excellent accuracy
- **AssemblyAI**: Universal Streaming model
- **Google Speech-to-Text**: Streaming recognition
- **Azure Speech**: Real-time transcription
- **Pros**: True real-time transcription, lower latency
- **Cons**: Additional service, potential cost increase
3. **Option C: Hybrid Model (Conservative)**
- Use WebRTC for TTS output only (low-latency playback)
- Keep WebSocket for STT input (push-to-talk)
- **Pros**: Simple migration, reuses existing STT
- **Cons**: Doesn't achieve full real-time input
**Recommendation**: Start with **Option C** (hybrid), then evaluate **Option A** (buffered chunks). Consider **Option B** (streaming STT) if latency is critical.
### Deepgram Integration (If Pursuing Option B)
```bash
npm install @deepgram/sdk
```
```typescript
import { createClient } from '@deepgram/sdk';
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
const connection = deepgram.listen.live({
model: 'nova-2',
language: 'en-US',
smart_format: true,
});
connection.on('transcript', (data) => {
console.log('Transcript:', data.channel.alternatives[0].transcript);
});
// Send audio chunks from WebRTC track
audioTrack.on('data', (chunk) => {
connection.send(chunk);
});
```
**Note**: The Deepgram SDK is already installed in the project (`@deepgram/sdk: ^3.4.0`).
---
## Dependencies
### New Dependencies
```json
{
"dependencies": {
"werift": "^0.22.2"
}
}
```
### Existing Dependencies (No Changes)
- `openai`: ^4.20.0
- `ws`: ^8.14.2
- `@deepgram/sdk`: ^3.4.0 (already installed, may use for streaming STT)
- `uuid`: ^9.0.1
---
## File Structure
```
src/
├── server/
│ ├── webrtc/
│ │ ├── peer-connection.ts # NEW: WebRTC peer wrapper
│ │ ├── manager.ts # NEW: Peer manager
│ │ └── signaling.ts # NEW: Signaling coordination
│ ├── audio/
│ │ ├── stream-processor.ts # NEW: Audio input processing
│ │ └── tts-stream.ts # NEW: TTS to WebRTC track
│ ├── websocket-server.ts # MODIFIED: Add WebRTC signaling
│ ├── types.ts # MODIFIED: Add WebRTC types
│ └── index.ts # MODIFIED: Initialize WebRTC
├── ui/
│ ├── lib/
│ │ ├── webrtc-client.ts # NEW: Browser WebRTC client
│ │ └── webrtc-playback.ts # NEW: Remote audio playback
│ ├── hooks/
│ │ ├── useWebRTC.ts # NEW: WebRTC connection hook
│ │ └── useWebSocket.ts # MODIFIED: Add signaling
│ ├── components/
│ │ └── VoiceControls.tsx # MODIFIED: WebRTC UI
│ └── App.tsx # MODIFIED: Integrate WebRTC
```
---
## Performance Expectations
### Latency Comparison
| Metric | WebSocket (Current) | WebRTC (Target) |
|-------------------------|---------------------|-----------------|
| STT Latency | 1-3 seconds | 0.5-2 seconds |
| TTS Latency | 1-2 seconds | 0.5-1 second |
| Total Round-Trip | 3-5 seconds | 1-3 seconds |
| Audio Quality | Good | Excellent |
| Bandwidth Efficiency | Low (base64) | High (binary) |
### Expected Improvements
- **50-60% reduction** in total latency
- **30-40% reduction** in bandwidth usage
- **Improved audio quality** (no encoding overhead)
- **True streaming** capability (with streaming STT)
---
## Known Limitations and Risks
### Technical Risks
1. **werift Maturity**: Library is still maturing
- Mitigation: Extensive testing, fallback to WebSocket
2. **Browser Compatibility**: WebRTC support varies
- Mitigation: Feature detection, graceful degradation
3. **NAT/Firewall Issues**: May require TURN server
- Mitigation: Provide TURN configuration option
4. **STT Streaming**: OpenAI Whisper doesn't stream
- Mitigation: Hybrid approach or alternative STT service
### Operational Risks
1. **TURN Server Costs**: If needed for production
- Mitigation: Use free STUN initially, add TURN only if needed
2. **Increased Complexity**: Two connection types to manage
- Mitigation: Clear separation of concerns, good documentation
3. **Debugging Difficulty**: WebRTC is complex to debug
- Mitigation: Extensive logging, use `chrome://webrtc-internals/`
---
## Success Criteria
- WebRTC connection established reliably between browser and server
- Microphone audio streams to server via WebRTC in real-time
- TTS audio streams from server to browser via WebRTC in real-time
- Total latency < 3 seconds for full voice round-trip
- Audio quality is excellent (no artifacts, clear speech)
- WebSocket still handles text messages and tool notifications
- Connection is stable (no disconnects or audio dropouts)
- Error handling is graceful (reconnection, fallback)
- Works in Chrome and Firefox
- No regression in existing text chat functionality
- Code is maintainable and well-documented
---
## Implementation Timeline
### Conservative Estimate (Phased Approach)
- **Phase 8A** (WebRTC TTS Only): 2-3 days
- Install werift, setup basic peer connection
- Implement signaling
- Wire TTS to WebRTC output
- Test playback
- **Phase 8B** (WebRTC STT Buffered): 2-3 days
- Implement audio input capture
- Buffer and send chunks to Whisper
- Test transcription accuracy
- **Phase 8C** (Streaming STT - Optional): 1-2 days
- Integrate Deepgram or similar
- Wire WebRTC audio to streaming STT
- Test real-time transcription
- **Phase 8D** (Cleanup): 1 day
- Remove old WebSocket audio code
- Update documentation
- Final testing
**Total**: 6-9 days
### Aggressive Estimate (Full Implementation)
- **All Phases**: 4-5 days
- Parallel development of client/server
- Accept some technical debt
- Minimal testing between phases
---
## Next Steps
1. **Confirm Approach**: Verify architecture decisions
2. **Install Dependencies**: `npm install werift`
3. **Start with Phase 8A**: Implement TTS via WebRTC first
4. **Iterative Testing**: Test each phase before proceeding
5. **Document Learnings**: Update this plan as we learn
---
## References
- **werift-webrtc**: https://github.com/shinyoshiaki/werift-webrtc
- **WebRTC API (MDN)**: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API
- **Deepgram Streaming**: https://developers.deepgram.com/docs/streaming
- **WebRTC Signaling**: https://blog.logrocket.com/webrtc-signaling-websocket-node-js/
---
**End of Plan**

View File

@@ -12,11 +12,13 @@
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.10",
"@ai-sdk/openai": "^2.0.52",
"@deepgram/sdk": "^3.4.0",
"ai": "^3.4.33",
"@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76",
"express": "^4.18.2",
"openai": "^4.20.0",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"zod": "^3.23.8"

View File

@@ -1,6 +1,6 @@
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import { stepCountIs, streamText, tool } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { z } from "zod";
import {
listTerminals,
createTerminal,
@@ -8,16 +8,8 @@ import {
sendText,
renameTerminal,
killTerminal,
} from '../daemon/terminal-manager.js';
let openaiClient: ReturnType<typeof createOpenAI> | null = null;
/**
* Initialize OpenAI client with API key
*/
export function initializeOpenAI(apiKey: string): void {
openaiClient = createOpenAI({ apiKey });
}
} from "../daemon/terminal-manager.js";
import invariant from "tiny-invariant";
/**
* Terminal tools using Vercel AI SDK tool() function
@@ -25,8 +17,8 @@ export function initializeOpenAI(apiKey: string): void {
*/
export const terminalTools = {
list_terminals: tool({
description: 'List all available terminals in the voice-dev session',
parameters: z.object({}),
description: "List all available terminals in the voice-dev session",
inputSchema: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
@@ -34,24 +26,38 @@ export const terminalTools = {
}),
create_terminal: tool({
description: 'Create a new terminal with specified name and working directory',
parameters: z.object({
name: z.string().describe('Unique name for the terminal'),
workingDirectory: z.string().describe('Working directory path'),
initialCommand: z.string().optional().describe('Optional command to run on creation'),
description:
"Create a new terminal with specified name and working directory",
inputSchema: z.object({
name: z.string().describe("Unique name for the terminal"),
workingDirectory: z.string().describe("Working directory path"),
initialCommand: z
.string()
.optional()
.describe("Optional command to run on creation"),
}),
execute: async ({ name, workingDirectory, initialCommand }) => {
const terminal = await createTerminal({ name, workingDirectory, initialCommand });
const terminal = await createTerminal({
name,
workingDirectory,
initialCommand,
});
return { terminal };
},
}),
capture_terminal: tool({
description: 'Capture and return the output from a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
lines: z.number().optional().describe('Number of lines to capture (default: 200)'),
wait: z.number().optional().describe('Milliseconds to wait before capture'),
description: "Capture and return the output from a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID (e.g., @123)"),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
wait: z
.number()
.optional()
.describe("Milliseconds to wait before capture"),
}),
execute: async ({ terminalId, lines, wait }) => {
const output = await captureTerminal(terminalId, lines, wait);
@@ -60,30 +66,38 @@ export const terminalTools = {
}),
send_text: tool({
description: 'Send text or commands to a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
text: z.string().describe('Text to send to the terminal'),
pressEnter: z.boolean().optional().describe('Whether to press Enter after text'),
description: "Send text or commands to a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID (e.g., @123)"),
text: z.string().describe("Text to send to the terminal"),
pressEnter: z
.boolean()
.optional()
.describe("Whether to press Enter after text"),
return_output: z
.object({
lines: z.number().optional(),
wait: z.number().optional(),
})
.optional()
.describe('Capture output after sending'),
.describe("Capture output after sending"),
}),
execute: async ({ terminalId, text, pressEnter, return_output }) => {
const output = await sendText(terminalId, text, pressEnter, return_output);
const output = await sendText(
terminalId,
text,
pressEnter,
return_output
);
return { output };
},
}),
rename_terminal: tool({
description: 'Rename an existing terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to rename'),
newName: z.string().describe('New unique name for the terminal'),
description: "Rename an existing terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID to rename"),
newName: z.string().describe("New unique name for the terminal"),
}),
execute: async ({ terminalId, newName }) => {
await renameTerminal(terminalId, newName);
@@ -92,9 +106,9 @@ export const terminalTools = {
}),
kill_terminal: tool({
description: 'Close and destroy a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to kill'),
description: "Close and destroy a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID to kill"),
}),
execute: async ({ terminalId }) => {
await killTerminal(terminalId);
@@ -107,7 +121,7 @@ export const terminalTools = {
* Message interface for conversation
*/
export interface Message {
role: 'system' | 'user' | 'assistant';
role: "system" | "user" | "assistant";
content: string;
}
@@ -126,21 +140,23 @@ export interface StreamLLMParams {
* Stream LLM response with automatic tool execution
*/
export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (!openaiClient) {
throw new Error('OpenAI client not initialized. Call initializeOpenAI() first.');
}
invariant(process.env.OPENROUTER_API_KEY, "OPENROUTER_API_KEY is required");
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const result = await streamText({
model: openaiClient('gpt-4o') as any, // Type assertion to work around AI SDK version conflicts
model: openrouter("anthropic/claude-haiku-4.5"),
messages: params.messages,
tools: terminalTools,
maxSteps: 5, // Allow up to 5 tool executions per turn
stopWhen: stepCountIs(10),
onStepFinish: async (event) => {
// Called after each step (tool call or text generation)
if (event.toolCalls && event.toolCalls.length > 0) {
for (const toolCall of event.toolCalls) {
if (params.onToolCall) {
params.onToolCall(toolCall.toolName, toolCall.args);
params.onToolCall(toolCall.toolName, toolCall.input);
}
}
}
@@ -148,14 +164,14 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (event.toolResults && event.toolResults.length > 0) {
for (const toolResult of event.toolResults) {
if (params.onToolResult) {
params.onToolResult(toolResult.toolName, toolResult.result);
params.onToolResult(toolResult.toolName, toolResult.output);
}
}
}
},
});
let fullText = '';
let fullText = "";
// Stream text chunks to the caller
for await (const chunk of result.textStream) {

View File

@@ -59,15 +59,8 @@ export async function processUserMessage(params: {
});
conversation.lastActivity = new Date();
// Broadcast user message to WebSocket clients
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: params.message,
});
}
// Note: User message is already broadcast by the caller (e.g., after STT in index.ts)
// No need to broadcast again here to avoid duplication
let assistantResponse = '';

View File

@@ -1,13 +1,17 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer as createHTTPServer } from 'http';
import { readFile } from 'fs/promises';
import { v4 as uuidv4 } from 'uuid';
import { createServer as createViteServer } from 'vite';
import type { ViteDevServer } from 'vite';
import type { ServerConfig } from './types.js';
import { VoiceAssistantWebSocketServer } from './websocket-server.js';
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import {
createServer as createHTTPServer,
Server as HttpServer,
RequestListener,
} from "http";
import { readFile } from "fs/promises";
import { v4 as uuidv4 } from "uuid";
import { createServer as createViteServer } from "vite";
import type { ViteDevServer } from "vite";
import type { ServerConfig } from "./types.js";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import {
initializeDefaultSession,
listTerminals,
@@ -15,94 +19,105 @@ import {
sendText,
captureTerminal,
killTerminal,
} from './daemon/terminal-manager.js';
import { initializeOpenAI } from './agent/llm-openai.js';
import { initializeSTT, transcribeAudio } from './agent/stt-openai.js';
import { initializeTTS, synthesizeSpeech } from './agent/tts-openai.js';
} from "./daemon/terminal-manager.js";
import { initializeOpenAI } from "./agent/llm-openai.js";
import { initializeSTT, transcribeAudio } from "./agent/stt-openai.js";
import { initializeTTS, synthesizeSpeech } from "./agent/tts-openai.js";
import {
createConversation,
processUserMessage,
cleanupConversations,
} from './agent/orchestrator.js';
} from "./agent/orchestrator.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function createServer(config: ServerConfig) {
async function createServer(httpServer: HttpServer, config: ServerConfig) {
const app = express();
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Test endpoint for LLM integration (removed - using orchestrator now)
// Test endpoint for terminal operations
app.get('/api/test-terminal', async (_req, res) => {
app.get("/api/test-terminal", async (_req, res) => {
try {
const testResults: string[] = [];
// 1. Initialize default session
testResults.push('1. Initializing default session...');
testResults.push("1. Initializing default session...");
await initializeDefaultSession();
testResults.push(' ✓ Session initialized');
testResults.push(" ✓ Session initialized");
// 2. List existing terminals
testResults.push('2. Listing existing terminals...');
testResults.push("2. Listing existing terminals...");
const beforeTerminals = await listTerminals();
testResults.push(` ✓ Found ${beforeTerminals.length} existing terminals`);
testResults.push(
` ✓ Found ${beforeTerminals.length} existing terminals`
);
// 3. Create a test terminal
testResults.push('3. Creating test terminal...');
testResults.push("3. Creating test terminal...");
const terminal = await createTerminal({
name: 'test-terminal',
workingDirectory: '~',
name: "test-terminal",
workingDirectory: "~",
initialCommand: undefined,
});
testResults.push(` ✓ Created terminal: ${terminal.id} (${terminal.name})`);
testResults.push(
` ✓ Created terminal: ${terminal.id} (${terminal.name})`
);
// 4. Send a command to the terminal
testResults.push('4. Sending command "echo hello world"...');
await sendText(terminal.id, 'echo "hello world"', true, { lines: 20, wait: 500 });
testResults.push(' ✓ Command sent');
await sendText(terminal.id, 'echo "hello world"', true, {
lines: 20,
wait: 500,
});
testResults.push(" ✓ Command sent");
// 5. Capture output
testResults.push('5. Capturing terminal output...');
testResults.push("5. Capturing terminal output...");
const output = await captureTerminal(terminal.id, 20);
testResults.push(` ✓ Captured ${output.split('\n').length} lines`);
testResults.push(` ✓ Captured ${output.split("\n").length} lines`);
testResults.push(` Output preview: ${output.substring(0, 100)}...`);
// 6. List terminals again
testResults.push('6. Listing terminals again...');
testResults.push("6. Listing terminals again...");
const afterTerminals = await listTerminals();
testResults.push(` ✓ Found ${afterTerminals.length} terminals`);
const testTerminal = afterTerminals.find((t) => t.name === 'test-terminal');
const testTerminal = afterTerminals.find(
(t) => t.name === "test-terminal"
);
if (testTerminal) {
testResults.push(` ✓ Found test-terminal: ${testTerminal.id}`);
}
// 7. Kill the test terminal
testResults.push('7. Killing test terminal...');
testResults.push("7. Killing test terminal...");
await killTerminal(terminal.id);
testResults.push(' ✓ Terminal killed');
testResults.push(" ✓ Terminal killed");
// 8. Verify it's gone
testResults.push('8. Verifying terminal was removed...');
testResults.push("8. Verifying terminal was removed...");
const finalTerminals = await listTerminals();
const stillExists = finalTerminals.find((t) => t.name === 'test-terminal');
const stillExists = finalTerminals.find(
(t) => t.name === "test-terminal"
);
if (!stillExists) {
testResults.push(' ✓ Terminal successfully removed');
testResults.push(" ✓ Terminal successfully removed");
} else {
testResults.push(' ✗ Terminal still exists!');
testResults.push(" ✗ Terminal still exists!");
}
res.json({
success: true,
message: 'All terminal operations completed successfully',
message: "All terminal operations completed successfully",
results: testResults,
});
} catch (error: any) {
@@ -119,37 +134,42 @@ async function createServer(config: ServerConfig) {
if (config.isDev) {
// Development: Create Vite server in middleware mode
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
server: {
middlewareMode: true,
allowedHosts: ["localhost", "mohameds-macbook-pro.tail8fe838.ts.net"],
port: 3000,
hmr: false,
},
appType: "custom",
});
// Use Vite's middleware to handle HMR, module transformation, etc.
app.use(vite.middlewares);
} else {
// Production: Serve static files from dist/ui
const uiPath = path.join(__dirname, '../ui');
const uiPath = path.join(__dirname, "../ui");
app.use(express.static(uiPath));
}
// SPA fallback - serve index.html for all non-API routes
app.use('*', async (req, res, next) => {
if (req.originalUrl.startsWith('/api')) {
return res.status(404).json({ error: 'Not found' });
app.use("*", async (req, res, next) => {
if (req.originalUrl.startsWith("/api")) {
return res.status(404).json({ error: "Not found" });
}
try {
const indexHtmlPath = config.isDev
? path.resolve(__dirname, '../../src/ui/index.html')
: path.join(__dirname, '../ui/index.html');
? path.resolve(__dirname, "../../src/ui/index.html")
: path.join(__dirname, "../ui/index.html");
let html = await readFile(indexHtmlPath, 'utf-8');
let html = await readFile(indexHtmlPath, "utf-8");
// In development, transform the HTML with Vite
if (config.isDev && vite) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
if (config.isDev && vite) {
vite.ssrFixStacktrace(e as Error);
@@ -162,14 +182,18 @@ async function createServer(config: ServerConfig) {
}
async function main() {
const port = parseInt(process.env.PORT || '3000', 10);
const isDev = process.env.NODE_ENV !== 'production';
const port = parseInt(process.env.PORT || "3000", 10);
const isDev = process.env.NODE_ENV !== "production";
const config: ServerConfig = { port, isDev };
const { app, vite } = await createServer(config);
const httpServer = createHTTPServer((req, res) => {
app(req, res);
});
const { app, vite } = await createServer(httpServer, config);
// Create HTTP server
const httpServer = createHTTPServer(app);
// Initialize WebSocket server
const wsServer = new VoiceAssistantWebSocketServer(httpServer);
@@ -178,22 +202,30 @@ async function main() {
const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) {
initializeOpenAI(apiKey);
console.log('✓ OpenAI client initialized');
console.log("✓ OpenAI client initialized");
// Initialize STT (Whisper)
initializeSTT({ apiKey });
// Initialize TTS
const ttsVoice = (process.env.TTS_VOICE || 'alloy') as 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
const ttsModel = (process.env.TTS_MODEL || 'tts-1') as 'tts-1' | 'tts-1-hd';
const ttsVoice = (process.env.TTS_VOICE || "alloy") as
| "alloy"
| "echo"
| "fable"
| "onyx"
| "nova"
| "shimmer";
const ttsModel = (process.env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd";
initializeTTS({
apiKey,
voice: ttsVoice,
model: ttsModel,
responseFormat: 'mp3',
responseFormat: "mp3",
});
} else {
console.warn('⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work');
console.warn(
"⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work"
);
}
// Create default conversation
@@ -201,7 +233,10 @@ async function main() {
console.log(`✓ Default conversation created: ${defaultConversationId}`);
// Helper function to process message and optionally generate TTS
async function processMessageWithOptionalTTS(message: string, enableTTS: boolean = false): Promise<string> {
async function processMessageWithOptionalTTS(
message: string,
enableTTS: boolean = false
): Promise<string> {
const response = await processUserMessage({
conversationId: defaultConversationId,
message,
@@ -211,15 +246,15 @@ async function main() {
// Generate TTS for the response if enabled
if (enableTTS && apiKey) {
try {
console.log('[TTS] Generating speech for assistant response...');
console.log("[TTS] Generating speech for assistant response...");
const speechResult = await synthesizeSpeech(response);
// Convert audio buffer to base64
const base64Audio = speechResult.audio.toString('base64');
const base64Audio = speechResult.audio.toString("base64");
// Send audio to client via WebSocket
wsServer.broadcast({
type: 'audio_output',
type: "audio_output",
payload: {
id: uuidv4(),
audio: base64Audio,
@@ -227,13 +262,15 @@ async function main() {
},
});
console.log(`[TTS] Sent audio to client via WebSocket (${speechResult.audio.length} bytes)`);
console.log(
`[TTS] Sent audio to client via WebSocket (${speechResult.audio.length} bytes)`
);
} catch (error: any) {
console.error('[TTS] Error generating speech:', error);
console.error("[TTS] Error generating speech:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `TTS error: ${error.message}`,
});
}
@@ -251,50 +288,52 @@ async function main() {
wsServer,
});
} catch (error: any) {
console.error('[Orchestrator] Error processing message:', error);
console.error("[Orchestrator] Error processing message:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `Error: ${error.message}`,
});
}
});
// Wire audio handler to WebSocket for voice input (STT)
wsServer.setAudioHandler(async (audio: Buffer, format: string): Promise<string> => {
try {
// Transcribe audio using OpenAI Whisper
const result = await transcribeAudio(audio, format);
wsServer.setAudioHandler(
async (audio: Buffer, format: string): Promise<string> => {
try {
// Transcribe audio using OpenAI Whisper
const result = await transcribeAudio(audio, format);
// Broadcast transcription result as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
});
// Broadcast transcription result as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "transcript",
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
});
// Process the transcribed text through the orchestrator WITH TTS enabled
// Since this came from voice input, respond with voice output
await processMessageWithOptionalTTS(result.text, true);
// Process the transcribed text through the orchestrator WITH TTS enabled
// Since this came from voice input, respond with voice output
await processMessageWithOptionalTTS(result.text, true);
return result.text;
} catch (error: any) {
console.error('[STT] Error transcribing audio:', error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Transcription error: ${error.message}`,
});
throw error;
return result.text;
} catch (error: any) {
console.error("[STT] Error transcribing audio:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Transcription error: ${error.message}`,
});
throw error;
}
}
});
);
// Start conversation cleanup interval (every 10 minutes)
setInterval(() => {
@@ -304,33 +343,35 @@ async function main() {
// Initialize default tmux session for terminal control
try {
await initializeDefaultSession();
console.log('✓ Default tmux session initialized');
console.log("✓ Default tmux session initialized");
} catch (error) {
console.error('Failed to initialize tmux session:', error);
console.error("Failed to initialize tmux session:", error);
}
httpServer.listen(port, () => {
console.log(`\n✓ Voice Assistant server running on http://localhost:${port}`);
console.log(
`\n✓ Voice Assistant server running on http://localhost:${port}`
);
if (isDev) {
console.log(`✓ Vite dev server integrated (HMR enabled)\n`);
}
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('\nSIGTERM received, shutting down gracefully...');
process.on("SIGTERM", async () => {
console.log("\nSIGTERM received, shutting down gracefully...");
wsServer.close();
if (vite) {
await vite.close();
}
httpServer.close(() => {
console.log('Server closed');
console.log("Server closed");
process.exit(0);
});
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
console.error("Failed to start server:", error);
process.exit(1);
});

View File

@@ -1,55 +1,63 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server as HTTPServer } from 'http';
import { WebSocketServer, WebSocket } from "ws";
import { Server as HTTPServer } from "http";
import type {
WebSocketMessage,
ActivityLogEntry,
AudioChunkPayload,
} from './types.js';
} from "./types.js";
export class VoiceAssistantWebSocketServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, string> = new Map(); // Map ws to client ID
private messageHandler?: (message: string) => Promise<void>;
private audioHandler?: (audio: Buffer, format: string) => Promise<string>;
private audioBuffers: Map<string, { chunks: Buffer[]; format: string }> = new Map();
private audioBuffers: Map<string, { chunks: Buffer[]; format: string }> =
new Map();
private clientIdCounter: number = 0;
constructor(server: HTTPServer) {
this.wss = new WebSocketServer({ server, path: '/ws' });
this.wss = new WebSocketServer({ server, path: "/ws" });
this.wss.on('connection', (ws) => {
this.wss.on("connection", (ws) => {
this.handleConnection(ws);
});
console.log('✓ WebSocket server initialized on /ws');
console.log("✓ WebSocket server initialized on /ws");
}
private handleConnection(ws: WebSocket): void {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
this.clients.set(ws, clientId);
console.log(`[WS] Client connected: ${clientId} (total: ${this.clients.size})`);
console.log(
`[WS] Client connected: ${clientId} (total: ${this.clients.size})`
);
// Send welcome message
this.sendToClient(ws, {
type: 'status',
payload: { status: 'connected', message: 'WebSocket connection established' },
type: "status",
payload: {
status: "connected",
message: "WebSocket connection established",
},
});
ws.on('message', (data) => {
ws.on("message", (data) => {
this.handleMessage(ws, data);
});
ws.on('close', () => {
ws.on("close", () => {
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
console.log(`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`);
console.log(
`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`
);
}
});
ws.on('error', (error) => {
console.error('[WS] Client error:', error);
ws.on("error", (error) => {
console.error("[WS] Client error:", error);
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
@@ -57,28 +65,31 @@ export class VoiceAssistantWebSocketServer {
});
}
private async handleMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
private async handleMessage(
ws: WebSocket,
data: Buffer | ArrayBuffer | Buffer[]
): Promise<void> {
try {
const message = JSON.parse(data.toString()) as WebSocketMessage;
console.log(`[WS] Received message type: ${message.type}`);
switch (message.type) {
case 'ping':
this.sendToClient(ws, { type: 'pong', payload: {} });
case "ping":
this.sendToClient(ws, { type: "pong", payload: {} });
break;
case 'user_message':
case "user_message":
// Handle user message through orchestrator
const payload = message.payload as { message: string };
if (this.messageHandler) {
await this.messageHandler(payload.message);
} else {
console.warn('[WS] No message handler registered');
console.warn("[WS] No message handler registered");
}
break;
case 'audio_chunk':
case "audio_chunk":
// Handle audio chunk for STT
await this.handleAudioChunk(ws, message.payload as AudioChunkPayload);
break;
@@ -87,12 +98,13 @@ export class VoiceAssistantWebSocketServer {
console.warn(`[WS] Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('[WS] Failed to parse message:', error);
console.error("[WS] Failed to parse message:", error);
}
}
private sendToClient(ws: WebSocket, message: WebSocketMessage): void {
if (ws.readyState === 1) { // WebSocket.OPEN = 1
if (ws.readyState === 1) {
// WebSocket.OPEN = 1
ws.send(JSON.stringify(message));
}
}
@@ -100,7 +112,8 @@ export class VoiceAssistantWebSocketServer {
public broadcast(message: WebSocketMessage): void {
const payload = JSON.stringify(message);
this.clients.forEach((_clientId, client) => {
if (client.readyState === 1) { // WebSocket.OPEN = 1
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});
@@ -108,14 +121,17 @@ export class VoiceAssistantWebSocketServer {
public broadcastActivityLog(entry: ActivityLogEntry): void {
this.broadcast({
type: 'activity_log',
type: "activity_log",
payload: entry,
});
}
public broadcastStatus(status: string, metadata?: Record<string, unknown>): void {
public broadcastStatus(
status: string,
metadata?: Record<string, unknown>
): void {
this.broadcast({
type: 'status',
type: "status",
payload: { status, ...metadata },
});
}
@@ -124,35 +140,49 @@ export class VoiceAssistantWebSocketServer {
this.messageHandler = handler;
}
public setAudioHandler(handler: (audio: Buffer, format: string) => Promise<string>): void {
public setAudioHandler(
handler: (audio: Buffer, format: string) => Promise<string>
): void {
this.audioHandler = handler;
}
private async handleAudioChunk(ws: WebSocket, payload: AudioChunkPayload): Promise<void> {
private async handleAudioChunk(
ws: WebSocket,
payload: AudioChunkPayload
): Promise<void> {
try {
// Use a client-specific key for buffering (in case multiple clients)
const clientId = 'default'; // Could be enhanced with per-client tracking
const clientId = "default"; // Could be enhanced with per-client tracking
// Decode base64 audio data
const audioBuffer = Buffer.from(payload.audio, 'base64');
const audioBuffer = Buffer.from(payload.audio, "base64");
if (!payload.isLast) {
// Buffer the chunk
if (!this.audioBuffers.has(clientId)) {
this.audioBuffers.set(clientId, { chunks: [], format: payload.format });
this.audioBuffers.set(clientId, {
chunks: [],
format: payload.format,
});
}
const buffer = this.audioBuffers.get(clientId)!;
buffer.chunks.push(audioBuffer);
console.log(`[WS] Buffered audio chunk (${audioBuffer.length} bytes, total chunks: ${buffer.chunks.length})`);
console.log(
`[WS] Buffered audio chunk (${audioBuffer.length} bytes, total chunks: ${buffer.chunks.length})`
);
} else {
// Last chunk - process complete audio
const buffer = this.audioBuffers.get(clientId);
const allChunks = buffer ? [...buffer.chunks, audioBuffer] : [audioBuffer];
const allChunks = buffer
? [...buffer.chunks, audioBuffer]
: [audioBuffer];
const format = buffer?.format || payload.format;
// Concatenate all chunks
const completeAudio = Buffer.concat(allChunks);
console.log(`[WS] Complete audio received (${completeAudio.length} bytes, ${allChunks.length} chunks)`);
console.log(
`[WS] Complete audio received (${completeAudio.length} bytes, ${allChunks.length} chunks)`
);
// Clear buffer
this.audioBuffers.delete(clientId);
@@ -162,27 +192,27 @@ export class VoiceAssistantWebSocketServer {
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'system',
content: 'Transcribing audio...',
type: "system",
content: "Transcribing audio...",
});
const transcript = await this.audioHandler(completeAudio, format);
// Send transcription result back to client
this.sendToClient(ws, {
type: 'transcription_result',
type: "transcription_result",
payload: { text: transcript },
});
} else {
console.warn('[WS] No audio handler registered');
console.warn("[WS] No audio handler registered");
}
}
} catch (error: any) {
console.error('[WS] Audio chunk handling error:', error);
console.error("[WS] Audio chunk handling error:", error);
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `Audio processing error: ${error.message}`,
});
}

View File

@@ -1,59 +1,72 @@
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { VoiceControls } from './components/VoiceControls';
import { createAudioPlayer } from './lib/audio-playback';
import './App.css';
import { useState, useEffect, useRef } from "react";
import { useWebSocket } from "./hooks/useWebSocket";
import { VoiceControls } from "./components/VoiceControls";
import { createAudioPlayer } from "./lib/audio-playback";
import "./App.css";
interface LogEntry {
id: string;
timestamp: number;
type: 'system' | 'info' | 'success' | 'error' | 'user' | 'assistant' | 'tool';
type: "system" | "info" | "success" | "error" | "user" | "assistant" | "tool";
message: string;
metadata?: Record<string, unknown>;
}
function App() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: '1', timestamp: Date.now(), type: 'system', message: 'System initialized' },
{
id: "1",
timestamp: Date.now(),
type: "system",
message: "System initialized",
},
]);
const [userInput, setUserInput] = useState('');
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
const [userInput, setUserInput] = useState("");
const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const audioPlayerRef = useRef(createAudioPlayer());
// WebSocket URL - use ws://localhost:3000/ws in dev, or construct from current host in prod
const wsUrl = `ws://${window.location.host}/ws`;
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${
window.location.host
}/ws`;
const ws = useWebSocket(wsUrl);
const addLog = (
type: LogEntry['type'],
type: LogEntry["type"],
message: string,
metadata?: Record<string, unknown>
) => {
console.trace("addLog", type, message, metadata);
setLogs((prev) => [
...prev,
{ id: Date.now().toString(), timestamp: Date.now(), type, message, metadata },
{
id: Date.now().toString(),
timestamp: Date.now(),
type,
message,
metadata,
},
]);
};
useEffect(() => {
// Listen for status messages
const unsubStatus = ws.on('status', (payload: unknown) => {
const unsubStatus = ws.on("status", (payload: unknown) => {
const data = payload as { status: string; message?: string };
addLog('info', data.message || `Status: ${data.status}`);
addLog("info", data.message || `Status: ${data.status}`);
});
// Listen for pong messages
const unsubPong = ws.on('pong', () => {
addLog('success', 'Received pong from server');
const unsubPong = ws.on("pong", () => {
addLog("success", "Received pong from server");
});
// Listen for activity log messages
const unsubActivity = ws.on('activity_log', (payload: unknown) => {
const unsubActivity = ws.on("activity_log", (payload: unknown) => {
const data = payload as {
type: string;
content: string;
@@ -61,34 +74,43 @@ function App() {
};
// Map activity log types to UI log types
let logType: LogEntry['type'] = 'info';
if (data.type === 'transcript') logType = 'user';
else if (data.type === 'assistant') logType = 'assistant';
else if (data.type === 'tool_call' || data.type === 'tool_result') logType = 'tool';
else if (data.type === 'error') logType = 'error';
let logType: LogEntry["type"] = "info";
if (data.type === "transcript") logType = "user";
else if (data.type === "assistant") logType = "assistant";
else if (data.type === "tool_call" || data.type === "tool_result")
logType = "tool";
else if (data.type === "error") logType = "error";
addLog(logType, data.content, data.metadata);
// Clear streaming state when complete assistant message arrives
if (data.type === "assistant") {
setCurrentAssistantMessage("");
}
});
// Listen for streaming assistant chunks
const unsubChunk = ws.on('assistant_chunk', (payload: unknown) => {
const unsubChunk = ws.on("assistant_chunk", (payload: unknown) => {
const data = payload as { chunk: string };
setCurrentAssistantMessage((prev) => prev + data.chunk);
});
// Listen for transcription results
const unsubTranscription = ws.on('transcription_result', (payload: unknown) => {
const data = payload as { text: string };
addLog('info', `Transcription: ${data.text}`);
setIsProcessingAudio(false);
});
const unsubTranscription = ws.on(
"transcription_result",
(payload: unknown) => {
// Note: Transcription is already broadcast as activity_log with type "transcript"
// No need to log it again here to avoid duplication
setIsProcessingAudio(false);
}
);
// Listen for audio output (TTS)
const unsubAudioOutput = ws.on('audio_output', async (payload: unknown) => {
const unsubAudioOutput = ws.on("audio_output", async (payload: unknown) => {
const data = payload as { audio: string; format: string; id: string };
try {
addLog('info', 'Playing assistant audio response...');
addLog("info", "Playing assistant audio response...");
setIsPlayingAudio(true);
// Decode base64 audio
@@ -99,17 +121,18 @@ function App() {
}
// Create blob with correct mime type
const mimeType = data.format === 'mp3' ? 'audio/mpeg' : `audio/${data.format}`;
const mimeType =
data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`;
const audioBlob = new Blob([bytes], { type: mimeType });
// Play audio
await audioPlayerRef.current.play(audioBlob);
setIsPlayingAudio(false);
addLog('success', 'Audio playback complete');
addLog("success", "Audio playback complete");
} catch (error: any) {
console.error('[App] Audio playback error:', error);
addLog('error', `Audio playback failed: ${error.message}`);
console.error("[App] Audio playback error:", error);
addLog("error", `Audio playback failed: ${error.message}`);
setIsPlayingAudio(false);
}
});
@@ -126,20 +149,20 @@ function App() {
useEffect(() => {
if (ws.isConnected) {
addLog('success', 'WebSocket connected');
addLog("success", "WebSocket connected");
} else {
addLog('system', 'WebSocket disconnected');
addLog("system", "WebSocket disconnected");
}
}, [ws.isConnected]);
// Auto-scroll to bottom when new logs are added
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs, currentAssistantMessage]);
const handlePing = () => {
ws.sendPing();
addLog('info', 'Sent ping to server');
addLog("info", "Sent ping to server");
};
const handleSendMessage = (e: React.FormEvent) => {
@@ -150,29 +173,32 @@ function App() {
ws.sendUserMessage(userInput);
// Clear input and reset streaming state
setUserInput('');
setCurrentAssistantMessage('');
setUserInput("");
setCurrentAssistantMessage("");
};
const handleAudioRecorded = async (audioBlob: Blob, format: string) => {
if (!ws.isConnected) {
addLog('error', 'Cannot send audio - not connected to server');
addLog("error", "Cannot send audio - not connected to server");
return;
}
try {
setIsProcessingAudio(true);
addLog('info', 'Sending audio to server...');
addLog("info", "Sending audio to server...");
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
const base64Audio = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
new Uint8Array(arrayBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
""
)
);
// Send audio chunk via WebSocket
ws.send({
type: 'audio_chunk',
type: "audio_chunk",
payload: {
audio: base64Audio,
format: format,
@@ -180,10 +206,12 @@ function App() {
},
});
console.log(`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`);
console.log(
`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`
);
} catch (error: any) {
console.error('[App] Error sending audio:', error);
addLog('error', `Failed to send audio: ${error.message}`);
console.error("[App] Error sending audio:", error);
addLog("error", `Failed to send audio: ${error.message}`);
setIsProcessingAudio(false);
}
};
@@ -192,8 +220,12 @@ function App() {
<div className="app">
<header className="header">
<h1>Voice Assistant</h1>
<div className={`status-indicator ${ws.isConnected ? 'connected' : 'disconnected'}`}>
{ws.isConnected ? 'connected' : 'disconnected'}
<div
className={`status-indicator ${
ws.isConnected ? "connected" : "disconnected"
}`}
>
{ws.isConnected ? "connected" : "disconnected"}
</div>
</header>
@@ -211,7 +243,9 @@ function App() {
<div className="log-entries">
{logs.map((log) => (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-time">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="log-time">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
@@ -223,7 +257,9 @@ function App() {
))}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-time">{new Date().toLocaleTimeString()}</span>
<span className="log-time">
{new Date().toLocaleTimeString()}
</span>
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
</div>
@@ -241,13 +277,21 @@ function App() {
disabled={!ws.isConnected}
className="message-input"
/>
<button type="submit" disabled={!ws.isConnected || !userInput.trim()} className="send-button">
<button
type="submit"
disabled={!ws.isConnected || !userInput.trim()}
className="send-button"
>
Send
</button>
</form>
<div className="test-controls">
<button onClick={handlePing} disabled={!ws.isConnected} className="ping-button">
<button
onClick={handlePing}
disabled={!ws.isConnected}
className="ping-button"
>
Send Ping (Test)
</button>
</div>

View File

@@ -1,52 +0,0 @@
#!/bin/bash
# Test script for LLM integration
# This script demonstrates how to use the /api/test-llm endpoint
echo "Testing LLM Integration"
echo "======================"
echo ""
# Check if server is running
if ! curl -s http://localhost:3000/api/health > /dev/null; then
echo "❌ Server is not running on port 3000"
echo "Please start the server with: npm start"
exit 1
fi
echo "✓ Server is running"
echo ""
# Test 1: Create a terminal
echo "Test 1: Create a terminal called 'llm-test'"
echo "--------------------------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called llm-test"}' | jq .
echo ""
# Test 2: List all terminals
echo ""
echo "Test 2: List all terminals"
echo "--------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
echo ""
# Test 3: Run a command in the terminal
echo ""
echo "Test 3: Run 'echo hello from LLM' in the llm-test terminal"
echo "-----------------------------------------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run echo \"hello from LLM\" in the llm-test terminal"}' | jq .
echo ""
echo ""
echo "✓ All tests completed!"
echo ""
echo "Note: You can also test manually with:"
echo "curl -X POST http://localhost:3000/api/test-llm \\"
echo " -H 'Content-Type: application/json' \\"
echo " -d '{\"message\": \"your message here\"}' | jq ."

View File

@@ -1,55 +0,0 @@
/**
* Simple test script to verify the orchestrator and LLM integration
* Run with: OPENAI_API_KEY=your_key node test-orchestrator.js
*/
import { initializeOpenAI, streamLLM } from './dist/server/agent/llm-openai.js';
import { getSystemPrompt } from './dist/server/agent/system-prompt.js';
async function main() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.error('ERROR: OPENAI_API_KEY environment variable is not set');
console.error('Usage: OPENAI_API_KEY=your_key node test-orchestrator.js');
process.exit(1);
}
console.log('Initializing OpenAI client...');
initializeOpenAI(apiKey);
console.log('\nStarting conversation with assistant...');
console.log('User: "list all terminals"\n');
const messages = [
{ role: 'system', content: getSystemPrompt() },
{ role: 'user', content: 'list all terminals' }
];
console.log('Assistant response:');
try {
const response = await streamLLM({
messages,
onChunk: (chunk) => {
process.stdout.write(chunk);
},
onToolCall: (toolName, args) => {
console.log(`\n[Tool Call] ${toolName}`, JSON.stringify(args, null, 2));
},
onToolResult: (toolName, result) => {
console.log(`[Tool Result] ${toolName}:`, JSON.stringify(result, null, 2));
},
onFinish: () => {
console.log('\n\nConversation complete!');
}
});
console.log('\n\nFinal response:', response);
} catch (error) {
console.error('\nError:', error.message);
console.error(error.stack);
}
}
main();

View File

@@ -1,13 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
root: 'src/ui',
root: "src/ui",
build: {
outDir: path.resolve(__dirname, 'dist/ui'),
outDir: path.resolve(__dirname, "dist/ui"),
emptyOutDir: true,
},
})
});

View File

@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"Read(//Users/moboudra/dev/tmux-mcp/**)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,3 +0,0 @@
OPENAI_API_KEY=sk-your-key-here
AUTH_PASSWORD=your-secure-password-here
MCP_SERVER_URL=https://your-mcp-server-url-here

View File

@@ -1,33 +0,0 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,237 +0,0 @@
# Audio Visualization Implementation Plan
## Overview
Implement real-time audio feedback visualization using the native Web Audio API to show user voice activity through a volume bar indicator.
## Architecture
### Components Hierarchy
```
Page (app/page.tsx)
└── VoiceClient (client component)
├── Audio Controls (mute/unmute button)
├── VolumeBar (visualization component)
└── WebRTC Connection Logic
```
## Implementation Steps
### 1. Create Audio Analysis Hook (`use-audio-level.ts`)
**Purpose:** Extract and calculate audio levels from MediaStream
**Implementation Details:**
- Accept `MediaStream | null` as input
- Return normalized volume level (0-100)
- Use Web Audio API components:
- `AudioContext` - main audio processing context
- `AnalyserNode` - provides real-time frequency/time-domain analysis
- `MediaStreamAudioSourceNode` - connects MediaStream to analyser
**Key Logic:**
```typescript
1. Create AudioContext when stream is available
2. Create AnalyserNode with fftSize = 2048 (provides 1024 frequency bins)
3. Create MediaStreamSource from the input stream
4. Connect: source analyser (do NOT connect to destination to avoid feedback)
5. Start animation loop using requestAnimationFrame
6. In each frame:
- Call analyser.getByteTimeDomainData() to get audio samples
- Calculate RMS (Root Mean Square) for volume level
- Normalize to 0-100 range
- Update state
7. Cleanup: disconnect nodes and close context on unmount
```
**Performance Considerations:**
- Use `requestAnimationFrame` for optimal update timing (~60fps)
- Reuse `Uint8Array` buffer instead of creating new ones each frame
- Close AudioContext on cleanup to prevent memory leaks
**Edge Cases:**
- Handle null/undefined stream gracefully
- Manage AudioContext state changes
- Handle browser autoplay policies (may require user gesture)
### 2. Create Volume Bar Component (`volume-bar.tsx`)
**Purpose:** Visual representation of audio level
**Props:**
- `volume: number` (0-100 range)
- `isMuted?: boolean` (optional, for visual state)
**Visual Design:**
```
Container: Fixed height bar (e.g., 200px tall, 40px wide)
└── Fill: Height changes based on volume level
├── Color zones:
│ ├── 0-60%: Green (#10b981)
│ ├── 60-80%: Yellow (#fbbf24)
│ └── 80-100%: Red (#ef4444)
└── Animation: Smooth transitions (transition: all 0.1s ease)
```
**States:**
- Active: Shows current volume with color-coded levels
- Muted: Grayed out or show muted icon overlay
- No stream: Empty/disabled state
**Implementation:**
- Use CSS flexbox for vertical orientation
- Apply `transform: scaleY()` or dynamic height for bar fill
- Add subtle animations for smoothness
- Responsive design considerations
### 3. Integrate into Voice Client Component
**File:** `app/voice-client.tsx` (or similar)
**Integration Flow:**
```
1. Manage MediaStream state
2. Pass stream to useAudioLevel hook
3. Get volume value from hook
4. Pass volume to VolumeBar component
5. Update VolumeBar based on mute state
```
**State Management:**
```typescript
const [stream, setStream] = useState<MediaStream | null>(null);
const [isMuted, setIsMuted] = useState(false);
const volume = useAudioLevel(stream);
// When muting: keep stream active but disable audio track
stream?.getAudioTracks().forEach(track => {
track.enabled = !isMuted;
});
```
**Mute/Unmute Logic:**
- Don't stop the stream when muting (keeps analysis running)
- Disable audio track: `track.enabled = false`
- Visual feedback: gray out volume bar or add overlay
- Optional: Stop showing volume updates when muted
## Technical Specifications
### Audio Analysis Parameters
**AnalyserNode Configuration:**
- `fftSize: 2048` - Higher values = more frequency detail but slower
- `smoothingTimeConstant: 0.8` (default) - Smooths out rapid changes
- `frequencyBinCount: analyser.fftSize / 2` - Number of data points (1024)
**Volume Calculation:**
```typescript
// Get time-domain data (waveform)
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(dataArray);
// Calculate RMS (Root Mean Square)
const rms = Math.sqrt(
dataArray.reduce((sum, value) => sum + value * value, 0) / dataArray.length
);
// Normalize: Byte values are 0-255, normalize to 0-100
const normalizedVolume = (rms / 255) * 100;
// Optional: Apply scaling for better visual response
const scaledVolume = Math.min(100, normalizedVolume * 1.5);
```
### Alternative: Using Frequency Data
```typescript
// For more responsive visualization
analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
const volume = (average / 255) * 100;
```
## File Structure
```
app/
├── page.tsx # Main page (server component)
├── voice-client.tsx # Client component with WebRTC
├── components/
│ └── volume-bar.tsx # Volume visualization component
└── hooks/
└── use-audio-level.ts # Audio analysis hook
```
## TypeScript Types
```typescript
// hooks/use-audio-level.ts
interface UseAudioLevelOptions {
enabled?: boolean;
smoothing?: number;
}
function useAudioLevel(
stream: MediaStream | null,
options?: UseAudioLevelOptions
): number;
// components/volume-bar.tsx
interface VolumeBarProps {
volume: number; // 0-100
isMuted?: boolean;
className?: string;
}
```
## Testing Considerations
1. **Manual Testing:**
- Test with different microphone input levels
- Verify mute/unmute visual feedback
- Check performance (CPU usage should be minimal)
- Test on different browsers (Chrome, Firefox, Safari)
2. **Edge Cases:**
- No microphone access granted
- Microphone disconnected mid-session
- Multiple rapid mute/unmute toggles
- Browser tab backgrounded (check if AudioContext suspends)
3. **Visual Verification:**
- Bar should respond quickly to voice (< 100ms latency feel)
- Color transitions should be smooth
- Muted state should be clearly visible
## Browser Compatibility
**Web Audio API Support:**
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Full support (requires user gesture for AudioContext)
**Important:** Safari requires a user interaction (e.g., button click) to start AudioContext due to autoplay policies.
## Performance Optimization
1. **Throttle/Debounce:** Already handled by `requestAnimationFrame`
2. **Memory:** Reuse typed arrays instead of creating new ones
3. **Cleanup:** Always disconnect nodes and close AudioContext
4. **Conditional Rendering:** Only run analysis when stream is active
## Future Enhancements (Optional)
- Add peak level indicator (shows max volume reached)
- Add threshold indicators for voice activity detection
- Implement waveform visualization as alternative view
- Add accessibility features (ARIA labels, screen reader support)
- Smoothing controls for users to adjust responsiveness
- Save user preferences (mute state, visualization style)
## Implementation Order
1. Create `use-audio-level.ts` hook (core functionality)
2. Create `volume-bar.tsx` component (visual display)
3. Integrate into voice client component
4. Test with microphone input
5. Refine visual styling and animations
6. Handle edge cases and error states

View File

@@ -1,615 +0,0 @@
# Real-Time Voice App Implementation Plan
## Overview
Build a Next.js application that connects directly to OpenAI's Realtime API using WebRTC for voice interaction with mute/unmute controls and audio visualization.
## Tech Stack
- Next.js 14+ (App Router)
- TypeScript
- OpenAI Realtime API (WebRTC)
- Web Audio API (for visualization)
- Native browser WebRTC APIs
## Project Structure
```
app/
├── page.tsx # Main page (server component)
├── voice-client.tsx # Main client component
├── components/
│ ├── volume-bar.tsx # Audio level visualization
│ └── mute-button.tsx # Mute/unmute control
├── hooks/
│ ├── use-audio-level.ts # Audio analysis
│ └── use-webrtc-voice.ts # WebRTC connection logic
└── api/
└── session/
└── route.ts # Generate ephemeral tokens
```
## Environment Setup
### Required Environment Variables
```
OPENAI_API_KEY=sk-...
```
### Dependencies (package.json additions)
```json
{
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.0.0"
}
}
```
**No additional dependencies needed** - using native browser APIs only.
## Implementation Steps
### Phase 1: Backend - Ephemeral Token Generation
#### File: `app/api/session/route.ts`
**Purpose:** Securely generate ephemeral tokens for WebRTC connection
**Implementation:**
```typescript
import { NextResponse } from 'next/server';
export async function POST() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'OpenAI API key not configured' },
{ status: 500 }
);
}
try {
const response = await fetch(
'https://api.openai.com/v1/realtime/sessions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'alloy',
}),
}
);
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Session creation error:', error);
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
);
}
}
```
**Key Details:**
- POST endpoint only
- Returns: `{ client_secret: { value: string, expires_at: number } }`
- Ephemeral token valid for 60 seconds
- Keep API key secure on server
---
### Phase 2: WebRTC Connection Hook
#### File: `app/hooks/use-webrtc-voice.ts`
**Purpose:** Manage WebRTC connection to OpenAI Realtime API
**State Management:**
```typescript
interface UseWebRTCVoiceReturn {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
stream: MediaStream | null;
connect: () => Promise<void>;
disconnect: () => void;
}
```
**Implementation Flow:**
```
1. Request microphone access
2. Fetch ephemeral token from /api/session
3. Create RTCPeerConnection
4. Add microphone audio track to peer connection
5. Create data channel for events
6. Create and set local SDP offer
7. Send offer to OpenAI and get answer
8. Set remote SDP answer
9. Connection established
```
**Key Code Patterns:**
```typescript
// 1. Get microphone
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
// 2. Get ephemeral token
const response = await fetch('/api/session', { method: 'POST' });
const { client_secret } = await response.json();
// 3. Create peer connection
const pc = new RTCPeerConnection();
// 4. Add audio track
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// 5. Create data channel
const dc = pc.createDataChannel('oai-events');
// 6. Handle incoming audio
pc.ontrack = (event) => {
const audioElement = new Audio();
audioElement.srcObject = event.streams[0];
audioElement.play();
};
// 7. Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 8. Send to OpenAI
const sdpResponse = await fetch(
`https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${client_secret.value}`,
'Content-Type': 'application/sdp',
},
body: offer.sdp,
}
);
// 9. Set answer
const answer = {
type: 'answer' as RTCSdpType,
sdp: await sdpResponse.text(),
};
await pc.setRemoteDescription(answer);
```
**Cleanup:**
```typescript
function disconnect() {
dataChannel?.close();
peerConnection?.close();
stream?.getTracks().forEach(track => track.stop());
if (audioElement) {
audioElement.pause();
audioElement.srcObject = null;
}
}
```
**Error Handling:**
- Microphone permission denied
- Network errors during token fetch
- WebRTC connection failures
- Token expiration
---
### Phase 3: Audio Level Hook
#### File: `app/hooks/use-audio-level.ts`
**Purpose:** Calculate real-time audio levels from microphone stream
**Implementation:**
```typescript
function useAudioLevel(stream: MediaStream | null): number {
const [volume, setVolume] = useState(0);
useEffect(() => {
if (!stream) {
setVolume(0);
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let animationId: number;
function updateVolume() {
analyser.getByteTimeDomainData(dataArray);
const rms = Math.sqrt(
dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length
);
const normalized = (rms / 255) * 100;
setVolume(Math.min(100, normalized * 1.5));
animationId = requestAnimationFrame(updateVolume);
}
updateVolume();
return () => {
cancelAnimationFrame(animationId);
source.disconnect();
audioContext.close();
};
}, [stream]);
return volume;
}
```
**Returns:** Volume level 0-100
---
### Phase 4: Volume Bar Component
#### File: `app/components/volume-bar.tsx`
**Purpose:** Visual representation of audio level
**Props:**
```typescript
interface VolumeBarProps {
volume: number; // 0-100
isMuted?: boolean;
}
```
**Visual Design:**
```typescript
function VolumeBar({ volume, isMuted }: VolumeBarProps) {
const getColor = () => {
if (isMuted) return 'bg-gray-300';
if (volume > 80) return 'bg-red-500';
if (volume > 60) return 'bg-yellow-400';
return 'bg-green-500';
};
return (
<div className="w-10 h-48 bg-gray-200 rounded-lg overflow-hidden flex flex-col-reverse">
<div
className={`${getColor()} transition-all duration-100 ease-out`}
style={{ height: `${isMuted ? 0 : volume}%` }}
/>
</div>
);
}
```
**Styling:**
- Vertical bar, 40px wide, 192px tall
- Color zones: green → yellow → red
- Smooth transitions (100ms)
- Gray when muted
---
### Phase 5: Mute Button Component
#### File: `app/components/mute-button.tsx`
**Purpose:** Toggle microphone mute state
**Props:**
```typescript
interface MuteButtonProps {
isMuted: boolean;
onToggle: () => void;
disabled?: boolean;
}
```
**Implementation:**
```typescript
function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) {
return (
<button
onClick={onToggle}
disabled={disabled}
className={`
px-6 py-3 rounded-lg font-medium transition-colors
${isMuted
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{isMuted ? 'Unmute' : 'Mute'}
</button>
);
}
```
**States:**
- Active (not muted): Blue background
- Muted: Red background
- Disabled: Grayed out when not connected
---
### Phase 6: Main Voice Client Component
#### File: `app/voice-client.tsx`
**Purpose:** Main client component orchestrating all functionality
**Implementation:**
```typescript
'use client';
import { useState } from 'react';
import { useWebRTCVoice } from './hooks/use-webrtc-voice';
import { useAudioLevel } from './hooks/use-audio-level';
import VolumeBar from './components/volume-bar';
import MuteButton from './components/mute-button';
export default function VoiceClient() {
const [isMuted, setIsMuted] = useState(false);
const {
isConnected,
isConnecting,
error,
stream,
connect,
disconnect
} = useWebRTCVoice();
const volume = useAudioLevel(stream);
function handleMuteToggle() {
if (!stream) return;
stream.getAudioTracks().forEach(track => {
track.enabled = !isMuted;
});
setIsMuted(!isMuted);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-8 p-8">
<h1 className="text-4xl font-bold">Real-Time Voice</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="flex items-end gap-8">
<VolumeBar volume={volume} isMuted={isMuted} />
<div className="flex flex-col gap-4">
{!isConnected ? (
<button
onClick={connect}
disabled={isConnecting}
className="px-8 py-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium disabled:opacity-50"
>
{isConnecting ? 'Connecting...' : 'Start Voice Chat'}
</button>
) : (
<>
<MuteButton
isMuted={isMuted}
onToggle={handleMuteToggle}
disabled={!isConnected}
/>
<button
onClick={disconnect}
className="px-8 py-4 bg-gray-500 hover:bg-gray-600 text-white rounded-lg font-medium"
>
Disconnect
</button>
</>
)}
</div>
</div>
{isConnected && (
<p className="text-green-600 font-medium">
Connected - Speak to interact with AI
</p>
)}
</div>
);
}
```
**State Management:**
- `isMuted` - local mute state
- `isConnected` - from WebRTC hook
- `volume` - from audio level hook
- `stream` - MediaStream from WebRTC hook
**User Flow:**
1. Click "Start Voice Chat" → requests mic permission → connects
2. Volume bar shows audio level
3. Click "Mute" → disables audio track, grays out volume bar
4. Click "Unmute" → re-enables audio track
5. Click "Disconnect" → closes connection, stops mic
---
### Phase 7: Main Page
#### File: `app/page.tsx`
**Purpose:** Entry point, server component wrapper
**Implementation:**
```typescript
import VoiceClient from './voice-client';
export default function Home() {
return <VoiceClient />;
}
```
**Simple wrapper** - all logic in client component
---
## Configuration Files
### TypeScript Config (`tsconfig.json`)
Ensure these compiler options:
```json
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"target": "ES2017",
"strict": true
}
}
```
### Tailwind Config
Standard Next.js Tailwind setup - no special configuration needed
---
## Testing Checklist
### Functionality
- [ ] Microphone permission request works
- [ ] Connection establishes successfully
- [ ] Can hear AI responses through speakers
- [ ] AI can hear user input
- [ ] Mute button disables microphone
- [ ] Unmute button re-enables microphone
- [ ] Volume bar reflects audio level
- [ ] Volume bar grays out when muted
- [ ] Disconnect stops all streams
- [ ] Error messages display correctly
### Browser Testing
- [ ] Chrome/Edge (primary target)
- [ ] Firefox
- [ ] Safari (note: may need user gesture for AudioContext)
### Edge Cases
- [ ] Microphone permission denied
- [ ] No microphone available
- [ ] Network errors during connection
- [ ] Token expiration (60 second timeout)
- [ ] Rapid mute/unmute toggles
- [ ] Disconnect and reconnect
- [ ] Browser tab backgrounded
---
## Implementation Order
### Step 1: Setup (5 min)
1. Verify Next.js project initialized
2. Verify OPENAI_API_KEY in .env.local
3. Ensure TypeScript configured
### Step 2: Backend (10 min)
1. Create `app/api/session/route.ts`
2. Test endpoint with curl or Postman
3. Verify token generation
### Step 3: Components (15 min)
1. Create `app/components/volume-bar.tsx`
2. Create `app/components/mute-button.tsx`
3. Test with mock data
### Step 4: Hooks (30 min)
1. Create `app/hooks/use-audio-level.ts`
2. Test with microphone input
3. Create `app/hooks/use-webrtc-voice.ts`
4. Test WebRTC connection
### Step 5: Integration (15 min)
1. Create `app/voice-client.tsx`
2. Update `app/page.tsx`
3. Wire all components together
### Step 6: Testing (20 min)
1. Test complete user flow
2. Test error cases
3. Verify audio quality
4. Check performance
### Step 7: Polish (10 min)
1. Refine styling
2. Improve error messages
3. Add loading states
**Total Estimated Time: ~2 hours**
---
## Common Issues & Solutions
### Issue: AudioContext suspended
**Solution:** Safari requires user gesture. Ensure AudioContext created after user clicks connect.
### Issue: No audio output
**Solution:** Check if Audio element is created and `play()` called. Verify speakers/volume.
### Issue: Microphone not working
**Solution:** Check browser permissions. Ensure HTTPS (required for getUserMedia).
### Issue: Connection fails
**Solution:** Verify token not expired (60s limit). Check network console for errors.
### Issue: High latency
**Solution:** WebRTC should be low-latency by default. Check network connection quality.
---
## Future Enhancements (Not in Initial Scope)
- Session configuration (change voice, temperature)
- Text transcript display
- Function calling support
- Audio recording/playback
- Multiple conversation modes
- Voice activity detection threshold
- Connection quality indicator
- Reconnection logic on disconnect
---
## Success Criteria
✅ User can click connect and start talking to AI
✅ AI responses are audible and clear
✅ Mute/unmute works correctly
✅ Volume bar shows real-time audio levels
✅ Error states handled gracefully
✅ Connection can be cleanly disconnected
✅ Code is clean, typed, and maintainable

View File

@@ -1,100 +0,0 @@
# MCP Integration
## Overview
This application now integrates with a Model Context Protocol (MCP) server to extend the AI's capabilities with custom tools.
## How It Works
The integration uses **Remote MCP Server** mode, where OpenAI's servers directly communicate with your MCP server:
1. During session creation, the app sends MCP configuration to OpenAI
2. OpenAI connects to your MCP server and discovers available tools
3. During conversation, when the AI needs to use a tool, it calls your MCP server automatically
4. Your MCP server executes the tool and returns results to OpenAI
5. OpenAI incorporates the results into the conversation and responds to the user
## Configuration
The MCP server is configured in `app/api/session/route.ts`:
```typescript
tools: [
{
type: 'mcp',
server_label: 'local-mcp',
server_url: 'https://mohameds-macbook-pro.tail8fe838.ts.net/mcp?password=dev-password',
require_approval: 'never',
},
]
```
### Configuration Options
- **`type`**: Must be `'mcp'` for MCP server integration
- **`server_label`**: A friendly name for your MCP server (used in logs/debugging)
- **`server_url`**: The HTTPS URL of your MCP server (including query parameters for auth)
- **`require_approval`**: Set to `'never'` for automatic tool execution, or `'always'` for manual approval
## Tool Discovery
The MCP protocol automatically discovers all available tools from your server. You don't need to:
- Manually define each tool
- Handle function call events in the client
- Send results back to OpenAI
Everything is handled automatically by OpenAI's servers.
## Benefits
1. **Zero Client Complexity**: No need to monitor WebRTC data channels or handle function calls
2. **Automatic Tool Discovery**: MCP servers expose their available tools automatically
3. **Scalable**: Easy to add more MCP servers or switch between them
4. **Production Ready**: This is OpenAI's recommended approach for tool integration
## Testing
To test the MCP integration:
1. Start the development server: `npm run dev`
2. Open http://localhost:3000
3. Click "Start Voice Chat"
4. Ask the AI to perform actions that require your MCP tools
5. The AI will automatically use the appropriate tools and respond
Example prompts to test (depending on your MCP tools):
- "What tools do you have access to?"
- "Can you [action that requires your MCP tool]?"
## Changing MCP Configuration
To use a different MCP server or modify settings:
1. Edit `app/api/session/route.ts`
2. Update the `tools` array in the session creation
3. Rebuild: `npm run build`
4. Restart the server
## Security Notes
- The MCP server URL includes authentication in the query string (`?password=dev-password`)
- This is acceptable for development but consider using proper OAuth or API keys for production
- The `require_approval: 'never'` setting means tools execute automatically without confirmation
- For production, consider using `require_approval: 'always'` with a UI approval mechanism
## Troubleshooting
### Tools Not Working
- Verify your MCP server is accessible at the configured URL
- Check that the password/auth is correct
- Ensure your MCP server is returning valid MCP protocol responses
### Session Creation Fails
- Check OpenAI API key is valid
- Verify the MCP server URL is properly formatted
- Check network connectivity to your MCP server
### AI Doesn't Use Tools
- The AI decides when to use tools based on the conversation context
- Try more explicit prompts that clearly require tool usage
- Verify tools are properly exposed by your MCP server

View File

@@ -1,232 +0,0 @@
# MCP Integration Research & Implementation Plan
## Executive Summary
Based on research into OpenAI's Realtime API and MCP (Model Context Protocol) integration, here's what I've discovered:
### Key Finding: Two Approaches Available
1. **Remote MCP Server** (Recommended for your use case)
2. **Client-Side Function Calling** (Traditional approach)
---
## Approach 1: Remote MCP Server (RECOMMENDED)
### How It Works
**OpenAI executes the MCP calls on their servers, not your client.**
When you configure a remote MCP server URL in the session configuration:
1. You provide the MCP server URL during session creation
2. OpenAI's servers connect to your MCP server directly
3. When the AI needs to call a tool, OpenAI's servers make the request to your MCP server
4. The MCP server returns results to OpenAI's servers
5. OpenAI processes the results and continues the conversation
### Configuration
```typescript
// In app/api/session/route.ts
const response = await fetch(
'https://api.openai.com/v1/realtime/sessions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'alloy',
tools: [
{
type: 'mcp',
server_label: 'your-mcp-label',
server_url: 'https://your-mcp-server.com',
authorization: 'optional-bearer-token', // If your MCP requires auth
require_approval: 'never' // or 'always' for manual approval
}
]
}),
}
);
```
### Pros
-**Zero client-side complexity** - No need to handle function calls in your app
-**Automatic tool discovery** - MCP protocol auto-discovers available tools
-**Seamless integration** - OpenAI handles everything after configuration
-**No data channel monitoring** - No need to listen for function call events
-**Production-ready** - Officially supported by OpenAI
### Cons
-**MCP server must be publicly accessible** - Needs HTTPS URL
-**Less control** - Can't intercept or modify tool calls client-side
-**Requires hosted MCP server** - Can't use local MCP tools directly
### Security Considerations
- `require_approval: "always"` - Each tool call needs explicit approval (safer)
- `require_approval: "never"` - Automatic execution (faster, but requires trust)
- `authorization` header can be used to secure your MCP server
---
## Approach 2: Client-Side Function Calling (Traditional)
### How It Works
**Your client executes the function calls, not OpenAI.**
1. Configure tools in `session.update` event via WebRTC data channel
2. Listen for `response.output_item.done` events on the data channel
3. When `item.type === 'function_call'`, execute the function client-side
4. Send results back via `conversation.item.create` event
5. Trigger response generation with `response.create` event
### Configuration
```typescript
// Send via WebRTC data channel after connection
dataChannel.send(JSON.stringify({
type: 'session.update',
session: {
tools: [
{
type: 'function',
name: 'webSearch',
description: 'Performs an internet search',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query'
}
},
required: ['query']
}
}
],
tool_choice: 'auto'
}
}));
// Listen for function calls
dataChannel.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'response.output_item.done') {
const item = message.item;
if (item.type === 'function_call') {
// Execute function locally
const result = await executeFunction(item.name, JSON.parse(item.arguments));
// Send result back
dataChannel.send(JSON.stringify({
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id: item.call_id,
output: JSON.stringify(result)
}
}));
// Request new response
dataChannel.send(JSON.stringify({ type: 'response.create' }));
}
}
};
```
### Pros
-**Full control** - Can intercept, modify, or reject function calls
-**Local execution** - Can use local tools, no server needed
-**Custom logic** - Can add validation, rate limiting, etc.
-**Debugging** - Easier to debug function calls
### Cons
-**Complex implementation** - Need to handle data channel events
-**Manual tool definition** - Must define every function manually
-**More code** - Need state management for function call flow
-**Error handling** - Must handle all failure cases
---
## Recommendation for Your Project
### Use Remote MCP Server (Approach 1)
**Reasons:**
1. **You mentioned having an MCP URL** - This is exactly what remote MCP is designed for
2. **Simpler implementation** - Just add configuration to session creation
3. **Production-ready** - This is the new, recommended approach from OpenAI
4. **Scalable** - Easy to add more MCP servers or change them
### Implementation Plan
#### Phase 1: Update Session Creation (5 minutes)
1. Add MCP configuration to `/api/session/route.ts`
2. Accept MCP URL/config from environment variables or request body
#### Phase 2: Environment Configuration (2 minutes)
1. Add MCP server URL to `.env.local`
2. Optionally add authorization token if needed
#### Phase 3: Testing (10 minutes)
1. Test that session creates successfully
2. Test that AI can discover and use MCP tools
3. Test tool execution and responses
#### Phase 4: UI Updates (Optional, 15 minutes)
1. Add ability to configure MCP URL from UI
2. Show when MCP tools are being used
3. Add approval mechanism if `require_approval: "always"`
---
## Alternative: Client-Side Function Calling
If you need **local tool execution** or **cannot host a public MCP server**, then use Approach 2.
This would require:
1. Updating `use-webrtc-voice.ts` to handle data channel messages
2. Creating a tool registry/executor system
3. Implementing event listeners for function calls
4. Managing function call state and results
**Estimated effort:** 2-3 hours for full implementation
---
## Questions to Clarify
Before implementing, please clarify:
1. **Do you have a publicly accessible MCP server URL?**
- If yes → Use Remote MCP (Approach 1)
- If no → Use Client-Side Function Calling (Approach 2)
2. **Does your MCP server require authentication?**
- If yes → We'll need to include the authorization header
3. **Do you want manual approval for tool calls?**
- `require_approval: "always"` - Safer, requires UI for approval
- `require_approval: "never"` - Automatic, simpler implementation
4. **Should MCP configuration be:**
- Hard-coded in session creation?
- Configurable via environment variables?
- Configurable via UI at runtime?
---
## Next Steps
Once you provide:
1. Your MCP server URL
2. Whether it requires authentication
3. Your approval preference
I can implement the integration in approximately 10-20 minutes using the Remote MCP approach.

View File

@@ -1,162 +0,0 @@
# Real-Time Voice App
A Next.js application that connects directly to OpenAI's Realtime API using WebRTC for voice interaction with mute/unmute controls and audio visualization.
## Features
- Real-time voice interaction with OpenAI's GPT-4o Realtime API
- WebRTC-based audio streaming for low latency
- Live audio level visualization
- Mute/unmute controls
- Password authentication for access control
- Agent activity transparency (debug log panel)
- Real-time agent status display
- Tool call visibility
- Clean, minimal UI
## Tech Stack
- Next.js 14+ (App Router)
- TypeScript
- OpenAI Realtime API (WebRTC)
- Web Audio API (for visualization)
- Tailwind CSS
- Native browser WebRTC APIs
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create a `.env.local` file in the root directory:
```bash
OPENAI_API_KEY=sk-your-api-key-here
AUTH_PASSWORD=your-secure-password-here
```
**Note**: The `AUTH_PASSWORD` is required to access the app. Users will be prompted to enter this password before they can use the voice interface.
3. Run the development server:
```bash
npm run dev
```
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Important: HTTPS Requirement
**This app requires a secure context (HTTPS or localhost) to access the microphone.** This is a browser security requirement, not a limitation of this app.
### Desktop Development
-`http://localhost:3000` works (localhost is considered secure)
- ✅ Desktop browsers allow microphone access on localhost
### Mobile Development
Mobile browsers **require HTTPS** for microphone access. Localhost won't work on mobile. You have several options:
#### Option 1: Cloudflare Tunnel (Recommended)
Free and easy to set up:
```bash
# Install cloudflared
# macOS
brew install cloudflare/cloudflare/cloudflared
# Start tunnel
cloudflared tunnel --url http://localhost:3000
```
You'll get an HTTPS URL like `https://xxx.trycloudflare.com` that works on mobile.
#### Option 2: ngrok
```bash
# Install ngrok from https://ngrok.com
ngrok http 3000
```
You'll get an HTTPS URL like `https://xxx.ngrok.io`
#### Option 3: Tailscale (For your local network)
If you're already using Tailscale:
```bash
# Your app is accessible at:
https://your-machine-name.tailnet-name.ts.net:3000
```
#### Option 4: Local HTTPS Certificate
Set up a local SSL certificate (more complex):
```bash
# Generate certificate
mkcert -install
mkcert localhost
# Update next.config.js to use HTTPS
# (Requires additional Next.js configuration)
```
### Testing on Mobile
1. Set up one of the HTTPS options above
2. Access the HTTPS URL on your mobile device
3. Grant microphone permissions when prompted
4. The app should work normally
## Usage
1. Click "Start Voice Chat" to begin
2. Allow microphone access when prompted
3. Start speaking - the AI will respond in real-time
4. Use the "Mute" button to disable your microphone
5. The volume bar shows your audio level in real-time
6. Click "Disconnect" to end the session
## Project Structure
```
app/
├── page.tsx # Main page
├── voice-client.tsx # Main client component
├── components/
│ ├── volume-bar.tsx # Audio level visualization
│ └── mute-button.tsx # Mute/unmute control
├── hooks/
│ ├── use-audio-level.ts # Audio analysis hook
│ └── use-webrtc-voice.ts # WebRTC connection logic
└── api/
└── session/
└── route.ts # Generate ephemeral tokens
```
## How It Works
1. **Token Generation**: The backend endpoint (`/api/session`) securely generates ephemeral tokens from OpenAI
2. **WebRTC Connection**: The client establishes a peer-to-peer WebRTC connection with OpenAI's servers
3. **Audio Streaming**: Microphone audio is streamed in real-time to OpenAI
4. **AI Responses**: OpenAI's responses are received and played through the browser's audio output
5. **Visualization**: Web Audio API analyzes the microphone input to display volume levels
## Browser Compatibility
- Chrome/Edge (recommended)
- Firefox
- Safari (may require user gesture for AudioContext)
## Troubleshooting
### Microphone not working
- Check browser permissions for microphone access
- Ensure you're using HTTPS or localhost
- Check system microphone settings
### No audio output
- Check speaker/volume settings
- Verify audio permissions in browser
- Check browser console for errors
### Connection fails
- Verify OPENAI_API_KEY is set correctly
- Check network connectivity
- Tokens expire after 60 seconds - reconnect if needed
## License
MIT

View File

@@ -1,315 +0,0 @@
# OpenAI Realtime API Configuration Options
This document lists all available configuration parameters for the OpenAI Realtime API session.
## Core Parameters
### `model`
- **Type**: `string`
- **Default**: `'gpt-4o-realtime-preview-2024-12-17'`
- **Options**: `'gpt-realtime'`, `'gpt-4o-realtime-preview-2024-12-17'`
- **Description**: The Realtime model to use. `gpt-realtime` is the latest production model.
### `voice`
- **Type**: `string`
- **Default**: `'alloy'`
- **Options**: `'alloy'`, `'echo'`, `'fable'`, `'onyx'`, `'nova'`, `'shimmer'`
- **Description**: Voice to use for AI speech generation
### `modalities`
- **Type**: `array of strings`
- **Default**: `['text', 'audio']`
- **Options**: `['text']`, `['audio']`, `['text', 'audio']`
- **Description**: Response modalities to use. Set to `['text']` for text-only mode with separate TTS.
### `temperature`
- **Type**: `float`
- **Default**: `0.8`
- **Range**: `0.6` to `1.2`
- **Description**: Controls response randomness. Lower values = more deterministic, higher values = more creative.
### `max_response_output_tokens`
- **Type**: `integer`
- **Default**: Not specified
- **Example**: `2048`
- **Description**: Maximum number of tokens in the model's response.
### `instructions`
- **Type**: `string`
- **Default**: None
- **Description**: System instructions for the model (like system messages in Chat API)
---
## Audio Configuration
### `input_audio_format`
- **Type**: `string`
- **Default**: `'pcm16'`
- **Options**: `'pcm16'`, `'g711_ulaw'`, `'g711_alaw'`
- **Description**: Format for input audio
### `output_audio_format`
- **Type**: `string`
- **Default**: `'pcm16'`
- **Options**: `'pcm16'`, `'g711_ulaw'`, `'g711_alaw'`
- **Description**: Format for output audio
### `input_audio_transcription`
- **Type**: `object`
- **Default**: None
- **Properties**:
- `model`: Transcription model (e.g., `'gpt-4o-transcribe'`)
- **Description**: Configuration for transcribing input audio
- **Example**:
```json
{
"model": "gpt-4o-transcribe"
}
```
### `input_audio_noise_reduction` (New in 2025)
- **Type**: `object | null`
- **Default**: `null`
- **Description**: Noise reduction configuration for input audio
- **Note**: Using noise reduction can impact latency, especially with semantic VAD turn detection
- **Status**: Recently added parameter, check latest API docs for configuration details
---
## Turn Detection Configuration
Controls when the AI should start/stop responding based on voice activity.
### `turn_detection`
- **Type**: `object | null`
- **Default**: Server VAD mode
- **Description**: Configuration for turn detection
#### Server VAD Mode (Default)
```json
{
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"create_response": true,
"interrupt_response": true
}
```
**Parameters**:
- `threshold` (float, 0.0-1.0): Voice activity detection threshold. Higher values (e.g., 0.7) work better in noisy environments.
- `prefix_padding_ms` (integer): Audio to include before speech starts (ms)
- `silence_duration_ms` (integer): Silence duration to wait before considering speech ended (ms)
- `create_response` (boolean): Automatically create response when turn detected
- `interrupt_response` (boolean): Allow user to interrupt AI responses
#### Semantic VAD Mode
```json
{
"type": "semantic_vad",
"eagerness": "auto",
"create_response": true,
"interrupt_response": true
}
```
**Parameters**:
- `eagerness` (string): `'low'`, `'medium'`, `'high'`, or `'auto'`. Controls how quickly the model responds.
- `create_response` (boolean): Automatically create response
- `interrupt_response` (boolean): Allow interruptions
**Note**: Semantic VAD can have higher latency, especially with noise reduction enabled.
#### Disable Turn Detection
```json
{
"type": "none"
}
```
Use this if you want manual control over when responses are generated.
---
## Tool Configuration
### `tools`
- **Type**: `array of objects`
- **Default**: `[]`
- **Description**: Tools available for the model to use
#### Remote MCP Server Tool
```json
{
"type": "mcp",
"server_label": "my-mcp-server",
"server_url": "https://example.com/mcp",
"authorization": "Bearer token", // Optional
"require_approval": "never" // or "always"
}
```
**Parameters**:
- `type`: Must be `'mcp'` for MCP servers
- `server_label`: Friendly name for the server
- `server_url`: HTTPS URL of the MCP server
- `authorization`: Optional auth header value
- `require_approval`: `'never'` for auto-execution, `'always'` for manual approval
#### Function Tool (Client-Side)
```json
{
"type": "function",
"name": "function_name",
"description": "What the function does",
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": "Parameter description"
}
},
"required": ["param1"]
}
}
```
### `tool_choice`
- **Type**: `string`
- **Default**: `'auto'`
- **Options**: `'auto'`, `'required'`, `'none'`
- **Description**: Controls when tools are used
- `'auto'`: AI decides when to use tools
- `'required'`: AI must use a tool
- `'none'`: Tools disabled for this response
---
## Current Configuration (app/api/session/route.ts)
```typescript
{
model: 'gpt-realtime',
voice: 'shimmer',
input_audio_transcription: {
model: 'gpt-4o-transcribe',
},
tools: [
{
type: 'mcp',
server_label: 'local-mcp',
server_url: 'https://mohameds-macbook-pro.tail8fe838.ts.net/mcp?password=dev-password',
require_approval: 'never',
},
],
}
```
---
## Recommended Settings for Different Use Cases
### High Quality Conversation (Default)
```json
{
"model": "gpt-realtime",
"temperature": 0.8,
"turn_detection": {
"type": "server_vad",
"threshold": 0.5
}
}
```
### Noisy Environment
```json
{
"model": "gpt-realtime",
"input_audio_noise_reduction": {},
"turn_detection": {
"type": "server_vad",
"threshold": 0.7, // Higher threshold for noisy environments
"silence_duration_ms": 700 // Wait longer for silence
}
}
```
### More Deterministic Responses
```json
{
"model": "gpt-realtime",
"temperature": 0.6,
"max_response_output_tokens": 1024
}
```
### Fast, Responsive Conversation
```json
{
"model": "gpt-realtime",
"turn_detection": {
"type": "semantic_vad",
"eagerness": "high"
}
}
```
### Manual Control (No Auto-Response)
```json
{
"model": "gpt-realtime",
"turn_detection": {
"type": "none"
}
}
```
---
## Notes on Noise Reduction
**Availability**: `input_audio_noise_reduction` is a newly added parameter (2025).
**Pros**:
- Improves recognition in noisy environments
- Better handling of background noise
**Cons**:
- Can increase latency, especially with semantic VAD
- May affect audio quality in some cases
**Recommendation**: Test with and without noise reduction to see if the latency trade-off is worth it for your use case.
---
## Client-Side Audio Processing
In addition to server-side configuration, consider client-side audio processing:
1. **Browser WebRTC**: Modern browsers include:
- Echo cancellation
- Noise suppression
- Automatic gain control
2. **getUserMedia constraints**:
```javascript
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
```
These are currently **not configured** in the app but can be added in `use-webrtc-voice.ts` for additional audio quality improvements.
---
## References
- [OpenAI Realtime API Documentation](https://platform.openai.com/docs/guides/realtime)
- [OpenAI Realtime API Reference](https://platform.openai.com/docs/api-reference/realtime)
- [LiveKit OpenAI Plugin Docs](https://docs.livekit.io/agents/integrations/realtime/openai/)

View File

@@ -1,30 +0,0 @@
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
const { password } = await request.json();
const correctPassword = process.env.AUTH_PASSWORD;
if (!correctPassword) {
return NextResponse.json(
{ authenticated: false, error: 'Authentication not configured' },
{ status: 500 }
);
}
if (password === correctPassword) {
return NextResponse.json({ authenticated: true });
}
return NextResponse.json(
{ authenticated: false, error: 'Invalid password' },
{ status: 401 }
);
} catch (error) {
return NextResponse.json(
{ authenticated: false, error: 'Invalid request' },
{ status: 400 }
);
}
}

View File

@@ -1,77 +0,0 @@
import { NextResponse } from "next/server";
import { AccessToken, type VideoGrant } from "livekit-server-sdk";
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY;
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
export async function POST() {
if (!LIVEKIT_API_KEY) {
return NextResponse.json(
{ error: "LiveKit API key not configured" },
{ status: 500 }
);
}
if (!LIVEKIT_API_SECRET) {
return NextResponse.json(
{ error: "LiveKit API secret not configured" },
{ status: 500 }
);
}
if (!LIVEKIT_URL) {
return NextResponse.json(
{ error: "LiveKit URL not configured" },
{ status: 500 }
);
}
try {
console.log("Creating LiveKit token...");
const participantIdentity = `voice_user_${Math.floor(
Math.random() * 10_000
)}`;
const participantName = "user";
const roomName = `voice_room_${Math.floor(Math.random() * 10_000)}`;
console.log("LIVEKIT_URL", LIVEKIT_URL);
console.log("LIVEKIT_API_KEY", LIVEKIT_API_KEY);
console.log("LIVEKIT_API_SECRET", LIVEKIT_API_SECRET);
console.log("participantIdentity", participantIdentity);
console.log("participantName", participantName);
console.log("roomName", roomName);
const token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantIdentity,
name: participantName,
ttl: "15m",
});
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
};
token.addGrant(grant);
const jwt = await token.toJwt();
return NextResponse.json({
serverUrl: LIVEKIT_URL,
roomName,
participantToken: jwt,
participantName,
});
} catch (error) {
console.error("Token creation error:", error);
return NextResponse.json(
{ error: "Failed to create token" },
{ status: 500 }
);
}
}

View File

@@ -1,189 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import type { LogEntry, EventCategory } from '../types/realtime-events';
interface ActivityLogPanelProps {
logs: LogEntry[];
onClear: () => void;
}
export default function ActivityLogPanel({ logs, onClear }: ActivityLogPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const logContainerRef = useRef<HTMLDivElement>(null);
const prevLogsLengthRef = useRef(0);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (logContainerRef.current && isOpen) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, isOpen]);
// Track unread count
useEffect(() => {
if (!isOpen && logs.length > prevLogsLengthRef.current) {
const newLogs = logs.length - prevLogsLengthRef.current;
setUnreadCount(prev => prev + newLogs);
}
prevLogsLengthRef.current = logs.length;
}, [logs, isOpen]);
// Reset unread count when opening
useEffect(() => {
if (isOpen) {
setUnreadCount(0);
}
}, [isOpen]);
function getCategoryColor(category: EventCategory): string {
switch (category) {
case 'connection':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'speech':
return 'bg-green-100 text-green-800 border-green-200';
case 'transcription':
return 'bg-cyan-100 text-cyan-800 border-cyan-200';
case 'tool_call':
return 'bg-purple-100 text-purple-800 border-purple-200';
case 'response':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'error':
return 'bg-red-100 text-red-800 border-red-200';
case 'buffer':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'audio':
return 'bg-indigo-100 text-indigo-800 border-indigo-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
function LogEntryItem({ entry }: { entry: LogEntry }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="border-b border-gray-700 pb-2 mb-2 last:border-b-0">
<div className="flex items-start gap-2">
<span className="text-xs text-gray-400 font-mono shrink-0">
{formatTime(entry.timestamp)}
</span>
<span
className={`text-xs px-2 py-0.5 rounded border font-medium shrink-0 ${getCategoryColor(entry.category)}`}
>
{entry.category.toUpperCase()}
</span>
</div>
<div className="mt-1 text-sm text-gray-200">
{entry.summary || entry.eventType}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-blue-400 hover:text-blue-300 mt-1"
>
{isExpanded ? '▼ Hide details' : '▶ Show details'}
</button>
{isExpanded && (
<pre className="mt-2 p-2 bg-gray-900 rounded text-xs text-gray-300 overflow-x-auto">
{JSON.stringify(entry.event, null, 2)}
</pre>
)}
</div>
);
}
return (
<>
{/* Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-4 right-4 z-50 bg-gray-800 hover:bg-gray-700 text-white rounded-full p-3 shadow-lg transition-colors"
aria-label="Toggle activity log"
>
<div className="relative">
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</div>
</button>
{/* Panel */}
<div
className={`fixed bottom-0 right-0 z-40 bg-gray-800 border-l border-t border-gray-700 shadow-2xl transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} w-full md:w-[450px] h-[50vh] md:h-[60vh] flex flex-col`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h3 className="text-lg font-semibold text-white">Activity Log</h3>
<div className="flex gap-2">
<button
onClick={onClear}
className="text-sm px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
>
Clear
</button>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* Log Container */}
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-2"
>
{logs.length === 0 ? (
<div className="text-center text-gray-400 py-8">
No events yet. Connect to start logging.
</div>
) : (
logs.map(entry => <LogEntryItem key={entry.id} entry={entry} />)
)}
</div>
</div>
</>
);
}

View File

@@ -1,76 +0,0 @@
import type { AgentStatus } from '../types/realtime-events';
interface AgentStatusProps {
status: AgentStatus;
}
export default function AgentStatus({ status }: AgentStatusProps) {
function getStatusConfig() {
switch (status) {
case 'disconnected':
return {
color: 'bg-gray-500',
text: 'Disconnected',
icon: '○',
animate: false
};
case 'connecting':
return {
color: 'bg-yellow-500',
text: 'Connecting',
icon: '◐',
animate: true
};
case 'connected':
return {
color: 'bg-green-500',
text: 'Connected',
icon: '●',
animate: false
};
case 'listening':
return {
color: 'bg-blue-500',
text: 'Listening',
icon: '🎤',
animate: true
};
case 'processing':
return {
color: 'bg-purple-500',
text: 'Processing',
icon: '⚙',
animate: true
};
case 'tool_executing':
return {
color: 'bg-orange-500',
text: 'Executing Tool',
icon: '🔧',
animate: true
};
case 'speaking':
return {
color: 'bg-cyan-500',
text: 'Speaking',
icon: '🔊',
animate: true
};
}
}
const config = getStatusConfig();
return (
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg">
<div className="relative">
<div
className={`w-3 h-3 rounded-full ${config.color} ${config.animate ? 'animate-pulse' : ''}`}
/>
</div>
<span className="text-sm font-medium text-gray-700">
{config.icon} {config.text}
</span>
</div>
);
}

View File

@@ -1,109 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface AuthGateProps {
children: React.ReactNode;
}
const SESSION_KEY = 'auth_session';
export default function AuthGate({ children }: AuthGateProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Check for existing session on mount
useEffect(() => {
const session = sessionStorage.getItem(SESSION_KEY);
if (session === 'authenticated') {
setIsAuthenticated(true);
}
setIsChecking(false);
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const response = await fetch(`${basePath}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (response.ok && data.authenticated) {
sessionStorage.setItem(SESSION_KEY, 'authenticated');
setIsAuthenticated(true);
setPassword('');
} else {
setError('Invalid password');
}
} catch (err) {
setError('Authentication failed');
} finally {
setIsSubmitting(false);
}
}
if (isChecking) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6">
Authentication Required
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-2"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
placeholder="Enter password"
disabled={isSubmitting}
autoFocus
/>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isSubmitting || !password}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Authenticating...' : 'Sign In'}
</button>
</form>
</div>
</div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -1,38 +0,0 @@
interface DeviceSelectorProps {
devices: Array<{ deviceId: string; label: string }>;
selectedDeviceId: string;
onDeviceChange: (deviceId: string) => void;
disabled?: boolean;
}
export default function DeviceSelector({
devices,
selectedDeviceId,
onDeviceChange,
disabled,
}: DeviceSelectorProps) {
if (devices.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-2 w-full">
<label htmlFor="audio-device" className="text-xs md:text-sm font-medium text-gray-700">
Microphone
</label>
<select
id="audio-device"
value={selectedDeviceId}
onChange={(e) => onDeviceChange(e.target.value)}
disabled={disabled}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,23 +0,0 @@
interface MuteButtonProps {
isMuted: boolean;
onToggle: () => void;
disabled?: boolean;
}
export default function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) {
return (
<button
onClick={onToggle}
disabled={disabled}
className={`
w-full px-6 md:px-8 py-3 md:py-4 rounded-lg font-medium transition-colors
${isMuted
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{isMuted ? 'Unmute' : 'Mute'}
</button>
);
}

View File

@@ -1,22 +0,0 @@
interface VolumeBarProps {
volume: number;
isMuted?: boolean;
}
export default function VolumeBar({ volume, isMuted }: VolumeBarProps) {
function getColor() {
if (isMuted) return 'bg-gray-300';
if (volume > 80) return 'bg-red-500';
if (volume > 60) return 'bg-yellow-400';
return 'bg-green-500';
}
return (
<div className="w-10 h-48 bg-gray-200 rounded-lg overflow-hidden flex flex-col-reverse">
<div
className={`${getColor()} transition-all duration-100 ease-out`}
style={{ height: `${isMuted ? 0 : volume}%` }}
/>
</div>
);
}

View File

@@ -1 +0,0 @@
@import "tailwindcss";

View File

@@ -1,80 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface AudioDevice {
deviceId: string;
label: string;
}
function isSecureContext(): boolean {
return typeof window !== 'undefined' && window.isSecureContext;
}
function hasMediaDevices(): boolean {
return typeof navigator !== 'undefined' && 'mediaDevices' in navigator && !!navigator.mediaDevices;
}
export function useAudioDevices() {
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadDevices() {
if (!isSecureContext()) {
setError('HTTPS required. Please access this app over HTTPS or localhost.');
setIsLoading(false);
return;
}
if (!hasMediaDevices()) {
setError('Media devices not supported in this browser.');
setIsLoading(false);
return;
}
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList
.filter(device => device.kind === 'audioinput')
.map(device => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
}));
setDevices(audioInputs);
if (audioInputs.length > 0 && !selectedDeviceId) {
setSelectedDeviceId(audioInputs[0].deviceId);
}
} catch (error) {
console.error('Error loading audio devices:', error);
setError('Failed to access microphone. Please grant permission.');
} finally {
setIsLoading(false);
}
}
loadDevices();
if (hasMediaDevices()) {
navigator.mediaDevices.addEventListener('devicechange', loadDevices);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', loadDevices);
};
}
}, [selectedDeviceId]);
return {
devices,
selectedDeviceId,
setSelectedDeviceId,
isLoading,
error,
};
}

View File

@@ -1,47 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
export function useAudioLevel(stream: MediaStream | null): number {
const [volume, setVolume] = useState(0);
useEffect(() => {
if (!stream) {
setVolume(0);
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let animationId: number;
function updateVolume() {
analyser.getByteTimeDomainData(dataArray);
const rms = Math.sqrt(
dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length
);
const normalized = (rms / 255) * 100;
setVolume(Math.min(100, normalized * 1.5));
animationId = requestAnimationFrame(updateVolume);
}
updateVolume();
return () => {
cancelAnimationFrame(animationId);
source.disconnect();
audioContext.close();
};
}, [stream]);
return volume;
}

View File

@@ -1,67 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import type {
RealtimeServerEvent,
AgentStatus,
LogEntry,
EventCategory
} from '../types/realtime-events';
import {
categorizeEvent,
getEventSummary,
getStatusFromEvent
} from '../types/realtime-events';
const MAX_LOG_ENTRIES = 100;
interface UseEventLogReturn {
logs: LogEntry[];
agentStatus: AgentStatus;
addLog: (event: RealtimeServerEvent) => void;
setAgentStatus: (status: AgentStatus) => void;
clearLogs: () => void;
}
export function useEventLog(): UseEventLogReturn {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
const addLog = useCallback((event: RealtimeServerEvent) => {
const entry: LogEntry = {
id: `${Date.now()}-${Math.random().toString(36).substring(7)}`,
timestamp: Date.now(),
category: categorizeEvent(event),
eventType: event.type,
event,
summary: getEventSummary(event)
};
setLogs(prevLogs => {
const newLogs = [...prevLogs, entry];
// Keep only the last MAX_LOG_ENTRIES
if (newLogs.length > MAX_LOG_ENTRIES) {
return newLogs.slice(-MAX_LOG_ENTRIES);
}
return newLogs;
});
// Auto-update agent status based on event
const newStatus = getStatusFromEvent(event);
if (newStatus) {
setAgentStatus(newStatus);
}
}, []);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
return {
logs,
agentStatus,
addLog,
setAgentStatus,
clearLogs
};
}

View File

@@ -1,214 +0,0 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { Room, RoomEvent, Track } from 'livekit-client';
import type { AgentStatus } from '../types/realtime-events';
interface UseLiveKitVoiceReturn {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
stream: MediaStream | null;
agentStatus: AgentStatus;
connect: (deviceId?: string) => Promise<void>;
disconnect: () => void;
}
interface UseLiveKitVoiceOptions {
onEvent?: (event: any) => void;
onStatusChange?: (status: AgentStatus) => void;
}
interface ConnectionDetails {
serverUrl: string;
roomName: string;
participantToken: string;
participantName: string;
}
export function useLiveKitVoice(options: UseLiveKitVoiceOptions = {}): UseLiveKitVoiceReturn {
const { onEvent, onStatusChange } = options;
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
const roomRef = useRef<Room | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const updateStatus = useCallback((status: AgentStatus) => {
setAgentStatus(status);
onStatusChange?.(status);
}, [onStatusChange]);
useEffect(() => {
const room = new Room();
roomRef.current = room;
room.on(RoomEvent.Connected, () => {
console.log('Connected to LiveKit room');
setIsConnected(true);
setIsConnecting(false);
updateStatus('connected');
});
room.on(RoomEvent.Disconnected, () => {
console.log('Disconnected from LiveKit room');
setIsConnected(false);
setIsConnecting(false);
updateStatus('disconnected');
});
room.on(RoomEvent.Reconnecting, () => {
console.log('Reconnecting to LiveKit room');
setIsConnecting(true);
updateStatus('connecting');
});
room.on(RoomEvent.Reconnected, () => {
console.log('Reconnected to LiveKit room');
setIsConnected(true);
setIsConnecting(false);
updateStatus('connected');
});
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
console.log('Track subscribed:', {
kind: track.kind,
participant: participant.identity,
});
if (track.kind === Track.Kind.Audio) {
const audioElement = track.attach();
audioElement.play().catch(err => {
console.error('Error playing audio:', err);
});
}
});
room.on(RoomEvent.ParticipantConnected, (participant) => {
console.log('Participant connected:', participant.identity);
if (participant.isAgent) {
console.log('Agent joined the room');
updateStatus('connected');
}
});
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
console.log('Participant disconnected:', participant.identity);
if (participant.isAgent) {
console.log('Agent left the room');
}
});
room.on(RoomEvent.DataReceived, (payload, participant) => {
try {
const decoder = new TextDecoder();
const text = decoder.decode(payload);
const data = JSON.parse(text);
console.log('Data received from', participant?.identity, ':', data);
onEvent?.(data);
} catch (err) {
console.error('Failed to parse data:', err);
}
});
return () => {
room.disconnect();
roomRef.current = null;
};
}, [updateStatus, onEvent]);
const connect = useCallback(async (deviceId?: string) => {
if (isConnecting || isConnected) return;
setIsConnecting(true);
setError(null);
updateStatus('connecting');
try {
if (!window.isSecureContext) {
throw new Error('HTTPS required. Please access this app over HTTPS or localhost.');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Media devices not supported in this browser.');
}
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) {
audioConstraints.deviceId = { exact: deviceId };
}
const micStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints
});
streamRef.current = micStream;
setStream(micStream);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const response = await fetch(`${basePath}/api/session`, { method: 'POST' });
if (!response.ok) {
throw new Error('Failed to get connection details');
}
const connectionDetails: ConnectionDetails = await response.json();
const room = roomRef.current;
if (!room) {
throw new Error('Room not initialized');
}
await room.connect(connectionDetails.serverUrl, connectionDetails.participantToken);
await room.localParticipant.setMicrophoneEnabled(true);
} catch (err) {
console.error('Connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect');
setIsConnecting(false);
setIsConnected(false);
updateStatus('disconnected');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
}
}, [isConnecting, isConnected, updateStatus]);
const disconnect = useCallback(() => {
const room = roomRef.current;
if (room) {
room.disconnect();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
setIsConnected(false);
setIsConnecting(false);
setError(null);
updateStatus('disconnected');
}, [updateStatus]);
return {
isConnected,
isConnecting,
error,
stream,
agentStatus,
connect,
disconnect,
};
}

View File

@@ -1,202 +0,0 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { RealtimeServerEvent, AgentStatus } from '../types/realtime-events';
interface UseWebRTCVoiceReturn {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
stream: MediaStream | null;
agentStatus: AgentStatus;
connect: (deviceId?: string) => Promise<void>;
disconnect: () => void;
}
interface UseWebRTCVoiceOptions {
onEvent?: (event: RealtimeServerEvent) => void;
onStatusChange?: (status: AgentStatus) => void;
}
export function useWebRTCVoice(options: UseWebRTCVoiceOptions = {}): UseWebRTCVoiceReturn {
const { onEvent, onStatusChange } = options;
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const audioElementRef = useRef<HTMLAudioElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const connect = useCallback(async (deviceId?: string) => {
if (isConnecting || isConnected) return;
setIsConnecting(true);
setError(null);
try {
if (!window.isSecureContext) {
throw new Error('HTTPS required. Please access this app over HTTPS or localhost.');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Media devices not supported in this browser.');
}
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) {
audioConstraints.deviceId = { exact: deviceId };
}
const micStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints
});
streamRef.current = micStream;
setStream(micStream);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const response = await fetch(`${basePath}/api/session`, { method: 'POST' });
if (!response.ok) {
throw new Error('Failed to get session token');
}
const { client_secret } = await response.json();
const pc = new RTCPeerConnection();
peerConnectionRef.current = pc;
micStream.getTracks().forEach(track => pc.addTrack(track, micStream));
const dc = pc.createDataChannel('oai-events');
dataChannelRef.current = dc;
// Listen for data channel messages (realtime events from server)
dc.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RealtimeServerEvent;
onEvent?.(data);
} catch (err) {
console.error('Failed to parse data channel message:', err);
}
};
dc.onopen = () => {
console.log('Data channel opened');
};
dc.onclose = () => {
console.log('Data channel closed');
};
pc.ontrack = (event) => {
const audioElement = new Audio();
audioElement.autoplay = true;
audioElement.srcObject = event.streams[0];
audioElementRef.current = audioElement;
audioElement.play().catch(err => {
console.error('Error playing audio:', err);
});
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
setIsConnected(true);
setIsConnecting(false);
setAgentStatus('connected');
onStatusChange?.('connected');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
setError('Connection failed');
setIsConnected(false);
setIsConnecting(false);
setAgentStatus('disconnected');
onStatusChange?.('disconnected');
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpResponse = await fetch(
`https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${client_secret.value}`,
'Content-Type': 'application/sdp',
},
body: offer.sdp,
}
);
if (!sdpResponse.ok) {
throw new Error('Failed to connect to OpenAI');
}
const answerSdp = await sdpResponse.text();
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: answerSdp,
};
await pc.setRemoteDescription(answer);
} catch (err) {
console.error('Connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect');
setIsConnecting(false);
setIsConnected(false);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
}
}, [isConnecting, isConnected]);
const disconnect = useCallback(() => {
if (dataChannelRef.current) {
dataChannelRef.current.close();
dataChannelRef.current = null;
}
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
if (audioElementRef.current) {
audioElementRef.current.pause();
audioElementRef.current.srcObject = null;
audioElementRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
setError(null);
setAgentStatus('disconnected');
}, []);
return {
isConnected,
isConnecting,
error,
stream,
agentStatus,
connect,
disconnect,
};
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import AuthGate from "./components/auth-gate";
export const metadata: Metadata = {
title: "Real-Time Voice",
description: "Real-time voice interaction with OpenAI",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthGate>{children}</AuthGate>
</body>
</html>
);
}

View File

@@ -1,5 +0,0 @@
import VoiceClient from './voice-client';
export default function Home() {
return <VoiceClient />;
}

View File

@@ -1,174 +0,0 @@
// LiveKit event types (simplified for our use case)
export interface LiveKitEvent {
type: string;
[key: string]: any;
}
// Keep OpenAI types for backward compatibility during transition
export type RealtimeServerEvent = LiveKitEvent;
export type RealtimeClientEvent = LiveKitEvent;
// Agent status enum
export type AgentStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'listening'
| 'processing'
| 'tool_executing'
| 'speaking';
// Event category for UI display
export type EventCategory =
| 'connection'
| 'audio'
| 'speech'
| 'transcription'
| 'response'
| 'tool_call'
| 'error'
| 'buffer'
| 'other';
// Log entry with parsed event data
export interface LogEntry {
id: string;
timestamp: number;
category: EventCategory;
eventType: string;
event: RealtimeServerEvent;
summary?: string;
}
// Helper to categorize events
export function categorizeEvent(event: RealtimeServerEvent): EventCategory {
const type = event.type;
// LiveKit events
if (type.includes('connected') || type.includes('disconnected')) return 'connection';
if (type.includes('track')) return 'audio';
if (type.includes('participant')) return 'connection';
if (type.includes('data')) return 'other';
// OpenAI events (for backward compatibility)
if (type.startsWith('session.')) return 'connection';
if (type.startsWith('input_audio_buffer.')) return 'speech';
if (type.includes('transcription')) return 'transcription';
if (type.includes('function_call')) return 'tool_call';
if (type.startsWith('response.audio')) return 'audio';
if (type.startsWith('response.')) return 'response';
if (type.startsWith('conversation.')) return 'audio';
if (type.includes('error')) return 'error';
if (type.includes('rate_limits')) return 'buffer';
return 'other';
}
// Helper to get event summary
export function getEventSummary(event: RealtimeServerEvent): string {
const type = event.type;
// LiveKit events
if (type === 'connected') return 'Connected to LiveKit';
if (type === 'disconnected') return 'Disconnected from LiveKit';
if (type === 'reconnecting') return 'Reconnecting...';
if (type === 'reconnected') return 'Reconnected';
if (type === 'track_subscribed') return 'Audio track subscribed';
if (type === 'participant_connected') return 'Participant connected';
if (type === 'participant_disconnected') return 'Participant disconnected';
if (type === 'data_received') return 'Data received';
// OpenAI events (for backward compatibility)
switch (type) {
case 'session.created':
return 'Session established';
case 'input_audio_buffer.speech_started':
return 'User started speaking';
case 'input_audio_buffer.speech_stopped':
return 'User stopped speaking';
case 'input_audio_buffer.committed':
return 'Audio committed for processing';
case 'conversation.item.input_audio_transcription.completed':
if ('transcript' in event) {
return `Transcription: "${event.transcript}"`;
}
return 'Transcription completed';
case 'response.audio_transcript.delta':
if ('delta' in event) {
return `AI: "${event.delta}"`;
}
return 'AI speaking...';
case 'response.audio_transcript.done':
if ('transcript' in event) {
return `AI said: "${event.transcript}"`;
}
return 'AI finished speaking';
case 'response.function_call_arguments.done':
if ('name' in event && 'arguments' in event) {
return `Tool: ${event.name}(${event.arguments})`;
}
return 'Tool call executed';
case 'response.audio.delta':
return 'AI audio streaming...';
case 'response.audio.done':
return 'AI audio complete';
case 'response.done':
return 'Response completed';
case 'error':
if ('error' in event && typeof event.error === 'object' && event.error !== null && 'message' in event.error) {
return `Error: ${event.error.message}`;
}
return 'Error occurred';
default:
return type;
}
}
// Helper to get status from event
export function getStatusFromEvent(event: RealtimeServerEvent): AgentStatus | null {
const type = event.type;
// LiveKit events
if (type === 'connected') return 'connected';
if (type === 'disconnected') return 'disconnected';
if (type === 'reconnecting') return 'connecting';
if (type === 'reconnected') return 'connected';
// OpenAI events (for backward compatibility)
switch (type) {
case 'session.created':
return 'connected';
case 'input_audio_buffer.speech_started':
return 'listening';
case 'input_audio_buffer.speech_stopped':
case 'input_audio_buffer.committed':
return 'processing';
case 'response.function_call_arguments.done':
return 'tool_executing';
case 'response.audio.delta':
return 'speaking';
case 'response.audio.done':
case 'response.done':
return 'connected';
default:
return null;
}
}

View File

@@ -1,131 +0,0 @@
'use client';
import { useState } from 'react';
import { useLiveKitVoice } from './hooks/use-livekit-voice';
import { useAudioLevel } from './hooks/use-audio-level';
import { useAudioDevices } from './hooks/use-audio-devices';
import { useEventLog } from './hooks/use-event-log';
import VolumeBar from './components/volume-bar';
import MuteButton from './components/mute-button';
import DeviceSelector from './components/device-selector';
import AgentStatus from './components/agent-status';
import ActivityLogPanel from './components/activity-log-panel';
export default function VoiceClient() {
const [isMuted, setIsMuted] = useState(false);
// Initialize event logging
const { logs, agentStatus, addLog, clearLogs } = useEventLog();
const {
isConnected,
isConnecting,
error,
stream,
connect,
disconnect
} = useLiveKitVoice({
onEvent: addLog
});
const {
devices,
selectedDeviceId,
setSelectedDeviceId,
error: devicesError,
} = useAudioDevices();
const volume = useAudioLevel(stream);
function handleConnect() {
connect(selectedDeviceId);
}
function handleMuteToggle() {
if (!stream) return;
const newMutedState = !isMuted;
stream.getAudioTracks().forEach(track => {
track.enabled = !newMutedState;
});
setIsMuted(newMutedState);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4 md:gap-8 p-4 md:p-8">
<h1 className="text-2xl md:text-4xl font-bold text-center">Real-Time Voice</h1>
{/* Agent Status */}
<AgentStatus status={agentStatus} />
{devicesError && (
<div className="bg-yellow-50 border border-yellow-400 text-yellow-800 px-4 py-3 rounded max-w-md text-sm md:text-base">
<p className="font-medium"> {devicesError}</p>
{devicesError.includes('HTTPS') && (
<p className="mt-2 text-xs md:text-sm">
For mobile access, use HTTPS or a tunneling service like ngrok or Cloudflare Tunnel.
</p>
)}
</div>
)}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded max-w-md text-sm md:text-base">
{error}
</div>
)}
<div className="flex flex-col md:flex-row items-center md:items-end gap-4 md:gap-8 w-full max-w-2xl">
<div className="flex justify-center w-full md:w-auto">
<VolumeBar volume={volume} isMuted={isMuted} />
</div>
<div className="flex flex-col gap-4 w-full md:w-auto md:min-w-[240px]">
{!isConnected && (
<DeviceSelector
devices={devices}
selectedDeviceId={selectedDeviceId}
onDeviceChange={setSelectedDeviceId}
disabled={isConnecting}
/>
)}
{!isConnected ? (
<button
onClick={handleConnect}
disabled={isConnecting || !!devicesError}
className="w-full px-6 md:px-8 py-3 md:py-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? 'Connecting...' : 'Start Voice Chat'}
</button>
) : (
<>
<MuteButton
isMuted={isMuted}
onToggle={handleMuteToggle}
disabled={!isConnected}
/>
<button
onClick={disconnect}
className="w-full px-6 md:px-8 py-3 md:py-4 bg-gray-500 hover:bg-gray-600 text-white rounded-lg font-medium"
>
Disconnect
</button>
</>
)}
</div>
</div>
{isConnected && (
<p className="text-green-600 font-medium text-center text-sm md:text-base">
Connected - Speak to interact with AI
</p>
)}
{/* Activity Log Panel */}
<ActivityLogPanel logs={logs} onClear={clearLogs} />
</div>
);
}

View File

@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -1,39 +0,0 @@
{
"name": "@voice-dev/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"keywords": [
"openai",
"realtime",
"voice",
"voice-assistant",
"nextjs"
],
"author": "moboudra",
"license": "MIT",
"description": "Voice development assistant web interface",
"dependencies": {
"@livekit/components-react": "^2.9.15",
"@tailwindcss/oxide": "^4.1.14",
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^24.7.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"autoprefixer": "^10.4.21",
"livekit-client": "^2.15.9",
"livekit-server-sdk": "^2.14.0",
"next": "^15.5.4",
"openai-realtime-api": "^1.0.8",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"allowJs": true,
"noEmit": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "npm run build",
"installCommand": "npm ci && npm install --no-save --platform=linuxmusl --arch=x64 lightningcss @tailwindcss/oxide",
"framework": "nextjs",
"env": {
"npm_config_target_platform": "linuxmusl",
"npm_config_target_arch": "x64"
}
}