@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.
- package/README.md +414 -17
- package/dist/index.cjs +29 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +262 -1
- package/dist/index.d.ts +262 -1
- package/dist/index.global.js +53 -53
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +29 -29
- package/dist/index.js.map +1 -1
- package/dist/widget.css +24 -0
- package/package.json +1 -1
- package/src/components/composer-builder.ts +5 -2
- package/src/index.ts +13 -0
- package/src/session.ts +407 -2
- package/src/styles/widget.css +24 -0
- package/src/types.ts +219 -0
- package/src/ui.ts +298 -41
- package/src/voice/audio-playback-manager.ts +187 -0
- package/src/voice/browser-voice-provider.ts +119 -0
- package/src/voice/index.ts +16 -0
- package/src/voice/runtype-voice-provider.ts +637 -0
- package/src/voice/voice-activity-detector.ts +90 -0
- package/src/voice/voice-factory.ts +56 -0
- package/src/voice/voice.test.ts +220 -0
|
@@ -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
|
+
});
|