pi-web-voice 0.1.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/LICENSE +21 -0
- package/README.md +331 -0
- package/bin/pi-web-voice.js +107 -0
- package/hook.cjs +40 -0
- package/lib/config.cjs +134 -0
- package/lib/context.cjs +312 -0
- package/lib/doctor.cjs +126 -0
- package/lib/patch.cjs +204 -0
- package/lib/providers.cjs +202 -0
- package/lib/routes.cjs +134 -0
- package/package.json +44 -0
- package/public/inject.js +526 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Speech-to-text backends. Each one declares how many vocabulary entries it
|
|
5
|
+
* can usefully take, and receives the terms the caller mined for this request.
|
|
6
|
+
*
|
|
7
|
+
* The browser always uploads 16 kHz mono PCM WAV, which every backend accepts,
|
|
8
|
+
* so no server-side ffmpeg or format negotiation is needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
async function withTimeout(ms, run) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
14
|
+
try {
|
|
15
|
+
return await run(controller.signal);
|
|
16
|
+
} finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readError(response) {
|
|
22
|
+
const body = await response.text().catch(() => "");
|
|
23
|
+
return new Error(`${response.status} ${response.statusText} ${body}`.trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function audioBlob(audio) {
|
|
27
|
+
return new Blob([audio], { type: "audio/wav" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Azure AI Speech — fast transcription, including MAI-Transcribe-2.
|
|
32
|
+
* Phrase list gives real decode-time keyword biasing, and leaving `locales`
|
|
33
|
+
* unset keeps automatic language identification and code switching on.
|
|
34
|
+
*/
|
|
35
|
+
async function azureSpeech(audio, config, terms) {
|
|
36
|
+
const { endpoint, key, model, apiVersion, style } = config.azureSpeech;
|
|
37
|
+
if (!endpoint || !key) throw new Error("azure-speech needs AZURE_SPEECH_ENDPOINT and AZURE_SPEECH_KEY");
|
|
38
|
+
|
|
39
|
+
const url = `${endpoint}/speechtotext/transcriptions:transcribe?api-version=${encodeURIComponent(apiVersion)}`;
|
|
40
|
+
|
|
41
|
+
const send = async (phrases) => {
|
|
42
|
+
const definition = {
|
|
43
|
+
enhancedMode: { enabled: true, model, modelOptions: { transcribeStyle: style } },
|
|
44
|
+
};
|
|
45
|
+
if (phrases.length > 0) definition.phraseList = { phrases };
|
|
46
|
+
// `locales` is deliberately never set: leaving it off keeps automatic
|
|
47
|
+
// language identification and mid-sentence code switching enabled.
|
|
48
|
+
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.append("audio", audioBlob(audio), "clip.wav");
|
|
51
|
+
form.append("definition", JSON.stringify(definition));
|
|
52
|
+
|
|
53
|
+
return withTimeout(config.limits.timeoutMs, (signal) =>
|
|
54
|
+
fetch(url, { method: "POST", headers: { "Ocp-Apim-Subscription-Key": key }, body: form, signal }),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let response = await send(terms);
|
|
59
|
+
|
|
60
|
+
// A rejected vocabulary must never cost someone their recording: drop the
|
|
61
|
+
// phrase list and transcribe anyway, slightly less accurately.
|
|
62
|
+
if (response.status === 400 && terms.length > 0) {
|
|
63
|
+
console.error("[pi-web-voice] phrase list rejected, retrying without it");
|
|
64
|
+
response = await send([]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!response.ok) throw await readError(response);
|
|
68
|
+
|
|
69
|
+
const result = await response.json();
|
|
70
|
+
return (result.combinedPhrases ?? []).map((phrase) => phrase.text).join(" ").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whisper-style models read a free-form `prompt`. Whisper only keeps the final
|
|
75
|
+
* 224 tokens of it, so the list goes last and stays short.
|
|
76
|
+
*/
|
|
77
|
+
function vocabularyPrompt(terms) {
|
|
78
|
+
if (terms.length === 0) return "";
|
|
79
|
+
let prompt = "";
|
|
80
|
+
for (const term of terms) {
|
|
81
|
+
const next = prompt ? `${prompt}, ${term}` : term;
|
|
82
|
+
if (next.length > 700) break;
|
|
83
|
+
prompt = next;
|
|
84
|
+
}
|
|
85
|
+
return `Terms that may appear: ${prompt}.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Azure OpenAI.
|
|
90
|
+
*
|
|
91
|
+
* `AZURE_OPENAI_ENDPOINT` may be a resource name, a resource URL, or the whole
|
|
92
|
+
* transcriptions URL copied from the portal — Azure has shipped the classic
|
|
93
|
+
* `/openai/deployments/<name>/audio/transcriptions?api-version=...` path and
|
|
94
|
+
* the newer `/openai/v1/audio/transcriptions?api-version=preview` surface, and
|
|
95
|
+
* which one a resource serves is not worth guessing.
|
|
96
|
+
*
|
|
97
|
+
* `gpt-transcribe` accepts structured `keywords[]` and `languages[]`, which
|
|
98
|
+
* suits a mined vocabulary far better than stuffing it into a free-form
|
|
99
|
+
* prompt. Older models get the prompt instead. If the service rejects the
|
|
100
|
+
* structured fields, the request is retried with the prompt so a recording is
|
|
101
|
+
* never lost to a parameter mismatch.
|
|
102
|
+
*/
|
|
103
|
+
async function azureOpenAI(audio, config, terms, languages) {
|
|
104
|
+
const { endpoint, key, deployment, apiVersion } = config.azureOpenAI;
|
|
105
|
+
if (!endpoint || !key) throw new Error("azure-openai needs AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY");
|
|
106
|
+
|
|
107
|
+
const explicit = /\/audio\/transcriptions/.test(endpoint);
|
|
108
|
+
const url = explicit
|
|
109
|
+
? endpoint
|
|
110
|
+
: `${endpoint}/openai/deployments/${encodeURIComponent(deployment)}/audio/transcriptions?api-version=${encodeURIComponent(apiVersion)}`;
|
|
111
|
+
const needsModel = /\/openai\/v1\//.test(url);
|
|
112
|
+
const structured = /gpt-transcribe/i.test(url) || /gpt-transcribe/i.test(deployment);
|
|
113
|
+
|
|
114
|
+
const send = async (withKeywords) => {
|
|
115
|
+
const form = new FormData();
|
|
116
|
+
form.append("file", audioBlob(audio), "clip.wav");
|
|
117
|
+
form.append("response_format", "json");
|
|
118
|
+
if (needsModel) form.append("model", deployment);
|
|
119
|
+
|
|
120
|
+
if (withKeywords) {
|
|
121
|
+
for (const term of terms) form.append("keywords[]", term);
|
|
122
|
+
for (const language of languages) form.append("languages[]", language);
|
|
123
|
+
} else {
|
|
124
|
+
const prompt = vocabularyPrompt(terms);
|
|
125
|
+
if (prompt) form.append("prompt", prompt);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return withTimeout(config.limits.timeoutMs, (signal) =>
|
|
129
|
+
fetch(url, { method: "POST", headers: { "api-key": key }, body: form, signal }),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let response = await send(structured);
|
|
134
|
+
if (!response.ok && response.status === 400 && structured) {
|
|
135
|
+
console.error("[pi-web-voice] keywords rejected, retrying with a prompt");
|
|
136
|
+
response = await send(false);
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok) throw await readError(response);
|
|
139
|
+
|
|
140
|
+
return String((await response.json()).text ?? "").trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** OpenAI, Groq, or any server exposing /audio/transcriptions. */
|
|
144
|
+
async function openAICompatible(audio, config, terms) {
|
|
145
|
+
const { baseUrl, key, model } = config.openai;
|
|
146
|
+
if (!baseUrl) throw new Error("openai needs PI_VOICE_OPENAI_BASE_URL");
|
|
147
|
+
|
|
148
|
+
const form = new FormData();
|
|
149
|
+
form.append("file", audioBlob(audio), "clip.wav");
|
|
150
|
+
form.append("model", model);
|
|
151
|
+
form.append("response_format", "json");
|
|
152
|
+
const prompt = vocabularyPrompt(terms);
|
|
153
|
+
if (prompt) form.append("prompt", prompt);
|
|
154
|
+
|
|
155
|
+
const response = await withTimeout(config.limits.timeoutMs, (signal) =>
|
|
156
|
+
fetch(`${baseUrl.replace(/\/+$/, "")}/audio/transcriptions`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: key ? { authorization: `Bearer ${key}` } : {},
|
|
159
|
+
body: form,
|
|
160
|
+
signal,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
if (!response.ok) throw await readError(response);
|
|
164
|
+
|
|
165
|
+
return String((await response.json()).text ?? "").trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** No credentials required. Used to verify the plumbing end to end. */
|
|
169
|
+
async function mock(audio, config, terms, languages) {
|
|
170
|
+
const seconds = Math.max(1, Math.round(audio.length / (16000 * 2)));
|
|
171
|
+
const vocabulary = terms.length > 0 ? ` · ${terms.length} terms: ${terms.slice(0, 8).join(", ")}` : "";
|
|
172
|
+
return `[pi-web-voice mock] received ${audio.length} bytes (~${seconds}s of audio) · ${languages.join("/")}${vocabulary}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// How many vocabulary entries each backend can usefully absorb.
|
|
176
|
+
// MAI-Transcribe rejects a phrase list longer than 50 outright, and
|
|
177
|
+
// whisper-style prompts are bounded by their token budget.
|
|
178
|
+
const TERM_BUDGET = {
|
|
179
|
+
"azure-speech": 50,
|
|
180
|
+
"azure-openai": 60,
|
|
181
|
+
openai: 60,
|
|
182
|
+
mock: 50,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const PROVIDERS = {
|
|
186
|
+
"azure-speech": azureSpeech,
|
|
187
|
+
"azure-openai": azureOpenAI,
|
|
188
|
+
openai: openAICompatible,
|
|
189
|
+
mock,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function termBudget(provider) {
|
|
193
|
+
return TERM_BUDGET[provider] ?? 50;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function transcribe(audio, config, terms = [], languages = ["en"]) {
|
|
197
|
+
const provider = PROVIDERS[config.provider];
|
|
198
|
+
if (!provider) throw new Error(`unknown provider: ${config.provider}`);
|
|
199
|
+
return provider(audio, config, terms, languages);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { transcribe, termBudget, PROVIDERS };
|
package/lib/routes.cjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { transcribe, termBudget } = require("./providers.cjs");
|
|
6
|
+
const { collectTerms } = require("./context.cjs");
|
|
7
|
+
|
|
8
|
+
const INJECT_FILE = path.join(__dirname, "..", "public", "inject.js");
|
|
9
|
+
|
|
10
|
+
function json(res, status, body) {
|
|
11
|
+
const payload = JSON.stringify(body);
|
|
12
|
+
res.writeHead(status, {
|
|
13
|
+
"content-type": "application/json; charset=utf-8",
|
|
14
|
+
"content-length": Buffer.byteLength(payload),
|
|
15
|
+
"cache-control": "no-store",
|
|
16
|
+
});
|
|
17
|
+
res.end(payload);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readBody(req, maxBytes) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let size = 0;
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > maxBytes) {
|
|
27
|
+
reject(new Error(`audio exceeds ${maxBytes} bytes`));
|
|
28
|
+
req.destroy();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
});
|
|
33
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
34
|
+
req.on("error", reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Settings the browser needs. Credentials never leave the server: the page
|
|
40
|
+
* only learns which provider is configured, never its key.
|
|
41
|
+
*/
|
|
42
|
+
function browserConfig(config) {
|
|
43
|
+
return { prefix: config.prefix, provider: config.provider };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Language hints for the models that accept several. The browser already says
|
|
48
|
+
* what it speaks, so nothing needs configuring; English is always included
|
|
49
|
+
* because the terms themselves are English.
|
|
50
|
+
*/
|
|
51
|
+
function languageHints(req) {
|
|
52
|
+
const header = String(req.headers["accept-language"] ?? "");
|
|
53
|
+
const languages = header
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((part) => part.split(";")[0].trim().split("-")[0].toLowerCase())
|
|
56
|
+
.filter((tag) => /^[a-z]{2,3}$/.test(tag));
|
|
57
|
+
return [...new Set([...languages, "en"])].slice(0, 3);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createRouter(config) {
|
|
61
|
+
return async function handleRoute(req, res) {
|
|
62
|
+
const url = new URL(req.url, "http://localhost");
|
|
63
|
+
const route = url.pathname.slice(config.prefix.length) || "/";
|
|
64
|
+
|
|
65
|
+
if (route === "/inject.js") {
|
|
66
|
+
// Read on every request so editing inject.js takes effect on reload,
|
|
67
|
+
// with no restart of pi-web.
|
|
68
|
+
const source = await fs.readFile(INJECT_FILE, "utf8");
|
|
69
|
+
const preamble = `window.__PI_WEB_VOICE__=${JSON.stringify(browserConfig(config))};\n`;
|
|
70
|
+
const body = preamble + source;
|
|
71
|
+
res.writeHead(200, {
|
|
72
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
73
|
+
"content-length": Buffer.byteLength(body),
|
|
74
|
+
"cache-control": "no-store",
|
|
75
|
+
});
|
|
76
|
+
res.end(body);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (route === "/health") {
|
|
81
|
+
json(res, 200, { ok: true, provider: config.provider });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Shows exactly which vocabulary a session would send. Useful for tuning,
|
|
86
|
+
// and for seeing what leaves the machine before it does.
|
|
87
|
+
if (route === "/terms") {
|
|
88
|
+
const sessionId = url.searchParams.get("session") ?? "";
|
|
89
|
+
const cwd = url.searchParams.get("cwd") ?? "";
|
|
90
|
+
const limit = Math.min(config.context.maxTerms, termBudget(config.provider));
|
|
91
|
+
const terms = collectTerms(sessionId, cwd, config, limit);
|
|
92
|
+
json(res, 200, { sessionId, cwd, count: terms.length, terms });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (route === "/transcribe") {
|
|
97
|
+
if (req.method !== "POST") return json(res, 405, { error: "POST only" });
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
const audio = await readBody(req, config.limits.maxBytes);
|
|
101
|
+
if (audio.length === 0) return json(res, 400, { error: "empty audio" });
|
|
102
|
+
|
|
103
|
+
const sessionId = url.searchParams.get("session") ?? "";
|
|
104
|
+
const cwd = url.searchParams.get("cwd") ?? "";
|
|
105
|
+
const limit = Math.min(config.context.maxTerms, termBudget(config.provider));
|
|
106
|
+
const terms = collectTerms(sessionId, cwd, config, limit);
|
|
107
|
+
const languages = languageHints(req);
|
|
108
|
+
|
|
109
|
+
const text = await transcribe(audio, config, terms, languages);
|
|
110
|
+
// Metadata only, never the transcript: enough to see how it is doing
|
|
111
|
+
// over a week without writing anything you said into a log file.
|
|
112
|
+
console.log(
|
|
113
|
+
`[pi-web-voice] ${((Date.now() - started) / 1000).toFixed(1)}s · ` +
|
|
114
|
+
`${(audio.length / 32000).toFixed(1)}s audio · ${terms.length} terms · ` +
|
|
115
|
+
`${text.length} chars · ${languages.join("/")}`,
|
|
116
|
+
);
|
|
117
|
+
json(res, 200, {
|
|
118
|
+
text,
|
|
119
|
+
ms: Date.now() - started,
|
|
120
|
+
provider: config.provider,
|
|
121
|
+
terms: terms.length,
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error("[pi-web-voice] transcribe failed:", error.message);
|
|
125
|
+
json(res, 502, { error: error.message });
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
json(res, 404, { error: "not found" });
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { createRouter };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-web-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Voice input for pi-web. A NODE_OPTIONS hook that injects a microphone button into the chat composer and transcribes speech with Azure AI Speech, Azure OpenAI, or any OpenAI-compatible endpoint. No fork, no patching, no rebuild.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-web",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"voice",
|
|
10
|
+
"dictation",
|
|
11
|
+
"speech-to-text",
|
|
12
|
+
"azure",
|
|
13
|
+
"whisper"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/lijunle/pi-web-voice#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/lijunle/pi-web-voice.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/lijunle/pi-web-voice/issues"
|
|
22
|
+
},
|
|
23
|
+
"author": "Junle Li",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"main": "hook.cjs",
|
|
26
|
+
"bin": {
|
|
27
|
+
"pi-web-voice": "bin/pi-web-voice.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"hook.cjs",
|
|
31
|
+
"lib",
|
|
32
|
+
"bin",
|
|
33
|
+
"public",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.0.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node test/run.mjs",
|
|
42
|
+
"test:e2e": "node test/e2e-edge.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|