ciyidogan commited on
Commit
285eb62
·
verified ·
1 Parent(s): 107986d

Update flare-ui/src/app/services/conversation-manager.service.ts

Browse files
flare-ui/src/app/services/conversation-manager.service.ts CHANGED
@@ -1,830 +1,830 @@
1
- // conversation-manager.service.ts
2
- // Path: /flare-ui/src/app/services/conversation-manager.service.ts
3
-
4
- import { Injectable, OnDestroy } from '@angular/core';
5
- import { Subject, Subscription, BehaviorSubject, throwError } from 'rxjs';
6
- import { catchError, retry } from 'rxjs/operators';
7
- import { WebSocketService } from './websocket.service';
8
- import { AudioStreamService } from './audio-stream.service';
9
-
10
- export type ConversationState =
11
- | 'idle'
12
- | 'listening'
13
- | 'processing_stt'
14
- | 'processing_llm'
15
- | 'processing_tts'
16
- | 'playing_audio'
17
- | 'error';
18
-
19
- export interface ConversationMessage {
20
- role: 'user' | 'assistant' | 'system';
21
- text: string;
22
- timestamp: Date;
23
- audioUrl?: string;
24
- error?: boolean;
25
- }
26
-
27
- export interface ConversationConfig {
28
- language?: string;
29
- stt_engine?: string;
30
- tts_engine?: string;
31
- enable_barge_in?: boolean;
32
- max_silence_duration?: number;
33
- }
34
-
35
- export interface ConversationError {
36
- type: 'websocket' | 'audio' | 'permission' | 'network' | 'unknown';
37
- message: string;
38
- details?: any;
39
- timestamp: Date;
40
- }
41
-
42
- @Injectable({
43
- providedIn: 'root'
44
- })
45
- export class ConversationManagerService implements OnDestroy {
46
- private subscriptions = new Subscription();
47
- private audioQueue: string[] = [];
48
- private isInterrupting = false;
49
- private sessionId: string | null = null;
50
- private conversationConfig: ConversationConfig = {
51
- language: 'tr-TR',
52
- stt_engine: 'google',
53
- enable_barge_in: true
54
- };
55
-
56
- // State management
57
- private currentStateSubject = new BehaviorSubject<ConversationState>('idle');
58
- public currentState$ = this.currentStateSubject.asObservable();
59
-
60
- // Message history
61
- private messagesSubject = new BehaviorSubject<ConversationMessage[]>([]);
62
- public messages$ = this.messagesSubject.asObservable();
63
-
64
- // Current transcription
65
- private transcriptionSubject = new BehaviorSubject<string>('');
66
- public transcription$ = this.transcriptionSubject.asObservable();
67
-
68
- // Error handling
69
- private errorSubject = new Subject<ConversationError>();
70
- public error$ = this.errorSubject.asObservable();
71
-
72
- private sttReadySubject = new Subject<boolean>();
73
-
74
- // Audio player reference
75
- private audioPlayer: HTMLAudioElement | null = null;
76
- private audioPlayerPromise: Promise<void> | null = null;
77
-
78
- constructor(
79
- private wsService: WebSocketService,
80
- private audioService: AudioStreamService
81
- ) {}
82
-
83
- ngOnDestroy(): void {
84
- this.cleanup();
85
- }
86
-
87
- async startConversation(sessionId: string, config?: ConversationConfig): Promise<void> {
88
- try {
89
- if (!sessionId) {
90
- throw new Error('Session ID is required');
91
- }
92
-
93
- // Update configuration
94
- if (config) {
95
- this.conversationConfig = { ...this.conversationConfig, ...config };
96
- }
97
-
98
- this.sessionId = sessionId;
99
-
100
- // Start in listening state
101
- this.currentStateSubject.next('listening');
102
- console.log('🎤 Starting conversation in continuous listening mode');
103
-
104
- // Connect WebSocket first
105
- await this.wsService.connect(sessionId).catch(error => {
106
- throw new Error(`WebSocket connection failed: ${error.message}`);
107
- });
108
-
109
- // Set up subscriptions BEFORE sending any messages
110
- this.setupSubscriptions();
111
-
112
- // Send start signal with configuration
113
- this.wsService.sendControl('start_session', {
114
- ...this.conversationConfig,
115
- continuous_listening: true
116
- });
117
-
118
- console.log('✅ [ConversationManager] Conversation started - waiting for welcome TTS');
119
-
120
- } catch (error: any) {
121
- console.error('Failed to start conversation:', error);
122
-
123
- const conversationError: ConversationError = {
124
- type: this.determineErrorType(error),
125
- message: error.message || 'Failed to start conversation',
126
- details: error,
127
- timestamp: new Date()
128
- };
129
-
130
- this.errorSubject.next(conversationError);
131
- this.currentStateSubject.next('error');
132
- this.cleanup();
133
-
134
- throw error;
135
- }
136
- }
137
-
138
- stopConversation(): void {
139
- try {
140
- // First stop audio recording
141
- this.audioService.stopRecording();
142
-
143
- // Then send stop signal if connected
144
- if (this.wsService.isConnected()) {
145
- this.wsService.sendControl('stop_session');
146
- }
147
-
148
- // Small delay before disconnecting
149
- setTimeout(() => {
150
- this.cleanup();
151
- this.addSystemMessage('Conversation ended');
152
- }, 100);
153
-
154
- } catch (error) {
155
- console.error('Error stopping conversation:', error);
156
- this.cleanup();
157
- }
158
- }
159
-
160
- private setupSubscriptions(): void {
161
- // Audio chunks from microphone
162
- this.subscriptions.add(
163
- this.audioService.audioChunk$.subscribe({
164
- next: (chunk) => {
165
- if (!this.isInterrupting && this.wsService.isConnected()) {
166
- try {
167
- this.wsService.sendAudioChunk(chunk.data);
168
- } catch (error) {
169
- console.error('Failed to send audio chunk:', error);
170
- }
171
- }
172
- },
173
- error: (error) => {
174
- console.error('Audio stream error:', error);
175
- this.handleAudioError(error);
176
- }
177
- })
178
- );
179
-
180
- // Audio stream errors
181
- this.subscriptions.add(
182
- this.audioService.error$.subscribe(error => {
183
- this.handleAudioError(error);
184
- })
185
- );
186
-
187
- // WebSocket messages
188
- this.subscriptions.add(
189
- this.wsService.message$.subscribe({
190
- next: (message) => {
191
- this.handleMessage(message);
192
- },
193
- error: (error) => {
194
- console.error('WebSocket message error:', error);
195
- this.handleWebSocketError(error);
196
- }
197
- })
198
- );
199
-
200
- // Subscribe to transcription updates - SADECE FINAL RESULTS
201
- this.subscriptions.add(
202
- this.wsService.transcription$.subscribe(result => {
203
- // SADECE final transcription'ları işle
204
- if (result.is_final) {
205
- console.log('📝 Final transcription received:', result);
206
- const messages = this.messagesSubject.value;
207
- const lastMessage = messages[messages.length - 1];
208
- if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== result.text) {
209
- this.addMessage('user', result.text);
210
- }
211
- }
212
- })
213
- );
214
-
215
- // State changes
216
- this.subscriptions.add(
217
- this.wsService.stateChange$.subscribe(change => {
218
- this.currentStateSubject.next(change.to as ConversationState);
219
- this.handleStateChange(change.from, change.to);
220
- })
221
- );
222
-
223
- // WebSocket errors
224
- this.subscriptions.add(
225
- this.wsService.error$.subscribe(error => {
226
- console.error('WebSocket error:', error);
227
- this.handleWebSocketError({ message: error });
228
- })
229
- );
230
-
231
- // WebSocket connection state
232
- this.subscriptions.add(
233
- this.wsService.connection$.subscribe(connected => {
234
- if (!connected && this.currentStateSubject.value !== 'idle') {
235
- this.addSystemMessage('Connection lost. Attempting to reconnect...');
236
- }
237
- })
238
- );
239
- }
240
-
241
- private handleMessage(message: any): void {
242
- try {
243
- switch (message.type) {
244
- case 'transcription':
245
- // SADECE final transcription'ları işle
246
- if (message['is_final']) {
247
- const messages = this.messagesSubject.value;
248
- const lastMessage = messages[messages.length - 1];
249
- if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== message['text']) {
250
- this.addMessage('user', message['text']);
251
- }
252
- }
253
- // Interim transcription'ları artık işlemiyoruz
254
- break;
255
-
256
- case 'assistant_response':
257
- // Welcome mesajı veya normal yanıt
258
- const isWelcome = message['is_welcome'] || false;
259
- this.addMessage('assistant', message['text']);
260
-
261
- if (isWelcome) {
262
- console.log('📢 Welcome message received:', message['text']);
263
- }
264
- break;
265
-
266
- case 'tts_audio':
267
- this.handleTTSAudio(message);
268
- break;
269
-
270
- case 'tts_error':
271
- // TTS hatası durumunda kullanıcıya bilgi ver
272
- console.error('TTS Error:', message['message']);
273
- this.addSystemMessage(message['message']);
274
- break;
275
-
276
- case 'control':
277
- if (message['action'] === 'stop_playback') {
278
- this.stopAudioPlayback();
279
- }
280
- break;
281
-
282
- case 'error':
283
- this.handleServerError(message);
284
- break;
285
-
286
- case 'session_config':
287
- // Update configuration from server
288
- if (message['config']) {
289
- this.conversationConfig = { ...this.conversationConfig, ...message['config'] };
290
- }
291
- break;
292
-
293
- case 'session_started':
294
- // Session başladı, STT durumunu kontrol et
295
- console.log('📢 Session started:', message);
296
- if (!message['stt_initialized']) {
297
- this.addSystemMessage('Speech recognition failed to initialize. Voice input will not be available.');
298
- }
299
- break;
300
-
301
- case 'stt_ready':
302
- console.log('✅ [ConversationManager] Backend STT ready signal received');
303
- this.sttReadySubject.next(true);
304
- break;
305
- }
306
- } catch (error) {
307
- console.error('Error handling message:', error);
308
- this.errorSubject.next({
309
- type: 'unknown',
310
- message: 'Failed to process message',
311
- details: error,
312
- timestamp: new Date()
313
- });
314
- }
315
- }
316
-
317
- private handleStateChange(from: string, to: string): void {
318
- console.log(`📊 State: ${from} → ${to}`);
319
-
320
- // State değişimlerinde transcription'ı temizleme
321
- // Sadece error durumunda temizle
322
- if (to === 'error') {
323
- this.transcriptionSubject.next('');
324
- }
325
-
326
- // Log state changes for debugging
327
- console.log(`🎤 Continuous listening mode - state: ${to}`);
328
- }
329
-
330
- private playQueuedAudio(): void {
331
- const messages = this.messagesSubject.value;
332
- const lastMessage = messages[messages.length - 1];
333
-
334
- if (lastMessage?.audioUrl && lastMessage.role === 'assistant') {
335
- this.playAudio(lastMessage.audioUrl);
336
- }
337
- }
338
-
339
- private async playAudio(audioUrl: string): Promise<void> {
340
- try {
341
- console.log('🎵 [ConversationManager] playAudio called', {
342
- hasAudioPlayer: !!this.audioPlayer,
343
- audioUrl: audioUrl,
344
- timestamp: new Date().toISOString()
345
- });
346
-
347
- // Her seferinde yeni audio player oluştur ve handler'ları set et
348
- if (this.audioPlayer) {
349
- // Eski player'ı temizle
350
- this.audioPlayer.pause();
351
- this.audioPlayer.src = '';
352
- this.audioPlayer = null;
353
- }
354
-
355
- // Yeni player oluştur
356
- this.audioPlayer = new Audio();
357
- this.setupAudioPlayerHandlers(); // HER SEFERINDE handler'ları set et
358
-
359
- this.audioPlayer.src = audioUrl;
360
-
361
- // Store the play promise to handle interruptions properly
362
- this.audioPlayerPromise = this.audioPlayer.play();
363
-
364
- await this.audioPlayerPromise;
365
-
366
- } catch (error: any) {
367
- // Check if error is due to interruption
368
- if (error.name === 'AbortError') {
369
- console.log('Audio playback interrupted');
370
- } else {
371
- console.error('Audio playback error:', error);
372
- this.errorSubject.next({
373
- type: 'audio',
374
- message: 'Failed to play audio response',
375
- details: error,
376
- timestamp: new Date()
377
- });
378
- }
379
- } finally {
380
- this.audioPlayerPromise = null;
381
- }
382
- }
383
-
384
- private setupAudioPlayerHandlers(): void {
385
- if (!this.audioPlayer) return;
386
-
387
- this.audioPlayer.onended = async () => {
388
- console.log('🎵 [ConversationManager] Audio playback ended', {
389
- currentState: this.currentStateSubject.value,
390
- isRecording: this.audioService.isRecording(),
391
- timestamp: new Date().toISOString()
392
- });
393
-
394
- try {
395
- // Backend'e audio bittiğini bildir
396
- if (this.wsService.isConnected()) {
397
- console.log('📤 [ConversationManager] Sending audio_ended to backend');
398
- this.wsService.sendControl('audio_ended');
399
-
400
- // Backend'den STT ready sinyalini bekle
401
- console.log('⏳ [ConversationManager] Waiting for STT ready signal...');
402
-
403
- // STT ready handler'ı kur
404
- const sttReadyPromise = new Promise<boolean>((resolve) => {
405
- const subscription = this.wsService.message$.subscribe(message => {
406
- if (message.type === 'stt_ready') {
407
- console.log('✅ [ConversationManager] STT ready signal received');
408
- subscription.unsubscribe();
409
- resolve(true);
410
- }
411
- });
412
-
413
- // 10 saniye timeout
414
- setTimeout(() => {
415
- subscription.unsubscribe();
416
- resolve(false);
417
- }, 10000);
418
- });
419
-
420
- const sttReady = await sttReadyPromise;
421
-
422
- if (sttReady) {
423
- console.log('🎤 [ConversationManager] Starting audio recording');
424
-
425
- // Recording'i başlat
426
- if (!this.audioService.isRecording()) {
427
- await this.audioService.startRecording();
428
- console.log('✅ [ConversationManager] Audio recording started');
429
- }
430
- } else {
431
- console.error('❌ [ConversationManager] STT ready timeout');
432
- this.addSystemMessage('Speech recognition initialization timeout. Please try again.');
433
- }
434
- }
435
-
436
- } catch (error) {
437
- console.error('❌ [ConversationManager] Failed to handle audio end:', error);
438
- this.handleAudioError(error);
439
- }
440
- };
441
-
442
- this.audioPlayer.onerror = (error) => {
443
- console.error('Audio player error:', error);
444
- };
445
- }
446
-
447
- private stopAudioPlayback(): void {
448
- try {
449
- if (this.audioPlayer) {
450
- this.audioPlayer.pause();
451
- this.audioPlayer.currentTime = 0;
452
-
453
- // Cancel any pending play promise
454
- if (this.audioPlayerPromise) {
455
- this.audioPlayerPromise.catch(() => {
456
- // Ignore abort errors
457
- });
458
- this.audioPlayerPromise = null;
459
- }
460
- }
461
- } catch (error) {
462
- console.error('Error stopping audio playback:', error);
463
- }
464
- }
465
-
466
- // Barge-in handling - DEVRE DIŞI
467
- performBargeIn(): void {
468
- // Barge-in özelliği devre dışı bırakıldı
469
- console.log('⚠️ Barge-in is currently disabled');
470
-
471
- // Kullanıcıya bilgi ver
472
- this.addSystemMessage('Barge-in feature is currently disabled.');
473
- }
474
-
475
- private addMessage(role: 'user' | 'assistant', text: string, error: boolean = false): void {
476
- if (!text || text.trim().length === 0) {
477
- return;
478
- }
479
-
480
- const messages = this.messagesSubject.value;
481
- messages.push({
482
- role,
483
- text,
484
- timestamp: new Date(),
485
- error
486
- });
487
- this.messagesSubject.next([...messages]);
488
- }
489
-
490
- private addSystemMessage(text: string): void {
491
- console.log(`📢 System: ${text}`);
492
- const messages = this.messagesSubject.value;
493
- messages.push({
494
- role: 'system',
495
- text,
496
- timestamp: new Date()
497
- });
498
- this.messagesSubject.next([...messages]);
499
- }
500
-
501
- private handleTTSAudio(message: any): void {
502
- try {
503
- // Validate audio data
504
- if (!message['data']) {
505
- console.warn('❌ TTS audio message missing data');
506
- return;
507
- }
508
-
509
- // Detailed log
510
- console.log('🎵 TTS chunk received:', {
511
- chunkIndex: message['chunk_index'],
512
- totalChunks: message['total_chunks'],
513
- dataLength: message['data'].length,
514
- dataPreview: message['data'].substring(0, 50) + '...',
515
- isLast: message['is_last'],
516
- mimeType: message['mime_type']
517
- });
518
-
519
- // Accumulate audio chunks (already base64)
520
- this.audioQueue.push(message['data']);
521
- console.log(`📦 Audio queue size: ${this.audioQueue.length} chunks`);
522
-
523
- if (message['is_last']) {
524
- console.log('🔧 Processing final audio chunk...');
525
-
526
- try {
527
- // All chunks received, combine and create audio blob
528
- const combinedBase64 = this.audioQueue.join('');
529
- console.log('✅ Combined audio data:', {
530
- totalLength: combinedBase64.length,
531
- queueSize: this.audioQueue.length,
532
- preview: combinedBase64.substring(0, 100) + '...'
533
- });
534
-
535
- // Validate base64
536
- console.log('🔍 Validating base64...');
537
- if (!this.isValidBase64(combinedBase64)) {
538
- throw new Error('Invalid base64 data received');
539
- }
540
- console.log('✅ Base64 validation passed');
541
-
542
- const audioBlob = this.base64ToBlob(combinedBase64, message['mime_type'] || 'audio/mpeg');
543
- const audioUrl = URL.createObjectURL(audioBlob);
544
- console.log('🎧 Audio URL created:', audioUrl);
545
-
546
- // Update last message with audio URL
547
- const messages = this.messagesSubject.value;
548
- if (messages.length > 0) {
549
- const lastAssistantMessageIndex = this.findLastAssistantMessageIndex(messages);
550
- if (lastAssistantMessageIndex >= 0) {
551
- messages[lastAssistantMessageIndex].audioUrl = audioUrl;
552
- this.messagesSubject.next([...messages]);
553
- console.log('✅ Audio URL attached to assistant message at index:', lastAssistantMessageIndex);
554
-
555
- // Auto-play if it's welcome message or if in playing_audio state
556
- const isWelcomeMessage = messages[lastAssistantMessageIndex].text &&
557
- messages[lastAssistantMessageIndex].timestamp &&
558
- (new Date().getTime() - messages[lastAssistantMessageIndex].timestamp.getTime()) < 10000; // 10 saniye içinde
559
-
560
- if (isWelcomeMessage || this.currentStateSubject.value === 'playing_audio') {
561
- setTimeout(() => {
562
- console.log('🎵 Auto-playing audio for welcome message');
563
- this.playAudio(audioUrl);
564
- }, 500);
565
- }
566
- } else {
567
- console.warn('⚠️ No assistant message found to attach audio');
568
- }
569
- }
570
-
571
- // Clear queue
572
- this.audioQueue = [];
573
- console.log('🧹 Audio queue cleared');
574
-
575
- console.log('✅ Audio processing completed successfully');
576
- } catch (error) {
577
- console.error('❌ Error creating audio blob:', error);
578
- console.error('Queue size was:', this.audioQueue.length);
579
- this.audioQueue = [];
580
- }
581
- }
582
- } catch (error) {
583
- console.error('❌ Error handling TTS audio:', error);
584
- this.audioQueue = []; // Clear queue on error
585
- }
586
- }
587
-
588
- private findLastAssistantMessageIndex(messages: ConversationMessage[]): number {
589
- for (let i = messages.length - 1; i >= 0; i--) {
590
- if (messages[i].role === 'assistant') {
591
- return i;
592
- }
593
- }
594
- return -1;
595
- }
596
-
597
- private isValidBase64(str: string): boolean {
598
- try {
599
- console.log(`🔍 Checking base64 validity for ${str.length} chars`);
600
-
601
- // Check if string contains only valid base64 characters
602
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
603
- if (!base64Regex.test(str)) {
604
- console.error('❌ Base64 regex test failed');
605
- return false;
606
- }
607
-
608
- // Try to decode to verify
609
- const decoded = atob(str);
610
- console.log(`✅ Base64 decode successful, decoded length: ${decoded.length}`);
611
- return true;
612
- } catch (e) {
613
- console.error('❌ Base64 validation error:', e);
614
- return false;
615
- }
616
- }
617
-
618
- private base64ToBlob(base64: string, mimeType: string): Blob {
619
- try {
620
- console.log('🔄 Converting base64 to blob:', {
621
- base64Length: base64.length,
622
- mimeType: mimeType
623
- });
624
-
625
- const byteCharacters = atob(base64);
626
- console.log(`📊 Decoded to ${byteCharacters.length} bytes`);
627
-
628
- const byteNumbers = new Array(byteCharacters.length);
629
-
630
- for (let i = 0; i < byteCharacters.length; i++) {
631
- byteNumbers[i] = byteCharacters.charCodeAt(i);
632
- }
633
-
634
- const byteArray = new Uint8Array(byteNumbers);
635
- const blob = new Blob([byteArray], { type: mimeType });
636
-
637
- console.log('✅ Blob created:', {
638
- size: blob.size,
639
- type: blob.type,
640
- sizeKB: (blob.size / 1024).toFixed(2) + ' KB'
641
- });
642
-
643
- return blob;
644
- } catch (error) {
645
- console.error('❌ Error converting base64 to blob:', error);
646
- console.error('Input details:', {
647
- base64Length: base64.length,
648
- base64Preview: base64.substring(0, 100) + '...',
649
- mimeType: mimeType
650
- });
651
- throw new Error('Failed to convert audio data');
652
- }
653
- }
654
-
655
- private handleAudioError(error: any): void {
656
- const conversationError: ConversationError = {
657
- type: error.type || 'audio',
658
- message: error.message || 'Audio error occurred',
659
- details: error,
660
- timestamp: new Date()
661
- };
662
-
663
- this.errorSubject.next(conversationError);
664
-
665
- // Add user-friendly message
666
- if (error.type === 'permission') {
667
- this.addSystemMessage('Microphone permission denied. Please allow microphone access.');
668
- } else if (error.type === 'device') {
669
- this.addSystemMessage('Microphone not found or not accessible.');
670
- } else {
671
- this.addSystemMessage('Audio error occurred. Please check your microphone.');
672
- }
673
-
674
- // Update state
675
- this.currentStateSubject.next('error');
676
- }
677
-
678
- private handleWebSocketError(error: any): void {
679
- const conversationError: ConversationError = {
680
- type: 'websocket',
681
- message: error.message || 'WebSocket error occurred',
682
- details: error,
683
- timestamp: new Date()
684
- };
685
-
686
- this.errorSubject.next(conversationError);
687
- this.addSystemMessage('Connection error. Please check your internet connection.');
688
-
689
- // Don't set error state for temporary connection issues
690
- if (this.wsService.getReconnectionInfo().isReconnecting) {
691
- this.addSystemMessage('Attempting to reconnect...');
692
- } else {
693
- this.currentStateSubject.next('error');
694
- }
695
- }
696
-
697
- private handleServerError(message: any): void {
698
- const errorType = message['error_type'] || 'unknown';
699
- const errorMessage = message['message'] || 'Server error occurred';
700
-
701
- const conversationError: ConversationError = {
702
- type: errorType === 'race_condition' ? 'network' : 'unknown',
703
- message: errorMessage,
704
- details: message,
705
- timestamp: new Date()
706
- };
707
-
708
- this.errorSubject.next(conversationError);
709
-
710
- // STT initialization hatası için özel handling
711
- if (errorType === 'stt_init_failed') {
712
- this.addSystemMessage('Speech recognition service failed to initialize. Please check your configuration.');
713
- // Konuşmayı durdur
714
- this.stopConversation();
715
- } else if (errorType === 'race_condition') {
716
- this.addSystemMessage('Session conflict detected. Please restart the conversation.');
717
- } else if (errorType === 'stt_error') {
718
- this.addSystemMessage('Speech recognition error. Please try speaking again.');
719
- // STT hatası durumunda yeniden başlatmayı dene
720
- if (errorMessage.includes('Streaming not started')) {
721
- this.addSystemMessage('Restarting speech recognition...');
722
- // WebSocket'e restart sinyali gönder
723
- if (this.wsService.isConnected()) {
724
- this.wsService.sendControl('restart_stt');
725
- }
726
- }
727
- } else if (errorType === 'tts_error') {
728
- this.addSystemMessage('Text-to-speech error. Response will be shown as text only.');
729
- } else {
730
- this.addSystemMessage(`Error: ${errorMessage}`);
731
- }
732
- }
733
-
734
- private determineErrorType(error: any): ConversationError['type'] {
735
- if (error.type) {
736
- return error.type;
737
- }
738
-
739
- if (error.message?.includes('WebSocket') || error.message?.includes('connection')) {
740
- return 'websocket';
741
- }
742
-
743
- if (error.message?.includes('microphone') || error.message?.includes('audio')) {
744
- return 'audio';
745
- }
746
-
747
- if (error.message?.includes('permission')) {
748
- return 'permission';
749
- }
750
-
751
- if (error.message?.includes('network') || error.status === 0) {
752
- return 'network';
753
- }
754
-
755
- return 'unknown';
756
- }
757
-
758
- private cleanup(): void {
759
- try {
760
- this.subscriptions.unsubscribe();
761
- this.subscriptions = new Subscription();
762
-
763
- // Audio recording'i kesinlikle durdur
764
- if (this.audioService.isRecording()) {
765
- this.audioService.stopRecording();
766
- }
767
-
768
- this.wsService.disconnect();
769
- this.stopAudioPlayback();
770
-
771
- if (this.audioPlayer) {
772
- this.audioPlayer = null;
773
- }
774
-
775
- this.audioQueue = [];
776
- this.isInterrupting = false;
777
- this.currentStateSubject.next('idle');
778
- this.sttReadySubject.complete();
779
-
780
- console.log('🧹 Conversation cleaned up');
781
- } catch (error) {
782
- console.error('Error during cleanup:', error);
783
- }
784
- }
785
-
786
- // Public methods for UI
787
- getCurrentState(): ConversationState {
788
- return this.currentStateSubject.value;
789
- }
790
-
791
- getMessages(): ConversationMessage[] {
792
- return this.messagesSubject.value;
793
- }
794
-
795
- clearMessages(): void {
796
- this.messagesSubject.next([]);
797
- this.transcriptionSubject.next('');
798
- }
799
-
800
- updateConfig(config: Partial<ConversationConfig>): void {
801
- this.conversationConfig = { ...this.conversationConfig, ...config };
802
-
803
- // Send config update if connected
804
- if (this.wsService.isConnected()) {
805
- try {
806
- this.wsService.sendControl('update_config', config);
807
- } catch (error) {
808
- console.error('Failed to update config:', error);
809
- }
810
- }
811
- }
812
-
813
- getConfig(): ConversationConfig {
814
- return { ...this.conversationConfig };
815
- }
816
-
817
- isConnected(): boolean {
818
- return this.wsService.isConnected();
819
- }
820
-
821
- // Retry connection
822
- async retryConnection(): Promise<void> {
823
- if (!this.sessionId) {
824
- throw new Error('No session ID available for retry');
825
- }
826
-
827
- this.currentStateSubject.next('idle');
828
- await this.startConversation(this.sessionId, this.conversationConfig);
829
- }
830
  }
 
