Spaces:
Running
Running
Upload 2 files
Browse files
flare-ui/src/app/services/audio-stream-service.ts
ADDED
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// audio-stream.service.ts (YENİ DOSYA)
|
2 |
+
// Path: /flare-ui/src/app/services/audio-stream.service.ts
|
3 |
+
|
4 |
+
import { Injectable, OnDestroy } from '@angular/core';
|
5 |
+
import { Subject, Observable } from 'rxjs';
|
6 |
+
|
7 |
+
export interface AudioChunk {
|
8 |
+
data: string; // Base64 encoded audio
|
9 |
+
timestamp: number;
|
10 |
+
}
|
11 |
+
|
12 |
+
@Injectable({
|
13 |
+
providedIn: 'root'
|
14 |
+
})
|
15 |
+
export class AudioStreamService implements OnDestroy {
|
16 |
+
private mediaRecorder: MediaRecorder | null = null;
|
17 |
+
private audioStream: MediaStream | null = null;
|
18 |
+
private audioChunkSubject = new Subject<AudioChunk>();
|
19 |
+
private recordingStateSubject = new Subject<boolean>();
|
20 |
+
|
21 |
+
public audioChunk$ = this.audioChunkSubject.asObservable();
|
22 |
+
public recordingState$ = this.recordingStateSubject.asObservable();
|
23 |
+
|
24 |
+
// Audio constraints
|
25 |
+
private constraints = {
|
26 |
+
audio: {
|
27 |
+
channelCount: 1,
|
28 |
+
sampleRate: 16000,
|
29 |
+
echoCancellation: true,
|
30 |
+
noiseSuppression: true,
|
31 |
+
autoGainControl: true
|
32 |
+
}
|
33 |
+
};
|
34 |
+
|
35 |
+
ngOnDestroy(): void {
|
36 |
+
this.stopRecording();
|
37 |
+
}
|
38 |
+
|
39 |
+
static checkBrowserSupport(): boolean {
|
40 |
+
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
41 |
+
}
|
42 |
+
|
43 |
+
async startRecording(): Promise<void> {
|
44 |
+
try {
|
45 |
+
// Request microphone access
|
46 |
+
this.audioStream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
47 |
+
|
48 |
+
// Create MediaRecorder with appropriate format
|
49 |
+
const options: MediaRecorderOptions = {
|
50 |
+
mimeType: this.getPreferredMimeType()
|
51 |
+
};
|
52 |
+
|
53 |
+
this.mediaRecorder = new MediaRecorder(this.audioStream, options);
|
54 |
+
|
55 |
+
// Handle data available
|
56 |
+
this.mediaRecorder.ondataavailable = async (event) => {
|
57 |
+
if (event.data.size > 0) {
|
58 |
+
const base64Data = await this.blobToBase64(event.data);
|
59 |
+
this.audioChunkSubject.next({
|
60 |
+
data: base64Data,
|
61 |
+
timestamp: Date.now()
|
62 |
+
});
|
63 |
+
}
|
64 |
+
};
|
65 |
+
|
66 |
+
// Handle errors
|
67 |
+
this.mediaRecorder.onerror = (error) => {
|
68 |
+
console.error('MediaRecorder error:', error);
|
69 |
+
this.stopRecording();
|
70 |
+
};
|
71 |
+
|
72 |
+
// Start recording with timeslice for real-time streaming
|
73 |
+
this.mediaRecorder.start(100); // Send chunks every 100ms
|
74 |
+
this.recordingStateSubject.next(true);
|
75 |
+
|
76 |
+
console.log('✅ Audio recording started');
|
77 |
+
|
78 |
+
} catch (error) {
|
79 |
+
console.error('Failed to start recording:', error);
|
80 |
+
throw error;
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
stopRecording(): void {
|
85 |
+
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
86 |
+
this.mediaRecorder.stop();
|
87 |
+
this.mediaRecorder = null;
|
88 |
+
}
|
89 |
+
|
90 |
+
if (this.audioStream) {
|
91 |
+
this.audioStream.getTracks().forEach(track => track.stop());
|
92 |
+
this.audioStream = null;
|
93 |
+
}
|
94 |
+
|
95 |
+
this.recordingStateSubject.next(false);
|
96 |
+
console.log('🛑 Audio recording stopped');
|
97 |
+
}
|
98 |
+
|
99 |
+
private getPreferredMimeType(): string {
|
100 |
+
const types = [
|
101 |
+
'audio/webm;codecs=opus',
|
102 |
+
'audio/webm',
|
103 |
+
'audio/ogg;codecs=opus',
|
104 |
+
'audio/ogg'
|
105 |
+
];
|
106 |
+
|
107 |
+
for (const type of types) {
|
108 |
+
if (MediaRecorder.isTypeSupported(type)) {
|
109 |
+
console.log(`Using MIME type: ${type}`);
|
110 |
+
return type;
|
111 |
+
}
|
112 |
+
}
|
113 |
+
|
114 |
+
// Fallback to default
|
115 |
+
return '';
|
116 |
+
}
|
117 |
+
|
118 |
+
private async blobToBase64(blob: Blob): Promise<string> {
|
119 |
+
return new Promise((resolve, reject) => {
|
120 |
+
const reader = new FileReader();
|
121 |
+
reader.onloadend = () => {
|
122 |
+
if (reader.result) {
|
123 |
+
// Remove data URL prefix
|
124 |
+
const base64 = (reader.result as string).split(',')[1];
|
125 |
+
resolve(base64);
|
126 |
+
} else {
|
127 |
+
reject(new Error('Failed to convert blob to base64'));
|
128 |
+
}
|
129 |
+
};
|
130 |
+
reader.onerror = reject;
|
131 |
+
reader.readAsDataURL(blob);
|
132 |
+
});
|
133 |
+
}
|
134 |
+
|
135 |
+
// Volume level monitoring
|
136 |
+
async getVolumeLevel(): Promise<number> {
|
137 |
+
if (!this.audioStream) return 0;
|
138 |
+
|
139 |
+
const audioContext = new AudioContext();
|
140 |
+
const analyser = audioContext.createAnalyser();
|
141 |
+
const source = audioContext.createMediaStreamSource(this.audioStream);
|
142 |
+
|
143 |
+
source.connect(analyser);
|
144 |
+
analyser.fftSize = 256;
|
145 |
+
|
146 |
+
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
147 |
+
analyser.getByteFrequencyData(dataArray);
|
148 |
+
|
149 |
+
// Calculate average volume
|
150 |
+
const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
|
151 |
+
|
152 |
+
// Cleanup
|
153 |
+
source.disconnect();
|
154 |
+
audioContext.close();
|
155 |
+
|
156 |
+
return average / 255; // Normalize to 0-1
|
157 |
+
}
|
158 |
+
|
159 |
+
// Check microphone permissions
|
160 |
+
async checkMicrophonePermission(): Promise<PermissionState> {
|
161 |
+
try {
|
162 |
+
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
|
163 |
+
return result.state;
|
164 |
+
} catch (error) {
|
165 |
+
console.warn('Permissions API not supported:', error);
|
166 |
+
// Assume granted if API not supported
|
167 |
+
return 'granted';
|
168 |
+
}
|
169 |
+
}
|
170 |
+
}
|
flare-ui/src/app/services/error-handler-service.ts
ADDED
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// error-handler.service.ts (YENİ DOSYA)
|
2 |
+
// Path: /flare-ui/src/app/services/error-handler.service.ts
|
3 |
+
|
4 |
+
import { ErrorHandler, Injectable, Injector } from '@angular/core';
|
5 |
+
import { MatSnackBar } from '@angular/material/snack-bar';
|
6 |
+
import { Router } from '@angular/router';
|
7 |
+
import { HttpErrorResponse } from '@angular/common/http';
|
8 |
+
|
9 |
+
interface FlareError {
|
10 |
+
error: string;
|
11 |
+
message: string;
|
12 |
+
details?: any;
|
13 |
+
request_id?: string;
|
14 |
+
timestamp?: string;
|
15 |
+
user_action?: string;
|
16 |
+
}
|
17 |
+
|
18 |
+
@Injectable({
|
19 |
+
providedIn: 'root'
|
20 |
+
})
|
21 |
+
export class GlobalErrorHandler implements ErrorHandler {
|
22 |
+
constructor(private injector: Injector) {}
|
23 |
+
|
24 |
+
handleError(error: Error | HttpErrorResponse): void {
|
25 |
+
// Get services lazily to avoid circular dependency
|
26 |
+
const snackBar = this.injector.get(MatSnackBar);
|
27 |
+
const router = this.injector.get(Router);
|
28 |
+
|
29 |
+
console.error('Global error caught:', error);
|
30 |
+
|
31 |
+
// Handle HTTP errors
|
32 |
+
if (error instanceof HttpErrorResponse) {
|
33 |
+
this.handleHttpError(error, snackBar, router);
|
34 |
+
} else {
|
35 |
+
// Handle client-side errors
|
36 |
+
this.handleClientError(error, snackBar);
|
37 |
+
}
|
38 |
+
}
|
39 |
+
|
40 |
+
private handleHttpError(error: HttpErrorResponse, snackBar: MatSnackBar, router: Router): void {
|
41 |
+
const flareError = error.error as FlareError;
|
42 |
+
|
43 |
+
// Race condition error (409)
|
44 |
+
if (error.status === 409 && flareError.error === 'RaceConditionError') {
|
45 |
+
const snackBarRef = snackBar.open(
|
46 |
+
flareError.message || 'The data was modified by another user. Please refresh and try again.',
|
47 |
+
'Refresh',
|
48 |
+
{
|
49 |
+
duration: 0,
|
50 |
+
panelClass: ['error-snackbar', 'race-condition-snackbar']
|
51 |
+
}
|
52 |
+
);
|
53 |
+
|
54 |
+
snackBarRef.onAction().subscribe(() => {
|
55 |
+
window.location.reload();
|
56 |
+
});
|
57 |
+
|
58 |
+
// Show additional info if available
|
59 |
+
if (flareError.details?.last_update_user) {
|
60 |
+
console.info(`Last updated by: ${flareError.details.last_update_user} at ${flareError.details.last_update_date}`);
|
61 |
+
}
|
62 |
+
return;
|
63 |
+
}
|
64 |
+
|
65 |
+
// Authentication error (401)
|
66 |
+
if (error.status === 401) {
|
67 |
+
snackBar.open(
|
68 |
+
'Your session has expired. Please login again.',
|
69 |
+
'Login',
|
70 |
+
{
|
71 |
+
duration: 5000,
|
72 |
+
panelClass: ['error-snackbar']
|
73 |
+
}
|
74 |
+
).onAction().subscribe(() => {
|
75 |
+
router.navigate(['/login']);
|
76 |
+
});
|
77 |
+
return;
|
78 |
+
}
|
79 |
+
|
80 |
+
// Validation error (422)
|
81 |
+
if (error.status === 422 && flareError.details) {
|
82 |
+
const fieldErrors = flareError.details
|
83 |
+
.map((d: any) => `${d.field}: ${d.message}`)
|
84 |
+
.join('\n');
|
85 |
+
|
86 |
+
snackBar.open(
|
87 |
+
flareError.message || 'Validation failed. Please check your input.',
|
88 |
+
'Close',
|
89 |
+
{
|
90 |
+
duration: 8000,
|
91 |
+
panelClass: ['error-snackbar', 'validation-snackbar']
|
92 |
+
}
|
93 |
+
);
|
94 |
+
|
95 |
+
console.error('Validation errors:', flareError.details);
|
96 |
+
return;
|
97 |
+
}
|
98 |
+
|
99 |
+
// Not found error (404)
|
100 |
+
if (error.status === 404) {
|
101 |
+
snackBar.open(
|
102 |
+
flareError.message || 'The requested resource was not found.',
|
103 |
+
'Close',
|
104 |
+
{
|
105 |
+
duration: 5000,
|
106 |
+
panelClass: ['error-snackbar']
|
107 |
+
}
|
108 |
+
);
|
109 |
+
return;
|
110 |
+
}
|
111 |
+
|
112 |
+
// Server errors (5xx)
|
113 |
+
if (error.status >= 500) {
|
114 |
+
const message = flareError.message || 'A server error occurred. Please try again later.';
|
115 |
+
const requestId = flareError.request_id;
|
116 |
+
|
117 |
+
snackBar.open(
|
118 |
+
requestId ? `${message} (Request ID: ${requestId})` : message,
|
119 |
+
'Close',
|
120 |
+
{
|
121 |
+
duration: 8000,
|
122 |
+
panelClass: ['error-snackbar', 'server-error-snackbar']
|
123 |
+
}
|
124 |
+
);
|
125 |
+
return;
|
126 |
+
}
|
127 |
+
|
128 |
+
// Generic HTTP error
|
129 |
+
snackBar.open(
|
130 |
+
flareError.message || `HTTP Error ${error.status}: ${error.statusText}`,
|
131 |
+
'Close',
|
132 |
+
{
|
133 |
+
duration: 6000,
|
134 |
+
panelClass: ['error-snackbar']
|
135 |
+
}
|
136 |
+
);
|
137 |
+
}
|
138 |
+
|
139 |
+
private handleClientError(error: Error, snackBar: MatSnackBar): void {
|
140 |
+
// Check if it's a network error
|
141 |
+
if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
|
142 |
+
snackBar.open(
|
143 |
+
'Network connection error. Please check your internet connection.',
|
144 |
+
'Retry',
|
145 |
+
{
|
146 |
+
duration: 0,
|
147 |
+
panelClass: ['error-snackbar', 'network-error-snackbar']
|
148 |
+
}
|
149 |
+
).onAction().subscribe(() => {
|
150 |
+
window.location.reload();
|
151 |
+
});
|
152 |
+
return;
|
153 |
+
}
|
154 |
+
|
155 |
+
// Generic client error
|
156 |
+
snackBar.open(
|
157 |
+
'An unexpected error occurred. Please refresh the page.',
|
158 |
+
'Refresh',
|
159 |
+
{
|
160 |
+
duration: 6000,
|
161 |
+
panelClass: ['error-snackbar']
|
162 |
+
}
|
163 |
+
).onAction().subscribe(() => {
|
164 |
+
window.location.reload();
|
165 |
+
});
|
166 |
+
}
|
167 |
+
}
|
168 |
+
|
169 |
+
// Error interceptor for consistent error format
|
170 |
+
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
|
171 |
+
import { Observable, throwError } from 'rxjs';
|
172 |
+
import { catchError } from 'rxjs/operators';
|
173 |
+
|
174 |
+
@Injectable()
|
175 |
+
export class ErrorInterceptor implements HttpInterceptor {
|
176 |
+
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
177 |
+
return next.handle(req).pipe(
|
178 |
+
catchError((error: HttpErrorResponse) => {
|
179 |
+
// Log request details for debugging
|
180 |
+
console.error('HTTP Error:', {
|
181 |
+
url: req.url,
|
182 |
+
method: req.method,
|
183 |
+
status: error.status,
|
184 |
+
error: error.error,
|
185 |
+
requestId: error.headers.get('X-Request-ID')
|
186 |
+
});
|
187 |
+
|
188 |
+
// Re-throw to be handled by global error handler
|
189 |
+
return throwError(() => error);
|
190 |
+
})
|
191 |
+
);
|
192 |
+
}
|
193 |
+
}
|