Spaces:
Paused
Paused
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'; | |
currentTranscription = ''; | |
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; | |
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; | |
this.updateRecordingState(state); | |
// Visualization'ı state'e göre güncelle | |
if (state === 'listening') { | |
this.isRecording = true; | |
} else { | |
this.isRecording = false; | |
} | |
}); | |
// Subscribe to transcription | |
this.conversationManager.transcription$.pipe( | |
takeUntil(this.destroyed$) | |
).subscribe(text => { | |
if (text) { | |
console.log('🎙️ Transcription:', text); | |
} | |
this.currentTranscription = text; | |
}); | |
// 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 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; | |
// 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.stopVisualization(); | |
this.snackBar.open('Konuşma sonlandırıldı', 'Close', { | |
duration: 2000 | |
}); | |
} | |
async retryConnection(): Promise<void> { | |
this.error = ''; | |
if (!this.isConversationActive && this.sessionId) { | |
await this.startConversation(); | |
} | |
} | |
clearChat(): void { | |
this.conversationManager.clearMessages(); | |
this.currentTranscription = ''; | |
this.error = ''; | |
} | |
performBargeIn(): void { | |
this.conversationManager.performBargeIn(); | |
this.snackBar.open('Kesme yapıldı', 'Close', { | |
duration: 1000 | |
}); | |
} | |
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 updateRecordingState(state: ConversationState): void { | |
// State'e göre recording durumunu güncelle | |
this.isRecording = state === 'listening'; | |
// Visualizer'ı güncelle | |
if (this.isRecording && this.isConversationActive) { | |
// Eğer visualizer çalışmıyorsa başlat | |
if (!this.animationId) { | |
this.startVisualization(); | |
} | |
} | |
} | |
private scrollToBottom(): void { | |
try { | |
if (this.scrollContainer?.nativeElement) { | |
const element = this.scrollContainer.nativeElement; | |
element.scrollTop = element.scrollHeight; | |
} | |
} catch(err) { | |
console.error('Scroll error:', err); | |
} | |
} | |
private startVisualization(): void { | |
if (!this.audioVisualizer) return; | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (!ctx) return; | |
let lastVolume = 0; | |
// Subscribe to volume changes | |
this.subscriptions.add( | |
this.audioService.volumeLevel$.subscribe(volume => { | |
lastVolume = volume; | |
}) | |
); | |
// Animation loop | |
const draw = () => { | |
if (!this.isConversationActive) { | |
this.clearVisualization(); | |
return; | |
} | |
// Clear canvas | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
if (this.isRecording && lastVolume > 0) { | |
// Draw volume visualization | |
ctx.fillStyle = '#4caf50'; | |
const barCount = 50; | |
const barWidth = canvas.width / barCount; | |
for (let i = 0; i < barCount; i++) { | |
const barHeight = (Math.random() * 0.5 + 0.5) * lastVolume * canvas.height; | |
const x = i * barWidth; | |
const y = canvas.height - barHeight; | |
ctx.fillRect(x, y, barWidth - 2, barHeight); | |
} | |
} | |
this.animationId = requestAnimationFrame(draw); | |
}; | |
draw(); | |
} | |
private drawVolumeLevel(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, volume: number): void { | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
ctx.fillStyle = '#4caf50'; | |
const barCount = 50; | |
const barWidth = canvas.width / barCount; | |
for (let i = 0; i < barCount; i++) { | |
const barHeight = (Math.random() * 0.5 + 0.5) * volume * canvas.height; | |
const x = i * barWidth; | |
const y = canvas.height - barHeight; | |
ctx.fillRect(x, y, barWidth - 2, barHeight); | |
} | |
} | |
private stopVisualization(): void { | |
if (this.animationId) { | |
cancelAnimationFrame(this.animationId); | |
this.animationId = null; | |
} | |
this.clearVisualization(); | |
} | |
private clearVisualization(): void { | |
if (!this.audioVisualizer) return; | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (ctx) { | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
} | |
} | |
private cleanupAudio(): void { | |
if (this.currentAudio) { | |
this.currentAudio.pause(); | |
this.currentAudio = null; | |
this.isPlayingAudio = false; | |
} | |
} | |
} |