1
+ // conversation-manager.service.ts
2
+ // Path: /flare-ui/src/app/services/conversation-manager.service.ts
3
+
4
+ import { Injectable, OnDestroy } from '@angular/core';
5
+ import { Subject, Subscription, BehaviorSubject, throwError } from 'rxjs';
6
+ import { catchError, retry } from 'rxjs/operators';
7
+ import { WebSocketService } from './websocket.service';
8
+ import { AudioStreamService } from './audio-stream.service';
9
+
10
+ export type ConversationState =
11
+ | 'idle'
12
+ | 'listening'
13
+ | 'processing_stt'
14
+ | 'processing_llm'
15
+ | 'processing_tts'
16
+ | 'playing_audio'
17
+ | 'error';
18
+
19
+ export interface ConversationMessage {
20
+ role: 'user' | 'assistant' | 'system';
21
+ text: string;
22
+ timestamp: Date;
23
+ audioUrl?: string;
24
+ error?: boolean;
25
+ }
26
+
27
+ export interface ConversationConfig {
28
+ language?: string;
29
+ stt_engine?: string;
30
+ tts_engine?: string;
31
+ enable_barge_in?: boolean;
32
+ max_silence_duration?: number;
33
+ }
34
+
35
+ export interface ConversationError {
36
+ type: 'websocket' | 'audio' | 'permission' | 'network' | 'unknown';
37
+ message: string;
38
+ details?: any;
39
+ timestamp: Date;
40
+ }
41
+
42
+ @Injectable({
43
+ providedIn: 'root'
44
+ })
45
+ export class ConversationManagerService implements OnDestroy {
46
+ private subscriptions = new Subscription();
47
+ private audioQueue: string[] = [];
48
+ private isInterrupting = false;
49
+ private sessionId: string | null = null;
50
+ private conversationConfig: ConversationConfig = {
51
+ language: 'tr-TR',
52
+ stt_engine: 'google',
53
+ enable_barge_in: true
54
+ };
55
+
56
+ // State management
57
+ private currentStateSubject = new BehaviorSubject<ConversationState>('idle');
58
+ public currentState$ = this.currentStateSubject.asObservable();
59
+
60
+ // Message history
61
+ private messagesSubject = new BehaviorSubject<ConversationMessage[]>([]);
62
+ public messages$ = this.messagesSubject.asObservable();
63
+
64
+ // Current transcription
65
+ private transcriptionSubject = new BehaviorSubject<string>('');
66
+ public transcription$ = this.transcriptionSubject.asObservable();
67
+
68
+ // Error handling
69
+ private errorSubject = new Subject<ConversationError>();
70
+ public error$ = this.errorSubject.asObservable();
71
+
72
+ private sttReadySubject = new Subject<boolean>();
73
+
74
+ // Audio player reference
75
+ private audioPlayer: HTMLAudioElement | null = null;
76
+ private audioPlayerPromise: Promise<void> | null = null;
77
+
78
+ constructor(
79
+ private wsService: WebSocketService,
80
+ private audioService: AudioStreamService
81
+ ) {}
82
+
83
+ ngOnDestroy(): void {
84
+ this.cleanup();
85
+ }
86
+
87
+ async startConversation(sessionId: string, config?: ConversationConfig): Promise<void> {
88
+ try {
89
+ if (!sessionId) {
90
+ throw new Error('Session ID is required');
91
+ }
92
+
93
+ // Update configuration
94
+ if (config) {
95
+ this.conversationConfig = { ...this.conversationConfig, ...config };
96
+ }
97
+
98
+ this.sessionId = sessionId;
99
+
100
+ // Start in listening state
101
+ this.currentStateSubject.next('listening');
102
+ console.log('🎤 Starting conversation in continuous listening mode');
103
+
104
+ // Connect WebSocket first
105
+ await this.wsService.connect(sessionId).catch(error => {
106
+ throw new Error(`WebSocket connection failed: ${error.message}`);
107
+ });
108
+
109
+ // Set up subscriptions BEFORE sending any messages
110
+ this.setupSubscriptions();
111
+
112
+ // Send start signal with configuration
113
+ this.wsService.sendControl('start_conversation', {
114
+ ...this.conversationConfig,
115
+ continuous_listening: true
116
+ });
117
+
118
+ console.log('✅ [ConversationManager] Conversation started - waiting for welcome TTS');
119
+
120
+ } catch (error: any) {
121
+ console.error('Failed to start conversation:', error);
122
+
123
+ const conversationError: ConversationError = {
124
+ type: this.determineErrorType(error),
125
+ message: error.message || 'Failed to start conversation',
126
+ details: error,
127
+ timestamp: new Date()
128
+ };
129
+
130
+ this.errorSubject.next(conversationError);
131
+ this.currentStateSubject.next('error');
132
+ this.cleanup();
133
+
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ stopConversation(): void {
139
+ try {
140
+ // First stop audio recording
141
+ this.audioService.stopRecording();
142
+
143
+ // Then send stop signal if connected
144
+ if (this.wsService.isConnected()) {
145
+ this.wsService.sendControl('stop_session');
146
+ }
147
+
148
+ // Small delay before disconnecting
149
+ setTimeout(() => {
150
+ this.cleanup();
151
+ this.addSystemMessage('Conversation ended');
152
+ }, 100);
153
+
154
+ } catch (error) {
155
+ console.error('Error stopping conversation:', error);
156
+ this.cleanup();
157
+ }
158
+ }
159
+
160
+ private setupSubscriptions(): void {
161
+ // Audio chunks from microphone
162
+ this.subscriptions.add(
163
+ this.audioService.audioChunk$.subscribe({
164
+ next: (chunk) => {
165
+ if (!this.isInterrupting && this.wsService.isConnected()) {
166
+ try {
167
+ this.wsService.sendAudioChunk(chunk.data);
168
+ } catch (error) {
169
+ console.error('Failed to send audio chunk:', error);
170
+ }
171
+ }
172
+ },
173
+ error: (error) => {
174
+ console.error('Audio stream error:', error);
175
+ this.handleAudioError(error);
176
+ }
177
+ })
178
+ );
179
+
180
+ // Audio stream errors
181
+ this.subscriptions.add(
182
+ this.audioService.error$.subscribe(error => {
183
+ this.handleAudioError(error);
184
+ })
185
+ );
186
+
187
+ // WebSocket messages
188
+ this.subscriptions.add(
189
+ this.wsService.message$.subscribe({
190
+ next: (message) => {
191
+ this.handleMessage(message);
192
+ },
193
+ error: (error) => {
194
+ console.error('WebSocket message error:', error);
195
+ this.handleWebSocketError(error);
196
+ }
197
+ })
198
+ );
199
+
200
+ // Subscribe to transcription updates - SADECE FINAL RESULTS
201
+ this.subscriptions.add(
202
+ this.wsService.transcription$.subscribe(result => {
203
+ // SADECE final transcription'ları işle
204
+ if (result.is_final) {
205
+ console.log('📝 Final transcription received:', result);
206
+ const messages = this.messagesSubject.value;
207
+ const lastMessage = messages[messages.length - 1];
208
+ if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== result.text) {
209
+ this.addMessage('user', result.text);
210
+ }
211
+ }
212
+ })
213
+ );
214
+
215
+ // State changes
216
+ this.subscriptions.add(
217
+ this.wsService.stateChange$.subscribe(change => {
218
+ this.currentStateSubject.next(change.to as ConversationState);
219
+ this.handleStateChange(change.from, change.to);
220
+ })
221
+ );
222
+
223
+ // WebSocket errors
224
+ this.subscriptions.add(
225
+ this.wsService.error$.subscribe(error => {
226
+ console.error('WebSocket error:', error);
227
+ this.handleWebSocketError({ message: error });
228
+ })
229
+ );
230
+
231
+ // WebSocket connection state
232
+ this.subscriptions.add(
233
+ this.wsService.connection$.subscribe(connected => {
234
+ if (!connected && this.currentStateSubject.value !== 'idle') {
235
+ this.addSystemMessage('Connection lost. Attempting to reconnect...');
236
+ }
237
+ })
238
+ );
239
+ }
240
+
241
+ private handleMessage(message: any): void {
242
+ try {
243
+ switch (message.type) {
244
+ case 'transcription':
245
+ // SADECE final transcription'ları işle
246
+ if (message['is_final']) {
247
+ const messages = this.messagesSubject.value;
248
+ const lastMessage = messages[messages.length - 1];
249
+ if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== message['text']) {
250
+ this.addMessage('user', message['text']);
251
+ }
252
+ }
253
+ // Interim transcription'ları artık işlemiyoruz
254
+ break;
255
+
256
+ case 'assistant_response':
257
+ // Welcome mesajı veya normal yanıt
258
+ const isWelcome = message['is_welcome'] || false;
259
+ this.addMessage('assistant', message['text']);
260
+
261
+ if (isWelcome) {
262
+ console.log('📢 Welcome message received:', message['text']);
263
+ }
264
+ break;
265
+
266
+ case 'tts_audio':
267
+ this.handleTTSAudio(message);
268
+ break;
269
+
270
+ case 'tts_error':
271
+ // TTS hatası durumunda kullanıcıya bilgi ver
272
+ console.error('TTS Error:', message['message']);
273
+ this.addSystemMessage(message['message']);
274
+ break;
275
+
276
+ case 'control':
277
+ if (message['action'] === 'stop_playback') {
278
+ this.stopAudioPlayback();
279
+ }
280
+ break;
281
+
282
+ case 'error':
283
+ this.handleServerError(message);
284
+ break;
285
+
286
+ case 'session_config':
287
+ // Update configuration from server
288
+ if (message['config']) {
289
+ this.conversationConfig = { ...this.conversationConfig, ...message['config'] };
290
+ }
291
+ break;
292
+
293
+ case 'session_started':
294
+ // Session başladı, STT durumunu kontrol et
295
+ console.log('📢 Session started:', message);
296
+ if (!message['stt_initialized']) {
297
+ this.addSystemMessage('Speech recognition failed to initialize. Voice input will not be available.');
298
+ }
299
+ break;
300
+
301
+ case 'stt_ready':
302
+ console.log('✅ [ConversationManager] Backend STT ready signal received');
303
+ this.sttReadySubject.next(true);
304
+ break;
305
+ }
306
+ } catch (error) {
307
+ console.error('Error handling message:', error);
308
+ this.errorSubject.next({
309
+ type: 'unknown',
310
+ message: 'Failed to process message',
311
+ details: error,
312
+ timestamp: new Date()
313
+ });
314
+ }
315
+ }
316
+
317
+ private handleStateChange(from: string, to: string): void {
318
+ console.log(`📊 State: ${from} → ${to}`);
319
+
320
+ // State değişimlerinde transcription'ı temizleme
321
+ // Sadece error durumunda temizle
322
+ if (to === 'error') {
323
+ this.transcriptionSubject.next('');
324
+ }
325
+
326
+ // Log state changes for debugging
327
+ console.log(`🎤 Continuous listening mode - state: ${to}`);
328
+ }
329
+
330
+ private playQueuedAudio(): void {
331
+ const messages = this.messagesSubject.value;
332
+ const lastMessage = messages[messages.length - 1];
333
+
334
+ if (lastMessage?.audioUrl && lastMessage.role === 'assistant') {
335
+ this.playAudio(lastMessage.audioUrl);
336
+ }
337
+ }
338
+
339
+ private async playAudio(audioUrl: string): Promise<void> {
340
+ try {
341
+ console.log('🎵 [ConversationManager] playAudio called', {
342
+ hasAudioPlayer: !!this.audioPlayer,
343
+ audioUrl: audioUrl,
344
+ timestamp: new Date().toISOString()
345
+ });
346
+
347
+ // Her seferinde yeni audio player oluştur ve handler'ları set et
348
+ if (this.audioPlayer) {
349
+ // Eski player'ı temizle
350
+ this.audioPlayer.pause();
351
+ this.audioPlayer.src = '';
352
+ this.audioPlayer = null;
353
+ }
354
+
355
+ // Yeni player oluştur
356
+ this.audioPlayer = new Audio();
357
+ this.setupAudioPlayerHandlers(); // HER SEFERINDE handler'ları set et
358
+
359
+ this.audioPlayer.src = audioUrl;
360
+
361
+ // Store the play promise to handle interruptions properly
362
+ this.audioPlayerPromise = this.audioPlayer.play();
363
+
364
+ await this.audioPlayerPromise;
365
+
366
+ } catch (error: any) {
367
+ // Check if error is due to interruption
368
+ if (error.name === 'AbortError') {
369
+ console.log('Audio playback interrupted');
370
+ } else {
371
+ console.error('Audio playback error:', error);
372
+ this.errorSubject.next({
373
+ type: 'audio',
374
+ message: 'Failed to play audio response',
375
+ details: error,
376
+ timestamp: new Date()
377
+ });
378
+ }
379
+ } finally {
380
+ this.audioPlayerPromise = null;
381
+ }
382
+ }
383
+
384
+ private setupAudioPlayerHandlers(): void {
385
+ if (!this.audioPlayer) return;
386
+
387
+ this.audioPlayer.onended = async () => {
388
+ console.log('🎵 [ConversationManager] Audio playback ended', {
389
+ currentState: this.currentStateSubject.value,
390
+ isRecording: this.audioService.isRecording(),
391
+ timestamp: new Date().toISOString()
392
+ });
393
+
394
+ try {
395
+ // Backend'e audio bittiğini bildir
396
+ if (this.wsService.isConnected()) {
397
+ console.log('📤 [ConversationManager] Sending audio_ended to backend');
398
+ this.wsService.sendControl('audio_ended');
399
+
400
+ // Backend'den STT ready sinyalini bekle
401
+ console.log('⏳ [ConversationManager] Waiting for STT ready signal...');
402
+
403
+ // STT ready handler'ı kur
404
+ const sttReadyPromise = new Promise<boolean>((resolve) => {
405
+ const subscription = this.wsService.message$.subscribe(message => {
406
+ if (message.type === 'stt_ready') {
407
+ console.log('✅ [ConversationManager] STT ready signal received');
408
+ subscription.unsubscribe();
409
+ resolve(true);
410
+ }
411
+ });
412
+
413
+ // 10 saniye timeout
414
+ setTimeout(() => {
415
+ subscription.unsubscribe();
416
+ resolve(false);
417
+ }, 10000);
418
+ });
419
+
420
+ const sttReady = await sttReadyPromise;
421
+
422
+ if (sttReady) {
423
+ console.log('🎤 [ConversationManager] Starting audio recording');
424
+
425
+ // Recording'i başlat
426
+ if (!this.audioService.isRecording()) {
427
+ await this.audioService.startRecording();
428
+ console.log('✅ [ConversationManager] Audio recording started');
429
+ }
430
+ } else {
431
+ console.error('❌ [ConversationManager] STT ready timeout');
432
+ this.addSystemMessage('Speech recognition initialization timeout. Please try again.');
433
+ }
434
+ }
435
+
436
+ } catch (error) {
437
+ console.error('❌ [ConversationManager] Failed to handle audio end:', error);
438
+ this.handleAudioError(error);
439
+ }
440
+ };
441
+
442
+ this.audioPlayer.onerror = (error) => {
443
+ console.error('Audio player error:', error);
444
+ };
445
+ }
446
+
447
+ private stopAudioPlayback(): void {
448
+ try {
449
+ if (this.audioPlayer) {
450
+ this.audioPlayer.pause();
451
+ this.audioPlayer.currentTime = 0;
452
+
453
+ // Cancel any pending play promise
454
+ if (this.audioPlayerPromise) {
455
+ this.audioPlayerPromise.catch(() => {
456
+ // Ignore abort errors
457
+ });
458
+ this.audioPlayerPromise = null;
459
+ }
460
+ }
461
+ } catch (error) {
462
+ console.error('Error stopping audio playback:', error);
463
+ }
464
+ }
465
+
466
+ // Barge-in handling - DEVRE DIŞI
467
+ performBargeIn(): void {
468
+ // Barge-in özelliği devre dışı bırakıldı
469
+ console.log('⚠️ Barge-in is currently disabled');
470
+
471
+ // Kullanıcıya bilgi ver
472
+ this.addSystemMessage('Barge-in feature is currently disabled.');
473
+ }
474
+
475
+ private addMessage(role: 'user' | 'assistant', text: string, error: boolean = false): void {
476
+ if (!text || text.trim().length === 0) {
477
+ return;
478
+ }
479
+
480
+ const messages = this.messagesSubject.value;
481
+ messages.push({
482
+ role,
483
+ text,
484
+ timestamp: new Date(),
485
+ error
486
+ });
487
+ this.messagesSubject.next([...messages]);
488
+ }
489
+
490
+ private addSystemMessage(text: string): void {
491
+ console.log(`📢 System: ${text}`);
492
+ const messages = this.messagesSubject.value;
493
+ messages.push({
494
+ role: 'system',
495
+ text,
496
+ timestamp: new Date()
497
+ });
498
+ this.messagesSubject.next([...messages]);
499
+ }
500
+
501
+ private handleTTSAudio(message: any): void {
502
+ try {
503
+ // Validate audio data
504
+ if (!message['data']) {
505
+ console.warn('❌ TTS audio message missing data');
506
+ return;
507
+ }
508
+
509
+ // Detailed log
510
+ console.log('🎵 TTS chunk received:', {
511
+ chunkIndex: message['chunk_index'],
512
+ totalChunks: message['total_chunks'],
513
+ dataLength: message['data'].length,
514
+ dataPreview: message['data'].substring(0, 50) + '...',
515
+ isLast: message['is_last'],
516
+ mimeType: message['mime_type']
517
+ });
518
+
519
+ // Accumulate audio chunks (already base64)
520
+ this.audioQueue.push(message['data']);
521
+ console.log(`📦 Audio queue size: ${this.audioQueue.length} chunks`);
522
+
523
+ if (message['is_last']) {
524
+ console.log('🔧 Processing final audio chunk...');
525
+
526
+ try {
527
+ // All chunks received, combine and create audio blob
528
+ const combinedBase64 = this.audioQueue.join('');
529
+ console.log('✅ Combined audio data:', {
530
+ totalLength: combinedBase64.length,
531
+ queueSize: this.audioQueue.length,
532
+ preview: combinedBase64.substring(0, 100) + '...'
533
+ });
534
+
535
+ // Validate base64
536
+ console.log('🔍 Validating base64...');
537
+ if (!this.isValidBase64(combinedBase64)) {
538
+ throw new Error('Invalid base64 data received');
539
+ }
540
+ console.log('✅ Base64 validation passed');
541
+
542
+ const audioBlob = this.base64ToBlob(combinedBase64, message['mime_type'] || 'audio/mpeg');
543
+ const audioUrl = URL.createObjectURL(audioBlob);
544
+ console.log('🎧 Audio URL created:', audioUrl);
545
+
546
+ // Update last message with audio URL
547
+ const messages = this.messagesSubject.value;
548
+ if (messages.length > 0) {
549
+ const lastAssistantMessageIndex = this.findLastAssistantMessageIndex(messages);
550
+ if (lastAssistantMessageIndex >= 0) {
551
+ messages[lastAssistantMessageIndex].audioUrl = audioUrl;
552
+ this.messagesSubject.next([...messages]);
553
+ console.log('✅ Audio URL attached to assistant message at index:', lastAssistantMessageIndex);
554
+
555
+ // Auto-play if it's welcome message or if in playing_audio state
556
+ const isWelcomeMessage = messages[lastAssistantMessageIndex].text &&
557
+ messages[lastAssistantMessageIndex].timestamp &&
558
+ (new Date().getTime() - messages[lastAssistantMessageIndex].timestamp.getTime()) < 10000; // 10 saniye içinde
559
+
560
+ if (isWelcomeMessage || this.currentStateSubject.value === 'playing_audio') {
561
+ setTimeout(() => {
562
+ console.log('🎵 Auto-playing audio for welcome message');
563
+ this.playAudio(audioUrl);
564
+ }, 500);
565
+ }
566
+ } else {
567
+ console.warn('⚠️ No assistant message found to attach audio');
568
+ }
569
+ }
570
+
571
+ // Clear queue
572
+ this.audioQueue = [];
573
+ console.log('🧹 Audio queue cleared');
574
+
575
+ console.log('✅ Audio processing completed successfully');
576
+ } catch (error) {
577
+ console.error('❌ Error creating audio blob:', error);
578
+ console.error('Queue size was:', this.audioQueue.length);
579
+ this.audioQueue = [];
580
+ }
581
+ }
582
+ } catch (error) {
583
+ console.error('❌ Error handling TTS audio:', error);
584
+ this.audioQueue = []; // Clear queue on error
585
+ }
586
+ }
587
+
588
+ private findLastAssistantMessageIndex(messages: ConversationMessage[]): number {
589
+ for (let i = messages.length - 1; i >= 0; i--) {
590
+ if (messages[i].role === 'assistant') {
591
+ return i;
592
+ }
593
+ }
594
+ return -1;
595
+ }
596
+
597
+ private isValidBase64(str: string): boolean {
598
+ try {
599
+ console.log(`🔍 Checking base64 validity for ${str.length} chars`);
600
+
601
+ // Check if string contains only valid base64 characters
602
+ const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
603
+ if (!base64Regex.test(str)) {
604
+ console.error('❌ Base64 regex test failed');
605
+ return false;
606
+ }
607
+
608
+ // Try to decode to verify
609
+ const decoded = atob(str);
610
+ console.log(`✅ Base64 decode successful, decoded length: ${decoded.length}`);
611
+ return true;
612
+ } catch (e) {
613
+ console.error('❌ Base64 validation error:', e);
614
+ return false;
615
+ }
616
+ }
617
+
618
+ private base64ToBlob(base64: string, mimeType: string): Blob {
619
+ try {
620
+ console.log('🔄 Converting base64 to blob:', {
621
+ base64Length: base64.length,
622
+ mimeType: mimeType
623
+ });
624
+
625
+ const byteCharacters = atob(base64);
626
+ console.log(`📊 Decoded to ${byteCharacters.length} bytes`);
627
+
628
+ const byteNumbers = new Array(byteCharacters.length);
629
+
630
+ for (let i = 0; i < byteCharacters.length; i++) {
631
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
632
+ }
633
+
634
+ const byteArray = new Uint8Array(byteNumbers);
635
+ const blob = new Blob([byteArray], { type: mimeType });
636
+
637
+ console.log('✅ Blob created:', {
638
+ size: blob.size,
639
+ type: blob.type,
640
+ sizeKB: (blob.size / 1024).toFixed(2) + ' KB'
641
+ });
642
+
643
+ return blob;
644
+ } catch (error) {
645
+ console.error('❌ Error converting base64 to blob:', error);
646
+ console.error('Input details:', {
647
+ base64Length: base64.length,
648
+ base64Preview: base64.substring(0, 100) + '...',
649
+ mimeType: mimeType
650
+ });
651
+ throw new Error('Failed to convert audio data');
652
+ }
653
+ }
654
+
655
+ private handleAudioError(error: any): void {
656
+ const conversationError: ConversationError = {
657
+ type: error.type || 'audio',
658
+ message: error.message || 'Audio error occurred',
659
+ details: error,
660
+ timestamp: new Date()
661
+ };
662
+
663
+ this.errorSubject.next(conversationError);
664
+
665
+ // Add user-friendly message
666
+ if (error.type === 'permission') {
667
+ this.addSystemMessage('Microphone permission denied. Please allow microphone access.');
668
+ } else if (error.type === 'device') {
669
+ this.addSystemMessage('Microphone not found or not accessible.');
670
+ } else {
671
+ this.addSystemMessage('Audio error occurred. Please check your microphone.');
672
+ }
673
+
674
+ // Update state
675
+ this.currentStateSubject.next('error');
676
+ }
677
+
678
+ private handleWebSocketError(error: any): void {
679
+ const conversationError: ConversationError = {
680
+ type: 'websocket',
681
+ message: error.message || 'WebSocket error occurred',
682
+ details: error,
683
+ timestamp: new Date()
684
+ };
685
+
686
+ this.errorSubject.next(conversationError);
687
+ this.addSystemMessage('Connection error. Please check your internet connection.');
688
+
689
+ // Don't set error state for temporary connection issues
690
+ if (this.wsService.getReconnectionInfo().isReconnecting) {
691
+ this.addSystemMessage('Attempting to reconnect...');
692
+ } else {
693
+ this.currentStateSubject.next('error');
694
+ }
695
+ }
696
+
697
+ private handleServerError(message: any): void {
698
+ const errorType = message['error_type'] || 'unknown';
699
+ const errorMessage = message['message'] || 'Server error occurred';
700
+
701
+ const conversationError: ConversationError = {
702
+ type: errorType === 'race_condition' ? 'network' : 'unknown',
703
+ message: errorMessage,
704
+ details: message,
705
+ timestamp: new Date()
706
+ };
707
+
708
+ this.errorSubject.next(conversationError);
709
+
710
+ // STT initialization hatası için özel handling
711
+ if (errorType === 'stt_init_failed') {
712
+ this.addSystemMessage('Speech recognition service failed to initialize. Please check your configuration.');
713
+ // Konuşmayı durdur
714
+ this.stopConversation();
715
+ } else if (errorType === 'race_condition') {
716
+ this.addSystemMessage('Session conflict detected. Please restart the conversation.');
717
+ } else if (errorType === 'stt_error') {
718
+ this.addSystemMessage('Speech recognition error. Please try speaking again.');
719
+ // STT hatası durumunda yeniden başlatmayı dene
720
+ if (errorMessage.includes('Streaming not started')) {
721
+ this.addSystemMessage('Restarting speech recognition...');
722
+ // WebSocket'e restart sinyali gönder
723
+ if (this.wsService.isConnected()) {
724
+ this.wsService.sendControl('restart_stt');
725
+ }
726
+ }
727
+ } else if (errorType === 'tts_error') {
728
+ this.addSystemMessage('Text-to-speech error. Response will be shown as text only.');
729
+ } else {
730
+ this.addSystemMessage(`Error: ${errorMessage}`);
731
+ }
732
+ }
733
+
734
+ private determineErrorType(error: any): ConversationError['type'] {
735
+ if (error.type) {
736
+ return error.type;
737
+ }
738
+
739
+ if (error.message?.includes('WebSocket') || error.message?.includes('connection')) {
740
+ return 'websocket';
741
+ }
742
+
743
+ if (error.message?.includes('microphone') || error.message?.includes('audio')) {
744
+ return 'audio';
745
+ }
746
+
747
+ if (error.message?.includes('permission')) {
748
+ return 'permission';
749
+ }
750
+
751
+ if (error.message?.includes('network') || error.status === 0) {
752
+ return 'network';
753
+ }
754
+
755
+ return 'unknown';
756
+ }
757
+
758
+ private cleanup(): void {
759
+ try {
760
+ this.subscriptions.unsubscribe();
761
+ this.subscriptions = new Subscription();
762
+
763
+ // Audio recording'i kesinlikle durdur
764
+ if (this.audioService.isRecording()) {
765
+ this.audioService.stopRecording();
766
+ }
767
+
768
+ this.wsService.disconnect();
769
+ this.stopAudioPlayback();
770
+
771
+ if (this.audioPlayer) {
772
+ this.audioPlayer = null;
773
+ }
774
+
775
+ this.audioQueue = [];
776
+ this.isInterrupting = false;
777
+ this.currentStateSubject.next('idle');
778
+ this.sttReadySubject.complete();
779
+
780
+ console.log('🧹 Conversation cleaned up');
781
+ } catch (error) {
782
+ console.error('Error during cleanup:', error);
783
+ }
784
+ }
785
+
786
+ // Public methods for UI
787
+ getCurrentState(): ConversationState {
788
+ return this.currentStateSubject.value;
789
+ }
790
+
791
+ getMessages(): ConversationMessage[] {
792
+ return this.messagesSubject.value;
793
+ }
794
+
795
+ clearMessages(): void {
796
+ this.messagesSubject.next([]);
797
+ this.transcriptionSubject.next('');
798
+ }
799
+
800
+ updateConfig(config: Partial<ConversationConfig>): void {
801
+ this.conversationConfig = { ...this.conversationConfig, ...config };
802
+
803
+ // Send config update if connected
804
+ if (this.wsService.isConnected()) {
805
+ try {
806
+ this.wsService.sendControl('update_config', config);
807
+ } catch (error) {
808
+ console.error('Failed to update config:', error);
809
+ }
810
+ }
811
+ }
812
+
813
+ getConfig(): ConversationConfig {
814
+ return { ...this.conversationConfig };
815
+ }
816
+
817
+ isConnected(): boolean {
818
+ return this.wsService.isConnected();
819
+ }
820
+
821
+ // Retry connection
822
+ async retryConnection(): Promise<void> {
823
+ if (!this.sessionId) {
824
+ throw new Error('No session ID available for retry');
825
+ }
826
+
827
+ this.currentStateSubject.next('idle');
828
+ await this.startConversation(this.sessionId, this.conversationConfig);
829
+ }
830
  }