Spaces:
Building
Building
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; | |
import { CommonModule } from '@angular/common'; | |
import { MatCardModule } from '@angular/material/card'; | |
import { MatButtonModule } from '@angular/material/button'; | |
import { MatIconModule } from '@angular/material/icon'; | |
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; | |
import { MatDividerModule } from '@angular/material/divider'; | |
import { MatChipsModule } from '@angular/material/chips'; | |
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; | |
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; | |
import { Inject } from '@angular/core'; | |
import { Subject, Subscription, takeUntil } from 'rxjs'; | |
import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service'; | |
import { AudioStreamService } from '../../services/audio-stream.service'; | |
({ | |
selector: 'app-realtime-chat', | |
standalone: true, | |
imports: [ | |
CommonModule, | |
MatCardModule, | |
MatButtonModule, | |
MatIconModule, | |
MatProgressSpinnerModule, | |
MatDividerModule, | |
MatChipsModule, | |
MatSnackBarModule | |
], | |
templateUrl: './realtime-chat.component.html', | |
styleUrls: ['./realtime-chat.component.scss'] | |
}) | |
export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { | |
'scrollContainer') private scrollContainer!: ElementRef; | (|
'audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>; | (|
sessionId: string | null = null; | |
projectName: string | null = null; | |
isConversationActive = false; | |
isRecording = false; | |
isPlayingAudio = false; | |
currentState: ConversationState = 'idle'; | |
messages: ConversationMessage[] = []; | |
error = ''; | |
loading = false; | |
conversationStates: ConversationState[] = [ | |
'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' | |
]; | |
private destroyed$ = new Subject<void>(); | |
private subscriptions = new Subscription(); | |
private shouldScrollToBottom = false; | |
private animationId: number | null = null; | |
private currentAudio: HTMLAudioElement | null = null; | |
private volumeUpdateSubscription?: Subscription; | |
constructor( | |
private conversationManager: ConversationManagerService, | |
private audioService: AudioStreamService, | |
private snackBar: MatSnackBar, | |
public dialogRef: MatDialogRef<RealtimeChatComponent>, | |
public data: { sessionId: string; projectName?: string } (MAT_DIALOG_DATA) | |
) { | |
this.sessionId = data.sessionId; | |
this.projectName = data.projectName || null; | |
} | |
ngOnInit(): void { | |
console.log('🎤 RealtimeChat component initialized'); | |
console.log('Session ID:', this.sessionId); | |
console.log('Project Name:', this.projectName); | |
// Subscribe to messages FIRST - before any connection | |
this.conversationManager.messages$.pipe( | |
takeUntil(this.destroyed$) | |
).subscribe(messages => { | |
console.log('💬 Messages updated:', messages.length, 'messages'); | |
this.messages = messages; | |
this.shouldScrollToBottom = true; | |
// Check if we have initial welcome message | |
if (messages.length > 0) { | |
const lastMessage = messages[messages.length - 1]; | |
console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...'); | |
} | |
}); | |
// Check browser support | |
if (!AudioStreamService.checkBrowserSupport()) { | |
this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.'; | |
this.snackBar.open(this.error, 'Close', { | |
duration: 5000, | |
panelClass: 'error-snackbar' | |
}); | |
return; | |
} | |
// Check microphone permission | |
this.checkMicrophonePermission(); | |
// Subscribe to conversation state | |
this.conversationManager.currentState$.pipe( | |
takeUntil(this.destroyed$) | |
).subscribe(state => { | |
console.log('📊 Conversation state:', state); | |
this.currentState = state; | |
// Recording state'i conversation active olduğu sürece true tut | |
// Sadece error state'inde false yap | |
this.isRecording = this.isConversationActive && state !== 'error'; | |
}); | |
// Subscribe to errors | |
this.conversationManager.error$.pipe( | |
takeUntil(this.destroyed$) | |
).subscribe(error => { | |
console.error('Conversation error:', error); | |
this.error = error.message; | |
}); | |
// Load initial messages from session if available | |
const initialMessages = this.conversationManager.getMessages(); | |
console.log('📋 Initial messages:', initialMessages.length); | |
if (initialMessages.length > 0) { | |
this.messages = initialMessages; | |
this.shouldScrollToBottom = true; | |
} | |
} | |
ngAfterViewChecked(): void { | |
if (this.shouldScrollToBottom) { | |
this.scrollToBottom(); | |
this.shouldScrollToBottom = false; | |
} | |
} | |
ngOnDestroy(): void { | |
this.destroyed$.next(); | |
this.destroyed$.complete(); | |
this.subscriptions.unsubscribe(); | |
this.stopVisualization(); | |
this.cleanupAudio(); | |
if (this.isConversationActive) { | |
this.conversationManager.stopConversation(); | |
} | |
} | |
async toggleConversation(): Promise<void> { | |
if (!this.sessionId) return; | |
if (this.isConversationActive) { | |
this.stopConversation(); | |
} else { | |
await this.startConversation(); | |
} | |
} | |
async retryConnection(): Promise<void> { | |
this.error = ''; | |
if (!this.isConversationActive && this.sessionId) { | |
await this.startConversation(); | |
} | |
} | |
clearChat(): void { | |
this.conversationManager.clearMessages(); | |
this.error = ''; | |
} | |
performBargeIn(): void { | |
// Barge-in özelliği devre dışı | |
this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', { | |
duration: 2000 | |
}); | |
} | |
playAudio(audioUrl?: string): void { | |
if (!audioUrl) return; | |
// Stop current audio if playing | |
if (this.currentAudio) { | |
this.currentAudio.pause(); | |
this.currentAudio = null; | |
this.isPlayingAudio = false; | |
return; | |
} | |
this.currentAudio = new Audio(audioUrl); | |
this.isPlayingAudio = true; | |
this.currentAudio.play().catch(error => { | |
console.error('Audio playback error:', error); | |
this.isPlayingAudio = false; | |
this.currentAudio = null; | |
}); | |
this.currentAudio.onended = () => { | |
this.isPlayingAudio = false; | |
this.currentAudio = null; | |
}; | |
this.currentAudio.onerror = () => { | |
this.isPlayingAudio = false; | |
this.currentAudio = null; | |
this.snackBar.open('Ses çalınamadı', 'Close', { | |
duration: 2000, | |
panelClass: 'error-snackbar' | |
}); | |
}; | |
} | |
getStateLabel(state: ConversationState): string { | |
const labels: Record<ConversationState, string> = { | |
'idle': 'Bekliyor', | |
'listening': 'Dinliyor', | |
'processing_stt': 'Metin Dönüştürme', | |
'processing_llm': 'Yanıt Hazırlanıyor', | |
'processing_tts': 'Ses Oluşturuluyor', | |
'playing_audio': 'Konuşuyor', | |
'error': 'Hata' | |
}; | |
return labels[state] || state; | |
} | |
closeDialog(): void { | |
const result = this.isConversationActive ? 'session_active' : 'closed'; | |
this.dialogRef.close(result); | |
} | |
trackByIndex(index: number): number { | |
return index; | |
} | |
private async checkMicrophonePermission(): Promise<void> { | |
try { | |
const permission = await this.audioService.checkMicrophonePermission(); | |
if (permission === 'denied') { | |
this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.'; | |
this.snackBar.open(this.error, 'Close', { | |
duration: 5000, | |
panelClass: 'error-snackbar' | |
}); | |
} | |
} catch (error) { | |
console.error('Failed to check microphone permission:', error); | |
} | |
} | |
private scrollToBottom(): void { | |
try { | |
if (this.scrollContainer?.nativeElement) { | |
const element = this.scrollContainer.nativeElement; | |
element.scrollTop = element.scrollHeight; | |
} | |
} catch(err) { | |
console.error('Scroll error:', err); | |
} | |
} | |
async startConversation(): Promise<void> { | |
try { | |
this.loading = true; | |
this.error = ''; | |
// Clear existing messages - welcome will come via WebSocket | |
this.conversationManager.clearMessages(); | |
await this.conversationManager.startConversation(this.sessionId!); | |
this.isConversationActive = true; | |
this.isRecording = true; // Konuşma başladığında recording'i aktif et | |
// Visualization'ı başlat | |
this.startVisualization(); | |
this.snackBar.open('Konuşma başlatıldı', 'Close', { | |
duration: 2000 | |
}); | |
} catch (error: any) { | |
console.error('Failed to start conversation:', error); | |
this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.'; | |
this.snackBar.open(this.error, 'Close', { | |
duration: 5000, | |
panelClass: 'error-snackbar' | |
}); | |
} finally { | |
this.loading = false; | |
} | |
} | |
private stopConversation(): void { | |
this.conversationManager.stopConversation(); | |
this.isConversationActive = false; | |
this.isRecording = false; // Konuşma bittiğinde recording'i kapat | |
this.stopVisualization(); | |
this.snackBar.open('Konuşma sonlandırıldı', 'Close', { | |
duration: 2000 | |
}); | |
} | |
private startVisualization(): void { | |
// Eğer zaten çalışıyorsa tekrar başlatma | |
if (!this.audioVisualizer || this.animationId) { | |
return; | |
} | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (!ctx) { | |
console.warn('Could not get canvas context'); | |
return; | |
} | |
// Set canvas size | |
canvas.width = canvas.offsetWidth; | |
canvas.height = canvas.offsetHeight; | |
// Create gradient for bars | |
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); | |
gradient.addColorStop(0, '#4caf50'); | |
gradient.addColorStop(0.5, '#66bb6a'); | |
gradient.addColorStop(1, '#4caf50'); | |
let lastVolume = 0; | |
let targetVolume = 0; | |
const smoothingFactor = 0.8; | |
// Subscribe to volume updates | |
this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => { | |
targetVolume = volume; | |
}); | |
// Animation loop | |
const animate = () => { | |
// isConversationActive kontrolü ile devam et | |
if (!this.isConversationActive) { | |
this.clearVisualization(); | |
return; | |
} | |
// Clear canvas | |
ctx.fillStyle = '#1a1a1a'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
// Smooth volume transition | |
lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor); | |
// Draw frequency bars | |
const barCount = 32; | |
const barWidth = canvas.width / barCount; | |
const barSpacing = 2; | |
for (let i = 0; i < barCount; i++) { | |
// Create natural wave effect based on volume | |
const frequencyFactor = Math.sin((i / barCount) * Math.PI); | |
const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8; | |
const randomFactor = 0.8 + Math.random() * 0.2; | |
const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor; | |
const x = i * barWidth; | |
const y = (canvas.height - barHeight) / 2; | |
// Draw bar | |
ctx.fillStyle = gradient; | |
ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight); | |
// Draw reflection | |
ctx.fillStyle = 'rgba(76, 175, 80, 0.2)'; | |
ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3); | |
} | |
// Draw center line | |
ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)'; | |
ctx.lineWidth = 1; | |
ctx.beginPath(); | |
ctx.moveTo(0, canvas.height / 2); | |
ctx.lineTo(canvas.width, canvas.height / 2); | |
ctx.stroke(); | |
this.animationId = requestAnimationFrame(animate); | |
}; | |
animate(); | |
} | |
private stopVisualization(): void { | |
if (this.animationId) { | |
cancelAnimationFrame(this.animationId); | |
this.animationId = null; | |
} | |
if (this.volumeUpdateSubscription) { | |
this.volumeUpdateSubscription.unsubscribe(); | |
this.volumeUpdateSubscription = undefined; | |
} | |
this.clearVisualization(); | |
} | |
private clearVisualization(): void { | |
if (!this.audioVisualizer) return; | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (ctx) { | |
ctx.fillStyle = '#212121'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
} | |
} | |
private cleanupAudio(): void { | |
if (this.currentAudio) { | |
this.currentAudio.pause(); | |
this.currentAudio = null; | |
this.isPlayingAudio = false; | |
} | |
} | |
} |