@medc-com-br/ngx-jaimes-scribe 0.1.2 → 0.1.4
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.
- package/fesm2022/medc-com-br-ngx-jaimes-scribe.mjs +1397 -0
- package/fesm2022/medc-com-br-ngx-jaimes-scribe.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/components/recorder/recorder.component.d.ts +97 -0
- package/lib/services/audio-capture.service.d.ts +44 -0
- package/lib/services/scribe-socket.service.d.ts +51 -0
- package/lib/services/vad.service.d.ts +21 -0
- package/lib/utils/pcm-to-wav.d.ts +3 -0
- package/package.json +21 -18
- package/.turbo/turbo-build.log +0 -28
- package/ng-package.json +0 -10
- package/src/lib/components/recorder/recorder.component.ts +0 -743
- package/src/lib/services/audio-capture.service.ts +0 -190
- package/src/lib/services/scribe-socket.service.ts +0 -264
- package/src/lib/services/vad.service.ts +0 -65
- package/src/lib/worklets/audio-worklet.d.ts +0 -14
- package/src/lib/worklets/pcm-processor.worklet.ts +0 -81
- package/tsconfig.lib.json +0 -24
- /package/{src/public-api.ts → public-api.d.ts} +0 -0
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { Injectable, InjectionToken, inject, NgZone } from '@angular/core';
|
|
2
|
-
import { Subject, Observable, BehaviorSubject } from 'rxjs';
|
|
3
|
-
import { VadService } from './vad.service';
|
|
4
|
-
|
|
5
|
-
export interface AudioCaptureConfig {
|
|
6
|
-
workletUrl: string;
|
|
7
|
-
levelUpdateInterval: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const AUDIO_CAPTURE_CONFIG = new InjectionToken<AudioCaptureConfig>('AUDIO_CAPTURE_CONFIG');
|
|
11
|
-
|
|
12
|
-
const DEFAULT_CONFIG: AudioCaptureConfig = {
|
|
13
|
-
workletUrl: '/assets/pcm-processor.js',
|
|
14
|
-
levelUpdateInterval: 50,
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export interface AudioChunk {
|
|
18
|
-
data: ArrayBuffer;
|
|
19
|
-
isSilence: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
@Injectable({ providedIn: 'root' })
|
|
23
|
-
export class AudioCaptureService {
|
|
24
|
-
private config: AudioCaptureConfig;
|
|
25
|
-
private ngZone: NgZone;
|
|
26
|
-
private vadService: VadService;
|
|
27
|
-
private audioContext: AudioContext | null = null;
|
|
28
|
-
private workletNode: AudioWorkletNode | null = null;
|
|
29
|
-
private analyserNode: AnalyserNode | null = null;
|
|
30
|
-
private source: MediaStreamAudioSourceNode | null = null;
|
|
31
|
-
private stream: MediaStream | null = null;
|
|
32
|
-
private workletRegistered = false;
|
|
33
|
-
private levelInterval: ReturnType<typeof setInterval> | null = null;
|
|
34
|
-
private paused = false;
|
|
35
|
-
|
|
36
|
-
private audioChunkSubject = new Subject<AudioChunk>();
|
|
37
|
-
private audioLevelSubject = new BehaviorSubject<number>(0);
|
|
38
|
-
private errorSubject = new Subject<Error>();
|
|
39
|
-
|
|
40
|
-
audioChunk$: Observable<AudioChunk> = this.audioChunkSubject.asObservable();
|
|
41
|
-
audioLevel$: Observable<number> = this.audioLevelSubject.asObservable();
|
|
42
|
-
error$: Observable<Error> = this.errorSubject.asObservable();
|
|
43
|
-
|
|
44
|
-
constructor() {
|
|
45
|
-
const injectedConfig = inject(AUDIO_CAPTURE_CONFIG, { optional: true });
|
|
46
|
-
this.config = injectedConfig ?? DEFAULT_CONFIG;
|
|
47
|
-
this.ngZone = inject(NgZone);
|
|
48
|
-
this.vadService = inject(VadService);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async startCapture(): Promise<void> {
|
|
52
|
-
try {
|
|
53
|
-
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
54
|
-
audio: {
|
|
55
|
-
channelCount: 1,
|
|
56
|
-
echoCancellation: true,
|
|
57
|
-
noiseSuppression: true,
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
this.audioContext = new AudioContext();
|
|
62
|
-
|
|
63
|
-
if (!this.workletRegistered) {
|
|
64
|
-
await this.audioContext.audioWorklet.addModule(this.config.workletUrl);
|
|
65
|
-
this.workletRegistered = true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor', {
|
|
69
|
-
processorOptions: {
|
|
70
|
-
inputSampleRate: this.audioContext.sampleRate,
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
this.workletNode.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
|
75
|
-
if (!this.paused) {
|
|
76
|
-
const level = this.getAudioLevel();
|
|
77
|
-
const { isSilence } = this.vadService.analyzeLevel(level);
|
|
78
|
-
this.audioChunkSubject.next({ data: event.data, isSilence });
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
this.analyserNode = this.audioContext.createAnalyser();
|
|
83
|
-
this.analyserNode.fftSize = 256;
|
|
84
|
-
this.analyserNode.smoothingTimeConstant = 0.8;
|
|
85
|
-
|
|
86
|
-
this.source = this.audioContext.createMediaStreamSource(this.stream);
|
|
87
|
-
this.source.connect(this.analyserNode);
|
|
88
|
-
this.source.connect(this.workletNode);
|
|
89
|
-
|
|
90
|
-
this.startLevelMonitoring();
|
|
91
|
-
} catch (error) {
|
|
92
|
-
const err = error instanceof Error ? error : new Error('Failed to start audio capture');
|
|
93
|
-
this.errorSubject.next(err);
|
|
94
|
-
throw err;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async stopCapture(): Promise<void> {
|
|
99
|
-
this.stopLevelMonitoring();
|
|
100
|
-
this.paused = false;
|
|
101
|
-
this.vadService.reset();
|
|
102
|
-
|
|
103
|
-
if (this.workletNode) {
|
|
104
|
-
this.workletNode.port.close();
|
|
105
|
-
this.workletNode.disconnect();
|
|
106
|
-
this.workletNode = null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (this.analyserNode) {
|
|
110
|
-
this.analyserNode.disconnect();
|
|
111
|
-
this.analyserNode = null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (this.source) {
|
|
115
|
-
this.source.disconnect();
|
|
116
|
-
this.source = null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (this.audioContext) {
|
|
120
|
-
await this.audioContext.close();
|
|
121
|
-
this.audioContext = null;
|
|
122
|
-
this.workletRegistered = false;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (this.stream) {
|
|
126
|
-
this.stream.getTracks().forEach((track) => track.stop());
|
|
127
|
-
this.stream = null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
this.audioLevelSubject.next(0);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
pauseCapture(): void {
|
|
134
|
-
if (this.isCapturing() && !this.paused) {
|
|
135
|
-
this.paused = true;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
resumeCapture(): void {
|
|
140
|
-
if (this.isCapturing() && this.paused) {
|
|
141
|
-
this.paused = false;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
isCapturing(): boolean {
|
|
146
|
-
return this.workletNode !== null && this.stream !== null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
isPaused(): boolean {
|
|
150
|
-
return this.paused;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
getSampleRate(): number | null {
|
|
154
|
-
return this.audioContext?.sampleRate ?? null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
private startLevelMonitoring(): void {
|
|
158
|
-
this.stopLevelMonitoring();
|
|
159
|
-
|
|
160
|
-
this.ngZone.runOutsideAngular(() => {
|
|
161
|
-
this.levelInterval = setInterval(() => {
|
|
162
|
-
const level = this.getAudioLevel();
|
|
163
|
-
this.ngZone.run(() => {
|
|
164
|
-
this.audioLevelSubject.next(level);
|
|
165
|
-
});
|
|
166
|
-
}, this.config.levelUpdateInterval);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private stopLevelMonitoring(): void {
|
|
171
|
-
if (this.levelInterval) {
|
|
172
|
-
clearInterval(this.levelInterval);
|
|
173
|
-
this.levelInterval = null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
private getAudioLevel(): number {
|
|
178
|
-
if (!this.analyserNode) {
|
|
179
|
-
return 0;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
183
|
-
this.analyserNode.getByteFrequencyData(dataArray);
|
|
184
|
-
|
|
185
|
-
const sum = dataArray.reduce((acc, val) => acc + val, 0);
|
|
186
|
-
const average = sum / dataArray.length;
|
|
187
|
-
|
|
188
|
-
return Math.round((average / 255) * 100);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { Injectable, InjectionToken, inject, NgZone } from '@angular/core';
|
|
2
|
-
import { BehaviorSubject, Subject, Observable } from 'rxjs';
|
|
3
|
-
import type { TranscriptionEvent } from '@medc-com-br/jaimes-shared';
|
|
4
|
-
|
|
5
|
-
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
6
|
-
|
|
7
|
-
export interface ScribeSocketConfig {
|
|
8
|
-
reconnectAttempts: number;
|
|
9
|
-
reconnectBaseDelay: number;
|
|
10
|
-
chunkBufferInterval: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const SCRIBE_SOCKET_CONFIG = new InjectionToken<ScribeSocketConfig>('SCRIBE_SOCKET_CONFIG');
|
|
14
|
-
|
|
15
|
-
const DEFAULT_CONFIG: ScribeSocketConfig = {
|
|
16
|
-
reconnectAttempts: 5,
|
|
17
|
-
reconnectBaseDelay: 1000,
|
|
18
|
-
chunkBufferInterval: 250,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
@Injectable({ providedIn: 'root' })
|
|
22
|
-
export class ScribeSocketService {
|
|
23
|
-
private config: ScribeSocketConfig;
|
|
24
|
-
private ngZone: NgZone;
|
|
25
|
-
private ws: WebSocket | null = null;
|
|
26
|
-
private wsUrl: string | null = null;
|
|
27
|
-
private token: string | null = null;
|
|
28
|
-
private premium = false;
|
|
29
|
-
private reconnectAttempt = 0;
|
|
30
|
-
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
-
private intentionalClose = false;
|
|
32
|
-
|
|
33
|
-
private chunkBuffer: string[] = [];
|
|
34
|
-
private sendInterval: ReturnType<typeof setInterval> | null = null;
|
|
35
|
-
private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
36
|
-
private currentlyInSilence = false;
|
|
37
|
-
|
|
38
|
-
private connectionStateSubject = new BehaviorSubject<ConnectionState>('disconnected');
|
|
39
|
-
private transcriptionSubject = new Subject<TranscriptionEvent>();
|
|
40
|
-
private errorSubject = new Subject<Error>();
|
|
41
|
-
private sessionIdSubject = new BehaviorSubject<string | null>(null);
|
|
42
|
-
|
|
43
|
-
connectionState$: Observable<ConnectionState> = this.connectionStateSubject.asObservable();
|
|
44
|
-
transcription$: Observable<TranscriptionEvent> = this.transcriptionSubject.asObservable();
|
|
45
|
-
error$: Observable<Error> = this.errorSubject.asObservable();
|
|
46
|
-
sessionId$: Observable<string | null> = this.sessionIdSubject.asObservable();
|
|
47
|
-
|
|
48
|
-
constructor() {
|
|
49
|
-
const injectedConfig = inject(SCRIBE_SOCKET_CONFIG, { optional: true });
|
|
50
|
-
this.config = injectedConfig ?? DEFAULT_CONFIG;
|
|
51
|
-
this.ngZone = inject(NgZone);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async connect(url: string, token: string, premium = false): Promise<void> {
|
|
55
|
-
if (this.connectionStateSubject.getValue() === 'connecting') {
|
|
56
|
-
throw new Error('Connection already in progress');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (this.isConnected()) {
|
|
60
|
-
this.disconnect();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
this.clearReconnectTimeout();
|
|
64
|
-
this.wsUrl = url;
|
|
65
|
-
this.token = token;
|
|
66
|
-
this.premium = premium;
|
|
67
|
-
this.intentionalClose = false;
|
|
68
|
-
this.reconnectAttempt = 0;
|
|
69
|
-
|
|
70
|
-
return this.createConnection();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
disconnect(): void {
|
|
74
|
-
this.intentionalClose = true;
|
|
75
|
-
this.flushAndStopBuffering();
|
|
76
|
-
this.clearReconnectTimeout();
|
|
77
|
-
|
|
78
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
79
|
-
this.ws.close(1000, 'Client disconnect');
|
|
80
|
-
}
|
|
81
|
-
this.ws = null;
|
|
82
|
-
|
|
83
|
-
this.connectionStateSubject.next('disconnected');
|
|
84
|
-
this.sessionIdSubject.next(null);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
sendAudioChunk(chunk: ArrayBuffer, isSilence: boolean): void {
|
|
88
|
-
const message = JSON.stringify({
|
|
89
|
-
type: 'audio',
|
|
90
|
-
isSilence,
|
|
91
|
-
data: this.arrayBufferToBase64(chunk),
|
|
92
|
-
});
|
|
93
|
-
this.chunkBuffer.push(message);
|
|
94
|
-
|
|
95
|
-
if (isSilence && !this.currentlyInSilence) {
|
|
96
|
-
this.currentlyInSilence = true;
|
|
97
|
-
this.startKeepaliveInterval();
|
|
98
|
-
} else if (!isSilence && this.currentlyInSilence) {
|
|
99
|
-
this.currentlyInSilence = false;
|
|
100
|
-
this.stopKeepaliveInterval();
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private startKeepaliveInterval(): void {
|
|
105
|
-
this.stopKeepaliveInterval();
|
|
106
|
-
this.keepaliveInterval = setInterval(() => {
|
|
107
|
-
if (this.isConnected()) {
|
|
108
|
-
this.ws!.send(JSON.stringify({ type: 'keepalive' }));
|
|
109
|
-
}
|
|
110
|
-
}, 3000);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private stopKeepaliveInterval(): void {
|
|
114
|
-
if (this.keepaliveInterval) {
|
|
115
|
-
clearInterval(this.keepaliveInterval);
|
|
116
|
-
this.keepaliveInterval = null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
121
|
-
const bytes = new Uint8Array(buffer);
|
|
122
|
-
let binary = '';
|
|
123
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
124
|
-
binary += String.fromCharCode(bytes[i]);
|
|
125
|
-
}
|
|
126
|
-
return btoa(binary);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
isConnected(): boolean {
|
|
130
|
-
return this.ws?.readyState === WebSocket.OPEN;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
getSessionId(): string | null {
|
|
134
|
-
return this.sessionIdSubject.getValue();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private createConnection(): Promise<void> {
|
|
138
|
-
return new Promise((resolve, reject) => {
|
|
139
|
-
if (!this.wsUrl) {
|
|
140
|
-
reject(new Error('WebSocket URL is required'));
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
this.connectionStateSubject.next('connecting');
|
|
145
|
-
const params = new URLSearchParams({ premium: String(this.premium) });
|
|
146
|
-
if (this.token) {
|
|
147
|
-
params.set('token', this.token);
|
|
148
|
-
}
|
|
149
|
-
const url = `${this.wsUrl}?${params.toString()}`;
|
|
150
|
-
|
|
151
|
-
this.ws = new WebSocket(url);
|
|
152
|
-
this.ws.binaryType = 'arraybuffer';
|
|
153
|
-
|
|
154
|
-
this.ws.onopen = () => {
|
|
155
|
-
this.ngZone.run(() => {
|
|
156
|
-
this.reconnectAttempt = 0;
|
|
157
|
-
this.connectionStateSubject.next('connected');
|
|
158
|
-
this.startBuffering();
|
|
159
|
-
resolve();
|
|
160
|
-
});
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
this.ws.onerror = () => {
|
|
164
|
-
this.ngZone.run(() => {
|
|
165
|
-
const error = new Error('WebSocket connection error');
|
|
166
|
-
this.errorSubject.next(error);
|
|
167
|
-
if (this.connectionStateSubject.getValue() === 'connecting') {
|
|
168
|
-
reject(error);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
this.ws.onmessage = (event) => {
|
|
174
|
-
this.ngZone.run(() => {
|
|
175
|
-
try {
|
|
176
|
-
const data = JSON.parse(event.data);
|
|
177
|
-
if (data.type === 'connected' && data.sessionId) {
|
|
178
|
-
this.sessionIdSubject.next(data.sessionId);
|
|
179
|
-
}
|
|
180
|
-
this.transcriptionSubject.next(data as TranscriptionEvent);
|
|
181
|
-
} catch (e) {
|
|
182
|
-
const error = new Error(`Failed to parse WebSocket message: ${e instanceof Error ? e.message : String(e)}`);
|
|
183
|
-
console.error(error.message);
|
|
184
|
-
this.errorSubject.next(error);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
this.ws.onclose = (event) => {
|
|
190
|
-
this.ngZone.run(() => {
|
|
191
|
-
this.stopBuffering();
|
|
192
|
-
|
|
193
|
-
if (!this.intentionalClose && event.code !== 1000) {
|
|
194
|
-
this.attemptReconnect();
|
|
195
|
-
} else {
|
|
196
|
-
this.connectionStateSubject.next('disconnected');
|
|
197
|
-
this.sessionIdSubject.next(null);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
};
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private attemptReconnect(): void {
|
|
205
|
-
if (this.reconnectAttempt >= this.config.reconnectAttempts) {
|
|
206
|
-
this.connectionStateSubject.next('disconnected');
|
|
207
|
-
this.errorSubject.next(new Error('Max reconnection attempts reached'));
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
this.connectionStateSubject.next('reconnecting');
|
|
212
|
-
const delay = this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt);
|
|
213
|
-
this.reconnectAttempt++;
|
|
214
|
-
|
|
215
|
-
this.reconnectTimeout = setTimeout(() => {
|
|
216
|
-
this.createConnection().catch(() => {});
|
|
217
|
-
}, delay);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private clearReconnectTimeout(): void {
|
|
221
|
-
if (this.reconnectTimeout) {
|
|
222
|
-
clearTimeout(this.reconnectTimeout);
|
|
223
|
-
this.reconnectTimeout = null;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private startBuffering(): void {
|
|
228
|
-
this.stopBuffering();
|
|
229
|
-
|
|
230
|
-
this.sendInterval = setInterval(() => {
|
|
231
|
-
if (this.chunkBuffer.length > 0 && this.isConnected()) {
|
|
232
|
-
for (const msg of this.chunkBuffer) {
|
|
233
|
-
this.ws!.send(msg);
|
|
234
|
-
}
|
|
235
|
-
this.chunkBuffer = [];
|
|
236
|
-
}
|
|
237
|
-
}, this.config.chunkBufferInterval);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private flushAndStopBuffering(): void {
|
|
241
|
-
if (this.sendInterval) {
|
|
242
|
-
clearInterval(this.sendInterval);
|
|
243
|
-
this.sendInterval = null;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (this.chunkBuffer.length > 0 && this.isConnected()) {
|
|
247
|
-
for (const msg of this.chunkBuffer) {
|
|
248
|
-
this.ws!.send(msg);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
this.chunkBuffer = [];
|
|
252
|
-
this.stopKeepaliveInterval();
|
|
253
|
-
this.currentlyInSilence = false;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private stopBuffering(): void {
|
|
257
|
-
if (this.sendInterval) {
|
|
258
|
-
clearInterval(this.sendInterval);
|
|
259
|
-
this.sendInterval = null;
|
|
260
|
-
}
|
|
261
|
-
this.chunkBuffer = [];
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { Injectable } from '@angular/core';
|
|
2
|
-
import { BehaviorSubject } from 'rxjs';
|
|
3
|
-
|
|
4
|
-
export type VadState = 'idle' | 'speech' | 'silence_pending' | 'silence';
|
|
5
|
-
|
|
6
|
-
export interface VadConfig {
|
|
7
|
-
speechThreshold: number;
|
|
8
|
-
silenceThreshold: number;
|
|
9
|
-
silenceDebounceMs: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const DEFAULT_CONFIG: VadConfig = {
|
|
13
|
-
speechThreshold: 8,
|
|
14
|
-
silenceThreshold: 3,
|
|
15
|
-
silenceDebounceMs: 1500,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
@Injectable({ providedIn: 'root' })
|
|
19
|
-
export class VadService {
|
|
20
|
-
private state: VadState = 'idle';
|
|
21
|
-
private silenceStartTime: number | null = null;
|
|
22
|
-
private config = { ...DEFAULT_CONFIG };
|
|
23
|
-
|
|
24
|
-
readonly state$ = new BehaviorSubject<VadState>('idle');
|
|
25
|
-
|
|
26
|
-
analyzeLevel(level: number): { isSilence: boolean } {
|
|
27
|
-
const now = Date.now();
|
|
28
|
-
|
|
29
|
-
if (level >= this.config.speechThreshold) {
|
|
30
|
-
this.state = 'speech';
|
|
31
|
-
this.silenceStartTime = null;
|
|
32
|
-
this.state$.next('speech');
|
|
33
|
-
return { isSilence: false };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (level < this.config.silenceThreshold) {
|
|
37
|
-
if (this.state === 'speech') {
|
|
38
|
-
this.silenceStartTime = now;
|
|
39
|
-
this.state = 'silence_pending';
|
|
40
|
-
} else if (this.state === 'silence_pending' && this.silenceStartTime) {
|
|
41
|
-
if (now - this.silenceStartTime >= this.config.silenceDebounceMs) {
|
|
42
|
-
this.state = 'silence';
|
|
43
|
-
this.state$.next('silence');
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
} else {
|
|
47
|
-
if (this.state === 'silence_pending') {
|
|
48
|
-
this.state = 'speech';
|
|
49
|
-
this.silenceStartTime = null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return { isSilence: this.state === 'silence' };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
reset(): void {
|
|
57
|
-
this.state = 'idle';
|
|
58
|
-
this.silenceStartTime = null;
|
|
59
|
-
this.state$.next('idle');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
getState(): VadState {
|
|
63
|
-
return this.state;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
declare class AudioWorkletProcessor {
|
|
2
|
-
readonly port: MessagePort;
|
|
3
|
-
constructor(options?: AudioWorkletNodeOptions);
|
|
4
|
-
process(
|
|
5
|
-
inputs: Float32Array[][],
|
|
6
|
-
outputs: Float32Array[][],
|
|
7
|
-
parameters: Record<string, Float32Array>
|
|
8
|
-
): boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
declare function registerProcessor(
|
|
12
|
-
name: string,
|
|
13
|
-
processorCtor: new (options?: AudioWorkletNodeOptions) => AudioWorkletProcessor
|
|
14
|
-
): void;
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/// <reference path="./audio-worklet.d.ts" />
|
|
2
|
-
|
|
3
|
-
const TARGET_SAMPLE_RATE = 16000;
|
|
4
|
-
const BUFFER_SIZE = 4096;
|
|
5
|
-
|
|
6
|
-
class PcmProcessor extends AudioWorkletProcessor {
|
|
7
|
-
private inputSampleRate: number;
|
|
8
|
-
private resampleRatio: number;
|
|
9
|
-
private inputBuffer: Float32Array;
|
|
10
|
-
private inputBufferIndex: number;
|
|
11
|
-
private resampleBuffer: Float32Array;
|
|
12
|
-
private resampleBufferIndex: number;
|
|
13
|
-
|
|
14
|
-
constructor(options?: AudioWorkletNodeOptions) {
|
|
15
|
-
super();
|
|
16
|
-
|
|
17
|
-
this.inputSampleRate = (options?.processorOptions?.inputSampleRate as number) || 48000;
|
|
18
|
-
this.resampleRatio = this.inputSampleRate / TARGET_SAMPLE_RATE;
|
|
19
|
-
|
|
20
|
-
this.inputBuffer = new Float32Array(BUFFER_SIZE * 2);
|
|
21
|
-
this.inputBufferIndex = 0;
|
|
22
|
-
|
|
23
|
-
const outputBufferSize = Math.ceil(BUFFER_SIZE / this.resampleRatio);
|
|
24
|
-
this.resampleBuffer = new Float32Array(outputBufferSize);
|
|
25
|
-
this.resampleBufferIndex = 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
process(inputs: Float32Array[][], _outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
|
|
29
|
-
const input = inputs[0];
|
|
30
|
-
if (!input || !input[0]) {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const inputChannel = input[0];
|
|
35
|
-
|
|
36
|
-
for (let i = 0; i < inputChannel.length; i++) {
|
|
37
|
-
this.inputBuffer[this.inputBufferIndex++] = inputChannel[i];
|
|
38
|
-
|
|
39
|
-
if (this.inputBufferIndex >= BUFFER_SIZE) {
|
|
40
|
-
this.processBuffer();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private processBuffer(): void {
|
|
48
|
-
const outputLength = Math.floor(this.inputBufferIndex / this.resampleRatio);
|
|
49
|
-
|
|
50
|
-
for (let i = 0; i < outputLength; i++) {
|
|
51
|
-
const srcIndex = i * this.resampleRatio;
|
|
52
|
-
const srcIndexFloor = Math.floor(srcIndex);
|
|
53
|
-
const srcIndexCeil = Math.min(srcIndexFloor + 1, this.inputBufferIndex - 1);
|
|
54
|
-
const t = srcIndex - srcIndexFloor;
|
|
55
|
-
|
|
56
|
-
const sample = this.inputBuffer[srcIndexFloor] * (1 - t) + this.inputBuffer[srcIndexCeil] * t;
|
|
57
|
-
this.resampleBuffer[this.resampleBufferIndex++] = sample;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const pcmData = this.float32ToPcm16(this.resampleBuffer.subarray(0, this.resampleBufferIndex));
|
|
61
|
-
this.port.postMessage(pcmData, [pcmData]);
|
|
62
|
-
|
|
63
|
-
this.inputBufferIndex = 0;
|
|
64
|
-
this.resampleBufferIndex = 0;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
private float32ToPcm16(float32Array: Float32Array): ArrayBuffer {
|
|
68
|
-
const buffer = new ArrayBuffer(float32Array.length * 2);
|
|
69
|
-
const view = new DataView(buffer);
|
|
70
|
-
|
|
71
|
-
for (let i = 0; i < float32Array.length; i++) {
|
|
72
|
-
const sample = Math.max(-1, Math.min(1, float32Array[i]));
|
|
73
|
-
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
74
|
-
view.setInt16(i * 2, int16, true);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return buffer;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
registerProcessor('pcm-processor', PcmProcessor);
|
package/tsconfig.lib.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../tsconfig.base.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"outDir": "../../dist/out-tsc",
|
|
5
|
-
"target": "ES2022",
|
|
6
|
-
"module": "ES2022",
|
|
7
|
-
"moduleResolution": "bundler",
|
|
8
|
-
"declaration": true,
|
|
9
|
-
"declarationMap": true,
|
|
10
|
-
"inlineSources": true,
|
|
11
|
-
"experimentalDecorators": true,
|
|
12
|
-
"importHelpers": true,
|
|
13
|
-
"types": [],
|
|
14
|
-
"lib": ["ES2022", "dom"]
|
|
15
|
-
},
|
|
16
|
-
"angularCompilerOptions": {
|
|
17
|
-
"enableI18nLegacyMessageIdFormat": false,
|
|
18
|
-
"strictInjectionParameters": true,
|
|
19
|
-
"strictInputAccessModifiers": true,
|
|
20
|
-
"strictTemplates": true
|
|
21
|
-
},
|
|
22
|
-
"exclude": ["**/*.spec.ts"],
|
|
23
|
-
"include": ["src/**/*.ts"]
|
|
24
|
-
}
|
|
File without changes
|