@chat21/chat21-web-widget 5.1.30 → 5.1.32-rc13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.github/workflows/docker-community-push-latest.yml +23 -13
  2. package/.github/workflows/docker-image-tag-community-tag-push.yml +22 -12
  3. package/CHANGELOG.md +89 -2
  4. package/Dockerfile +4 -5
  5. package/angular.json +5 -2
  6. package/deploy_amazon_beta.sh +17 -7
  7. package/docs/changelog/this-branch.md +36 -0
  8. package/nginx.conf +22 -2
  9. package/package.json +4 -1
  10. package/src/app/app.component.ts +10 -9
  11. package/src/app/app.module.ts +11 -0
  12. package/src/app/component/conversation-detail/conversation/conversation.component.html +9 -2
  13. package/src/app/component/conversation-detail/conversation/conversation.component.scss +12 -2
  14. package/src/app/component/conversation-detail/conversation/conversation.component.ts +46 -5
  15. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +9 -5
  16. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +19 -1
  17. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +2 -0
  18. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +128 -80
  19. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +117 -13
  20. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +120 -8
  21. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -0
  22. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +79 -0
  23. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  24. package/src/app/component/last-message/last-message.component.ts +4 -1
  25. package/src/app/component/message/audio/audio.component.ts +0 -5
  26. package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
  27. package/src/app/component/message/audio-sync/audio-sync.component.scss +64 -0
  28. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +23 -0
  29. package/src/app/component/message/audio-sync/audio-sync.component.ts +558 -0
  30. package/src/app/component/message/bubble-message/bubble-message.component.html +6 -1
  31. package/src/app/component/message/bubble-message/bubble-message.component.ts +2 -1
  32. package/src/app/providers/global-settings.service.ts +21 -0
  33. package/src/app/providers/translator.service.ts +2 -0
  34. package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
  35. package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
  36. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +171 -0
  37. package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
  38. package/src/app/providers/voice/audio.types.ts +34 -0
  39. package/src/app/providers/voice/vad.service.spec.ts +28 -0
  40. package/src/app/providers/voice/vad.service.ts +70 -0
  41. package/src/app/providers/voice/voice.service.spec.ts +60 -0
  42. package/src/app/providers/voice/voice.service.ts +376 -0
  43. package/src/app/sass/_variables.scss +3 -0
  44. package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
  45. package/src/app/utils/conversation-sender-classifier.ts +21 -0
  46. package/src/app/utils/globals.ts +7 -1
  47. package/src/assets/i18n/en.json +1 -0
  48. package/src/assets/i18n/es.json +1 -0
  49. package/src/assets/i18n/fr.json +1 -0
  50. package/src/assets/i18n/it.json +1 -0
  51. package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
  52. package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
  53. package/src/assets/vad/silero_vad_legacy.onnx +0 -0
  54. package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
  55. package/src/chat21-core/models/message.ts +2 -1
  56. package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
  57. package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
  58. package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
  59. package/src/chat21-core/utils/utils-message.ts +7 -0
  60. package/src/chat21-core/utils/utils.ts +5 -2
  61. package/src/launch.js +41 -32
  62. package/src/launch_template.js +41 -32
  63. package/tsconfig.json +5 -0
  64. package/deploy_amazon_prod.sh +0 -41
