pi-web-voice 0.1.0 → 0.1.2
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 +8 -3
- package/lib/config.cjs +43 -10
- package/lib/doctor.cjs +3 -0
- package/lib/providers.cjs +21 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -297,9 +297,14 @@ no restart, and any agent session you have running stays alive.
|
|
|
297
297
|
|
|
298
298
|
## Privacy
|
|
299
299
|
|
|
300
|
-
Audio goes from your browser to your own pi-web origin, and from there to the
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
Audio goes from your browser to your own pi-web origin, and from there to the speech
|
|
301
|
+
backend you configured. The page is only told which provider is active, never the key.
|
|
302
|
+
Nothing is written to disk and nothing else is contacted.
|
|
303
|
+
|
|
304
|
+
`~/.pi/agent/voice.env` is parsed into a private object rather than merged into
|
|
305
|
+
`process.env`. pi-web runs the agent's shell commands as children of its own process, so
|
|
306
|
+
anything placed in that environment would be handed to every command the agent ever runs.
|
|
307
|
+
Keeping the credentials out of it means only the transcription request sees them.
|
|
303
308
|
|
|
304
309
|
## Compatibility
|
|
305
310
|
|
package/lib/config.cjs
CHANGED
|
@@ -19,17 +19,39 @@ const path = require("node:path");
|
|
|
19
19
|
|
|
20
20
|
const ENV_FILE = path.join(os.homedir(), ".pi", "agent", "voice.env");
|
|
21
21
|
|
|
22
|
-
let
|
|
22
|
+
let fileEnv = null;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Reads the key file into a private object.
|
|
26
|
+
*
|
|
27
|
+
* Node's own `process.loadEnvFile` would be shorter, but it merges into
|
|
28
|
+
* `process.env`, and pi-web spawns the agent's shell commands as children of
|
|
29
|
+
* this process. That would hand every command the agent ever runs a copy of
|
|
30
|
+
* the speech credentials. Keeping them in a local object means they are only
|
|
31
|
+
* ever read by the request that needs them.
|
|
32
|
+
*/
|
|
24
33
|
function loadEnvFile() {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
if (!fs.existsSync(ENV_FILE)) return
|
|
34
|
+
if (fileEnv !== null) return fileEnv;
|
|
35
|
+
fileEnv = {};
|
|
36
|
+
if (!fs.existsSync(ENV_FILE)) return fileEnv;
|
|
28
37
|
|
|
29
38
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
for (const line of fs.readFileSync(ENV_FILE, "utf8").split("\n")) {
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
42
|
+
const eq = trimmed.indexOf("=");
|
|
43
|
+
if (eq < 1) continue;
|
|
44
|
+
const key = trimmed.slice(0, eq).replace(/^export\s+/, "").trim();
|
|
45
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
46
|
+
if (
|
|
47
|
+
value.length >= 2 &&
|
|
48
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
49
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
50
|
+
) {
|
|
51
|
+
value = value.slice(1, -1);
|
|
52
|
+
}
|
|
53
|
+
if (key) fileEnv[key] = value;
|
|
54
|
+
}
|
|
33
55
|
|
|
34
56
|
const mode = fs.statSync(ENV_FILE).mode & 0o077;
|
|
35
57
|
if (mode !== 0 && process.platform !== "win32") {
|
|
@@ -38,7 +60,12 @@ function loadEnvFile() {
|
|
|
38
60
|
} catch (error) {
|
|
39
61
|
console.error(`[pi-web-voice] cannot read ${ENV_FILE}: ${error.message}`);
|
|
40
62
|
}
|
|
41
|
-
return
|
|
63
|
+
return fileEnv;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Real environment first, then the key file. */
|
|
67
|
+
function fromEnv(name) {
|
|
68
|
+
return process.env[name] ?? loadEnvFile()[name];
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
// Not worth configuring: one correct value each.
|
|
@@ -76,8 +103,14 @@ function azureEndpoint(value, suffix) {
|
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
function loadConfig() {
|
|
79
|
-
|
|
80
|
-
const
|
|
106
|
+
loadEnvFile();
|
|
107
|
+
const envFile = fs.existsSync(ENV_FILE) ? ENV_FILE : "";
|
|
108
|
+
const env = new Proxy(
|
|
109
|
+
{},
|
|
110
|
+
{
|
|
111
|
+
get: (_target, name) => (typeof name === "string" ? fromEnv(name) : undefined),
|
|
112
|
+
},
|
|
113
|
+
);
|
|
81
114
|
|
|
82
115
|
const provider = text(
|
|
83
116
|
env.PI_VOICE_PROVIDER,
|
package/lib/doctor.cjs
CHANGED
|
@@ -68,6 +68,9 @@ function describe(config) {
|
|
|
68
68
|
|
|
69
69
|
/** Turns provider failures into the thing that is actually wrong. */
|
|
70
70
|
function diagnose(message) {
|
|
71
|
+
if (/cannot go in an HTTP header|ByteString/i.test(message)) {
|
|
72
|
+
return "the key in ~/.pi/agent/voice.env is not a real key yet";
|
|
73
|
+
}
|
|
71
74
|
if (/\b401\b|Unauthorized|Access denied/i.test(message)) {
|
|
72
75
|
return "the key is wrong, or it belongs to a different resource";
|
|
73
76
|
}
|
package/lib/providers.cjs
CHANGED
|
@@ -27,6 +27,22 @@ function audioBlob(audio) {
|
|
|
27
27
|
return new Blob([audio], { type: "audio/wav" });
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Credentials travel in HTTP headers, which only carry bytes. A key that is
|
|
32
|
+
* still the placeholder, or that picked up a stray quote or newline, would
|
|
33
|
+
* otherwise surface as "Cannot convert argument to a ByteString", which says
|
|
34
|
+
* nothing about what to go and fix.
|
|
35
|
+
*/
|
|
36
|
+
function checkKey(key, variable) {
|
|
37
|
+
if (!key) throw new Error(`${variable} is not set in ~/.pi/agent/voice.env`);
|
|
38
|
+
if (!/^[\x21-\x7e]+$/.test(key)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`${variable} contains characters that cannot go in an HTTP header — ` +
|
|
41
|
+
`is the placeholder still in ~/.pi/agent/voice.env?`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
/**
|
|
31
47
|
* Azure AI Speech — fast transcription, including MAI-Transcribe-2.
|
|
32
48
|
* Phrase list gives real decode-time keyword biasing, and leaving `locales`
|
|
@@ -34,7 +50,8 @@ function audioBlob(audio) {
|
|
|
34
50
|
*/
|
|
35
51
|
async function azureSpeech(audio, config, terms) {
|
|
36
52
|
const { endpoint, key, model, apiVersion, style } = config.azureSpeech;
|
|
37
|
-
if (!endpoint
|
|
53
|
+
if (!endpoint) throw new Error("azure-speech needs AZURE_SPEECH_ENDPOINT");
|
|
54
|
+
checkKey(key, "AZURE_SPEECH_KEY");
|
|
38
55
|
|
|
39
56
|
const url = `${endpoint}/speechtotext/transcriptions:transcribe?api-version=${encodeURIComponent(apiVersion)}`;
|
|
40
57
|
|
|
@@ -102,7 +119,8 @@ function vocabularyPrompt(terms) {
|
|
|
102
119
|
*/
|
|
103
120
|
async function azureOpenAI(audio, config, terms, languages) {
|
|
104
121
|
const { endpoint, key, deployment, apiVersion } = config.azureOpenAI;
|
|
105
|
-
if (!endpoint
|
|
122
|
+
if (!endpoint) throw new Error("azure-openai needs AZURE_OPENAI_ENDPOINT");
|
|
123
|
+
checkKey(key, "AZURE_OPENAI_API_KEY");
|
|
106
124
|
|
|
107
125
|
const explicit = /\/audio\/transcriptions/.test(endpoint);
|
|
108
126
|
const url = explicit
|
|
@@ -144,6 +162,7 @@ async function azureOpenAI(audio, config, terms, languages) {
|
|
|
144
162
|
async function openAICompatible(audio, config, terms) {
|
|
145
163
|
const { baseUrl, key, model } = config.openai;
|
|
146
164
|
if (!baseUrl) throw new Error("openai needs PI_VOICE_OPENAI_BASE_URL");
|
|
165
|
+
if (key) checkKey(key, "OPENAI_API_KEY");
|
|
147
166
|
|
|
148
167
|
const form = new FormData();
|
|
149
168
|
form.append("file", audioBlob(audio), "clip.wav");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi",
|