@runtypelabs/persona 1.46.1 → 1.48.0

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Voice Activity Detector (VAD)
3
+ *
4
+ * Reusable RMS-based voice activity detection that monitors a mic stream
5
+ * and fires a callback when a condition is sustained for a given duration.
6
+ *
7
+ * - mode 'silence': fires when volume stays below threshold (user stopped talking)
8
+ * - mode 'speech': fires when volume stays above threshold (user started talking)
9
+ *
10
+ * Fires callback exactly once per start() call, then stops checking.
11
+ * Calling start() again implicitly calls stop() first.
12
+ */
13
+ export class VoiceActivityDetector {
14
+ private sourceNode: MediaStreamAudioSourceNode | null = null;
15
+ private analyserNode: AnalyserNode | null = null;
16
+ private interval: ReturnType<typeof setInterval> | null = null;
17
+ private conditionStart: number | null = null;
18
+ private fired = false;
19
+
20
+ /**
21
+ * Begin monitoring the given stream for voice activity.
22
+ *
23
+ * @param audioContext Active AudioContext
24
+ * @param stream MediaStream from getUserMedia
25
+ * @param mode 'silence' fires when quiet for duration, 'speech' fires when loud for duration
26
+ * @param config threshold (RMS level) and duration (ms)
27
+ * @param callback Fires exactly once when the condition is met
28
+ */
29
+ start(
30
+ audioContext: AudioContext,
31
+ stream: MediaStream,
32
+ mode: "silence" | "speech",
33
+ config: { threshold: number; duration: number },
34
+ callback: () => void,
35
+ ): void {
36
+ this.stop();
37
+
38
+ this.fired = false;
39
+ this.conditionStart = null;
40
+
41
+ this.sourceNode = audioContext.createMediaStreamSource(stream);
42
+ this.analyserNode = audioContext.createAnalyser();
43
+ this.analyserNode.fftSize = 2048;
44
+ this.sourceNode.connect(this.analyserNode);
45
+
46
+ const dataArray = new Float32Array(this.analyserNode.fftSize);
47
+
48
+ this.interval = setInterval(() => {
49
+ if (!this.analyserNode || this.fired) return;
50
+ this.analyserNode.getFloatTimeDomainData(dataArray);
51
+
52
+ // Compute RMS volume
53
+ let sum = 0;
54
+ for (let i = 0; i < dataArray.length; i++) {
55
+ sum += dataArray[i] * dataArray[i];
56
+ }
57
+ const rms = Math.sqrt(sum / dataArray.length);
58
+
59
+ const conditionMet =
60
+ mode === "silence"
61
+ ? rms < config.threshold
62
+ : rms >= config.threshold;
63
+
64
+ if (conditionMet) {
65
+ if (this.conditionStart === null) {
66
+ this.conditionStart = Date.now();
67
+ } else if (Date.now() - this.conditionStart >= config.duration) {
68
+ this.fired = true;
69
+ callback();
70
+ }
71
+ } else {
72
+ this.conditionStart = null;
73
+ }
74
+ }, 100);
75
+ }
76
+
77
+ stop(): void {
78
+ if (this.interval) {
79
+ clearInterval(this.interval);
80
+ this.interval = null;
81
+ }
82
+ if (this.sourceNode) {
83
+ this.sourceNode.disconnect();
84
+ this.sourceNode = null;
85
+ }
86
+ this.analyserNode = null;
87
+ this.conditionStart = null;
88
+ this.fired = false;
89
+ }
90
+ }
@@ -0,0 +1,56 @@
1
+ // Voice Provider Factory
2
+ // Creates appropriate voice provider based on configuration
3
+
4
+ import type { VoiceProvider, VoiceConfig } from '../types';
5
+ import { RuntypeVoiceProvider } from './runtype-voice-provider';
6
+ import { BrowserVoiceProvider } from './browser-voice-provider';
7
+
8
+ export function createVoiceProvider(config: VoiceConfig): VoiceProvider {
9
+ switch (config.type) {
10
+ case 'runtype':
11
+ if (!config.runtype) {
12
+ throw new Error('Runtype voice provider requires configuration');
13
+ }
14
+ return new RuntypeVoiceProvider(config.runtype);
15
+
16
+ case 'browser':
17
+ if (!BrowserVoiceProvider.isSupported()) {
18
+ throw new Error('Browser speech recognition not supported');
19
+ }
20
+ return new BrowserVoiceProvider(config.browser || {});
21
+
22
+ case 'custom':
23
+ throw new Error('Custom voice providers not yet implemented');
24
+
25
+ default:
26
+ throw new Error(`Unknown voice provider type: ${config.type}`);
27
+ }
28
+ }
29
+
30
+ // Auto-select the best available provider
31
+ export function createBestAvailableVoiceProvider(config?: Partial<VoiceConfig>): VoiceProvider {
32
+ // Prefer Runtype if configured
33
+ if (config?.type === 'runtype' && config.runtype) {
34
+ return createVoiceProvider({ type: 'runtype', runtype: config.runtype });
35
+ }
36
+
37
+ // Fall back to browser if supported
38
+ if (BrowserVoiceProvider.isSupported()) {
39
+ return createVoiceProvider({
40
+ type: 'browser',
41
+ browser: config?.browser || { language: 'en-US' }
42
+ });
43
+ }
44
+
45
+ throw new Error('No supported voice providers available');
46
+ }
47
+
48
+ // Check if any voice provider is available
49
+ export function isVoiceSupported(config?: Partial<VoiceConfig>): boolean {
50
+ try {
51
+ createBestAvailableVoiceProvider(config);
52
+ return true;
53
+ } catch (error) {
54
+ return false;
55
+ }
56
+ }
@@ -0,0 +1,220 @@
1
+ // Voice SDK Tests
2
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3
+ import type { VoiceConfig } from '../types';
4
+ import { RuntypeVoiceProvider } from './runtype-voice-provider';
5
+ import { BrowserVoiceProvider } from './browser-voice-provider';
6
+ import { createVoiceProvider, createBestAvailableVoiceProvider, isVoiceSupported } from './voice-factory';
7
+
8
+ // Mock window object for browser tests
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ const mockWindow: any = {
11
+ SpeechRecognition: undefined,
12
+ webkitSpeechRecognition: undefined
13
+ };
14
+
15
+ beforeAll(() => {
16
+ // @ts-ignore
17
+ global.window = mockWindow;
18
+ });
19
+
20
+ afterAll(() => {
21
+ // @ts-ignore
22
+ delete global.window;
23
+ });
24
+
25
+ function mockBrowserSupport(supported: boolean) {
26
+ if (supported) {
27
+ mockWindow.SpeechRecognition = class {};
28
+ mockWindow.webkitSpeechRecognition = class {};
29
+ } else {
30
+ delete mockWindow.SpeechRecognition;
31
+ delete mockWindow.webkitSpeechRecognition;
32
+ }
33
+ }
34
+
35
+ // Note: TypeScript interfaces don't exist at runtime, so we can't test them directly
36
+ // We test the concrete implementations instead
37
+
38
+ describe('RuntypeVoiceProvider', () => {
39
+ it('should create instance with valid config', () => {
40
+ const config = {
41
+ agentId: 'test-agent',
42
+ clientToken: 'test-token',
43
+ host: 'localhost:8787',
44
+ voiceId: 'rachel'
45
+ };
46
+
47
+ const provider = new RuntypeVoiceProvider(config);
48
+ expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
49
+ expect(provider.type).toBe('runtype');
50
+ });
51
+
52
+ it('should have correct methods', () => {
53
+ const config = {
54
+ agentId: 'test-agent',
55
+ clientToken: 'test-token'
56
+ };
57
+
58
+ const provider = new RuntypeVoiceProvider(config);
59
+ expect(typeof provider.connect).toBe('function');
60
+ expect(typeof provider.disconnect).toBe('function');
61
+ expect(typeof provider.startListening).toBe('function');
62
+ expect(typeof provider.stopListening).toBe('function');
63
+ expect(typeof provider.onResult).toBe('function');
64
+ expect(typeof provider.onError).toBe('function');
65
+ expect(typeof provider.onStatusChange).toBe('function');
66
+ });
67
+ });
68
+
69
+ describe('BrowserVoiceProvider', () => {
70
+ it('should create instance with default config', () => {
71
+ const provider = new BrowserVoiceProvider();
72
+ expect(provider).toBeInstanceOf(BrowserVoiceProvider);
73
+ expect(provider.type).toBe('browser');
74
+ });
75
+
76
+ it('should have correct methods', () => {
77
+ const provider = new BrowserVoiceProvider();
78
+ expect(typeof provider.connect).toBe('function');
79
+ expect(typeof provider.disconnect).toBe('function');
80
+ expect(typeof provider.startListening).toBe('function');
81
+ expect(typeof provider.stopListening).toBe('function');
82
+ expect(typeof provider.onResult).toBe('function');
83
+ expect(typeof provider.onError).toBe('function');
84
+ expect(typeof provider.onStatusChange).toBe('function');
85
+ });
86
+
87
+ it('should check browser support', () => {
88
+ // Test supported
89
+ mockBrowserSupport(true);
90
+ expect(BrowserVoiceProvider.isSupported()).toBe(true);
91
+
92
+ // Test unsupported
93
+ mockBrowserSupport(false);
94
+ expect(BrowserVoiceProvider.isSupported()).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('Voice Factory', () => {
99
+ it('should create Runtype provider', () => {
100
+ const config: VoiceConfig = {
101
+ type: 'runtype',
102
+ runtype: {
103
+ agentId: 'test-agent',
104
+ clientToken: 'test-token'
105
+ }
106
+ };
107
+
108
+ const provider = createVoiceProvider(config);
109
+ expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
110
+ expect(provider.type).toBe('runtype');
111
+ });
112
+
113
+ it('should create Browser provider when supported', () => {
114
+ // Mock browser support
115
+ mockBrowserSupport(true);
116
+
117
+ const config: VoiceConfig = {
118
+ type: 'browser',
119
+ browser: {
120
+ language: 'en-US'
121
+ }
122
+ };
123
+
124
+ const provider = createVoiceProvider(config);
125
+ expect(provider).toBeInstanceOf(BrowserVoiceProvider);
126
+ expect(provider.type).toBe('browser');
127
+ });
128
+
129
+ it('should throw error for unsupported browser provider', () => {
130
+ // Mock no browser support
131
+ mockBrowserSupport(false);
132
+
133
+ const config: VoiceConfig = {
134
+ type: 'browser'
135
+ };
136
+
137
+ expect(() => createVoiceProvider(config)).toThrow('Browser speech recognition not supported');
138
+ });
139
+
140
+ it('should throw error for custom provider', () => {
141
+ const config: VoiceConfig = {
142
+ type: 'custom',
143
+ custom: {}
144
+ };
145
+
146
+ expect(() => createVoiceProvider(config)).toThrow('Custom voice providers not yet implemented');
147
+ });
148
+
149
+ it('should throw error for unknown provider type', () => {
150
+ const config = {
151
+ type: 'unknown' as any
152
+ };
153
+
154
+ expect(() => createVoiceProvider(config)).toThrow('Unknown voice provider type: unknown');
155
+ });
156
+ });
157
+
158
+ describe('Best Available Voice Provider', () => {
159
+ it('should prefer Runtype when configured', () => {
160
+ // Mock no browser support
161
+ mockBrowserSupport(false);
162
+
163
+ const config = {
164
+ type: 'runtype' as const,
165
+ runtype: {
166
+ agentId: 'test-agent',
167
+ clientToken: 'test-token'
168
+ }
169
+ };
170
+
171
+ const provider = createBestAvailableVoiceProvider(config);
172
+ expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
173
+ });
174
+
175
+ it('should fall back to browser when Runtype not configured', () => {
176
+ // Mock browser support
177
+ mockBrowserSupport(true);
178
+
179
+ const provider = createBestAvailableVoiceProvider();
180
+ expect(provider).toBeInstanceOf(BrowserVoiceProvider);
181
+ });
182
+
183
+ it('should throw error when no providers available', () => {
184
+ // Mock no browser support
185
+ mockBrowserSupport(false);
186
+
187
+ expect(() => createBestAvailableVoiceProvider()).toThrow('No supported voice providers available');
188
+ });
189
+ });
190
+
191
+ describe('Voice Support Check', () => {
192
+ it('should return true when voice is supported', () => {
193
+ // Mock browser support
194
+ mockBrowserSupport(true);
195
+
196
+ expect(isVoiceSupported()).toBe(true);
197
+ });
198
+
199
+ it('should return true when Runtype is configured', () => {
200
+ // Mock no browser support
201
+ mockBrowserSupport(false);
202
+
203
+ const config = {
204
+ type: 'runtype' as const,
205
+ runtype: {
206
+ agentId: 'test-agent',
207
+ clientToken: 'test-token'
208
+ }
209
+ };
210
+
211
+ expect(isVoiceSupported(config)).toBe(true);
212
+ });
213
+
214
+ it('should return false when no voice support available', () => {
215
+ // Mock no browser support
216
+ mockBrowserSupport(false);
217
+
218
+ expect(isVoiceSupported()).toBe(false);
219
+ });
220
+ });