@@ -0,0 +1,93 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { BehaviorSubject, Observable, Subject } from 'rxjs';
3
+
4
+ /**
5
+ * Garantisce un solo messaggio TTS in riproduzione alla volta.
6
+ * Se arrivano più messaggi TTS, vengono riprodotti in coda (FIFO) senza interrompere quello corrente.
7
+ */
8
+ @Injectable({ providedIn: 'root' })
9
+ export class TtsAudioPlaybackCoordinator {
10
+ private currentOwnerId: string | null = null;
11
+ private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
12
+
13
+ /** Emits true while any TTS is playing or queued; false when the queue is fully drained. */
14
+ private readonly _isTTSPlaying$ = new BehaviorSubject<boolean>(false);
15
+ readonly isTTSPlaying$ = this._isTTSPlaying$.asObservable();
16
+
17
+ /** Emits once when stopAll() is called — signals every AudioSyncComponent to abort immediately. */
18
+ private readonly _stopAll$ = new Subject<void>();
19
+ readonly stopAllPlayback$: Observable<void> = this._stopAll$.asObservable();
20
+
21
+ /**
22
+ * Richiede l'avvio della riproduzione TTS per `ownerId`.
23
+ * Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
24
+ */
25
+ requestStart(ownerId: string, start: () => void): void {
26
+ const id = (ownerId || '').trim();
27
+ if (!id) {
28
+ return;
29
+ }
30
+ if (this.currentOwnerId === id) {
31
+ return;
32
+ }
33
+ if (this.queue.some((j) => j.ownerId === id)) {
34
+ return;
35
+ }
36
+ if (this.currentOwnerId) {
37
+ this.queue.push({ ownerId: id, start });
38
+ return;
39
+ }
40
+ this.currentOwnerId = id;
41
+ this._isTTSPlaying$.next(true);
42
+ try {
43
+ start();
44
+ } catch {
45
+ this.releaseIfCurrent(id);
46
+ }
47
+ }
48
+
49
+ /** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
50
+ releaseIfCurrent(ownerId: string): void {
51
+ const id = (ownerId || '').trim();
52
+ if (!id) {
53
+ return;
54
+ }
55
+ if (this.currentOwnerId !== id) {
56
+ // Se era in coda, rimuovilo.
57
+ const idx = this.queue.findIndex((j) => j.ownerId === id);
58
+ if (idx !== -1) {
59
+ this.queue.splice(idx, 1);
60
+ }
61
+ return;
62
+ }
63
+
64
+ this.currentOwnerId = null;
65
+ const next = this.queue.shift();
66
+ if (!next) {
67
+ this._isTTSPlaying$.next(false);
68
+ return;
69
+ }
70
+ this.currentOwnerId = next.ownerId;
71
+ try {
72
+ next.start();
73
+ } catch {
74
+ this.releaseIfCurrent(next.ownerId);
75
+ }
76
+ }
77
+
78
+ /** Distruzione componente o stop esplicito. */
79
+ release(ownerId: string): void {
80
+ this.releaseIfCurrent(ownerId);
81
+ }
82
+
83
+ /**
84
+ * Stops all TTS playback immediately and clears the queue.
85
+ * Broadcasts on stopAllPlayback$ so every AudioSyncComponent can abort its stream and reveal all text.
86
+ */
87
+ stopAll(): void {
88
+ this.queue.length = 0;
89
+ this.currentOwnerId = null;
90
+ this._isTTSPlaying$.next(false);
91
+ this._stopAll$.next();
92
+ }
93
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Configurazione opzionale per i servizi voce OpenAI (da `environment` o runtime).
3
+ */
4
+ export interface OpenAiVoiceEnvironmentConfig {
5
+ /** Obbligatoria per chiamate API reali; se assente, STT/TTS non inviano richieste. */
6
+ apiKey?: string;
7
+ baseUrl?: string;
8
+ transcriptionModel?: string;
9
+ ttsModel?: string;
10
+ /** Voce predefinita TTS (es. `alloy`). */
11
+ ttsVoice?: string;
12
+ }
@@ -0,0 +1,171 @@
1
+ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
2
+ import { Injectable } from '@angular/core';
3
+ import { firstValueFrom } from 'rxjs';
4
+ import { environment } from 'src/environments/environment';
5
+
6
+ import type { OpenAiVoiceEnvironmentConfig } from './openai-voice.config';
7
+ import {
8
+ SpeechToTextProvider,
9
+ TextToSpeechProvider,
10
+ type SpeechToTextRequest,
11
+ type SpeechToTextResult,
12
+ type TextToSpeechRequest,
13
+ type TextToSpeechResult,
14
+ } from './speech-provider.abstract';
15
+ import { AppConfigService } from '../../app-config.service';
16
+
17
+ const DEFAULT_BASE = 'https://api.openai.com/v1';
18
+ const DEFAULT_TRANSCRIPTION_MODEL = 'whisper-1';
19
+ const DEFAULT_TTS_MODEL = 'tts-1';
20
+ const DEFAULT_VOICE = 'alloy';
21
+ const DEFAULT_FORMAT = 'mp3';
22
+
23
+ /**
24
+ * Provider OpenAI unico: STT (Whisper) + TTS, entrambi via {@link HttpClient}.
25
+ */
26
+ @Injectable({ providedIn: 'root' })
27
+ export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
28
+ constructor(
29
+ private readonly httpClient: HttpClient,
30
+ private readonly appConfig: AppConfigService
31
+ ) {
32
+ super();
33
+ }
34
+
35
+ async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
36
+ const cfg = this.getConfig();
37
+ const apiKey = cfg.apiKey?.trim();
38
+ if (!apiKey) {
39
+ return { text: '' };
40
+ }
41
+
42
+ const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
43
+ const model = cfg.transcriptionModel ?? DEFAULT_TRANSCRIPTION_MODEL;
44
+ const url = `${base}/audio/transcriptions`;
45
+
46
+ const ext = this.extensionForMime(request.mimeType);
47
+ const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
48
+
49
+ const form = new FormData();
50
+ form.append('file', file);
51
+ form.append('model', model);
52
+ if (request.language) {
53
+ form.append('language', request.language);
54
+ }
55
+
56
+ const headers = new HttpHeaders({
57
+ Authorization: `Bearer ${apiKey}`,
58
+ });
59
+
60
+ try {
61
+ const data = await firstValueFrom(
62
+ this.httpClient.post<{ text?: string }>(url, form, { headers }),
63
+ );
64
+ return { text: (data.text ?? '').trim() };
65
+ } catch (e) {
66
+ if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
67
+ const errText = await e.error.text();
68
+ throw new Error(`OpenAI transcription ${e.status}: ${errText || e.statusText}`);
69
+ }
70
+ throw this.mapOpenAiHttpError(e);
71
+ }
72
+ }
73
+
74
+ async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
75
+ const cfg = this.getConfig();
76
+ const apiKey = cfg.apiKey?.trim();
77
+ if (!apiKey) {
78
+ throw new Error('OpenAI API key not configured (environment.openAiVoice.apiKey)');
79
+ }
80
+
81
+ const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
82
+ const model = cfg.ttsModel ?? DEFAULT_TTS_MODEL;
83
+ const voice = request.voice ?? cfg.ttsVoice ?? DEFAULT_VOICE;
84
+ const responseFormat =
85
+ (request.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac' | undefined) ?? DEFAULT_FORMAT;
86
+ const url = `${base}/audio/speech`;
87
+
88
+ const body = {
89
+ model,
90
+ voice,
91
+ input: request.text,
92
+ response_format: responseFormat,
93
+ };
94
+
95
+ const headers = new HttpHeaders({
96
+ Authorization: `Bearer ${apiKey}`,
97
+ 'Content-Type': 'application/json',
98
+ });
99
+
100
+ try {
101
+ const blob = await firstValueFrom(
102
+ this.httpClient.post(url, body, {
103
+ headers,
104
+ responseType: 'blob',
105
+ }),
106
+ );
107
+ return { audio: blob, mimeType: this.mimeForFormat(responseFormat) };
108
+ } catch (e) {
109
+ if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
110
+ const errText = await e.error.text();
111
+ throw new Error(`OpenAI TTS ${e.status}: ${errText || e.statusText}`);
112
+ }
113
+ if (e instanceof HttpErrorResponse) {
114
+ throw new Error(`OpenAI TTS ${e.status}: ${e.message || e.statusText}`);
115
+ }
116
+ throw e;
117
+ }
118
+ }
119
+
120
+ private getConfig(): OpenAiVoiceEnvironmentConfig {
121
+ return this.appConfig.getConfig().openAiKey ?? {};
122
+ }
123
+
124
+ private mapOpenAiHttpError(e: unknown): Error {
125
+ if (!(e instanceof HttpErrorResponse)) {
126
+ return e instanceof Error ? e : new Error(String(e));
127
+ }
128
+ const label = 'OpenAI transcription';
129
+ if (e.error instanceof Blob) {
130
+ return new Error(`${label} ${e.status}: ${e.statusText}`);
131
+ }
132
+ if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
133
+ const err = (e.error as { error?: { message?: string } }).error;
134
+ return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
135
+ }
136
+ if (typeof e.error === 'string') {
137
+ return new Error(`${label} ${e.status}: ${e.error}`);
138
+ }
139
+ return new Error(`${label} ${e.status}: ${e.message || e.statusText}`);
140
+ }
141
+
142
+ private extensionForMime(mime: string): string {
143
+ if (mime.includes('webm')) {
144
+ return 'webm';
145
+ }
146
+ if (mime.includes('mp4') || mime.includes('m4a')) {
147
+ return 'm4a';
148
+ }
149
+ if (mime.includes('wav')) {
150
+ return 'wav';
151
+ }
152
+ if (mime.includes('mpeg') || mime.includes('mp3')) {
153
+ return 'mp3';
154
+ }
155
+ return 'webm';
156
+ }
157
+
158
+ private mimeForFormat(fmt: string): string {
159
+ switch (fmt) {
160
+ case 'opus':
161
+ return 'audio/opus';
162
+ case 'aac':
163
+ return 'audio/aac';
164
+ case 'flac':
165
+ return 'audio/flac';
166
+ case 'mp3':
167
+ default:
168
+ return 'audio/mpeg';
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Contratti astratti per Speech-to-Text e Text-to-Speech.
3
+ * Implementazione OpenAI unificata: `OpenAiVoiceProviderService` (`openai-voice.provider.ts`).
4
+ */
5
+
6
+ /** Input per la trascrizione di un segmento audio. */
7
+ export interface SpeechToTextRequest {
8
+ audio: Blob;
9
+ mimeType: string;
10
+ /** ISO 639-1 opzionale (es. `it`, `en`). */
11
+ language?: string;
12
+ }
13
+
14
+ export interface SpeechToTextResult {
15
+ text: string;
16
+ }
17
+
18
+ /** Input per la sintesi vocale. */
19
+ export interface TextToSpeechRequest {
20
+ text: string;
21
+ /** Voce provider-specific (es. OpenAI: `alloy`, `echo`, …). */
22
+ voice?: string;
23
+ language?: string;
24
+ /** Formato audio desiderato (dipende dal provider). */
25
+ responseFormat?: string;
26
+ }
27
+
28
+ export interface TextToSpeechResult {
29
+ audio: Blob;
30
+ mimeType: string;
31
+ }
32
+
33
+ export abstract class SpeechToTextProvider {
34
+ abstract transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult>;
35
+ }
36
+
37
+ export abstract class TextToSpeechProvider {
38
+ abstract synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult>;
39
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Tipi condivisi per cattura microfono, VAD e registrazione (WebM).
3
+ */
4
+
5
+ export const DEFAULT_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
6
+ echoCancellation: true,
7
+ noiseSuppression: true,
8
+ autoGainControl: true,
9
+ };
10
+
11
+ export const DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS: MediaStreamConstraints = {
12
+ audio: DEFAULT_VOICE_AUDIO_CONSTRAINTS,
13
+ };
14
+
15
+ export interface VoiceRecordedBlob {
16
+ blob: Blob;
17
+ mimeType: string;
18
+ }
19
+
20
+ /**
21
+ * Segmento audio dopo VAD; può includere `transcript` se STT è configurato e abilitato.
22
+ */
23
+ export interface VoiceSegmentPayload extends VoiceRecordedBlob {
24
+ transcript?: string;
25
+ transcriptionError?: string;
26
+ }
27
+
28
+ export interface VoiceSessionStartOptions {
29
+ /** Opzionale se usi solo {@link VoiceService.audioSegment$}. */
30
+ onRecordingComplete?: (result: VoiceSegmentPayload) => void;
31
+ constraints?: MediaStreamConstraints;
32
+ /** Default `true`. Se `false`, non viene chiamato lo STT sul segmento. */
33
+ enableTranscription?: boolean;
34
+ }
@@ -0,0 +1,28 @@
1
+ import { Location } from '@angular/common';
2
+ import { TestBed } from '@angular/core/testing';
3
+
4
+ import { VadService } from './vad.service';
5
+
6
+ describe('VadService', () => {
7
+ let service: VadService;
8
+
9
+ beforeEach(() => {
10
+ TestBed.configureTestingModule({
11
+ providers: [
12
+ VadService,
13
+ {
14
+ provide: Location,
15
+ useValue: {
16
+ prepareExternalUrl: (url: string) => `/${url}`,
17
+ },
18
+ },
19
+ ],
20
+ });
21
+ service = TestBed.inject(VadService);
22
+ });
23
+
24
+ it('should expose VAD and ONNX WASM base URLs with trailing slash', () => {
25
+ expect(service.getVadAssetBaseUrl()).toBe('/assets/vad/');
26
+ expect(service.getOnnxWasmBaseUrl()).toBe('/assets/onnx/');
27
+ });
28
+ });
@@ -0,0 +1,70 @@
1
+ import { Location } from '@angular/common';
2
+ import { Injectable } from '@angular/core';
3
+ import { MicVAD, getDefaultRealTimeVADOptions } from '@ricky0123/vad-web';
4
+ import type { RealTimeVADOptions } from '@ricky0123/vad-web';
5
+
6
+ /**
7
+ * MicVAD (@ricky0123/vad-web): modelli in assets/vad/, WASM ONNX in assets/onnx/
8
+ * (allineato a ort.env.wasm.wasmPaths = "/assets/onnx/").
9
+ */
10
+ @Injectable({ providedIn: 'root' })
11
+ export class VadService {
12
+ private onnxRuntimeEnvPromise: Promise<void> | null = null;
13
+
14
+ constructor(private readonly location: Location) {}
15
+
16
+ /**
17
+ * Base URL per silero_vad_legacy.onnx / vad.worklet.bundle.min.js
18
+ * (MicVAD usa baseAssetPath + nome file interno, non modelURL singolo).
19
+ */
20
+ getVadAssetBaseUrl(): string {
21
+ return this.ensureTrailingSlash(this.location.prepareExternalUrl('assets/vad/'));
22
+ }
23
+
24
+ /** Base URL per ort-wasm-*.mjs / .wasm (es. /assets/onnx/). */
25
+ getOnnxWasmBaseUrl(): string {
26
+ return this.ensureTrailingSlash(this.location.prepareExternalUrl('assets/onnx/'));
27
+ }
28
+
29
+ /**
30
+ * Pre-configura il modulo onnxruntime-web/wasm (stesso usato da MicVAD):
31
+ * wasmPaths + numThreads prima del primo MicVAD.new.
32
+ */
33
+ ensureOnnxRuntimeEnv(): Promise<void> {
34
+ if (!this.onnxRuntimeEnvPromise) {
35
+ this.onnxRuntimeEnvPromise = (async () => {
36
+ const ort = await import('onnxruntime-web/wasm');
37
+ const wasmBase = this.getOnnxWasmBaseUrl();
38
+ ort.env.wasm.wasmPaths = wasmBase;
39
+ ort.env.wasm.numThreads = 1;
40
+ ort.env.logLevel = 'error';
41
+ })();
42
+ }
43
+ return this.onnxRuntimeEnvPromise;
44
+ }
45
+
46
+ async createMicVad(overrides: Partial<RealTimeVADOptions>): Promise<MicVAD> {
47
+ await this.ensureOnnxRuntimeEnv();
48
+ const base = getDefaultRealTimeVADOptions('legacy');
49
+ const vadBase = this.getVadAssetBaseUrl();
50
+ const ortWasmBase = this.getOnnxWasmBaseUrl();
51
+
52
+ return MicVAD.new({
53
+ ...base,
54
+ startOnLoad: false,
55
+ baseAssetPath: vadBase,
56
+ onnxWASMBasePath: ortWasmBase,
57
+ ortConfig: (ort) => {
58
+ base.ortConfig?.(ort);
59
+ ort.env.wasm.wasmPaths = ortWasmBase;
60
+ ort.env.wasm.numThreads = 1;
61
+ ort.env.logLevel = 'error';
62
+ },
63
+ ...overrides,
64
+ });
65
+ }
66
+
67
+ private ensureTrailingSlash(path: string): string {
68
+ return path.endsWith('/') ? path : `${path}/`;
69
+ }
70
+ }
@@ -0,0 +1,60 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { VoiceService } from './voice.service';
4
+ import { VadService } from './vad.service';
5
+
6
+ describe('VoiceService', () => {
7
+ let service: VoiceService;
8
+ let vadService: jasmine.SpyObj<VadService>;
9
+
10
+ let mockVad: { start: jasmine.Spy; pause: jasmine.Spy; destroy: jasmine.Spy };
11
+
12
+ beforeEach(() => {
13
+ mockVad = {
14
+ start: jasmine.createSpy('start').and.returnValue(Promise.resolve()),
15
+ pause: jasmine.createSpy('pause').and.returnValue(Promise.resolve()),
16
+ destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()),
17
+ };
18
+ vadService = jasmine.createSpyObj('VadService', ['ensureOnnxRuntimeEnv', 'createMicVad']);
19
+ vadService.ensureOnnxRuntimeEnv.and.returnValue(Promise.resolve());
20
+ vadService.createMicVad.and.returnValue(Promise.resolve(mockVad as any));
21
+
22
+ TestBed.configureTestingModule({
23
+ providers: [VoiceService, { provide: VadService, useValue: vadService }],
24
+ });
25
+ service = TestBed.inject(VoiceService);
26
+ });
27
+
28
+ it('startSession should call ensureOnnxRuntimeEnv', async () => {
29
+ const stream = new MediaStream();
30
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
31
+
32
+ await service.startSession({});
33
+
34
+ expect(vadService.ensureOnnxRuntimeEnv).toHaveBeenCalled();
35
+ });
36
+
37
+ it('startSession should request mic, create MicVAD, and start', async () => {
38
+ const stream = new MediaStream();
39
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
40
+
41
+ await service.startSession({
42
+ onRecordingComplete: () => {},
43
+ });
44
+
45
+ expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
46
+ expect(vadService.createMicVad).toHaveBeenCalled();
47
+ expect(mockVad.start).toHaveBeenCalled();
48
+ });
49
+
50
+ it('stopSession should destroy VAD and stop tracks', async () => {
51
+ const track = jasmine.createSpyObj<MediaStreamTrack>('MediaStreamTrack', ['stop']);
52
+ const stream = new MediaStream([track]);
53
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
54
+
55
+ await service.startSession({ onRecordingComplete: () => {} });
56
+ await service.stopSession();
57
+
58
+ expect(track.stop).toHaveBeenCalled();
59
+ });
60
+ });