Files
sam-kd/ai_sam/ref/services/liveManager.ts
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

202 lines
7.0 KiB
TypeScript

import { GoogleGenAI, LiveServerMessage, Modality, FunctionDeclaration, Type } from '@google/genai';
import { decode, decodeAudioData, createPcmBlob } from '../utils/audio';
import { SYSTEM_INSTRUCTION } from '../constants';
export type ToolHandler = (name: string, args: any) => Promise<any>;
export class LiveManager {
private ai: GoogleGenAI;
private inputAudioContext: AudioContext | null = null;
private outputAudioContext: AudioContext | null = null;
private sessionPromise: Promise<any> | null = null;
private nextStartTime = 0;
private sources = new Set<AudioBufferSourceNode>();
private toolHandler: ToolHandler;
private onStatusChange: (status: string) => void;
private onAudioLevel: (level: number) => void; // For visualizer
public outputAnalyser: AnalyserNode | null = null;
constructor(apiKey: string, toolHandler: ToolHandler, onStatusChange: (status: string) => void, onAudioLevel: (level: number) => void) {
this.ai = new GoogleGenAI({ apiKey });
this.toolHandler = toolHandler;
this.onStatusChange = onStatusChange;
this.onAudioLevel = onAudioLevel;
}
async connect() {
this.onStatusChange('connecting');
// Audio Context Setup
this.inputAudioContext = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
this.outputAudioContext = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
// Setup Analyser for Visualizer
this.outputAnalyser = this.outputAudioContext.createAnalyser();
this.outputAnalyser.fftSize = 256;
this.outputAnalyser.connect(this.outputAudioContext.destination);
// Tools Definition
const navigateToPage: FunctionDeclaration = {
name: 'navigateToPage',
parameters: {
type: Type.OBJECT,
description: 'Navigate the user to a specific page in the application menu.',
properties: {
pageId: {
type: Type.STRING,
description: 'The ID of the page to navigate to (e.g., dashboard, files, analytics, settings, team, calendar).',
},
},
required: ['pageId'],
},
};
const searchDocuments: FunctionDeclaration = {
name: 'searchDocuments',
parameters: {
type: Type.OBJECT,
description: 'Search for work documents or files based on a query.',
properties: {
query: {
type: Type.STRING,
description: 'The search keywords to filter files by.',
},
},
required: ['query'],
},
};
try {
// Connect to Gemini
this.sessionPromise = this.ai.live.connect({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: SYSTEM_INSTRUCTION,
tools: [{ functionDeclarations: [navigateToPage, searchDocuments] }],
},
callbacks: {
onopen: this.handleOpen.bind(this),
onmessage: this.handleMessage.bind(this),
onclose: () => this.onStatusChange('disconnected'),
onerror: (err) => {
console.error(err);
this.onStatusChange('error');
},
},
});
} catch (error) {
console.error("Connection failed", error);
this.onStatusChange('error');
}
}
private async handleOpen() {
this.onStatusChange('connected');
// Start Microphone Stream
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!this.inputAudioContext) return;
const source = this.inputAudioContext.createMediaStreamSource(stream);
const scriptProcessor = this.inputAudioContext.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// Calculate volume for visualizer (input)
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += inputData[i] * inputData[i];
this.onAudioLevel(Math.sqrt(sum / inputData.length));
const pcmBlob = createPcmBlob(inputData);
if (this.sessionPromise) {
this.sessionPromise.then((session) => {
session.sendRealtimeInput({ media: pcmBlob });
});
}
};
source.connect(scriptProcessor);
scriptProcessor.connect(this.inputAudioContext.destination);
} catch (err) {
console.error("Microphone access denied", err);
this.onStatusChange('error');
}
}
private async handleMessage(message: LiveServerMessage) {
// 1. Handle Audio
const base64Audio = message.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
if (base64Audio && this.outputAudioContext && this.outputAnalyser) {
// Ensure playback context is running
if(this.outputAudioContext.state === 'suspended') {
await this.outputAudioContext.resume();
}
const audioBuffer = await decodeAudioData(
decode(base64Audio),
this.outputAudioContext,
24000
);
this.nextStartTime = Math.max(this.outputAudioContext.currentTime, this.nextStartTime);
const source = this.outputAudioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.outputAnalyser); // Connect to analyser instead of destination directly
source.start(this.nextStartTime);
this.nextStartTime += audioBuffer.duration;
this.sources.add(source);
source.onended = () => this.sources.delete(source);
}
// 2. Handle Tool Calls
if (message.toolCall) {
for (const fc of message.toolCall.functionCalls) {
console.log(`Tool Call: ${fc.name}`, fc.args);
let result;
try {
result = await this.toolHandler(fc.name, fc.args);
} catch(e) {
result = { error: 'Failed to execute tool' };
}
// Send response back
if (this.sessionPromise) {
this.sessionPromise.then(session => {
session.sendToolResponse({
functionResponses: {
id: fc.id,
name: fc.name,
response: { result },
}
});
});
}
}
}
// 3. Handle Interruption
if (message.serverContent?.interrupted) {
this.sources.forEach(s => s.stop());
this.sources.clear();
this.nextStartTime = 0;
}
}
disconnect() {
// Since there is no direct close method on the session object exposed in the quick snippet,
// we usually rely on closing the underlying websocket or just refreshing contexts.
// However, the example shows session.close is not directly on the session object but implies clean up.
// We will close contexts to stop processing.
this.inputAudioContext?.close();
this.outputAudioContext?.close();
this.inputAudioContext = null;
this.outputAudioContext = null;
this.onStatusChange('disconnected');
}
}