ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Frontend environment for the Live Voice Chat demo.
|
||||
# Copy this file to `.env` (which is gitignored) before running `npm run dev`.
|
||||
#
|
||||
# WEBSOCKET_PORT must match the backend's LISTEN_PORT in backend/config.js
|
||||
# (default 8848). The frontend connects to ws://localhost:${WEBSOCKET_PORT}.
|
||||
WEBSOCKET_PORT=8848
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,24 @@
|
||||
const path = require('path');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'export',
|
||||
env: {
|
||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT || '8848',
|
||||
IS_PRODUCTION: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
// Since we're using static export, we need to disable image optimization
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': path.resolve(__dirname),
|
||||
};
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
+7553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "livechat-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "cross-env NODE_ENV=production next build",
|
||||
"start": "cross-env NODE_ENV=production next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.33",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
"clsx": "^1.2.1",
|
||||
"lucide-react": "^0.130.1",
|
||||
"next": "13.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwind-merge": "^1.10.0",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"typescript": "5.0.4",
|
||||
"web-audio-resampler": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Mic, MicOff, Trash2 } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
type: 'info' | 'error' | 'latency' | 'llm';
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'transcript';
|
||||
content: string;
|
||||
isFinal?: boolean;
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CodeBlock = ({ node, inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match ? match[1] : '';
|
||||
|
||||
if (!inline && lang) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={lang}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: '0.5em 0',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
return <code className={className} {...props}>{children}</code>;
|
||||
};
|
||||
|
||||
const TabButton: React.FC<TabButtonProps> = ({ active, onClick, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${
|
||||
active
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
|
||||
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const echoNodeRef = useRef<AudioWorkletNode | null>(null);
|
||||
const audioFormatRef = useRef<any>(null);
|
||||
const speechEndTimeRef = useRef<number | null>(null);
|
||||
const llmStartTimeRef = useRef<number | null>(null);
|
||||
const vadEndTimeRef = useRef<number | null>(null);
|
||||
const hasPlaybackLatencyRef = useRef<boolean>(false);
|
||||
const vadStartTimeRef = useRef<number | null>(null);
|
||||
const audioQueueRef = useRef<Float32Array[]>([]);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
|
||||
const playbackStartTimeRef = useRef<number | null>(null);
|
||||
const [finalTranscripts, setFinalTranscripts] = useState<string>('');
|
||||
// Add this ref to track the current valid message ID
|
||||
const currentValidMessageIdRef = useRef<string | null>(null);
|
||||
// Add this ref to track if audio is currently playing
|
||||
const isPlayingRef = useRef<boolean>(false);
|
||||
// Add these refs after other refs
|
||||
const chatHistoryRef = useRef<HTMLDivElement>(null);
|
||||
const logsRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScrollChatRef = useRef(true);
|
||||
const shouldAutoScrollLogsRef = useRef(true);
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'logs'>('chat');
|
||||
|
||||
const addLog = (message: string, type: LogEntry['type'] = 'info') => {
|
||||
setLogs(prev => [...prev, {
|
||||
timestamp: Date.now(),
|
||||
message,
|
||||
type
|
||||
}]);
|
||||
};
|
||||
|
||||
const setupWebSocket = () => {
|
||||
if (websocketRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
// Determine if we're running on localhost
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
let wsUrl;
|
||||
if (isLocalhost) {
|
||||
// For localhost development
|
||||
wsUrl = `ws://localhost:${process.env.WEBSOCKET_PORT}`;
|
||||
} else {
|
||||
// For production deployment
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
console.log('Connecting to WebSocket:', wsUrl);
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
websocketRef.current.binaryType = 'arraybuffer';
|
||||
|
||||
websocketRef.current.onopen = () => {
|
||||
// Start sending pings when connection opens
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (websocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
websocketRef.current.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = async (event) => {
|
||||
try {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const now = Date.now();
|
||||
|
||||
if (audioFormatRef.current) {
|
||||
const int16Array = new Int16Array(event.data);
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
|
||||
for (let i = 0; i < int16Array.length; i++) {
|
||||
float32Array[i] = int16Array[i] / 32768.0;
|
||||
}
|
||||
|
||||
if (echoNodeRef.current) {
|
||||
// Set playback start time when first audio chunk is received
|
||||
if (!playbackStartTimeRef.current) {
|
||||
playbackStartTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
echoNodeRef.current.port.postMessage({ type: 'unmute' });
|
||||
|
||||
if (vadEndTimeRef.current && !hasPlaybackLatencyRef.current) {
|
||||
const latency = now - vadEndTimeRef.current;
|
||||
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
|
||||
addLog(`First audio playback latency - Browser VAD: ${latency}ms`, 'latency');
|
||||
addLog(`First audio playback latency - Server VAD: ${serverVadLatency}ms`, 'latency');
|
||||
hasPlaybackLatencyRef.current = true;
|
||||
}
|
||||
echoNodeRef.current.port.postMessage(float32Array);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const jsonData = JSON.parse(event.data);
|
||||
const now = Date.now();
|
||||
|
||||
// Add ping handling
|
||||
if (jsonData.type === 'pong') {
|
||||
const latency = now - jsonData.timestamp;
|
||||
addLog(`WebSocket latency: ${latency}ms`, 'latency');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (jsonData.type) {
|
||||
case 'speech_end':
|
||||
speechEndTimeRef.current = now;
|
||||
if (vadEndTimeRef.current) {
|
||||
const vadToServerLatency = now - vadEndTimeRef.current;
|
||||
addLog(`[Server] Speech end detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
|
||||
} else {
|
||||
// Use server's speech end as VAD end time if frontend didn't detect it
|
||||
vadEndTimeRef.current = now;
|
||||
hasPlaybackLatencyRef.current = false;
|
||||
addLog('[Server] Speech end detected (using as VAD endpoint)', 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speech_start':
|
||||
// Handle interrupt
|
||||
handleInterrupt();
|
||||
// Clear audio queue and stop playback
|
||||
if (echoNodeRef.current) {
|
||||
echoNodeRef.current.port.postMessage({ type: 'clear' });
|
||||
}
|
||||
audioQueueRef.current = [];
|
||||
|
||||
if (vadStartTimeRef.current) {
|
||||
const vadToServerLatency = now - vadStartTimeRef.current;
|
||||
addLog(`[Server] Speech start detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
|
||||
} else {
|
||||
vadStartTimeRef.current = now;
|
||||
addLog('[Server] Speech start detected (using as VAD start point)', 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'llm_start':
|
||||
llmStartTimeRef.current = now;
|
||||
if (vadEndTimeRef.current) {
|
||||
const browserVadLatency = now - vadEndTimeRef.current;
|
||||
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
|
||||
addLog(`Transcribe latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
|
||||
addLog(`Transcribe latency - Server VAD: ${serverVadLatency}ms`, 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'llm_first_token':
|
||||
if (llmStartTimeRef.current) {
|
||||
const latency = now - llmStartTimeRef.current;
|
||||
addLog(`LLM time to first token: ${latency}ms`, 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'llm_first_sentence':
|
||||
if (vadEndTimeRef.current) {
|
||||
const llmLatency = now - llmStartTimeRef.current;
|
||||
const browserVadLatency = now - vadEndTimeRef.current;
|
||||
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
|
||||
addLog(`LLM first sentence latency - LLM: ${llmLatency}ms`, 'latency');
|
||||
addLog(`LLM first sentence latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
|
||||
addLog(`LLM first sentence latency - Server VAD: ${serverVadLatency}ms`, 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tts_complete':
|
||||
if (vadEndTimeRef.current) {
|
||||
addLog(`TTS synthesis time: ${jsonData.synthesisTime}ms`, 'latency');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'audio_start':
|
||||
// Reset playback start time when new audio stream starts
|
||||
playbackStartTimeRef.current = null;
|
||||
if (echoNodeRef.current) {
|
||||
echoNodeRef.current.port.postMessage({ type: 'unmute' });
|
||||
}
|
||||
addLog(`Audio format: ${JSON.stringify(jsonData.format)}`);
|
||||
audioFormatRef.current = jsonData.format;
|
||||
break;
|
||||
|
||||
case 'audio_end':
|
||||
addLog('Audio streaming completed');
|
||||
break;
|
||||
|
||||
case 'transcript':
|
||||
if (jsonData.messageId) {
|
||||
// Update the current valid message ID when receiving a transcript
|
||||
currentValidMessageIdRef.current = jsonData.messageId;
|
||||
}
|
||||
if (jsonData.isFinal) {
|
||||
addLog(`[ASR] Final transcript: "${jsonData.text}"`);
|
||||
} else {
|
||||
addLog(`[ASR] Interim transcript: "${jsonData.text}"`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
addLog(`Error: ${jsonData.message}`, 'error');
|
||||
break;
|
||||
|
||||
case 'llm_sentence':
|
||||
if (jsonData.messageId === currentValidMessageIdRef.current) {
|
||||
addLog(`LLM: "${jsonData.text}"`, 'llm');
|
||||
} else {
|
||||
addLog(`Ignored LLM response for recalled message: "${jsonData.text}"`, 'info');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'websocket_latency':
|
||||
addLog(`WebSocket RTT: ${jsonData.roundTripTime}ms`, 'latency');
|
||||
break;
|
||||
|
||||
case 'chat_history_delta':
|
||||
setChatHistory(prev => {
|
||||
// Remove messages from startIndex onwards and insert new messages
|
||||
return [
|
||||
...prev.slice(0, jsonData.startIndex),
|
||||
...jsonData.messages
|
||||
];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'debug_info':
|
||||
addLog(jsonData.message, 'info');
|
||||
break;
|
||||
|
||||
case 'vad_status':
|
||||
// Silently ignore VAD status messages to reduce log noise
|
||||
break;
|
||||
|
||||
default:
|
||||
addLog(`Received message: ${JSON.stringify(jsonData)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addLog(`Error processing message: ${error}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
websocketRef.current.onclose = () => {
|
||||
addLog('WebSocket closed');
|
||||
websocketRef.current = null;
|
||||
// A server restart or network blip fires onclose mid-session. Without
|
||||
// tearing down, the button stays "Stop Recording", the mic keeps
|
||||
// capturing, the ping interval keeps firing, and the audio worklet
|
||||
// silently discards every captured buffer (it checks
|
||||
// websocketRef.current?.readyState, now null) -- a "zombie recording".
|
||||
// websocketRef is already null here, so stopRecording won't re-close the
|
||||
// socket or re-enter onclose.
|
||||
stopRecording();
|
||||
};
|
||||
|
||||
websocketRef.current.onerror = (error) => {
|
||||
addLog(`WebSocket error: ${error}`, 'error');
|
||||
};
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
// Setup WebSocket first
|
||||
setupWebSocket();
|
||||
|
||||
// Check if AudioContext and AudioWorklet are supported
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
throw new Error('AudioContext is not supported in this browser');
|
||||
}
|
||||
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||
|
||||
// Create AudioContext with specific sample rate
|
||||
audioContextRef.current = new AudioContextClass({
|
||||
sampleRate: 16000
|
||||
});
|
||||
|
||||
// Check if audioWorklet is supported
|
||||
if (!audioContextRef.current.audioWorklet) {
|
||||
throw new Error('AudioWorklet is not supported in this browser. Please use a modern browser like Chrome or Firefox.');
|
||||
}
|
||||
|
||||
// Resume the audio context first (needed for some browsers)
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
await audioContextRef.current.resume();
|
||||
}
|
||||
|
||||
console.log('Audio context created with sample rate:', audioContextRef.current.sampleRate);
|
||||
|
||||
try {
|
||||
// Add the audio worklet module with the full URL path
|
||||
const workletUrl = new URL('/audioWorklet.js', window.location.origin).href;
|
||||
console.log('Loading audio worklet from:', workletUrl);
|
||||
|
||||
// Add a timeout to the worklet loading
|
||||
const workletLoadPromise = audioContextRef.current.audioWorklet.addModule(workletUrl);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Audio worklet load timeout')), 5000);
|
||||
});
|
||||
|
||||
await Promise.race([workletLoadPromise, timeoutPromise]);
|
||||
console.log('Audio worklet loaded successfully');
|
||||
|
||||
} catch (workletError) {
|
||||
console.error('Error loading audio worklet:', workletError);
|
||||
throw new Error(`Failed to load audio worklet module: ${workletError.message}`);
|
||||
}
|
||||
|
||||
// Check if getUserMedia is supported
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('getUserMedia is not supported in this browser');
|
||||
}
|
||||
|
||||
// Get user media after worklet is loaded
|
||||
try {
|
||||
streamRef.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 16000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
} catch (mediaError) {
|
||||
console.error('Error accessing microphone:', mediaError);
|
||||
throw new Error(`Microphone access failed: ${mediaError.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
sourceNodeRef.current = audioContextRef.current.createMediaStreamSource(streamRef.current);
|
||||
workletNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'audio-processor');
|
||||
echoNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'echo-processor');
|
||||
|
||||
sourceNodeRef.current.connect(workletNodeRef.current);
|
||||
echoNodeRef.current.connect(audioContextRef.current.destination);
|
||||
|
||||
console.log('Audio nodes connected successfully');
|
||||
} catch (nodeError) {
|
||||
console.error('Error setting up audio nodes:', nodeError);
|
||||
throw new Error(`Audio node setup failed: ${nodeError.message}`);
|
||||
}
|
||||
|
||||
workletNodeRef.current.port.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
if (websocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
websocketRef.current.send(event.data);
|
||||
}
|
||||
} else if (event.data.type === 'vad') {
|
||||
if (event.data.status === 'speech_end') {
|
||||
vadEndTimeRef.current = Date.now();
|
||||
hasPlaybackLatencyRef.current = false;
|
||||
addLog('[Frontend VAD] End of speech detected');
|
||||
} else if (event.data.status === 'speech_start') {
|
||||
vadStartTimeRef.current = Date.now();
|
||||
addLog('[Frontend VAD] Start of speech detected');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add message handler for the echo node
|
||||
echoNodeRef.current.port.onmessage = (event) => {
|
||||
if (event.data.type === 'queue_empty' && isPlayingRef.current) {
|
||||
isPlayingRef.current = false;
|
||||
|
||||
// Reset playback state
|
||||
playbackStartTimeRef.current = null;
|
||||
hasPlaybackLatencyRef.current = false;
|
||||
addLog('Audio playback completed');
|
||||
}
|
||||
};
|
||||
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error('Error in startRecording:', error);
|
||||
addLog(`Error: ${error.message}`, 'error');
|
||||
// Clean up any partially initialized resources
|
||||
stopRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
hasPlaybackLatencyRef.current = false;
|
||||
if (workletNodeRef.current) {
|
||||
workletNodeRef.current.disconnect();
|
||||
}
|
||||
if (echoNodeRef.current) {
|
||||
echoNodeRef.current.disconnect();
|
||||
}
|
||||
if (sourceNodeRef.current) {
|
||||
sourceNodeRef.current.disconnect();
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
// Guard against closing an already-closed context (stopRecording is now
|
||||
// idempotent because onclose also calls it).
|
||||
if (audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.close();
|
||||
websocketRef.current = null;
|
||||
}
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// Add this helper function before the return statement
|
||||
const formatLatencyLog = (message: string) => {
|
||||
// Check if it's a latency message with ":" or "-"
|
||||
const splitChar = message.includes(':') ? ':' : message.includes('-') ? '-' : null;
|
||||
if (!splitChar) return message;
|
||||
|
||||
const [label, values] = message.split(splitChar);
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr,auto] gap-2">
|
||||
<span>{label}{splitChar}</span>
|
||||
<span className="font-mono">{values}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Update the handleInterrupt function
|
||||
const handleInterrupt = () => {
|
||||
// Stop current audio playback and clear queue
|
||||
if (echoNodeRef.current) {
|
||||
echoNodeRef.current.port.postMessage({ type: 'clear' });
|
||||
echoNodeRef.current.port.postMessage({ type: 'mute' });
|
||||
echoNodeRef.current.disconnect();
|
||||
echoNodeRef.current.connect(audioContextRef.current!.destination);
|
||||
}
|
||||
audioQueueRef.current = [];
|
||||
|
||||
// Reset playback state
|
||||
playbackStartTimeRef.current = null;
|
||||
hasPlaybackLatencyRef.current = false;
|
||||
isPlayingRef.current = false;
|
||||
};
|
||||
|
||||
// Add scroll event handlers
|
||||
const handleChatScroll = () => {
|
||||
if (!chatHistoryRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current;
|
||||
// Consider "at bottom" if within 100 pixels of the bottom
|
||||
shouldAutoScrollChatRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
|
||||
};
|
||||
|
||||
const handleLogsScroll = () => {
|
||||
if (!logsRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsRef.current;
|
||||
// Consider "at bottom" if within 100 pixels of the bottom
|
||||
shouldAutoScrollLogsRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
|
||||
};
|
||||
|
||||
// Add scroll to bottom functions
|
||||
const scrollChatToBottom = () => {
|
||||
if (chatHistoryRef.current && shouldAutoScrollChatRef.current) {
|
||||
chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollLogsToBottom = () => {
|
||||
if (logsRef.current && shouldAutoScrollLogsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Update useEffect to scroll when chat history or logs change
|
||||
useEffect(() => {
|
||||
scrollChatToBottom();
|
||||
}, [chatHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollLogsToBottom();
|
||||
}, [logs]);
|
||||
|
||||
// Tear down the mic stream, AudioContext, WebSocket and ping interval if the
|
||||
// component unmounts mid-recording (client-side nav / fast refresh), instead
|
||||
// of leaving the microphone active and the ping interval firing.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopRecording();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-100 p-2 sm:p-4">
|
||||
{/* Top Controls */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row justify-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
variant={isRecording ? "destructive" : "default"}
|
||||
className={`px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2 ${
|
||||
!isRecording ? 'bg-green-600 hover:bg-green-700' : ''
|
||||
}`}
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Stop Recording
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Start Recording
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearLogs}
|
||||
variant="outline"
|
||||
className="px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Clear Logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Tabs - Only show on small screens */}
|
||||
<div className="lg:hidden mb-2">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<TabButton
|
||||
active={activeTab === 'chat'}
|
||||
onClick={() => setActiveTab('chat')}
|
||||
>
|
||||
Chat History
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === 'logs'}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
Logs
|
||||
</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col lg:flex-row flex-1 gap-2 sm:gap-4">
|
||||
{/* Chat History Panel */}
|
||||
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
|
||||
activeTab === 'chat' ? 'block' : 'hidden lg:block'
|
||||
}`}>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2 hidden lg:block">
|
||||
<CardTitle className="text-center text-base sm:text-lg">Chat History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
ref={chatHistoryRef}
|
||||
onScroll={handleChatScroll}
|
||||
className="h-full lg:h-[calc(100%-3rem)] overflow-y-auto pt-4 lg:pt-0"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{chatHistory.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${
|
||||
message.role === 'assistant' ? 'bg-gray-50' :
|
||||
message.role === 'transcript' ? 'bg-blue-50' :
|
||||
'bg-white'
|
||||
} p-3 sm:p-4 rounded-lg animate-slide-in`}
|
||||
>
|
||||
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full flex-shrink-0 mr-3 sm:mr-4">
|
||||
{message.role === 'assistant' ? (
|
||||
<div className="w-full h-full bg-green-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
|
||||
AI
|
||||
</div>
|
||||
) : message.role === 'transcript' ? (
|
||||
<div className="w-full h-full bg-blue-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
|
||||
T
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
|
||||
U
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 prose prose-sm dark:prose-invert prose-p:my-3 prose-headings:mb-3 prose-headings:mt-6 prose-li:my-2 prose-pre:bg-gray-800 prose-pre:text-gray-100 max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.role === 'transcript' && !message.isFinal && (
|
||||
<span className="text-xs text-gray-500 ml-2 animate-fade-in">(typing...)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Logs Panel */}
|
||||
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
|
||||
activeTab === 'logs' ? 'block' : 'hidden lg:block'
|
||||
}`}>
|
||||
<Card className="h-full overflow-hidden">
|
||||
<CardHeader className="pb-2 hidden lg:block">
|
||||
<CardTitle className="text-center text-base sm:text-lg">Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
ref={logsRef}
|
||||
onScroll={handleLogsScroll}
|
||||
className="h-full lg:h-[calc(100%-3rem)] bg-gray-900 p-2 lg:p-3 overflow-y-auto"
|
||||
>
|
||||
<div className="font-mono text-xs sm:text-sm">
|
||||
{logs.map((log, index) => {
|
||||
const time = new Date(log.timestamp).toISOString().split('T')[1].slice(0, -1);
|
||||
const baseClasses = "mb-1 font-mono";
|
||||
|
||||
const typeClasses = {
|
||||
error: "text-red-400",
|
||||
latency: "text-cyan-400",
|
||||
llm: "text-green-400",
|
||||
info: "text-gray-300"
|
||||
};
|
||||
|
||||
// Special styling for different event types
|
||||
const prefixColor = {
|
||||
vad: "text-purple-400",
|
||||
asr: "text-yellow-400",
|
||||
server: "text-blue-400"
|
||||
};
|
||||
|
||||
let content = log.message;
|
||||
let prefix = null;
|
||||
|
||||
// Extract prefix if message starts with [Something]
|
||||
const prefixMatch = log.message.match(/^\[(.*?)\]/);
|
||||
if (prefixMatch) {
|
||||
prefix = prefixMatch[0];
|
||||
content = log.message.slice(prefix.length);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={`${baseClasses} ${typeClasses[log.type]}`}>
|
||||
<span className="text-gray-500">[{time}]</span>{' '}
|
||||
{prefix && (
|
||||
<span className={
|
||||
Object.entries(prefixColor).find(([key]) =>
|
||||
prefix?.toLowerCase().includes(key))?.[1] || typeClasses[log.type]
|
||||
}>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
{log.type === 'latency' ? (
|
||||
formatLatencyLog(content)
|
||||
) : (
|
||||
<span>{content}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.inputBuffer = [];
|
||||
|
||||
// Remove VAD parameters - now using server-side Silero VAD only
|
||||
// The client-side VAD was primarily for debugging and is no longer needed
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (!input || !input[0]) return true;
|
||||
|
||||
// Convert to mono
|
||||
const monoInput = new Float32Array(input[0].length);
|
||||
for (let i = 0; i < input[0].length; i++) {
|
||||
let sum = 0;
|
||||
for (let channel = 0; channel < input.length; channel++) {
|
||||
sum += input[channel][i];
|
||||
}
|
||||
monoInput[i] = sum / input.length;
|
||||
}
|
||||
|
||||
// Process in chunks - removed VAD processing
|
||||
const CHUNK_SIZE = 1024;
|
||||
this.inputBuffer.push(...monoInput);
|
||||
|
||||
while (this.inputBuffer.length >= CHUNK_SIZE) {
|
||||
const chunk = this.inputBuffer.slice(0, CHUNK_SIZE);
|
||||
this.inputBuffer = this.inputBuffer.slice(CHUNK_SIZE);
|
||||
|
||||
// Convert to 16-bit PCM
|
||||
const pcmData = new Int16Array(chunk.length);
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, chunk[i]));
|
||||
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
|
||||
// Send the data - server-side Silero VAD will handle speech detection
|
||||
this.port.postMessage(pcmData.buffer, [pcmData.buffer]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class EchoProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.audioBuffer = [];
|
||||
this.playbackPosition = 0;
|
||||
this.isPlaying = false;
|
||||
this.sampleRate = 16000;
|
||||
this.isMuted = false;
|
||||
this.outputBufferSize = 2048;
|
||||
this.outputBuffer = new Float32Array(this.outputBufferSize);
|
||||
this.outputBufferPosition = 0;
|
||||
this.hasNotifiedQueueEmpty = false; // Track if we've sent the queue empty notification
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data instanceof Float32Array) {
|
||||
if (!this.isMuted) {
|
||||
const audioData = event.data;
|
||||
const newBuffer = new Float32Array(audioData.length);
|
||||
newBuffer.set(audioData);
|
||||
|
||||
if (!this.isPlaying) {
|
||||
this.audioBuffer = Array.from(newBuffer);
|
||||
this.playbackPosition = 0;
|
||||
this.outputBufferPosition = 0;
|
||||
this.hasNotifiedQueueEmpty = false; // Reset notification flag when starting new playback
|
||||
} else {
|
||||
this.audioBuffer.push(...Array.from(newBuffer));
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
}
|
||||
} else if (event.data.type === 'clear') {
|
||||
// Clear the buffer and stop playback
|
||||
this.audioBuffer = [];
|
||||
this.playbackPosition = 0;
|
||||
this.outputBufferPosition = 0;
|
||||
this.isPlaying = false;
|
||||
this.isMuted = true;
|
||||
this.hasNotifiedQueueEmpty = false;
|
||||
// Notify that the queue is empty after clearing
|
||||
this.port.postMessage({ type: 'queue_empty' });
|
||||
} else if (event.data.type === 'unmute') {
|
||||
this.isMuted = false;
|
||||
this.hasNotifiedQueueEmpty = false;
|
||||
} else if (event.data.type === 'mute') {
|
||||
this.isMuted = true;
|
||||
this.audioBuffer = [];
|
||||
this.playbackPosition = 0;
|
||||
this.isPlaying = false;
|
||||
this.hasNotifiedQueueEmpty = false;
|
||||
// Notify that the queue is empty after muting
|
||||
this.port.postMessage({ type: 'queue_empty' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const output = outputs[0];
|
||||
|
||||
// If muted or not playing, output silence
|
||||
if (this.isMuted || !this.isPlaying || this.audioBuffer.length === 0) {
|
||||
for (let channel = 0; channel < output.length; channel++) {
|
||||
output[channel].fill(0);
|
||||
}
|
||||
|
||||
// Send queue_empty notification if we haven't already
|
||||
if (this.isPlaying && !this.hasNotifiedQueueEmpty) {
|
||||
this.port.postMessage({ type: 'queue_empty' });
|
||||
this.hasNotifiedQueueEmpty = true;
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const outputChannel = output[0];
|
||||
const bufferSize = outputChannel.length;
|
||||
|
||||
if (this.isPlaying && this.audioBuffer.length > 0) {
|
||||
// Fill the output buffer
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
if (this.playbackPosition < this.audioBuffer.length) {
|
||||
const sample = this.audioBuffer[this.playbackPosition];
|
||||
for (let channel = 0; channel < output.length; channel++) {
|
||||
output[channel][i] = sample;
|
||||
}
|
||||
this.playbackPosition++;
|
||||
} else {
|
||||
// End of buffer reached
|
||||
for (let channel = 0; channel < output.length; channel++) {
|
||||
output[channel][i] = 0;
|
||||
}
|
||||
|
||||
// If we've played everything, reset and notify
|
||||
if (this.playbackPosition >= this.audioBuffer.length && !this.hasNotifiedQueueEmpty) {
|
||||
this.isPlaying = false;
|
||||
this.playbackPosition = 0;
|
||||
this.audioBuffer = [];
|
||||
this.port.postMessage({ type: 'queue_empty' });
|
||||
this.hasNotifiedQueueEmpty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Output silence if we're not playing
|
||||
for (let channel = 0; channel < output.length; channel++) {
|
||||
output[channel].fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-processor', AudioProcessor);
|
||||
registerProcessor('echo-processor', EchoProcessor);
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
"fade-in": {
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
"slide-in": {
|
||||
'0%': { transform: 'translateY(5px)', opacity: 0 },
|
||||
'100%': { transform: 'translateY(0)', opacity: 1 },
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"fade-in": "fade-in 0.3s ease-out",
|
||||
"slide-in": "slide-in 0.3s ease-out",
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
'--tw-prose-body': theme('colors.gray.900'),
|
||||
'--tw-prose-headings': theme('colors.gray.900'),
|
||||
'--tw-prose-links': theme('colors.blue.600'),
|
||||
'--tw-prose-code': theme('colors.gray.900'),
|
||||
maxWidth: 'none',
|
||||
color: 'var(--tw-prose-body)',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.75',
|
||||
p: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
fontSize: '1rem',
|
||||
'&:first-child': {
|
||||
marginTop: 0,
|
||||
},
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
'ul, ol': {
|
||||
paddingLeft: '1.5em',
|
||||
marginTop: '0.5em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
li: {
|
||||
marginTop: '0.25em',
|
||||
marginBottom: '0.25em',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
p: {
|
||||
marginTop: '0.375em',
|
||||
marginBottom: '0.375em',
|
||||
},
|
||||
},
|
||||
'h1, h2, h3, h4': {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
marginTop: '1.5em',
|
||||
marginBottom: '0.5em',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
lineHeight: '1.3',
|
||||
'&:first-child': {
|
||||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
pre: {
|
||||
margin: '0.5em 0',
|
||||
padding: '0.5em',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
code: {
|
||||
color: 'var(--tw-prose-code)',
|
||||
backgroundColor: theme('colors.gray.100'),
|
||||
padding: '0.2em 0.4em',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '400',
|
||||
},
|
||||
'pre code': {
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: '0.875rem',
|
||||
color: 'inherit',
|
||||
fontWeight: '400',
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: '4px',
|
||||
borderLeftColor: theme('colors.gray.200'),
|
||||
paddingLeft: '1em',
|
||||
fontStyle: 'italic',
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
hr: {
|
||||
marginTop: '2em',
|
||||
marginBottom: '2em',
|
||||
},
|
||||
a: {
|
||||
color: 'var(--tw-prose-links)',
|
||||
textDecoration: 'underline',
|
||||
'&:hover': {
|
||||
color: theme('colors.blue.700'),
|
||||
},
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
'th, td': {
|
||||
padding: '0.5em',
|
||||
borderWidth: '1px',
|
||||
borderColor: theme('colors.gray.200'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
require("tailwindcss-animate")
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user