@luxass/pi-voice 0.0.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/package.json +30 -0
- package/src/index.ts +178 -0
- package/src/profile.ts +57 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luxass/pi-voice",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"pi-package"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@luxass/agent-voice": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@earendil-works/pi-ai": "*",
|
|
19
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
20
|
+
"@earendil-works/pi-tui": "*"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
9
|
+
import {
|
|
10
|
+
createRecorder,
|
|
11
|
+
getActiveProfile,
|
|
12
|
+
listInputDevices,
|
|
13
|
+
loadVoiceSettings,
|
|
14
|
+
saveVoiceSettings,
|
|
15
|
+
transcribe,
|
|
16
|
+
} from "@luxass/agent-voice";
|
|
17
|
+
|
|
18
|
+
import { promptProfile } from "./profile";
|
|
19
|
+
|
|
20
|
+
export default function voice(pi: ExtensionAPI) {
|
|
21
|
+
const path = process.env.AGENT_VOICE_SETTINGS ?? join(getAgentDir(), "voice-settings.json");
|
|
22
|
+
const settings = loadVoiceSettings(path);
|
|
23
|
+
const recorder = createRecorder();
|
|
24
|
+
|
|
25
|
+
function statusText(state = recorder.isRecording ? "● recording" : "ready"): string {
|
|
26
|
+
const { name, transcription } = getActiveProfile(settings);
|
|
27
|
+
const model =
|
|
28
|
+
transcription.type === "api"
|
|
29
|
+
? transcription.model
|
|
30
|
+
: transcription.model === undefined
|
|
31
|
+
? "auto model"
|
|
32
|
+
: basename(transcription.model, ".bin");
|
|
33
|
+
return `voice ${state} · ${name} · ${model} · ${settings.inputDevice?.name ?? "system default"}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function showStatus(ui: ExtensionContext["ui"], state?: string): void {
|
|
37
|
+
ui.setStatus("voice", statusText(state));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Errors thrown from command and shortcut handlers are reported by Pi.
|
|
41
|
+
async function toggle(ctx: ExtensionContext): Promise<void> {
|
|
42
|
+
if (!recorder.isRecording) {
|
|
43
|
+
recorder.start(settings.inputDevice, (error) => {
|
|
44
|
+
showStatus(ctx.ui);
|
|
45
|
+
ctx.ui.notify(`Recording failed: ${error.message}`, "error");
|
|
46
|
+
});
|
|
47
|
+
showStatus(ctx.ui);
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
`Recording from ${settings.inputDevice?.name ?? "system default"}… run again to transcribe`,
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
showStatus(ctx.ui, "transcribing…");
|
|
55
|
+
try {
|
|
56
|
+
const file = await recorder.stop();
|
|
57
|
+
try {
|
|
58
|
+
const text = await transcribe(file, getActiveProfile(settings).transcription);
|
|
59
|
+
ctx.ui.pasteToEditor(text);
|
|
60
|
+
ctx.ui.notify("Transcription pasted into the composer", "info");
|
|
61
|
+
} finally {
|
|
62
|
+
recorder.discard(file);
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
showStatus(ctx.ui);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pi.registerShortcut(Key.ctrlShift("v"), {
|
|
70
|
+
description: "record/transcribe into the composer",
|
|
71
|
+
handler: toggle,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.registerCommand("voice-record", {
|
|
75
|
+
description: "start/stop recording, transcribe on stop",
|
|
76
|
+
handler: (_args, ctx) => toggle(ctx),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerCommand("voice-cancel", {
|
|
80
|
+
description: "discard the in-progress recording",
|
|
81
|
+
// oxlint-disable-next-line require-await
|
|
82
|
+
handler: async (_args, ctx) => {
|
|
83
|
+
if (!recorder.isRecording) return;
|
|
84
|
+
recorder.cancel();
|
|
85
|
+
showStatus(ctx.ui);
|
|
86
|
+
ctx.ui.notify("Recording discarded");
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
pi.registerCommand("voice-device", {
|
|
91
|
+
description: "select input device",
|
|
92
|
+
handler: async (_args, ctx) => {
|
|
93
|
+
if (recorder.isRecording) {
|
|
94
|
+
ctx.ui.notify("Stop recording before changing the input", "warning");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const devices = await listInputDevices();
|
|
98
|
+
const selected = await ctx.ui.select("Voice input device", [
|
|
99
|
+
"System default",
|
|
100
|
+
...devices.map((device) => device.name),
|
|
101
|
+
]);
|
|
102
|
+
if (selected === undefined) return;
|
|
103
|
+
const device = devices.find((candidate) => candidate.name === selected);
|
|
104
|
+
if (device) settings.inputDevice = device;
|
|
105
|
+
else delete settings.inputDevice;
|
|
106
|
+
saveVoiceSettings(path, settings);
|
|
107
|
+
showStatus(ctx.ui);
|
|
108
|
+
ctx.ui.notify(`Voice input: ${selected}`);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
pi.registerCommand("voice-profile", {
|
|
113
|
+
description: "select transcription profile, `add` one, or change the API `model`",
|
|
114
|
+
getArgumentCompletions: (prefix) => {
|
|
115
|
+
const items = [
|
|
116
|
+
{ value: "add", label: "add", description: "create a transcription profile" },
|
|
117
|
+
{ value: "model", label: "model", description: "change the active API profile's model" },
|
|
118
|
+
].filter((item) => item.value.startsWith(prefix));
|
|
119
|
+
return items.length > 0 ? items : null;
|
|
120
|
+
},
|
|
121
|
+
handler: async (args, ctx) => {
|
|
122
|
+
const subcommand = args.trim();
|
|
123
|
+
if (subcommand === "model") {
|
|
124
|
+
const { name, transcription } = getActiveProfile(settings);
|
|
125
|
+
if (transcription.type !== "api") {
|
|
126
|
+
ctx.ui.notify(`${name} is a local profile; switch profiles to change models`, "warning");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const model = (await ctx.ui.input(`Model for ${name}`, transcription.model))?.trim();
|
|
130
|
+
if (!model) return;
|
|
131
|
+
transcription.model = model;
|
|
132
|
+
saveVoiceSettings(path, settings);
|
|
133
|
+
showStatus(ctx.ui);
|
|
134
|
+
ctx.ui.notify(`Voice profile ${name}: ${model}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (subcommand === "add") {
|
|
138
|
+
const added = await promptProfile(ctx.ui, Object.keys(settings.profiles ?? {}));
|
|
139
|
+
if (!added) return;
|
|
140
|
+
settings.profiles = { ...settings.profiles, [added.name]: added.profile };
|
|
141
|
+
settings.activeProfile = added.name;
|
|
142
|
+
saveVoiceSettings(path, settings);
|
|
143
|
+
showStatus(ctx.ui);
|
|
144
|
+
ctx.ui.notify(`Voice profile: ${added.name}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!settings.profiles) {
|
|
148
|
+
ctx.ui.notify("No profiles yet; run /voice-profile add", "warning");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const selected = await ctx.ui.select(
|
|
152
|
+
"Voice transcription profile",
|
|
153
|
+
Object.keys(settings.profiles),
|
|
154
|
+
);
|
|
155
|
+
if (selected === undefined) return;
|
|
156
|
+
settings.activeProfile = selected;
|
|
157
|
+
saveVoiceSettings(path, settings);
|
|
158
|
+
showStatus(ctx.ui);
|
|
159
|
+
ctx.ui.notify(`Voice profile: ${selected}`);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerCommand("voice-status", {
|
|
164
|
+
description: "show profile and input",
|
|
165
|
+
// oxlint-disable-next-line require-await
|
|
166
|
+
handler: async (_args, ctx) => {
|
|
167
|
+
ctx.ui.notify(statusText());
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.on("session_start", (_event, ctx) => {
|
|
172
|
+
showStatus(ctx.ui);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
pi.on("session_shutdown", () => {
|
|
176
|
+
recorder.cancel();
|
|
177
|
+
});
|
|
178
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { discoverLocalModels, type TranscriptionProfile } from "@luxass/agent-voice";
|
|
5
|
+
|
|
6
|
+
const AUTO_MODEL = "Auto-detect when transcribing";
|
|
7
|
+
|
|
8
|
+
async function promptLocal(ui: ExtensionUIContext): Promise<TranscriptionProfile | undefined> {
|
|
9
|
+
const models = discoverLocalModels();
|
|
10
|
+
const picked = await ui.select("Whisper model", [
|
|
11
|
+
AUTO_MODEL,
|
|
12
|
+
...models.map((model) => basename(model)),
|
|
13
|
+
]);
|
|
14
|
+
if (picked === undefined) return undefined;
|
|
15
|
+
const language = await ui.input("Language code (blank for auto)", "en");
|
|
16
|
+
if (language === undefined) return undefined;
|
|
17
|
+
|
|
18
|
+
const profile: TranscriptionProfile = { type: "local" };
|
|
19
|
+
const model = models.find((candidate) => basename(candidate) === picked);
|
|
20
|
+
if (model) profile.model = model;
|
|
21
|
+
if (language.trim()) profile.language = language.trim();
|
|
22
|
+
return profile;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function promptApi(ui: ExtensionUIContext): Promise<TranscriptionProfile | undefined> {
|
|
26
|
+
const endpoint = (await ui.input("API endpoint", "https://api.openai.com/v1"))?.trim();
|
|
27
|
+
if (!endpoint) return undefined;
|
|
28
|
+
const model = (await ui.input("Model", "whisper-1"))?.trim();
|
|
29
|
+
if (!model) return undefined;
|
|
30
|
+
const apiKeyEnv = await ui.input(
|
|
31
|
+
"API key environment variable (blank for none)",
|
|
32
|
+
"OPENAI_API_KEY",
|
|
33
|
+
);
|
|
34
|
+
if (apiKeyEnv === undefined) return undefined;
|
|
35
|
+
const format = await ui.select("Request format", ["multipart", "openrouter"]);
|
|
36
|
+
if (format !== "multipart" && format !== "openrouter") return undefined;
|
|
37
|
+
|
|
38
|
+
const profile: TranscriptionProfile = { type: "api", endpoint, model, format };
|
|
39
|
+
if (apiKeyEnv.trim()) profile.apiKeyEnv = apiKeyEnv.trim();
|
|
40
|
+
return profile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ask for a profile name and its transcription settings. Returns undefined when the user backs out. */
|
|
44
|
+
export async function promptProfile(
|
|
45
|
+
ui: ExtensionUIContext,
|
|
46
|
+
existing: readonly string[],
|
|
47
|
+
): Promise<{ name: string; profile: TranscriptionProfile } | undefined> {
|
|
48
|
+
const name = (await ui.input("Profile name", "e.g. local, groq"))?.trim();
|
|
49
|
+
if (!name) return undefined;
|
|
50
|
+
if (existing.includes(name) && !(await ui.confirm("Replace profile", `Replace "${name}"?`)))
|
|
51
|
+
return undefined;
|
|
52
|
+
|
|
53
|
+
const type = await ui.select("Transcription", ["local", "api"]);
|
|
54
|
+
const profile =
|
|
55
|
+
type === "local" ? await promptLocal(ui) : type === "api" ? await promptApi(ui) : undefined;
|
|
56
|
+
return profile && { name, profile };
|
|
57
|
+
}
|