pi-voice-input 0.1.3 → 0.2.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/AGENTS.md +3 -4
- package/README.md +34 -8
- package/extensions/voice-input.ts +437 -142
- package/package.json +7 -3
- package/.env.example +0 -27
package/AGENTS.md
CHANGED
|
@@ -13,8 +13,8 @@ Development workflow for this repo.
|
|
|
13
13
|
|
|
14
14
|
## Secrets and local data
|
|
15
15
|
|
|
16
|
-
- Never commit API keys, `.env`, recordings, logs, caches, or `node_modules`.
|
|
17
|
-
- User credentials belong in `~/.pi/agent/voice-input.
|
|
16
|
+
- Never commit API keys, `.env`, local config JSON, recordings, logs, caches, or `node_modules`.
|
|
17
|
+
- User credentials and plugin settings belong in `~/.pi/agent/voice-input.config.json`, usually written by `/voice key` or `/voice init`.
|
|
18
18
|
- Do not print or copy real API keys into commits, docs, tests, or command output.
|
|
19
19
|
- The explicit VolcEngine API key URL that should be shown to users is:
|
|
20
20
|
`https://console.volcengine.com/speech/new/setting/apikeys?projectName=default`
|
|
@@ -33,7 +33,6 @@ npm pack --dry-run
|
|
|
33
33
|
Check that `npm pack --dry-run` includes only publishable files, normally:
|
|
34
34
|
|
|
35
35
|
```text
|
|
36
|
-
.env.example
|
|
37
36
|
AGENTS.md
|
|
38
37
|
README.md
|
|
39
38
|
extensions/voice-input.ts
|
|
@@ -50,7 +49,7 @@ Then check:
|
|
|
50
49
|
|
|
51
50
|
```bash
|
|
52
51
|
git status --short
|
|
53
|
-
rg -n "VOLC_API_KEY=|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
|
|
52
|
+
rg -n '"volcApiKey"\\s*:\\s*"[^"]+"|VOLC_API_KEY=|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' \
|
|
54
53
|
--glob '!node_modules/**' --glob '!package-lock.json' . || true
|
|
55
54
|
```
|
|
56
55
|
|
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ pi extension: extensions/voice-input.ts
|
|
|
23
23
|
├─ parses the WAV container in TypeScript and extracts raw PCM
|
|
24
24
|
├─ sends PCM frames to the configured ASR provider via ws
|
|
25
25
|
│ └─ current provider: VolcEngine /api/v3/sauc/bigmodel_nostream
|
|
26
|
+
├─ optionally post-processes raw ASR text with a configured pi model
|
|
27
|
+
│ └─ default: deepseek/deepseek-v4-flash, no reasoning option
|
|
26
28
|
└─ appends the final transcript to pi's editor with ctx.ui.setEditorText()
|
|
27
29
|
```
|
|
28
30
|
|
|
@@ -56,23 +58,44 @@ Planned provider direction:
|
|
|
56
58
|
- add more ASR providers without changing the shortcut/user workflow
|
|
57
59
|
- keep provider credentials and options isolated in config
|
|
58
60
|
|
|
59
|
-
## Configure
|
|
61
|
+
## Configure
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
All plugin settings live in one JSON file:
|
|
62
64
|
|
|
63
65
|
```text
|
|
64
|
-
/voice
|
|
66
|
+
~/.pi/agent/voice-input.config.json
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
Package-local and project-local env files are not read.
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
Create or normalize the file from inside pi:
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
```text
|
|
74
|
+
/voice init
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then set the VolcEngine Speech API key:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
/voice key
|
|
81
|
+
```
|
|
72
82
|
|
|
73
83
|
The key URL is also shown inside pi when the key is missing, when you run `/voice key`, and in `/voice help`:
|
|
74
84
|
|
|
75
|
-
|
|
85
|
+
https://console.volcengine.com/speech/new/setting/apikeys?projectName=default
|
|
86
|
+
|
|
87
|
+
The config file is plain JSON and can be edited directly:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"volcApiKey": "",
|
|
92
|
+
"polishModel": "deepseek/deepseek-v4-flash"
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`polishModel` is resolved from pi's model registry, so any model shown by `pi --list-models` can be used. Leave it empty to disable polish. If polishing fails, the raw ASR transcript is inserted instead.
|
|
97
|
+
|
|
98
|
+
Verify the effective non-secret config:
|
|
76
99
|
|
|
77
100
|
```text
|
|
78
101
|
/voice config
|
|
@@ -95,6 +118,7 @@ Slash commands:
|
|
|
95
118
|
/voice cancel # stop recording without transcribing
|
|
96
119
|
/voice status # show recorder state
|
|
97
120
|
/voice config # show effective non-secret config and whether API key is detected
|
|
121
|
+
/voice init # create or normalize ~/.pi/agent/voice-input.config.json
|
|
98
122
|
/voice key # prompt for and save the current provider API key
|
|
99
123
|
/voice help # show setup help, including the explicit VolcEngine API key URL
|
|
100
124
|
```
|
|
@@ -102,8 +126,10 @@ Slash commands:
|
|
|
102
126
|
## Notes
|
|
103
127
|
|
|
104
128
|
- The extension uses post-recording WebSocket ASR: it records locally first, then sends the stopped recording in chunks. It is optimized for fast voice input, not live subtitles.
|
|
105
|
-
- The default
|
|
129
|
+
- The default ASR segment size is intentionally larger than realtime packet sizes because this workflow sends already-recorded audio.
|
|
106
130
|
- The transcript is inserted into the editor only; it is not submitted automatically.
|
|
131
|
+
- When `polishModel` is set, polishing uses the current editor content and recent session messages as context, but outputs only the refined user instruction.
|
|
132
|
+
- While recording, the status line and tool panel show `Recording with [device name]`.
|
|
107
133
|
|
|
108
134
|
## Development
|
|
109
135
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai";
|
|
2
3
|
import { Key } from "@earendil-works/pi-tui";
|
|
3
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -15,15 +16,23 @@ import {
|
|
|
15
16
|
} from "node:fs";
|
|
16
17
|
import { homedir } from "node:os";
|
|
17
18
|
import path from "node:path";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { gzipSync, gunzipSync } from "node:zlib";
|
|
20
20
|
import WebSocket from "ws";
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
const PACKAGE_ROOT = path.resolve(EXTENSION_DIR, "..");
|
|
24
|
-
const PRIVATE_CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.env");
|
|
22
|
+
const CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.config.json");
|
|
25
23
|
const VOLC_API_KEY_URL = "https://console.volcengine.com/speech/new/setting/apikeys?projectName=default";
|
|
26
24
|
const DEFAULT_SHORTCUT = Key.ctrlShift("r");
|
|
25
|
+
const DEFAULT_POSTPROCESS_MODEL = "deepseek/deepseek-v4-flash";
|
|
26
|
+
const POSTPROCESS_SYSTEM_PROMPT = `你是 pi 语音输入插件的语音识别后处理器。你的唯一任务是把原始 ASR 文本改写为可直接提交给编码智能体的用户指令。
|
|
27
|
+
|
|
28
|
+
规则:
|
|
29
|
+
- 只输出优化后的用户指令正文,不要输出解释、标题、前后缀、引号、代码围栏或寒暄。
|
|
30
|
+
- 结合上下文理解省略指代、当前任务、文件/项目名称和用户意图;上下文仅用于理解,不要重复上下文内容,除非原始语音明确要求引用或修改它。
|
|
31
|
+
- 修正明显的语音识别错误、同音/近音错误、断句和标点错误;保留代码标识符、命令、路径、URL、模型名、包名和专有名词。
|
|
32
|
+
- 如果用户口误后自我更正(例如“不是……是……”“不对……”“算了改成……”),只保留更正后的正确指令,删除错误说法和更正过程。
|
|
33
|
+
- 让结果完整、符合逻辑、指令明确、有指导性;必要时拆成条目或步骤。
|
|
34
|
+
- 不要凭空添加原始语音没有表达的新需求;不确定时保留原意并用更清晰的措辞表达。
|
|
35
|
+
- 输出语言通常与原始语音一致。`;
|
|
27
36
|
|
|
28
37
|
const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
|
|
29
38
|
const MSG_TYPE_CLIENT_AUDIO_ONLY_REQUEST = 0b0010;
|
|
@@ -35,9 +44,15 @@ const SERIALIZATION_NONE = 0b0000;
|
|
|
35
44
|
const SERIALIZATION_JSON = 0b0001;
|
|
36
45
|
const COMPRESSION_GZIP = 0b0001;
|
|
37
46
|
|
|
38
|
-
type
|
|
47
|
+
type JsonObject = Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
type VoiceInputConfigFile = {
|
|
50
|
+
volcApiKey: string;
|
|
51
|
+
polishModel: string;
|
|
52
|
+
};
|
|
39
53
|
|
|
40
54
|
type VoiceConfig = {
|
|
55
|
+
configPath: string;
|
|
41
56
|
apiKey: string;
|
|
42
57
|
wsUrl: string;
|
|
43
58
|
resourceId: string;
|
|
@@ -56,6 +71,11 @@ type VoiceConfig = {
|
|
|
56
71
|
enablePunc: boolean;
|
|
57
72
|
enableDdc: boolean;
|
|
58
73
|
showUtterances: boolean;
|
|
74
|
+
postprocessEnabled: boolean;
|
|
75
|
+
postprocessModel: string;
|
|
76
|
+
postprocessTimeoutMs: number;
|
|
77
|
+
postprocessMaxTokens: number;
|
|
78
|
+
postprocessContextChars: number;
|
|
59
79
|
};
|
|
60
80
|
|
|
61
81
|
type RecordingState = {
|
|
@@ -64,6 +84,7 @@ type RecordingState = {
|
|
|
64
84
|
logPath: string;
|
|
65
85
|
startedAt: string;
|
|
66
86
|
recorderTarget?: string;
|
|
87
|
+
deviceName?: string;
|
|
67
88
|
};
|
|
68
89
|
|
|
69
90
|
type DecodedFrame = {
|
|
@@ -85,139 +106,94 @@ type TranscriptionResult = {
|
|
|
85
106
|
};
|
|
86
107
|
};
|
|
87
108
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
91
|
-
const line = rawLine.trim();
|
|
92
|
-
if (!line || line.startsWith("#")) continue;
|
|
93
|
-
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
94
|
-
if (!match) continue;
|
|
95
|
-
const key = match[1];
|
|
96
|
-
let value = match[2] ?? "";
|
|
97
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
98
|
-
value = value.slice(1, -1);
|
|
99
|
-
}
|
|
100
|
-
env[key] = value;
|
|
101
|
-
}
|
|
102
|
-
return env;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function loadEnvFiles(): EnvMap {
|
|
106
|
-
const candidates = [
|
|
107
|
-
PRIVATE_CONFIG_PATH,
|
|
108
|
-
path.join(PACKAGE_ROOT, ".env"),
|
|
109
|
-
path.join(process.cwd(), ".env"),
|
|
110
|
-
];
|
|
111
|
-
const merged: EnvMap = {};
|
|
112
|
-
for (const file of candidates) {
|
|
113
|
-
if (!existsSync(file)) continue;
|
|
114
|
-
Object.assign(merged, parseEnvText(readFileSync(file, "utf8")));
|
|
115
|
-
}
|
|
116
|
-
return merged;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function setting(env: EnvMap, name: string, fallback = ""): string {
|
|
120
|
-
const value = process.env[name] ?? env[name];
|
|
121
|
-
return value == null ? fallback : value;
|
|
109
|
+
function ensureDir(dir: string) {
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
122
111
|
}
|
|
123
112
|
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
return fallback;
|
|
113
|
+
function defaultConfigFile(): VoiceInputConfigFile {
|
|
114
|
+
return {
|
|
115
|
+
volcApiKey: "",
|
|
116
|
+
polishModel: DEFAULT_POSTPROCESS_MODEL,
|
|
117
|
+
};
|
|
130
118
|
}
|
|
131
119
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
if (["1", "true", "yes", "on"].includes(raw)) return true;
|
|
135
|
-
if (["0", "false", "no", "off"].includes(raw)) return false;
|
|
136
|
-
return fallback;
|
|
120
|
+
function isObject(value: unknown): value is JsonObject {
|
|
121
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
137
122
|
}
|
|
138
123
|
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
return Number.isFinite(value) ? value : fallback;
|
|
124
|
+
function stringField(source: JsonObject, name: string, fallback: string): string {
|
|
125
|
+
const value = source[name];
|
|
126
|
+
return typeof value === "string" ? value : fallback;
|
|
143
127
|
}
|
|
144
128
|
|
|
145
|
-
function
|
|
146
|
-
|
|
129
|
+
function normalizeConfigFile(input: unknown): VoiceInputConfigFile {
|
|
130
|
+
const defaults = defaultConfigFile();
|
|
131
|
+
const root = isObject(input) ? input : {};
|
|
132
|
+
return {
|
|
133
|
+
volcApiKey: stringField(root, "volcApiKey", defaults.volcApiKey).trim(),
|
|
134
|
+
polishModel: stringField(root, "polishModel", defaults.polishModel).trim(),
|
|
135
|
+
};
|
|
147
136
|
}
|
|
148
137
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
138
|
+
function writeConfigFile(config: unknown) {
|
|
139
|
+
ensureDir(path.dirname(CONFIG_PATH));
|
|
140
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(normalizeConfigFile(config), null, 2)}\n`, { mode: 0o600 });
|
|
141
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
153
142
|
}
|
|
154
143
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
144
|
+
function loadConfigFile(): VoiceInputConfigFile {
|
|
145
|
+
if (!existsSync(CONFIG_PATH)) return defaultConfigFile();
|
|
146
|
+
try {
|
|
147
|
+
return normalizeConfigFile(JSON.parse(readFileSync(CONFIG_PATH, "utf8")));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new Error(`Failed to read voice input config ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
|
+
}
|
|
158
151
|
}
|
|
159
152
|
|
|
160
153
|
function getConfig(): VoiceConfig {
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
154
|
+
const fileConfig = loadConfigFile();
|
|
155
|
+
const voiceHome = path.join(homedir(), ".pi", "agent", "voice-input");
|
|
156
|
+
const polishModel = fileConfig.polishModel.trim();
|
|
164
157
|
|
|
165
158
|
return {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
159
|
+
configPath: CONFIG_PATH,
|
|
160
|
+
apiKey: fileConfig.volcApiKey.trim(),
|
|
161
|
+
wsUrl: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_nostream",
|
|
162
|
+
resourceId: "volc.seedasr.sauc.duration",
|
|
163
|
+
language: "",
|
|
164
|
+
uid: "pi-voice-input",
|
|
165
|
+
prompt: "",
|
|
166
|
+
segmentMs: 5000,
|
|
167
|
+
requestTimeoutMs: 90000,
|
|
168
|
+
finalizeDelayMs: 100,
|
|
169
|
+
recorderTarget: "",
|
|
170
|
+
recordingsDir: path.join(voiceHome, "recordings"),
|
|
171
|
+
statePath: path.join(voiceHome, "recording.json"),
|
|
172
|
+
logDir: path.join(voiceHome, "logs"),
|
|
173
|
+
shortcut: DEFAULT_SHORTCUT,
|
|
174
|
+
enableItn: true,
|
|
175
|
+
enablePunc: true,
|
|
176
|
+
enableDdc: false,
|
|
177
|
+
showUtterances: false,
|
|
178
|
+
postprocessEnabled: polishModel.length > 0,
|
|
179
|
+
postprocessModel: polishModel,
|
|
180
|
+
postprocessTimeoutMs: 30000,
|
|
181
|
+
postprocessMaxTokens: 2048,
|
|
182
|
+
postprocessContextChars: 6000,
|
|
184
183
|
};
|
|
185
184
|
}
|
|
186
185
|
|
|
187
|
-
function
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
function envValue(value: string): string {
|
|
192
|
-
if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
|
|
193
|
-
return JSON.stringify(value);
|
|
186
|
+
function ensureConfigFile(): boolean {
|
|
187
|
+
const existed = existsSync(CONFIG_PATH);
|
|
188
|
+
writeConfigFile(loadConfigFile());
|
|
189
|
+
return !existed;
|
|
194
190
|
}
|
|
195
191
|
|
|
196
|
-
function
|
|
197
|
-
if (/\r|\n/.test(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const lines = original ? original.split(/\r?\n/) : [];
|
|
202
|
-
const replacement = `${name}=${envValue(value)}`;
|
|
203
|
-
let replaced = false;
|
|
204
|
-
|
|
205
|
-
const nextLines = lines.map((line) => {
|
|
206
|
-
if (new RegExp(`^\\s*${name}\\s*=`).test(line)) {
|
|
207
|
-
replaced = true;
|
|
208
|
-
return replacement;
|
|
209
|
-
}
|
|
210
|
-
return line;
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
if (!replaced) {
|
|
214
|
-
if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") nextLines.push("");
|
|
215
|
-
nextLines.push("# Managed by pi-voice-input. You can also update this with /voice key.");
|
|
216
|
-
nextLines.push(replacement);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
writeFileSync(PRIVATE_CONFIG_PATH, nextLines.join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
220
|
-
chmodSync(PRIVATE_CONFIG_PATH, 0o600);
|
|
192
|
+
function writeConfigApiKey(apiKey: string) {
|
|
193
|
+
if (/\r|\n/.test(apiKey)) throw new Error("volcApiKey must be a single-line value");
|
|
194
|
+
const config = loadConfigFile();
|
|
195
|
+
config.volcApiKey = apiKey.trim();
|
|
196
|
+
writeConfigFile(config);
|
|
221
197
|
}
|
|
222
198
|
|
|
223
199
|
function timestampForFilename(): string {
|
|
@@ -228,6 +204,12 @@ function commandExists(command: string): boolean {
|
|
|
228
204
|
return spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
229
205
|
}
|
|
230
206
|
|
|
207
|
+
function commandOutput(command: string, args: string[], timeoutMs = 1500): string {
|
|
208
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: timeoutMs });
|
|
209
|
+
if (result.status !== 0) return "";
|
|
210
|
+
return (result.stdout || "").trim();
|
|
211
|
+
}
|
|
212
|
+
|
|
231
213
|
function recorderCommand(config: VoiceConfig, outputPath: string): string[] {
|
|
232
214
|
if (commandExists("pw-record")) {
|
|
233
215
|
const cmd = ["pw-record", "--rate", "16000", "--channels", "1", "--format", "s16"];
|
|
@@ -241,6 +223,98 @@ function recorderCommand(config: VoiceConfig, outputPath: string): string[] {
|
|
|
241
223
|
throw new Error("No recorder found. Install PipeWire tools (pw-record) or alsa-utils (arecord).");
|
|
242
224
|
}
|
|
243
225
|
|
|
226
|
+
type PipeWireSource = {
|
|
227
|
+
id: string;
|
|
228
|
+
name: string;
|
|
229
|
+
description: string;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function parsePactlSources(text: string): PipeWireSource[] {
|
|
233
|
+
const sources: PipeWireSource[] = [];
|
|
234
|
+
let current: PipeWireSource | null = null;
|
|
235
|
+
for (const line of text.split(/\r?\n/)) {
|
|
236
|
+
const sourceMatch = line.match(/^Source #(\S+)/);
|
|
237
|
+
if (sourceMatch) {
|
|
238
|
+
if (current) sources.push(current);
|
|
239
|
+
current = { id: sourceMatch[1], name: "", description: "" };
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!current) continue;
|
|
243
|
+
const nameMatch = line.match(/^\s*Name:\s*(.+)$/);
|
|
244
|
+
if (nameMatch) {
|
|
245
|
+
current.name = nameMatch[1].trim();
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const descriptionMatch = line.match(/^\s*Description:\s*(.+)$/);
|
|
249
|
+
if (descriptionMatch) current.description = descriptionMatch[1].trim();
|
|
250
|
+
}
|
|
251
|
+
if (current) sources.push(current);
|
|
252
|
+
return sources;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function wpctlProperty(text: string, property: string): string {
|
|
256
|
+
const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
257
|
+
const match = text.match(new RegExp(`(?:^|\\n)\\s*\\*?\\s*${escaped}\\s*=\\s*"([^"]+)"`));
|
|
258
|
+
return match?.[1]?.trim() ?? "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function inspectPipeWireSource(target: string): string {
|
|
262
|
+
if (!commandExists("wpctl")) return "";
|
|
263
|
+
const inspect = commandOutput("wpctl", ["inspect", target]);
|
|
264
|
+
return (
|
|
265
|
+
wpctlProperty(inspect, "node.description") ||
|
|
266
|
+
wpctlProperty(inspect, "node.nick") ||
|
|
267
|
+
wpctlProperty(inspect, "node.name")
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function defaultPipeWireSourceFromStatus(): string {
|
|
272
|
+
if (!commandExists("wpctl")) return "";
|
|
273
|
+
const status = commandOutput("wpctl", ["status"]);
|
|
274
|
+
let inSources = false;
|
|
275
|
+
for (const line of status.split(/\r?\n/)) {
|
|
276
|
+
if (/Sources:/.test(line)) {
|
|
277
|
+
inSources = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (inSources && /^\s*[├└]─/.test(line)) break;
|
|
281
|
+
if (!inSources) continue;
|
|
282
|
+
const match = line.match(/^\s*│\s+\*\s+\d+\.\s+(.+?)(?:\s+\[|$)/);
|
|
283
|
+
if (match) return match[1].trim();
|
|
284
|
+
}
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function pipeWireSourceName(target: string): string {
|
|
289
|
+
const sources = commandExists("pactl") ? parsePactlSources(commandOutput("pactl", ["list", "sources"])) : [];
|
|
290
|
+
|
|
291
|
+
if (!target) {
|
|
292
|
+
const defaultName = commandExists("pactl") ? commandOutput("pactl", ["get-default-source"]) : "";
|
|
293
|
+
const source = sources.find((item) => item.name === defaultName);
|
|
294
|
+
return (
|
|
295
|
+
source?.description ||
|
|
296
|
+
source?.name ||
|
|
297
|
+
inspectPipeWireSource("@DEFAULT_SOURCE@") ||
|
|
298
|
+
defaultPipeWireSourceFromStatus() ||
|
|
299
|
+
defaultName ||
|
|
300
|
+
"default microphone"
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const source = sources.find((item) => item.id === target || item.name === target || item.description === target);
|
|
305
|
+
return source?.description || source?.name || (/^\d+$/.test(target) ? inspectPipeWireSource(target) : "") || target;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function recordingDeviceName(config: VoiceConfig, recorderExecutable: string): string {
|
|
309
|
+
if (recorderExecutable === "pw-record") return pipeWireSourceName(config.recorderTarget);
|
|
310
|
+
if (recorderExecutable === "arecord") return "ALSA default microphone";
|
|
311
|
+
return config.recorderTarget || "default microphone";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function recordingStatusText(deviceName: string): string {
|
|
315
|
+
return `Recording with ${deviceName || "default microphone"}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
244
318
|
function readState(config: VoiceConfig): RecordingState | null {
|
|
245
319
|
if (!existsSync(config.statePath)) return null;
|
|
246
320
|
return JSON.parse(readFileSync(config.statePath, "utf8")) as RecordingState;
|
|
@@ -457,8 +531,9 @@ function parseRecordedWav(filePath: string): { pcm: Buffer; durationMs: number }
|
|
|
457
531
|
|
|
458
532
|
function missingCredentialsMessage(): string {
|
|
459
533
|
return [
|
|
460
|
-
"Missing
|
|
534
|
+
"Missing VolcEngine API key in the pi voice input config.",
|
|
461
535
|
"Run /voice key and paste your VolcEngine Speech API key.",
|
|
536
|
+
`Config file: ${CONFIG_PATH}`,
|
|
462
537
|
`Get/create the key here: ${VOLC_API_KEY_URL}`,
|
|
463
538
|
"Run /voice config to verify whether the key is detected.",
|
|
464
539
|
].join("\n");
|
|
@@ -611,6 +686,196 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
|
|
|
611
686
|
};
|
|
612
687
|
}
|
|
613
688
|
|
|
689
|
+
function tailText(text: string, maxChars: number): string {
|
|
690
|
+
if (maxChars <= 0) return "";
|
|
691
|
+
if (text.length <= maxChars) return text;
|
|
692
|
+
return `…${text.slice(-maxChars)}`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function truncateText(text: string, maxChars: number): string {
|
|
696
|
+
if (maxChars <= 0) return "";
|
|
697
|
+
if (text.length <= maxChars) return text;
|
|
698
|
+
return `${text.slice(0, maxChars)}…`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function textFromContent(content: unknown): string {
|
|
702
|
+
if (typeof content === "string") return content;
|
|
703
|
+
if (!Array.isArray(content)) return "";
|
|
704
|
+
return content
|
|
705
|
+
.map((part) => {
|
|
706
|
+
if (!part || typeof part !== "object") return "";
|
|
707
|
+
const block = part as { type?: unknown; text?: unknown };
|
|
708
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
709
|
+
return "";
|
|
710
|
+
})
|
|
711
|
+
.filter(Boolean)
|
|
712
|
+
.join("\n");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function getEditorContext(ctx: ExtensionContext, maxChars: number): string {
|
|
716
|
+
if (maxChars <= 0) return "";
|
|
717
|
+
try {
|
|
718
|
+
return tailText(ctx.ui.getEditorText().trim(), maxChars);
|
|
719
|
+
} catch {
|
|
720
|
+
return "";
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function getRecentSessionContext(ctx: ExtensionContext, maxChars: number): string {
|
|
725
|
+
if (maxChars <= 0) return "";
|
|
726
|
+
const lines: string[] = [];
|
|
727
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
728
|
+
if (entry.type !== "message") continue;
|
|
729
|
+
const message = entry.message as { role?: unknown; content?: unknown };
|
|
730
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
731
|
+
const text = textFromContent(message.content).replace(/\s+/g, " ").trim();
|
|
732
|
+
if (!text) continue;
|
|
733
|
+
lines.push(`${message.role}: ${truncateText(text, 1200)}`);
|
|
734
|
+
}
|
|
735
|
+
return tailText(lines.slice(-8).join("\n"), maxChars);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function simplifyModelReference(value: string): string {
|
|
739
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function stripThinkingSuffix(value: string): string {
|
|
743
|
+
return value.replace(/:(?:off|minimal|low|medium|high|xhigh)$/i, "");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function modelLabel(model: Model<Api>): string {
|
|
747
|
+
return `${model.provider}/${model.id}`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function resolvePostprocessModel(ctx: ExtensionContext, reference: string): Model<Api> {
|
|
751
|
+
const requested = stripThinkingSuffix(reference.trim());
|
|
752
|
+
if (!requested) throw new Error("polishModel is empty in voice input config");
|
|
753
|
+
|
|
754
|
+
const models = ctx.modelRegistry.getAll();
|
|
755
|
+
const lower = requested.toLowerCase();
|
|
756
|
+
const simple = simplifyModelReference(requested);
|
|
757
|
+
|
|
758
|
+
const exactCanonical = models.filter((model) => modelLabel(model).toLowerCase() === lower);
|
|
759
|
+
if (exactCanonical.length === 1) return exactCanonical[0];
|
|
760
|
+
|
|
761
|
+
const exactBare = models.filter((model) => model.id.toLowerCase() === lower || model.name.toLowerCase() === lower);
|
|
762
|
+
if (exactBare.length === 1) return exactBare[0];
|
|
763
|
+
if (exactBare.length > 1) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactBare.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const exactSimple = models.filter(
|
|
770
|
+
(model) =>
|
|
771
|
+
simplifyModelReference(modelLabel(model)) === simple ||
|
|
772
|
+
simplifyModelReference(model.id) === simple ||
|
|
773
|
+
simplifyModelReference(model.name) === simple,
|
|
774
|
+
);
|
|
775
|
+
if (exactSimple.length === 1) return exactSimple[0];
|
|
776
|
+
if (exactSimple.length > 1) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactSimple.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const fuzzy = models.filter(
|
|
783
|
+
(model) =>
|
|
784
|
+
modelLabel(model).toLowerCase().includes(lower) ||
|
|
785
|
+
model.id.toLowerCase().includes(lower) ||
|
|
786
|
+
model.name.toLowerCase().includes(lower) ||
|
|
787
|
+
simplifyModelReference(modelLabel(model)).includes(simple) ||
|
|
788
|
+
simplifyModelReference(model.id).includes(simple) ||
|
|
789
|
+
simplifyModelReference(model.name).includes(simple),
|
|
790
|
+
);
|
|
791
|
+
if (fuzzy.length === 1) return fuzzy[0];
|
|
792
|
+
if (fuzzy.length > 1) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${fuzzy.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
throw new Error(`Postprocess model "${reference}" not found. Run pi --list-models to see available models.`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function extractAssistantText(message: { content: unknown }): string {
|
|
802
|
+
return textFromContent(message.content).trim();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function cleanPostprocessOutput(output: string): string {
|
|
806
|
+
let text = output.trim();
|
|
807
|
+
const fence = text.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```$/);
|
|
808
|
+
if (fence) text = fence[1].trim();
|
|
809
|
+
text = text.replace(/^(?:优化后的(?:用户)?指令|整理后的(?:用户)?指令|改写后的(?:用户)?指令)\s*[::]\s*/u, "").trim();
|
|
810
|
+
return text;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config: VoiceConfig): string {
|
|
814
|
+
const contextBudget = config.postprocessContextChars;
|
|
815
|
+
const editorContext = getEditorContext(ctx, Math.floor(contextBudget / 2));
|
|
816
|
+
const sessionContext = getRecentSessionContext(ctx, Math.ceil(contextBudget / 2));
|
|
817
|
+
|
|
818
|
+
return [
|
|
819
|
+
"请根据上下文优化下面的原始语音识别结果。",
|
|
820
|
+
"如果上下文为空,直接依据原始文本优化。",
|
|
821
|
+
"不要重复上下文本身;只输出原始语音对应的最终用户指令。",
|
|
822
|
+
"",
|
|
823
|
+
"--- 上下文:当前编辑器已有内容 ---",
|
|
824
|
+
editorContext || "(空)",
|
|
825
|
+
"",
|
|
826
|
+
"--- 上下文:最近会话 ---",
|
|
827
|
+
sessionContext || "(空)",
|
|
828
|
+
"",
|
|
829
|
+
"--- 原始语音识别结果 ---",
|
|
830
|
+
rawText.trim(),
|
|
831
|
+
].join("\n");
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function postprocessTranscript(ctx: ExtensionContext, rawText: string, config: VoiceConfig): Promise<string> {
|
|
835
|
+
if (!config.postprocessEnabled) return rawText;
|
|
836
|
+
|
|
837
|
+
const raw = rawText.trim();
|
|
838
|
+
if (!raw) return rawText;
|
|
839
|
+
|
|
840
|
+
const model = resolvePostprocessModel(ctx, config.postprocessModel);
|
|
841
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
842
|
+
if (!auth.ok) {
|
|
843
|
+
throw new Error(`Postprocess model ${modelLabel(model)} is not ready: ${auth.error}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const response = await completeSimple(
|
|
847
|
+
model,
|
|
848
|
+
{
|
|
849
|
+
systemPrompt: POSTPROCESS_SYSTEM_PROMPT,
|
|
850
|
+
messages: [
|
|
851
|
+
{
|
|
852
|
+
role: "user",
|
|
853
|
+
content: buildPostprocessPrompt(ctx, raw, config),
|
|
854
|
+
timestamp: Date.now(),
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
tools: [],
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
apiKey: auth.apiKey,
|
|
861
|
+
headers: auth.headers,
|
|
862
|
+
temperature: 0,
|
|
863
|
+
maxTokens: config.postprocessMaxTokens,
|
|
864
|
+
timeoutMs: config.postprocessTimeoutMs,
|
|
865
|
+
maxRetries: 0,
|
|
866
|
+
cacheRetention: "none",
|
|
867
|
+
signal: ctx.signal,
|
|
868
|
+
},
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
872
|
+
throw new Error(response.errorMessage || `Postprocess model stopped with ${response.stopReason}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const polished = cleanPostprocessOutput(extractAssistantText(response));
|
|
876
|
+
return polished || rawText;
|
|
877
|
+
}
|
|
878
|
+
|
|
614
879
|
function appendToEditor(ctx: ExtensionContext, text: string) {
|
|
615
880
|
const trimmed = text.trim();
|
|
616
881
|
if (!trimmed) return;
|
|
@@ -628,8 +893,9 @@ async function startRecording(ctx: ExtensionContext) {
|
|
|
628
893
|
const config = getConfig();
|
|
629
894
|
const existing = readState(config);
|
|
630
895
|
if (existing && pidAlive(existing.pid)) {
|
|
631
|
-
|
|
632
|
-
ctx.ui.
|
|
896
|
+
const deviceName = existing.deviceName || recordingDeviceName(config, commandExists("pw-record") ? "pw-record" : "arecord");
|
|
897
|
+
ctx.ui.notify(`Already recording: pid=${existing.pid}. ${recordingStatusText(deviceName)}`, "warning");
|
|
898
|
+
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("error", recordingStatusText(deviceName)));
|
|
633
899
|
return;
|
|
634
900
|
}
|
|
635
901
|
if (existing) clearState(config);
|
|
@@ -639,6 +905,7 @@ async function startRecording(ctx: ExtensionContext) {
|
|
|
639
905
|
const outputPath = path.join(config.recordingsDir, `recording-${timestampForFilename()}.wav`);
|
|
640
906
|
const logPath = path.join(config.logDir, `recording-${timestampForFilename()}.log`);
|
|
641
907
|
const cmd = recorderCommand(config, outputPath);
|
|
908
|
+
const deviceName = recordingDeviceName(config, cmd[0]);
|
|
642
909
|
|
|
643
910
|
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("warning", "● starting mic"));
|
|
644
911
|
const logFd = openSync(logPath, "a");
|
|
@@ -656,10 +923,11 @@ async function startRecording(ctx: ExtensionContext) {
|
|
|
656
923
|
logPath,
|
|
657
924
|
startedAt: new Date().toISOString(),
|
|
658
925
|
recorderTarget: config.recorderTarget || undefined,
|
|
926
|
+
deviceName,
|
|
659
927
|
});
|
|
660
928
|
|
|
661
|
-
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("error",
|
|
662
|
-
ctx.ui.notify(
|
|
929
|
+
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("error", recordingStatusText(deviceName)));
|
|
930
|
+
ctx.ui.notify(`${recordingStatusText(deviceName)}. Press Ctrl+Shift+R again to stop/transcribe.`, "info");
|
|
663
931
|
}
|
|
664
932
|
|
|
665
933
|
async function stopRecording(ctx: ExtensionContext, transcribe = true) {
|
|
@@ -691,9 +959,9 @@ async function stopRecording(ctx: ExtensionContext, transcribe = true) {
|
|
|
691
959
|
const { pcm, durationMs } = parseRecordedWav(state.path);
|
|
692
960
|
const decodeMs = Date.now() - decodeStart;
|
|
693
961
|
const result = await transcribePcm(pcm, durationMs, config);
|
|
694
|
-
ctx.ui.setStatus("voice-input", undefined);
|
|
695
962
|
|
|
696
963
|
if (!result.text.trim()) {
|
|
964
|
+
ctx.ui.setStatus("voice-input", undefined);
|
|
697
965
|
ctx.ui.notify(
|
|
698
966
|
`Transcription finished but no text was returned. audio=${(durationMs / 1000).toFixed(2)}s total=${result.timings.totalMs}ms`,
|
|
699
967
|
"warning",
|
|
@@ -701,9 +969,31 @@ async function stopRecording(ctx: ExtensionContext, transcribe = true) {
|
|
|
701
969
|
return;
|
|
702
970
|
}
|
|
703
971
|
|
|
704
|
-
|
|
972
|
+
let finalText = result.text;
|
|
973
|
+
let postprocessMs = 0;
|
|
974
|
+
let postprocessUsed = false;
|
|
975
|
+
if (config.postprocessEnabled) {
|
|
976
|
+
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("warning", "● polishing"));
|
|
977
|
+
const postprocessStart = Date.now();
|
|
978
|
+
try {
|
|
979
|
+
finalText = await postprocessTranscript(ctx, result.text, config);
|
|
980
|
+
postprocessMs = Date.now() - postprocessStart;
|
|
981
|
+
postprocessUsed = finalText.trim() !== result.text.trim();
|
|
982
|
+
} catch (error) {
|
|
983
|
+
postprocessMs = Date.now() - postprocessStart;
|
|
984
|
+
ctx.ui.notify(
|
|
985
|
+
`Voice postprocess failed; inserting raw transcript. ${error instanceof Error ? error.message : String(error)}`,
|
|
986
|
+
"warning",
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
ctx.ui.setStatus("voice-input", undefined);
|
|
992
|
+
appendToEditor(ctx, finalText);
|
|
705
993
|
ctx.ui.notify(
|
|
706
|
-
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms
|
|
994
|
+
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms${
|
|
995
|
+
config.postprocessEnabled ? ` postprocess=${postprocessMs}ms${postprocessUsed ? " polished" : ""}` : ""
|
|
996
|
+
} packets=${result.packets}`,
|
|
707
997
|
"info",
|
|
708
998
|
);
|
|
709
999
|
}
|
|
@@ -722,8 +1012,11 @@ function setupHelp(config = getConfig()): string {
|
|
|
722
1012
|
return [
|
|
723
1013
|
"pi Voice Input setup:",
|
|
724
1014
|
"- Current provider: VolcEngine WebSocket ASR",
|
|
1015
|
+
`- Config file: ${config.configPath}`,
|
|
725
1016
|
`- API key: ${config.apiKey ? "set" : "missing"}`,
|
|
1017
|
+
"- To create/update the JSON config file, run: /voice init",
|
|
726
1018
|
"- To save/update the key, run: /voice key",
|
|
1019
|
+
`- Polish: ${config.postprocessEnabled ? config.postprocessModel : "disabled"}`,
|
|
727
1020
|
`- Get/create a VolcEngine Speech API key here: ${VOLC_API_KEY_URL}`,
|
|
728
1021
|
"- After saving the key, run: /voice config",
|
|
729
1022
|
].join("\n");
|
|
@@ -734,12 +1027,12 @@ async function configureApiKey(ctx: ExtensionContext, providedKey = "") {
|
|
|
734
1027
|
|
|
735
1028
|
if (!apiKey) {
|
|
736
1029
|
if (!ctx.hasUI) {
|
|
737
|
-
ctx.ui.notify(`Run /voice key in interactive pi, or
|
|
1030
|
+
ctx.ui.notify(`Run /voice key in interactive pi, or edit ${CONFIG_PATH}. Get a key from ${VOLC_API_KEY_URL}.`, "error");
|
|
738
1031
|
return;
|
|
739
1032
|
}
|
|
740
1033
|
ctx.ui.notify(`Get/create a VolcEngine Speech API key here:\n${VOLC_API_KEY_URL}`, "info");
|
|
741
1034
|
const current = getConfig().apiKey;
|
|
742
|
-
const placeholder = current ? "Paste a new VolcEngine API key (current key is already set)" : "Paste
|
|
1035
|
+
const placeholder = current ? "Paste a new VolcEngine API key (current key is already set)" : "Paste VolcEngine API key";
|
|
743
1036
|
apiKey = (await ctx.ui.input("VolcEngine API key", placeholder))?.trim() ?? "";
|
|
744
1037
|
}
|
|
745
1038
|
|
|
@@ -748,25 +1041,21 @@ async function configureApiKey(ctx: ExtensionContext, providedKey = "") {
|
|
|
748
1041
|
return;
|
|
749
1042
|
}
|
|
750
1043
|
|
|
751
|
-
|
|
752
|
-
ctx.ui.notify(
|
|
1044
|
+
writeConfigApiKey(apiKey);
|
|
1045
|
+
ctx.ui.notify(`VolcEngine API key saved in ${CONFIG_PATH}. Run /voice config to verify it is detected.`, "info");
|
|
753
1046
|
}
|
|
754
1047
|
|
|
755
1048
|
function configSummary(config: VoiceConfig): string {
|
|
1049
|
+
const recorderExecutable = commandExists("pw-record") ? "pw-record" : commandExists("arecord") ? "arecord" : "";
|
|
1050
|
+
const currentDevice = recorderExecutable ? recordingDeviceName(config, recorderExecutable) : "no recorder found";
|
|
756
1051
|
return [
|
|
757
1052
|
"Voice input config:",
|
|
758
|
-
`-
|
|
759
|
-
`-
|
|
760
|
-
`-
|
|
761
|
-
`-
|
|
762
|
-
|
|
763
|
-
`- segment: ${config.segmentMs}ms`,
|
|
764
|
-
`- recordings: ${config.recordingsDir}`,
|
|
765
|
-
`- state: ${config.statePath}`,
|
|
766
|
-
`- shortcut: ${config.shortcut}`,
|
|
767
|
-
"Run /voice key to save/update the current provider API key.",
|
|
1053
|
+
`- config file: ${config.configPath}${existsSync(config.configPath) ? "" : " (missing; run /voice init to create it)"}`,
|
|
1054
|
+
`- volcApiKey: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
|
|
1055
|
+
`- polishModel: ${config.postprocessEnabled ? config.postprocessModel : "disabled"}`,
|
|
1056
|
+
`- current recording device: ${currentDevice}`,
|
|
1057
|
+
"Config keys: volcApiKey, polishModel. Leave polishModel empty to disable polish.",
|
|
768
1058
|
`VolcEngine API key URL: ${VOLC_API_KEY_URL}`,
|
|
769
|
-
"Config files checked: ~/.pi/agent/voice-input.env, package .env, current .env; shell env overrides them.",
|
|
770
1059
|
].join("\n");
|
|
771
1060
|
}
|
|
772
1061
|
|
|
@@ -786,7 +1075,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
786
1075
|
});
|
|
787
1076
|
|
|
788
1077
|
pi.registerCommand("voice", {
|
|
789
|
-
description: "Voice input: start | stop | status | toggle | cancel | config | key | help",
|
|
1078
|
+
description: "Voice input: start | stop | status | toggle | cancel | config | init | key | help",
|
|
790
1079
|
handler: async (args, ctx) => {
|
|
791
1080
|
const input = (args || "toggle").trim();
|
|
792
1081
|
const action = (input.split(/\s+/, 1)[0] || "toggle").toLowerCase();
|
|
@@ -814,6 +1103,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
814
1103
|
ctx.ui.notify(configSummary(getConfig()), "info");
|
|
815
1104
|
return;
|
|
816
1105
|
}
|
|
1106
|
+
if (action === "init") {
|
|
1107
|
+
const created = ensureConfigFile();
|
|
1108
|
+
ctx.ui.notify(`${created ? "Created" : "Updated"} voice input config: ${CONFIG_PATH}`, "info");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
817
1111
|
if (["key", "api-key", "apikey", "setup", "configure"].includes(action)) {
|
|
818
1112
|
await configureApiKey(ctx, rest);
|
|
819
1113
|
return;
|
|
@@ -826,7 +1120,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
826
1120
|
await toggleRecording(ctx);
|
|
827
1121
|
return;
|
|
828
1122
|
}
|
|
829
|
-
ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config | key | help", "error");
|
|
1123
|
+
ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config | init | key | help", "error");
|
|
830
1124
|
} catch (error) {
|
|
831
1125
|
ctx.ui.setStatus("voice-input", undefined);
|
|
832
1126
|
ctx.ui.notify(`Voice command error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -842,7 +1136,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
842
1136
|
ctx.ui.notify(
|
|
843
1137
|
[
|
|
844
1138
|
`Voice input loaded: ${startupConfig.shortcut} toggles recording.`,
|
|
845
|
-
"API key is missing. Run /voice key to set it up.",
|
|
1139
|
+
"API key is missing. Run /voice key to set it up, or edit the JSON config file.",
|
|
1140
|
+
`Config file: ${startupConfig.configPath}`,
|
|
846
1141
|
`Get/create a VolcEngine Speech API key here: ${VOLC_API_KEY_URL}`,
|
|
847
1142
|
].join("\n"),
|
|
848
1143
|
"warning",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-voice-input",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "provider-extensible voice input extension for pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"author": "tr-nc",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/tr-nc/pi-voice-input.git"
|
|
18
|
+
"url": "git+https://github.com/tr-nc/pi-voice-input.git"
|
|
19
19
|
},
|
|
20
20
|
"bugs": {
|
|
21
21
|
"url": "https://github.com/tr-nc/pi-voice-input/issues"
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"homepage": "https://github.com/tr-nc/pi-voice-input#readme",
|
|
24
24
|
"files": [
|
|
25
25
|
"extensions",
|
|
26
|
-
".env.example",
|
|
27
26
|
"README.md",
|
|
28
27
|
"AGENTS.md"
|
|
29
28
|
],
|
|
@@ -36,6 +35,7 @@
|
|
|
36
35
|
"ws": "^8.20.1"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
40
40
|
"@earendil-works/pi-tui": "*",
|
|
41
41
|
"@types/node": "^25.8.0",
|
|
@@ -43,10 +43,14 @@
|
|
|
43
43
|
"typescript": "^6.0.3"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
46
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
47
48
|
"@earendil-works/pi-tui": "*"
|
|
48
49
|
},
|
|
49
50
|
"peerDependenciesMeta": {
|
|
51
|
+
"@earendil-works/pi-ai": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
50
54
|
"@earendil-works/pi-coding-agent": {
|
|
51
55
|
"optional": true
|
|
52
56
|
},
|
package/.env.example
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Copy to ~/.pi/agent/voice-input.env or to this package as .env.
|
|
2
|
-
# Do not commit real credentials.
|
|
3
|
-
|
|
4
|
-
# Required for the current provider: VolcEngine speech API key.
|
|
5
|
-
VOLC_API_KEY=
|
|
6
|
-
|
|
7
|
-
# Optional ASR settings.
|
|
8
|
-
VOLC_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_nostream
|
|
9
|
-
VOLC_STREAM_RESOURCE_ID=volc.seedasr.sauc.duration
|
|
10
|
-
ASR_LANGUAGE=
|
|
11
|
-
ASR_PROMPT=
|
|
12
|
-
STREAM_SEGMENT_MS=5000
|
|
13
|
-
ASR_REQUEST_TIMEOUT_MS=90000
|
|
14
|
-
|
|
15
|
-
# Optional recorder settings.
|
|
16
|
-
# Leave empty to let PipeWire choose the default microphone.
|
|
17
|
-
RECORDER_TARGET=
|
|
18
|
-
RECORDING_FINALIZE_DELAY=0.1
|
|
19
|
-
|
|
20
|
-
# Optional storage settings. Defaults to ~/.pi/agent/voice-input.
|
|
21
|
-
VOICE_INPUT_HOME=~/.pi/agent/voice-input
|
|
22
|
-
RECORDINGS_DIR=recordings
|
|
23
|
-
RECORDER_STATE=recording.json
|
|
24
|
-
RECORDER_LOG_DIR=logs
|
|
25
|
-
|
|
26
|
-
# Optional shortcut. Default is Ctrl+Shift+R.
|
|
27
|
-
VOICE_INPUT_SHORTCUT=ctrl+shift+r
|