@myscheme/voice-navigation-sdk 0.1.8 → 0.1.10
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 +130 -17
- package/dist/actions.d.ts +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +51 -1
- package/dist/index.d.ts +38 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +158 -1
- package/dist/languages.d.ts +17 -0
- package/dist/languages.d.ts.map +1 -0
- package/dist/languages.js +86 -0
- package/dist/microphone-handler.d.ts +9 -2
- package/dist/microphone-handler.d.ts.map +1 -1
- package/dist/microphone-handler.js +104 -6
- package/dist/navigation-controller.d.ts +5 -0
- package/dist/navigation-controller.d.ts.map +1 -1
- package/dist/navigation-controller.js +149 -35
- package/dist/server/azure-speech-handler.d.ts +3 -2
- package/dist/server/azure-speech-handler.d.ts.map +1 -1
- package/dist/server/azure-speech-handler.js +26 -2
- package/dist/server/bedrock-embedding-handler.d.ts +2 -1
- package/dist/server/bedrock-embedding-handler.d.ts.map +1 -1
- package/dist/server/bedrock-embedding-handler.js +73 -19
- package/dist/server/bedrock-handler.d.ts +2 -1
- package/dist/server/bedrock-handler.d.ts.map +1 -1
- package/dist/server/bedrock-handler.js +124 -19
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/opensearch-env-handler.d.ts +2 -1
- package/dist/server/opensearch-env-handler.d.ts.map +1 -1
- package/dist/server/opensearch-env-handler.js +23 -53
- package/dist/server/sarvam-stt-relay.d.ts +8 -0
- package/dist/server/sarvam-stt-relay.d.ts.map +1 -0
- package/dist/server/sarvam-stt-relay.js +111 -0
- package/dist/server/security.d.ts +26 -0
- package/dist/server/security.d.ts.map +1 -0
- package/dist/server/security.js +89 -0
- package/dist/services/bedrock.d.ts.map +1 -1
- package/dist/services/bedrock.js +13 -0
- package/dist/services/sarvam-speech.d.ts +47 -0
- package/dist/services/sarvam-speech.d.ts.map +1 -0
- package/dist/services/sarvam-speech.js +418 -0
- package/dist/services/voice-feedback.d.ts +7 -2
- package/dist/services/voice-feedback.d.ts.map +1 -1
- package/dist/services/voice-feedback.js +45 -5
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const rateLimitMap = new Map();
|
|
2
|
+
const MAX_TRACKED_CLIENTS = 20000;
|
|
3
|
+
function headerString(value) {
|
|
4
|
+
if (Array.isArray(value)) {
|
|
5
|
+
return value[0] ?? "";
|
|
6
|
+
}
|
|
7
|
+
return value ?? "";
|
|
8
|
+
}
|
|
9
|
+
function hostOf(urlOrOrigin) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(urlOrOrigin).host;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function isTrustProxyEnabled() {
|
|
18
|
+
return String(process.env.VOICE_NAV_TRUST_PROXY || "").toLowerCase() === "true";
|
|
19
|
+
}
|
|
20
|
+
export function parseAllowedOrigins() {
|
|
21
|
+
return String(process.env.VOICE_NAV_ALLOWED_ORIGINS || "")
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((entry) => entry.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function getClientId(req) {
|
|
27
|
+
if (isTrustProxyEnabled()) {
|
|
28
|
+
const forwardedFor = headerString(req.headers?.["x-forwarded-for"]);
|
|
29
|
+
if (forwardedFor) {
|
|
30
|
+
return forwardedFor.split(",")[0].trim();
|
|
31
|
+
}
|
|
32
|
+
const realIp = headerString(req.headers?.["x-real-ip"]);
|
|
33
|
+
if (realIp) {
|
|
34
|
+
return realIp;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return req.socket?.remoteAddress || "unknown";
|
|
38
|
+
}
|
|
39
|
+
export function isOriginAllowed(req) {
|
|
40
|
+
const origin = headerString(req.headers?.origin);
|
|
41
|
+
const referer = headerString(req.headers?.referer);
|
|
42
|
+
const host = headerString(req.headers?.host);
|
|
43
|
+
const candidateHost = origin
|
|
44
|
+
? hostOf(origin)
|
|
45
|
+
: referer
|
|
46
|
+
? hostOf(referer)
|
|
47
|
+
: null;
|
|
48
|
+
if (!candidateHost) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const allowed = parseAllowedOrigins();
|
|
52
|
+
if (allowed.length > 0) {
|
|
53
|
+
return allowed.some((entry) => (hostOf(entry) || entry) === candidateHost);
|
|
54
|
+
}
|
|
55
|
+
return host ? candidateHost === host : false;
|
|
56
|
+
}
|
|
57
|
+
export function checkRateLimit(identifier, maxRequests, windowMs) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (rateLimitMap.size > MAX_TRACKED_CLIENTS) {
|
|
60
|
+
for (const [key, record] of rateLimitMap) {
|
|
61
|
+
if (now > record.resetTime) {
|
|
62
|
+
rateLimitMap.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (rateLimitMap.size > MAX_TRACKED_CLIENTS) {
|
|
66
|
+
rateLimitMap.clear();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const record = rateLimitMap.get(identifier);
|
|
70
|
+
if (!record || now > record.resetTime) {
|
|
71
|
+
rateLimitMap.set(identifier, { count: 1, resetTime: now + windowMs });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (record.count >= maxRequests) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
record.count++;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
export function evaluateGate(req, options) {
|
|
81
|
+
if (!isOriginAllowed(req)) {
|
|
82
|
+
return { ok: false, status: 403, error: "Origin not allowed" };
|
|
83
|
+
}
|
|
84
|
+
const key = `${options.bucket ?? "default"}:${getClientId(req)}`;
|
|
85
|
+
if (!checkRateLimit(key, options.maxRequests, options.windowMs)) {
|
|
86
|
+
return { ok: false, status: 429, error: "Too many requests" };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bedrock.d.ts","sourceRoot":"","sources":["../../src/services/bedrock.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,mBAAmB,EAAmB,MAAM,aAAa,CAAC;AACxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,WAAW,aAAa;IAE5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAG3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;
|
|
1
|
+
{"version":3,"file":"bedrock.d.ts","sourceRoot":"","sources":["../../src/services/bedrock.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,mBAAmB,EAAmB,MAAM,aAAa,CAAC;AACxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,WAAW,aAAa;IAE5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAG3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAiED,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAqC;IACnD,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,gBAAgB,CAAgB;IACxC,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,oBAAoB,CAAuB;IACnD,OAAO,CAAC,QAAQ,CAAkB;gBAEtB,MAAM,EAAE,aAAa;IA6CjC,eAAe,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI;IAO7C,OAAO,CAAC,iBAAiB;IA+BnB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;YAWtD,qBAAqB;YA2ErB,mBAAmB;IAyEjC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,cAAc;IAO9C,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;YAWlC,iBAAiB;YAkFjB,eAAe;IAwFvB,uBAAuB,CAC3B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC;YA2BJ,wBAAwB;YAwExB,sBAAsB;CA+DrC"}
|
package/dist/services/bedrock.js
CHANGED
|
@@ -11,12 +11,24 @@ CRITICAL RULES:
|
|
|
11
11
|
6. FOR PAGE NAVIGATION, USE THE EXACT "navigate_<id>" ACTION PROVIDED FOR THAT PAGE
|
|
12
12
|
7. IF UNCERTAIN, RESPOND WITH {"action": "unknown"}
|
|
13
13
|
|
|
14
|
+
MULTILINGUAL INPUT:
|
|
15
|
+
- The user may speak in ANY supported Indian language (e.g. Hindi, Tamil, Telugu, Bengali, Marathi, Gujarati, Kannada, Malayalam, Punjabi, Odia, Assamese, Urdu, and others), or mix languages.
|
|
16
|
+
- Understand the MEANING of the command regardless of the input language and map it to the correct action.
|
|
17
|
+
- The "action" value, action IDs, and "navigate_<id>" page IDs are ALWAYS in English/ASCII and MUST NOT be translated or transliterated.
|
|
18
|
+
- For "query" (search) and "text" (type) fields, preserve the user's own words as spoken.
|
|
19
|
+
|
|
14
20
|
RESPONSE FORMAT:
|
|
15
21
|
- Core actions: {"action": "action_name"}
|
|
16
22
|
- Search: {"action": "search_content", "query": "keywords"}
|
|
17
23
|
- Type text: {"action": "type_text", "text": "text to type"}
|
|
18
24
|
- Page navigation: {"action": "navigate_<page_id>"}
|
|
19
25
|
|
|
26
|
+
MULTILINGUAL EXAMPLES:
|
|
27
|
+
- User says "नीचे स्क्रॉल करो" (Hindi: scroll down) → {"action": "scroll_down"}
|
|
28
|
+
- User says "डैशबोर्ड खोलो" (Hindi: open dashboard) → {"action": "navigate_dashboard"}
|
|
29
|
+
- User says "மேலே செல்" (Tamil: go up) → {"action": "scroll_up"}
|
|
30
|
+
- User says "వెనక్కి వెళ్ళు" (Telugu: go back) → {"action": "go_back"}
|
|
31
|
+
|
|
20
32
|
EXAMPLES:
|
|
21
33
|
- User says "go to dashboard" → {"action": "navigate_dashboard"}
|
|
22
34
|
- User says "show me the team page" → {"action": "navigate_team"}
|
|
@@ -362,6 +374,7 @@ export class BedrockService {
|
|
|
362
374
|
Generate brief, natural, conversational feedback messages (maximum 10 words).
|
|
363
375
|
Be specific and clear about what action was performed.
|
|
364
376
|
Use the language: ${language}
|
|
377
|
+
Never output internal action identifiers, underscores, technical codes, or JSON — describe the action only in plain, everyday words a non-technical listener would understand.
|
|
365
378
|
Respond with ONLY the feedback message text, no JSON, no explanations.`;
|
|
366
379
|
const userPrompt = prompt;
|
|
367
380
|
console.log("[Bedrock] Using proxy mode:", this.useProxy);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface SarvamSpeechConfig {
|
|
2
|
+
ttsUrl?: string;
|
|
3
|
+
sttUrl?: string;
|
|
4
|
+
sttModel?: "saaras:v3" | "saarika:v2.5";
|
|
5
|
+
ttsModel?: "bulbul:v3" | "bulbul:v2";
|
|
6
|
+
speaker?: string;
|
|
7
|
+
sttSampleRate?: 8000 | 16000;
|
|
8
|
+
ttsSampleRate?: 8000 | 16000 | 22050 | 24000 | 32000 | 44100 | 48000;
|
|
9
|
+
}
|
|
10
|
+
export interface SarvamSttHandlers {
|
|
11
|
+
onPartial?: (text: string) => void;
|
|
12
|
+
onFinal?: (text: string) => void;
|
|
13
|
+
onSpeechStart?: () => void;
|
|
14
|
+
onSpeechEnd?: () => void;
|
|
15
|
+
onError?: (error: Error) => void;
|
|
16
|
+
shouldSendAudio?: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare class SarvamSpeechService {
|
|
19
|
+
private readonly ttsUrl;
|
|
20
|
+
private readonly sttUrl;
|
|
21
|
+
private readonly sttModel;
|
|
22
|
+
private readonly ttsModel;
|
|
23
|
+
private readonly speaker;
|
|
24
|
+
private readonly sttSampleRate;
|
|
25
|
+
private readonly ttsSampleRate;
|
|
26
|
+
private sttSocket;
|
|
27
|
+
private sttContext;
|
|
28
|
+
private sttMediaStream;
|
|
29
|
+
private sttSourceNode;
|
|
30
|
+
private sttProcessorNode;
|
|
31
|
+
private audioElement;
|
|
32
|
+
private currentBlobUrl;
|
|
33
|
+
private speechGeneration;
|
|
34
|
+
private listeningGeneration;
|
|
35
|
+
constructor(config?: SarvamSpeechConfig);
|
|
36
|
+
startListening(language: string, handlers: SarvamSttHandlers): Promise<void>;
|
|
37
|
+
private sendFlush;
|
|
38
|
+
stopListening(): Promise<void>;
|
|
39
|
+
speak(text: string, opts?: {
|
|
40
|
+
language?: string;
|
|
41
|
+
onAudioStart?: () => void;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
stopSpeaking(): void;
|
|
44
|
+
dispose(): Promise<void>;
|
|
45
|
+
private cleanupAudioElement;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=sarvam-speech.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sarvam-speech.d.ts","sourceRoot":"","sources":["../../src/services/sarvam-speech.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,WAAW,GAAG,cAAc,CAAC;IACxC,QAAQ,CAAC,EAAE,WAAW,GAAG,WAAW,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,IAAI,GAAG,KAAK,CAAC;IAC7B,aAAa,CAAC,EAAE,IAAI,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC;CACtE;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC;CACjC;AAmHD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA+B;IACxD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;IACrD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAe;IAC7C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAQhB;IAEd,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,aAAa,CAA2C;IAChE,OAAO,CAAC,gBAAgB,CAAoC;IAE5D,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,cAAc,CAAuB;IAC7C,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,mBAAmB,CAAa;gBAE5B,MAAM,GAAE,kBAAuB;IAUrC,cAAc,CAClB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,iBAAiB,GAC1B,OAAO,CAAC,IAAI,CAAC;IA+KhB,OAAO,CAAC,SAAS;IAWX,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IAiE9B,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,GACtD,OAAO,CAAC,IAAI,CAAC;IAqGhB,YAAY,IAAI,IAAI;IAuBd,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAK9B,OAAO,CAAC,mBAAmB;CAc5B"}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
const DEFAULT_TTS_URL = "/api/speech/sarvam";
|
|
2
|
+
const DEFAULT_STT_URL = "/api/speech/sarvam-stt";
|
|
3
|
+
const DEFAULT_STT_MODEL = "saaras:v3";
|
|
4
|
+
const DEFAULT_TTS_MODEL = "bulbul:v3";
|
|
5
|
+
const DEFAULT_SPEAKER = "manan";
|
|
6
|
+
const DEFAULT_STT_SAMPLE_RATE = 16000;
|
|
7
|
+
function toBase64(bytes) {
|
|
8
|
+
let binary = "";
|
|
9
|
+
const chunkSize = 0x8000;
|
|
10
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
11
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
12
|
+
}
|
|
13
|
+
return btoa(binary);
|
|
14
|
+
}
|
|
15
|
+
function downsampleTo16BitMono(input, inputRate, outputRate) {
|
|
16
|
+
if (outputRate === inputRate) {
|
|
17
|
+
const pcm = new Int16Array(input.length);
|
|
18
|
+
for (let i = 0; i < input.length; i++) {
|
|
19
|
+
const clamped = Math.max(-1, Math.min(1, input[i]));
|
|
20
|
+
pcm[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
|
|
21
|
+
}
|
|
22
|
+
return pcm;
|
|
23
|
+
}
|
|
24
|
+
const ratio = inputRate / outputRate;
|
|
25
|
+
const outputLength = Math.max(1, Math.floor(input.length / ratio));
|
|
26
|
+
const pcm = new Int16Array(outputLength);
|
|
27
|
+
let outputIndex = 0;
|
|
28
|
+
let inputIndex = 0;
|
|
29
|
+
while (outputIndex < outputLength) {
|
|
30
|
+
const nextInputIndex = Math.floor((outputIndex + 1) * ratio);
|
|
31
|
+
let accum = 0;
|
|
32
|
+
let count = 0;
|
|
33
|
+
for (let i = inputIndex; i < nextInputIndex && i < input.length; i++) {
|
|
34
|
+
accum += input[i];
|
|
35
|
+
count++;
|
|
36
|
+
}
|
|
37
|
+
const sample = count > 0 ? accum / count : 0;
|
|
38
|
+
const clamped = Math.max(-1, Math.min(1, sample));
|
|
39
|
+
pcm[outputIndex] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
|
|
40
|
+
outputIndex++;
|
|
41
|
+
inputIndex = nextInputIndex;
|
|
42
|
+
}
|
|
43
|
+
return pcm;
|
|
44
|
+
}
|
|
45
|
+
function encodeWav(pcm, sampleRate) {
|
|
46
|
+
const bytesPerSample = 2;
|
|
47
|
+
const numChannels = 1;
|
|
48
|
+
const dataSize = pcm.length * bytesPerSample;
|
|
49
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
50
|
+
const view = new DataView(buffer);
|
|
51
|
+
const writeString = (offset, value) => {
|
|
52
|
+
for (let i = 0; i < value.length; i++) {
|
|
53
|
+
view.setUint8(offset + i, value.charCodeAt(i));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
writeString(0, "RIFF");
|
|
57
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
58
|
+
writeString(8, "WAVE");
|
|
59
|
+
writeString(12, "fmt ");
|
|
60
|
+
view.setUint32(16, 16, true);
|
|
61
|
+
view.setUint16(20, 1, true);
|
|
62
|
+
view.setUint16(22, numChannels, true);
|
|
63
|
+
view.setUint32(24, sampleRate, true);
|
|
64
|
+
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
|
|
65
|
+
view.setUint16(32, numChannels * bytesPerSample, true);
|
|
66
|
+
view.setUint16(34, 16, true);
|
|
67
|
+
writeString(36, "data");
|
|
68
|
+
view.setUint32(40, dataSize, true);
|
|
69
|
+
let offset = 44;
|
|
70
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
71
|
+
view.setInt16(offset, pcm[i], true);
|
|
72
|
+
offset += 2;
|
|
73
|
+
}
|
|
74
|
+
return new Uint8Array(buffer);
|
|
75
|
+
}
|
|
76
|
+
function resolveWsUrl(path) {
|
|
77
|
+
if (/^wss?:\/\//i.test(path)) {
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
if (typeof window === "undefined") {
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
84
|
+
return `${protocol}//${window.location.host}${path}`;
|
|
85
|
+
}
|
|
86
|
+
export class SarvamSpeechService {
|
|
87
|
+
constructor(config = {}) {
|
|
88
|
+
this.sttSocket = null;
|
|
89
|
+
this.sttContext = null;
|
|
90
|
+
this.sttMediaStream = null;
|
|
91
|
+
this.sttSourceNode = null;
|
|
92
|
+
this.sttProcessorNode = null;
|
|
93
|
+
this.audioElement = null;
|
|
94
|
+
this.currentBlobUrl = null;
|
|
95
|
+
this.speechGeneration = 0;
|
|
96
|
+
this.listeningGeneration = 0;
|
|
97
|
+
this.ttsUrl = config.ttsUrl ?? DEFAULT_TTS_URL;
|
|
98
|
+
this.sttUrl = config.sttUrl ?? DEFAULT_STT_URL;
|
|
99
|
+
this.sttModel = config.sttModel ?? DEFAULT_STT_MODEL;
|
|
100
|
+
this.ttsModel = config.ttsModel ?? DEFAULT_TTS_MODEL;
|
|
101
|
+
this.speaker = config.speaker ?? DEFAULT_SPEAKER;
|
|
102
|
+
this.sttSampleRate = config.sttSampleRate ?? DEFAULT_STT_SAMPLE_RATE;
|
|
103
|
+
this.ttsSampleRate = config.ttsSampleRate;
|
|
104
|
+
}
|
|
105
|
+
async startListening(language, handlers) {
|
|
106
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
107
|
+
throw new Error("navigator.mediaDevices is unavailable for Sarvam STT");
|
|
108
|
+
}
|
|
109
|
+
if (this.sttSocket || this.sttMediaStream) {
|
|
110
|
+
await this.stopListening();
|
|
111
|
+
}
|
|
112
|
+
const generation = ++this.listeningGeneration;
|
|
113
|
+
const isCurrentSession = () => generation === this.listeningGeneration && this.sttSocket !== null;
|
|
114
|
+
const query = new URLSearchParams({
|
|
115
|
+
"language-code": language,
|
|
116
|
+
model: this.sttModel,
|
|
117
|
+
sample_rate: String(this.sttSampleRate),
|
|
118
|
+
input_audio_codec: "wav",
|
|
119
|
+
high_vad_sensitivity: "true",
|
|
120
|
+
vad_signals: "true",
|
|
121
|
+
flush_signal: "true",
|
|
122
|
+
});
|
|
123
|
+
const socket = new WebSocket(`${resolveWsUrl(this.sttUrl)}?${query.toString()}`);
|
|
124
|
+
this.sttSocket = socket;
|
|
125
|
+
socket.onmessage = (event) => {
|
|
126
|
+
if (!isCurrentSession()) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let message;
|
|
130
|
+
try {
|
|
131
|
+
message = JSON.parse(event.data);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!message || typeof message !== "object") {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
switch (message.type) {
|
|
140
|
+
case "events": {
|
|
141
|
+
const signalType = message.data?.signal_type;
|
|
142
|
+
if (signalType === "START_SPEECH") {
|
|
143
|
+
handlers.onSpeechStart?.();
|
|
144
|
+
}
|
|
145
|
+
else if (signalType === "END_SPEECH") {
|
|
146
|
+
this.sendFlush(socket);
|
|
147
|
+
handlers.onSpeechEnd?.();
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "data": {
|
|
152
|
+
const transcript = typeof message.data?.transcript === "string"
|
|
153
|
+
? message.data.transcript.trim()
|
|
154
|
+
: "";
|
|
155
|
+
if (!transcript) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
handlers.onPartial?.(transcript);
|
|
159
|
+
handlers.onFinal?.(transcript);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "transcript": {
|
|
163
|
+
const transcript = typeof message.text === "string" ? message.text.trim() : "";
|
|
164
|
+
if (!transcript) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
handlers.onPartial?.(transcript);
|
|
168
|
+
handlers.onFinal?.(transcript);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "error": {
|
|
172
|
+
const errorMessage = typeof message.data?.error === "string"
|
|
173
|
+
? message.data.error
|
|
174
|
+
: "Sarvam STT error";
|
|
175
|
+
handlers.onError?.(new Error(errorMessage));
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
socket.onerror = () => {
|
|
183
|
+
if (!isCurrentSession()) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
handlers.onError?.(new Error("Sarvam STT socket error"));
|
|
187
|
+
};
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
190
|
+
socket.addEventListener("error", () => reject(new Error("Failed to connect to Sarvam STT relay")), { once: true });
|
|
191
|
+
});
|
|
192
|
+
if (!isCurrentSession()) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
196
|
+
audio: true,
|
|
197
|
+
});
|
|
198
|
+
const audioContext = new AudioContext();
|
|
199
|
+
const sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
|
200
|
+
const processorNode = audioContext.createScriptProcessor(4096, 1, 1);
|
|
201
|
+
sourceNode.connect(processorNode);
|
|
202
|
+
processorNode.connect(audioContext.destination);
|
|
203
|
+
processorNode.onaudioprocess = (event) => {
|
|
204
|
+
if (!isCurrentSession()) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (handlers.shouldSendAudio && !handlers.shouldSendAudio()) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const floatSamples = event.inputBuffer.getChannelData(0);
|
|
214
|
+
if (!floatSamples || floatSamples.length === 0) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const pcm = downsampleTo16BitMono(floatSamples, audioContext.sampleRate, this.sttSampleRate);
|
|
219
|
+
const wav = encodeWav(pcm, this.sttSampleRate);
|
|
220
|
+
const audioBase64 = toBase64(wav);
|
|
221
|
+
socket.send(JSON.stringify({
|
|
222
|
+
audio: {
|
|
223
|
+
data: audioBase64,
|
|
224
|
+
sample_rate: this.sttSampleRate,
|
|
225
|
+
encoding: "audio/wav",
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
handlers.onError?.(error instanceof Error
|
|
231
|
+
? error
|
|
232
|
+
: new Error(`Failed to stream audio: ${String(error)}`));
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
this.sttContext = audioContext;
|
|
236
|
+
this.sttMediaStream = mediaStream;
|
|
237
|
+
this.sttSourceNode = sourceNode;
|
|
238
|
+
this.sttProcessorNode = processorNode;
|
|
239
|
+
}
|
|
240
|
+
sendFlush(socket) {
|
|
241
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
socket.send(JSON.stringify({ type: "flush" }));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async stopListening() {
|
|
251
|
+
this.listeningGeneration++;
|
|
252
|
+
const socket = this.sttSocket;
|
|
253
|
+
this.sttSocket = null;
|
|
254
|
+
const processorNode = this.sttProcessorNode;
|
|
255
|
+
const sourceNode = this.sttSourceNode;
|
|
256
|
+
const audioContext = this.sttContext;
|
|
257
|
+
const mediaStream = this.sttMediaStream;
|
|
258
|
+
this.sttProcessorNode = null;
|
|
259
|
+
this.sttSourceNode = null;
|
|
260
|
+
this.sttContext = null;
|
|
261
|
+
this.sttMediaStream = null;
|
|
262
|
+
if (processorNode) {
|
|
263
|
+
try {
|
|
264
|
+
processorNode.disconnect();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
}
|
|
268
|
+
processorNode.onaudioprocess = null;
|
|
269
|
+
}
|
|
270
|
+
if (sourceNode) {
|
|
271
|
+
try {
|
|
272
|
+
sourceNode.disconnect();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (mediaStream) {
|
|
278
|
+
for (const track of mediaStream.getTracks()) {
|
|
279
|
+
try {
|
|
280
|
+
track.stop();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (audioContext) {
|
|
287
|
+
try {
|
|
288
|
+
await audioContext.close();
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (socket) {
|
|
294
|
+
this.sendFlush(socket);
|
|
295
|
+
try {
|
|
296
|
+
socket.onmessage = null;
|
|
297
|
+
socket.onerror = null;
|
|
298
|
+
socket.onclose = null;
|
|
299
|
+
socket.close();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async speak(text, opts) {
|
|
306
|
+
if (!text || !text.trim()) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.stopSpeaking();
|
|
310
|
+
const generation = ++this.speechGeneration;
|
|
311
|
+
const language = opts?.language ?? "en-IN";
|
|
312
|
+
const body = {
|
|
313
|
+
text,
|
|
314
|
+
target_language_code: language,
|
|
315
|
+
model: this.ttsModel,
|
|
316
|
+
speaker: this.speaker,
|
|
317
|
+
output_audio_codec: "wav",
|
|
318
|
+
};
|
|
319
|
+
if (this.ttsSampleRate) {
|
|
320
|
+
body.speech_sample_rate = String(this.ttsSampleRate);
|
|
321
|
+
}
|
|
322
|
+
const response = await fetch(this.ttsUrl, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: {
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
},
|
|
327
|
+
body: JSON.stringify(body),
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const errorText = await response.text().catch(() => "");
|
|
331
|
+
throw new Error(`Sarvam TTS failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`);
|
|
332
|
+
}
|
|
333
|
+
const payload = (await response.json());
|
|
334
|
+
const audioBase64 = Array.isArray(payload.audios) && payload.audios.length > 0
|
|
335
|
+
? payload.audios[0]
|
|
336
|
+
: typeof payload.audio === "string"
|
|
337
|
+
? payload.audio
|
|
338
|
+
: null;
|
|
339
|
+
if (!audioBase64) {
|
|
340
|
+
throw new Error("Sarvam TTS response does not contain audio data");
|
|
341
|
+
}
|
|
342
|
+
const binaryString = atob(audioBase64);
|
|
343
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
344
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
345
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
346
|
+
}
|
|
347
|
+
if (generation !== this.speechGeneration) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const blob = new Blob([bytes], { type: "audio/wav" });
|
|
351
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
352
|
+
if (generation !== this.speechGeneration) {
|
|
353
|
+
URL.revokeObjectURL(blobUrl);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.currentBlobUrl = blobUrl;
|
|
357
|
+
const audio = new Audio(blobUrl);
|
|
358
|
+
this.audioElement = audio;
|
|
359
|
+
await new Promise((resolve, reject) => {
|
|
360
|
+
audio.onended = () => {
|
|
361
|
+
this.cleanupAudioElement(audio, blobUrl);
|
|
362
|
+
resolve();
|
|
363
|
+
};
|
|
364
|
+
audio.onerror = () => {
|
|
365
|
+
this.cleanupAudioElement(audio, blobUrl);
|
|
366
|
+
reject(new Error("Sarvam TTS playback failed"));
|
|
367
|
+
};
|
|
368
|
+
audio
|
|
369
|
+
.play()
|
|
370
|
+
.then(() => {
|
|
371
|
+
if (generation === this.speechGeneration) {
|
|
372
|
+
opts?.onAudioStart?.();
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
.catch((error) => {
|
|
376
|
+
this.cleanupAudioElement(audio, blobUrl);
|
|
377
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
stopSpeaking() {
|
|
382
|
+
this.speechGeneration++;
|
|
383
|
+
if (this.audioElement) {
|
|
384
|
+
try {
|
|
385
|
+
this.audioElement.pause();
|
|
386
|
+
this.audioElement.src = "";
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
}
|
|
390
|
+
this.audioElement = null;
|
|
391
|
+
}
|
|
392
|
+
if (this.currentBlobUrl) {
|
|
393
|
+
try {
|
|
394
|
+
URL.revokeObjectURL(this.currentBlobUrl);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
}
|
|
398
|
+
this.currentBlobUrl = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async dispose() {
|
|
402
|
+
await this.stopListening();
|
|
403
|
+
this.stopSpeaking();
|
|
404
|
+
}
|
|
405
|
+
cleanupAudioElement(audio, blobUrl) {
|
|
406
|
+
if (this.audioElement === audio) {
|
|
407
|
+
this.audioElement = null;
|
|
408
|
+
}
|
|
409
|
+
if (this.currentBlobUrl === blobUrl) {
|
|
410
|
+
try {
|
|
411
|
+
URL.revokeObjectURL(blobUrl);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
}
|
|
415
|
+
this.currentBlobUrl = null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { NavigationAction, ActionResult } from "../types.js";
|
|
1
|
+
import type { NavigationAction, ActionResult, SpeechProvider } from "../types.js";
|
|
2
2
|
import type { AzureSpeechService } from "./azure-speech.js";
|
|
3
3
|
import type { BedrockService } from "./bedrock.js";
|
|
4
|
+
import type { SarvamSpeechService } from "./sarvam-speech.js";
|
|
4
5
|
interface VoiceFeedbackConfig {
|
|
5
6
|
enabled?: boolean;
|
|
6
7
|
language?: string;
|
|
7
|
-
|
|
8
|
+
speechProvider?: SpeechProvider;
|
|
9
|
+
azureSpeechService?: AzureSpeechService;
|
|
10
|
+
sarvamSpeechService?: SarvamSpeechService;
|
|
8
11
|
bedrockService: BedrockService;
|
|
9
12
|
}
|
|
10
13
|
interface FeedbackContext {
|
|
@@ -21,7 +24,9 @@ interface FeedbackContext {
|
|
|
21
24
|
export declare class VoiceFeedbackService {
|
|
22
25
|
private enabled;
|
|
23
26
|
private language;
|
|
27
|
+
private speechProvider;
|
|
24
28
|
private azureSpeechService;
|
|
29
|
+
private sarvamSpeechService;
|
|
25
30
|
private bedrockService;
|
|
26
31
|
private synthesizer;
|
|
27
32
|
private isSpeaking;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-feedback.d.ts","sourceRoot":"","sources":["../../src/services/voice-feedback.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"voice-feedback.d.ts","sourceRoot":"","sources":["../../src/services/voice-feedback.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,gBAAgB,EAChB,YAAY,EACZ,cAAc,EACf,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAI9D,UAAU,mBAAmB;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,cAAc,EAAE,cAAc,CAAC;CAChC;AAED,UAAU,eAAe;IACvB,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QACZ,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,mBAAmB,CAA6B;IACxD,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,WAAW,CAAsC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,MAAM,EAAE,mBAAmB;IA2BvC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAclC,SAAS,IAAI,OAAO;IAOpB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;YAQrB,qBAAqB;IA2CnC,OAAO,CAAC,mBAAmB;YAsBb,uBAAuB;IAwCrC,OAAO,CAAC,oBAAoB;IA4C5B,OAAO,CAAC,kBAAkB;YAuCZ,KAAK;IAyFnB,OAAO,CAAC,YAAY;IAwBd,eAAe,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAoFxD,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBxD,mBAAmB,CACvB,OAAO,EAAE,WAAW,EACpB,SAAS,EAAE,MAAM,GAAG,UAAU,GAC7B,OAAO,CAAC,IAAI,CAAC;IAwBhB,OAAO,CAAC,kBAAkB;IAwF1B,OAAO,IAAI,IAAI;CAWhB"}
|