@xynogen/pix-9router 0.6.0 → 0.7.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/README.md +6 -30
- package/package.json +4 -5
- package/src/data.ts +3 -3
- package/src/index.ts +4 -13
- package/src/catalog.ts +0 -45
- package/src/command.ts +0 -212
- package/src/defaults.ts +0 -44
- package/src/http.ts +0 -102
- package/src/recorder.ts +0 -103
- package/src/render.ts +0 -151
- package/src/stt-command.ts +0 -127
- package/src/transcribe.ts +0 -472
- package/src/tts.ts +0 -210
package/README.md
CHANGED
|
@@ -1,37 +1,13 @@
|
|
|
1
1
|
# pix-9router
|
|
2
2
|
|
|
3
|
-
Pi extension with a 9Router model provider
|
|
3
|
+
Pi extension with a 9Router model provider.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Other 9Router features live in separate packages. Each one reuses
|
|
6
|
+
`NINEROUTER_URL` and `NINEROUTER_KEY`:
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| `tts` | TTS model or voice, plus automatic playback |
|
|
11
|
-
|
|
12
|
-
Tool arguments override saved defaults. Tool calls never fetch the model catalog.
|
|
13
|
-
The `/9router` menu fetches live models or voices only when you open a setting,
|
|
14
|
-
then saves the choice in `~/.pi/agent/9router.json`.
|
|
15
|
-
|
|
16
|
-
## Settings
|
|
17
|
-
|
|
18
|
-
Run:
|
|
19
|
-
|
|
20
|
-
```text
|
|
21
|
-
/9router
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
The menu controls:
|
|
25
|
-
|
|
26
|
-
- STT model
|
|
27
|
-
- TTS model or voice
|
|
28
|
-
- TTS playback (`on` by default)
|
|
29
|
-
|
|
30
|
-
Catalogs come from `/v1/models/stt` and `/v1/models/tts`.
|
|
31
|
-
|
|
32
|
-
Web search and fetch moved to `@xynogen/pix-web`. Its `/search` and `/fetch`
|
|
33
|
-
commands include a 9Router provider that reuses `NINEROUTER_URL` and
|
|
34
|
-
`NINEROUTER_KEY`.
|
|
8
|
+
- Speech tools (`transcribe`, `speak`, `/stt`) moved to `@xynogen/pix-voice` in
|
|
9
|
+
0.7.0. Pick `9router` as the provider in its `/voice` command.
|
|
10
|
+
- Web search and fetch live in `@xynogen/pix-web`.
|
|
35
11
|
|
|
36
12
|
## Environment
|
|
37
13
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension — 9Router model provider
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Pi extension — 9Router model provider",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -44,8 +44,7 @@
|
|
|
44
44
|
"@earendil-works/pi-tui": "*"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@xynogen/pix-
|
|
48
|
-
"
|
|
49
|
-
"@xynogen/pix-runtime": "^0.10.1"
|
|
47
|
+
"@xynogen/pix-data": "^0.5.1",
|
|
48
|
+
"@xynogen/pix-runtime": "^0.10.2"
|
|
50
49
|
}
|
|
51
50
|
}
|
package/src/data.ts
CHANGED
|
@@ -17,20 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
import { CACHE_DIR, DataSource } from "
|
|
20
|
+
import { CACHE_DIR, DataSource } from "@xynogen/pix-data";
|
|
21
21
|
|
|
22
22
|
export type {
|
|
23
23
|
ModelGrepModel,
|
|
24
24
|
ModelsDevApi,
|
|
25
25
|
ModelsDevModel,
|
|
26
|
-
} from "
|
|
26
|
+
} from "@xynogen/pix-data";
|
|
27
27
|
// Re-export the shared models.dev API so existing consumers (provider.ts)
|
|
28
28
|
// can keep importing these names from "./data".
|
|
29
29
|
export {
|
|
30
30
|
buildModelsDevIndex,
|
|
31
31
|
fetchModelsDevIndex,
|
|
32
32
|
lookupInIndex,
|
|
33
|
-
} from "
|
|
33
|
+
} from "@xynogen/pix-data";
|
|
34
34
|
|
|
35
35
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
36
36
|
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pix-9router — Pi extension
|
|
2
|
+
* pix-9router — Pi extension
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - tts tool (text-to-speech via audio speech API)
|
|
4
|
+
* Registers the 9router model provider (model list from the self-hosted router API).
|
|
5
|
+
* Speech tools moved to @xynogen/pix-voice. Web search and fetch live in
|
|
6
|
+
* @xynogen/pix-web. Both reuse the same 9Router environment variables.
|
|
8
7
|
*
|
|
9
8
|
* Environment:
|
|
10
9
|
* NINEROUTER_URL — canonical base URL
|
|
@@ -13,16 +12,8 @@
|
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import registerRouterCommand from "./command.js";
|
|
17
15
|
import registerProvider from "./provider.js";
|
|
18
|
-
import registerSttCommand from "./stt-command.js";
|
|
19
|
-
import registerTranscribe from "./transcribe.js";
|
|
20
|
-
import registerTts from "./tts.js";
|
|
21
16
|
|
|
22
17
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
23
18
|
await registerProvider(pi);
|
|
24
|
-
registerRouterCommand(pi);
|
|
25
|
-
registerSttCommand(pi);
|
|
26
|
-
registerTranscribe(pi);
|
|
27
|
-
registerTts(pi);
|
|
28
19
|
}
|
package/src/catalog.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { routerBaseUrl } from "./data.js";
|
|
2
|
-
import { auth } from "./http.js";
|
|
3
|
-
|
|
4
|
-
interface CatalogItem {
|
|
5
|
-
id?: string;
|
|
6
|
-
kind?: string;
|
|
7
|
-
model?: string;
|
|
8
|
-
name?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function get(path: string): Promise<unknown> {
|
|
12
|
-
const key = auth();
|
|
13
|
-
const response = await fetch(`${routerBaseUrl()}${path}`, {
|
|
14
|
-
headers: key ? { Authorization: `Bearer ${key}` } : undefined,
|
|
15
|
-
});
|
|
16
|
-
if (!response.ok) throw new Error(`9Router catalog ${response.status}`);
|
|
17
|
-
return response.json();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function items(value: unknown): CatalogItem[] {
|
|
21
|
-
if (!value || typeof value !== "object") return [];
|
|
22
|
-
const data = (value as { data?: unknown }).data;
|
|
23
|
-
return Array.isArray(data) ? (data as CatalogItem[]) : [];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function catalogModels(kind: "stt" | "tts"): Promise<string[]> {
|
|
27
|
-
return items(await get(`/models/${kind}`)).flatMap((item) => (item.id ? [item.id] : []));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface VoiceChoice {
|
|
31
|
-
id: string;
|
|
32
|
-
language: string;
|
|
33
|
-
provider: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function groupVoice(id: string): VoiceChoice {
|
|
37
|
-
const [provider = "other", ...rest] = id.split("/");
|
|
38
|
-
const voice = rest.join("/") || provider;
|
|
39
|
-
const locale = voice.match(/(?:^|[^A-Za-z])([a-z]{2,3})[-_]([A-Z]{2})(?:[^A-Za-z]|$)/);
|
|
40
|
-
return { id, language: locale ? `${locale[1]}-${locale[2]}` : "Other", provider };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function catalogVoices(): Promise<VoiceChoice[]> {
|
|
44
|
-
return (await catalogModels("tts")).map(groupVoice);
|
|
45
|
-
}
|
package/src/command.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Key, matchesKey, type SelectItem, SelectList } from "@earendil-works/pi-tui";
|
|
3
|
-
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
4
|
-
import {
|
|
5
|
-
frameModal,
|
|
6
|
-
MIN_MODAL_HEIGHT,
|
|
7
|
-
ModalPager,
|
|
8
|
-
modalOverlayOptions,
|
|
9
|
-
modalWidth,
|
|
10
|
-
selectListTheme,
|
|
11
|
-
terminalModalHeight,
|
|
12
|
-
} from "@xynogen/pix-pretty/modal-frame";
|
|
13
|
-
import { catalogModels, catalogVoices } from "./catalog.js";
|
|
14
|
-
import { routerDefaults, saveDefaults } from "./defaults.js";
|
|
15
|
-
|
|
16
|
-
async function pick(
|
|
17
|
-
ctx: ExtensionContext,
|
|
18
|
-
title: string,
|
|
19
|
-
values: string[],
|
|
20
|
-
current?: string,
|
|
21
|
-
): Promise<string | undefined> {
|
|
22
|
-
const items: SelectItem[] = [...new Set(current ? [current, ...values] : values)].map(
|
|
23
|
-
(value) => ({
|
|
24
|
-
value,
|
|
25
|
-
label: value,
|
|
26
|
-
description: value === current ? "current" : undefined,
|
|
27
|
-
}),
|
|
28
|
-
);
|
|
29
|
-
if (items.length === 0) return undefined;
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
(await ctx.ui.custom<string | null>(
|
|
33
|
-
(tui, theme, _keybindings, done) => {
|
|
34
|
-
const list = new SelectList(items, items.length, selectListTheme(theme));
|
|
35
|
-
const currentIndex = items.findIndex((item) => item.value === current);
|
|
36
|
-
if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
|
|
37
|
-
list.onSelect = (item) => done(item.value);
|
|
38
|
-
list.onCancel = () => done(null);
|
|
39
|
-
const pager = new ModalPager();
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
render(width: number) {
|
|
43
|
-
const result = frameModal({
|
|
44
|
-
width: modalWidth(width),
|
|
45
|
-
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
46
|
-
minHeight: MIN_MODAL_HEIGHT,
|
|
47
|
-
header: [theme.fg("accent", theme.bold(title)), ""],
|
|
48
|
-
body: list.render(modalWidth(width) - 4),
|
|
49
|
-
bodyOffset: pager.bodyOffset,
|
|
50
|
-
footer: ["", theme.fg("muted", "↑↓ navigate · enter select · esc cancel")],
|
|
51
|
-
color: (text) => theme.fg("accent", text),
|
|
52
|
-
bg: (text) => theme.bg("customMessageBg", text),
|
|
53
|
-
fg: (text) => theme.fg("text", text),
|
|
54
|
-
});
|
|
55
|
-
pager.sync(result);
|
|
56
|
-
return result.lines;
|
|
57
|
-
},
|
|
58
|
-
invalidate: () => list.invalidate(),
|
|
59
|
-
handleInput(data: string) {
|
|
60
|
-
if (pager.handleInput(data, undefined, true)) tui.requestRender();
|
|
61
|
-
else if (matchesKey(data, "escape")) done(null);
|
|
62
|
-
else list.handleInput(data);
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
},
|
|
66
|
-
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
67
|
-
)) ?? undefined
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface SettingRow {
|
|
72
|
-
key: string;
|
|
73
|
-
section: string;
|
|
74
|
-
label: string;
|
|
75
|
-
value: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function pickSetting(ctx: ExtensionContext, rows: SettingRow[]): Promise<string | undefined> {
|
|
79
|
-
return (
|
|
80
|
-
(await ctx.ui.custom<string | null>(
|
|
81
|
-
(tui, theme, keybindings, done) => {
|
|
82
|
-
let selected = 0;
|
|
83
|
-
const move = (direction: -1 | 1) => {
|
|
84
|
-
selected = (selected + direction + rows.length) % rows.length;
|
|
85
|
-
};
|
|
86
|
-
return {
|
|
87
|
-
render(width: number) {
|
|
88
|
-
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
89
|
-
const body: string[] = [];
|
|
90
|
-
const rowLines: number[] = [];
|
|
91
|
-
let section = "";
|
|
92
|
-
for (let index = 0; index < rows.length; index++) {
|
|
93
|
-
const row = rows[index];
|
|
94
|
-
if (!row) continue;
|
|
95
|
-
if (row.section !== section) {
|
|
96
|
-
if (section) body.push("");
|
|
97
|
-
body.push(theme.fg("dim", ` ${row.section}`));
|
|
98
|
-
section = row.section;
|
|
99
|
-
}
|
|
100
|
-
const active = index === selected;
|
|
101
|
-
rowLines[index] = body.length;
|
|
102
|
-
body.push(
|
|
103
|
-
`${active ? theme.fg("accent", "→") : " "} ${theme.fg(active ? "accent" : "text", row.label.padEnd(labelWidth))} ${theme.fg("success", row.value)}`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const result = frameModal({
|
|
107
|
-
width: modalWidth(width),
|
|
108
|
-
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
109
|
-
minHeight: MIN_MODAL_HEIGHT,
|
|
110
|
-
title: `${icon("settings")} 9Router Settings`,
|
|
111
|
-
titleColor: (text) => theme.fg("accent", theme.bold(text)),
|
|
112
|
-
header: [""],
|
|
113
|
-
body,
|
|
114
|
-
footer: ["", theme.fg("muted", "↑↓ move · enter change · esc close")],
|
|
115
|
-
selectedBodyLine: rowLines[selected],
|
|
116
|
-
color: (text) => theme.fg("accent", text),
|
|
117
|
-
bg: (text) => theme.bg("customMessageBg", text),
|
|
118
|
-
});
|
|
119
|
-
return result.lines;
|
|
120
|
-
},
|
|
121
|
-
invalidate: () => {},
|
|
122
|
-
handleInput(data: string) {
|
|
123
|
-
if (keybindings.matches(data, "tui.select.cancel")) done(null);
|
|
124
|
-
else if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
125
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
126
|
-
else if (matchesKey(data, "enter")) done(rows[selected]?.key ?? null);
|
|
127
|
-
else if (matchesKey(data, Key.left) || matchesKey(data, Key.right))
|
|
128
|
-
done(rows[selected]?.key ?? null);
|
|
129
|
-
else return;
|
|
130
|
-
tui.requestRender();
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
},
|
|
134
|
-
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
135
|
-
)) ?? undefined
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function choose(
|
|
140
|
-
ctx: ExtensionContext,
|
|
141
|
-
title: string,
|
|
142
|
-
current: string,
|
|
143
|
-
load: () => Promise<string[]>,
|
|
144
|
-
): Promise<string | undefined> {
|
|
145
|
-
return pick(ctx, `${title} · current: ${current}`, await load(), current);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export default function registerRouterCommand(pi: ExtensionAPI): void {
|
|
149
|
-
pi.registerCommand("9router", {
|
|
150
|
-
description: "Set default models, voice, and TTS playback",
|
|
151
|
-
handler: async (_args, ctx) => {
|
|
152
|
-
while (true) {
|
|
153
|
-
const setting = await pickSetting(ctx, [
|
|
154
|
-
{ key: "stt", section: "Audio", label: "STT model", value: routerDefaults.sttModel },
|
|
155
|
-
{
|
|
156
|
-
key: "tts",
|
|
157
|
-
section: "Audio",
|
|
158
|
-
label: "TTS voice",
|
|
159
|
-
value: routerDefaults.ttsModel,
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
key: "playback",
|
|
163
|
-
section: "Audio",
|
|
164
|
-
label: "play after generation",
|
|
165
|
-
value: routerDefaults.ttsPlay ? "on" : "off",
|
|
166
|
-
},
|
|
167
|
-
]);
|
|
168
|
-
if (!setting) return;
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
let value: string | undefined;
|
|
172
|
-
if (setting === "stt") {
|
|
173
|
-
value = await choose(ctx, "STT model", routerDefaults.sttModel, () =>
|
|
174
|
-
catalogModels("stt"),
|
|
175
|
-
);
|
|
176
|
-
if (value) routerDefaults.sttModel = value;
|
|
177
|
-
} else if (setting === "tts") {
|
|
178
|
-
const voices = await catalogVoices();
|
|
179
|
-
const language = await pick(
|
|
180
|
-
ctx,
|
|
181
|
-
"TTS · 1/3 · language",
|
|
182
|
-
[...new Set(voices.map((voice) => voice.language))].sort(),
|
|
183
|
-
);
|
|
184
|
-
if (!language) continue;
|
|
185
|
-
const languageVoices = voices.filter((voice) => voice.language === language);
|
|
186
|
-
const provider = await pick(
|
|
187
|
-
ctx,
|
|
188
|
-
`TTS · 2/3 · ${language} · provider`,
|
|
189
|
-
[...new Set(languageVoices.map((voice) => voice.provider))].sort(),
|
|
190
|
-
);
|
|
191
|
-
if (!provider) continue;
|
|
192
|
-
value = await pick(
|
|
193
|
-
ctx,
|
|
194
|
-
`TTS · 3/3 · ${language} · ${provider}`,
|
|
195
|
-
languageVoices
|
|
196
|
-
.filter((voice) => voice.provider === provider)
|
|
197
|
-
.map((voice) => voice.id),
|
|
198
|
-
routerDefaults.ttsModel,
|
|
199
|
-
);
|
|
200
|
-
if (value) routerDefaults.ttsModel = value;
|
|
201
|
-
} else if (setting === "playback") {
|
|
202
|
-
value = await pick(ctx, "Play generated speech by default", ["on", "off"]);
|
|
203
|
-
if (value) routerDefaults.ttsPlay = value === "on";
|
|
204
|
-
}
|
|
205
|
-
if (value) saveDefaults(routerDefaults);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
}
|
package/src/defaults.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export interface RouterDefaults {
|
|
6
|
-
sttModel: string;
|
|
7
|
-
ttsModel: string;
|
|
8
|
-
ttsPlay: boolean;
|
|
9
|
-
sttDevice: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const DEFAULTS_PATH = join(homedir(), ".pi", "agent", "9router.json");
|
|
13
|
-
|
|
14
|
-
const FALLBACK: RouterDefaults = {
|
|
15
|
-
sttModel: "dg/nova-3",
|
|
16
|
-
ttsModel: "edge-tts/en-US-AriaNeural",
|
|
17
|
-
ttsPlay: true,
|
|
18
|
-
sttDevice: "default",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function loadDefaults(path = DEFAULTS_PATH): RouterDefaults {
|
|
22
|
-
let value: unknown;
|
|
23
|
-
try {
|
|
24
|
-
value = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
-
} catch {
|
|
26
|
-
return { ...FALLBACK };
|
|
27
|
-
}
|
|
28
|
-
if (!value || typeof value !== "object") return { ...FALLBACK };
|
|
29
|
-
const item = value as Record<string, unknown>;
|
|
30
|
-
return {
|
|
31
|
-
sttModel: typeof item.sttModel === "string" ? item.sttModel : FALLBACK.sttModel,
|
|
32
|
-
ttsModel: typeof item.ttsModel === "string" ? item.ttsModel : FALLBACK.ttsModel,
|
|
33
|
-
ttsPlay: typeof item.ttsPlay === "boolean" ? item.ttsPlay : FALLBACK.ttsPlay,
|
|
34
|
-
sttDevice: typeof item.sttDevice === "string" ? item.sttDevice : FALLBACK.sttDevice,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function saveDefaults(defaults: RouterDefaults, path = DEFAULTS_PATH): void {
|
|
39
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
-
writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { mode: 0o600 });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Shared in-memory defaults. Tool calls never fetch the catalog. */
|
|
44
|
-
export const routerDefaults = loadDefaults();
|
package/src/http.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* http.ts — shared 9Router HTTP plumbing.
|
|
3
|
-
*
|
|
4
|
-
* auth and curl serve the transcribe/tts fallback paths and the catalog reader.
|
|
5
|
-
* curl differs only by timeout (audio needs longer) and stdin body, so both are
|
|
6
|
-
* parameters with the old per-file defaults.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { basename } from "node:path";
|
|
11
|
-
import { ioTimeoutMs } from "@xynogen/pix-runtime/io";
|
|
12
|
-
|
|
13
|
-
export function auth(): string | undefined {
|
|
14
|
-
return process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface CurlOptions {
|
|
18
|
-
/** Request timeout in ms (default ioTimeoutMs()). */
|
|
19
|
-
timeoutMs?: number;
|
|
20
|
-
/** Optional request body (was piped to curl stdin). */
|
|
21
|
-
stdin?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ponytail: this is NOT a general curl shim — it parses only the flag forms the
|
|
25
|
-
// three fallback call sites actually pass (-L, -X, -H, -d, -F key=value /
|
|
26
|
-
// file=@path, plus the -sS/--connect-timeout/--max-time defaults added below).
|
|
27
|
-
// New curl flags won't work here; extend the parser or call fetch directly.
|
|
28
|
-
function curlArgsToRequest(args: string[]): { url: string; init: RequestInit } {
|
|
29
|
-
const headers = new Headers();
|
|
30
|
-
let method = "GET";
|
|
31
|
-
let body: string | undefined;
|
|
32
|
-
let form: FormData | undefined;
|
|
33
|
-
let url = "";
|
|
34
|
-
for (let i = 0; i < args.length; i++) {
|
|
35
|
-
const a = args[i] ?? "";
|
|
36
|
-
switch (a) {
|
|
37
|
-
case "-sS":
|
|
38
|
-
case "-L":
|
|
39
|
-
break; // silent / follow-redirects are fetch defaults
|
|
40
|
-
case "--connect-timeout":
|
|
41
|
-
case "--max-time":
|
|
42
|
-
i++; // value handled by AbortSignal below
|
|
43
|
-
break;
|
|
44
|
-
case "-X":
|
|
45
|
-
method = args[++i] ?? method;
|
|
46
|
-
break;
|
|
47
|
-
case "-H": {
|
|
48
|
-
const h = args[++i] ?? "";
|
|
49
|
-
const idx = h.indexOf(":");
|
|
50
|
-
if (idx >= 0) headers.set(h.slice(0, idx).trim(), h.slice(idx + 1).trim());
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
case "-d":
|
|
54
|
-
body = args[++i] ?? "";
|
|
55
|
-
break;
|
|
56
|
-
case "-F": {
|
|
57
|
-
form ??= new FormData();
|
|
58
|
-
const field = args[++i] ?? "";
|
|
59
|
-
const eq = field.indexOf("=");
|
|
60
|
-
const key = field.slice(0, eq);
|
|
61
|
-
const val = field.slice(eq + 1);
|
|
62
|
-
if (val.startsWith("@")) {
|
|
63
|
-
const path = val.slice(1);
|
|
64
|
-
form.set(key, new Blob([readFileSync(path)]), basename(path));
|
|
65
|
-
} else {
|
|
66
|
-
form.set(key, val);
|
|
67
|
-
}
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
default:
|
|
71
|
-
if (!a.startsWith("-")) url = a; // positional = URL
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const init: RequestInit = { method };
|
|
75
|
-
if (form)
|
|
76
|
-
init.body = form; // let fetch set the multipart boundary
|
|
77
|
-
else if (body !== undefined) init.body = body;
|
|
78
|
-
if ([...headers.keys()].length > 0) init.headers = headers;
|
|
79
|
-
return { url, init };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Raw fetch fallback (curl-signature compatible), rejecting on non-2xx or timeout. */
|
|
83
|
-
export async function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
|
|
84
|
-
const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
|
|
85
|
-
const { url, init } = curlArgsToRequest(args);
|
|
86
|
-
if (opts.stdin !== undefined) init.body = opts.stdin;
|
|
87
|
-
init.signal = AbortSignal.timeout(timeoutMs);
|
|
88
|
-
let res: Response;
|
|
89
|
-
try {
|
|
90
|
-
res = await fetch(url, init);
|
|
91
|
-
} catch (err) {
|
|
92
|
-
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
93
|
-
throw new Error("curl timed out");
|
|
94
|
-
}
|
|
95
|
-
throw new Error(`curl failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
|
-
}
|
|
97
|
-
if (!res.ok) {
|
|
98
|
-
const errText = await res.text().catch(() => "");
|
|
99
|
-
throw new Error(`curl exit ${res.status}: ${errText.slice(0, 300)}`);
|
|
100
|
-
}
|
|
101
|
-
return res.text();
|
|
102
|
-
}
|
package/src/recorder.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { findExecutableSync } from "@xynogen/pix-runtime/which";
|
|
6
|
-
|
|
7
|
-
export function parsePulseSources(output: string): string[] {
|
|
8
|
-
return output
|
|
9
|
-
.split("\n")
|
|
10
|
-
.map((line) => line.split("\t")[1]?.trim())
|
|
11
|
-
.filter((name): name is string => Boolean(name));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function parseRmsDb(output: string): number | undefined {
|
|
15
|
-
const matches = [
|
|
16
|
-
...output.matchAll(/(?:RMS level dB:\s*|lavfi\.astats\.Overall\.RMS_level=)(-?\d+(?:\.\d+)?)/g),
|
|
17
|
-
];
|
|
18
|
-
const value = matches.at(-1)?.[1];
|
|
19
|
-
return value === undefined ? undefined : Number(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function ffmpegRecordArgs(device: string, output: string): string[] {
|
|
23
|
-
return [
|
|
24
|
-
"-hide_banner",
|
|
25
|
-
"-loglevel",
|
|
26
|
-
"info",
|
|
27
|
-
"-f",
|
|
28
|
-
"pulse",
|
|
29
|
-
"-i",
|
|
30
|
-
device,
|
|
31
|
-
"-ac",
|
|
32
|
-
"1",
|
|
33
|
-
"-ar",
|
|
34
|
-
"16000",
|
|
35
|
-
"-af",
|
|
36
|
-
"astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level",
|
|
37
|
-
"-y",
|
|
38
|
-
output,
|
|
39
|
-
];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function run(command: string, args: string[]): Promise<string> {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
45
|
-
let stdout = "";
|
|
46
|
-
let stderr = "";
|
|
47
|
-
child.stdout.on("data", (data) => (stdout += String(data)));
|
|
48
|
-
child.stderr.on("data", (data) => (stderr += String(data)));
|
|
49
|
-
child.once("error", reject);
|
|
50
|
-
child.once("exit", (code) =>
|
|
51
|
-
code === 0
|
|
52
|
-
? resolve(stdout)
|
|
53
|
-
: reject(new Error(stderr.trim() || `${command} exited ${code}`)),
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function microphoneDevices(): Promise<string[]> {
|
|
59
|
-
const pactl = findExecutableSync("pactl");
|
|
60
|
-
if (!pactl) return ["default"];
|
|
61
|
-
const names = parsePulseSources(await run(pactl, ["list", "short", "sources"]));
|
|
62
|
-
return [...new Set(["default", ...names])];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface Recording {
|
|
66
|
-
path: string;
|
|
67
|
-
stop(): Promise<number | undefined>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function startRecording(device: string, onLevel: (db: number) => void): Recording {
|
|
71
|
-
const ffmpeg = findExecutableSync("ffmpeg");
|
|
72
|
-
if (!ffmpeg) throw new Error("Microphone recording needs ffmpeg on PATH.");
|
|
73
|
-
const path = join(tmpdir(), `pix-stt-${randomUUID()}.wav`);
|
|
74
|
-
const child = spawn(ffmpeg, ffmpegRecordArgs(device, path), {
|
|
75
|
-
stdio: ["pipe", "ignore", "pipe"],
|
|
76
|
-
});
|
|
77
|
-
let stderr = "";
|
|
78
|
-
let lastLevel: number | undefined;
|
|
79
|
-
child.stderr.on("data", (data) => {
|
|
80
|
-
const text = String(data);
|
|
81
|
-
stderr += text;
|
|
82
|
-
const level = parseRmsDb(text);
|
|
83
|
-
if (level !== undefined) {
|
|
84
|
-
lastLevel = level;
|
|
85
|
-
onLevel(level);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
const exit = new Promise<void>((resolve, reject) => {
|
|
89
|
-
child.once("error", reject);
|
|
90
|
-
child.once("exit", (code) =>
|
|
91
|
-
code === 0 ? resolve() : reject(new Error(stderr.trim() || `ffmpeg exited ${code}`)),
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
return {
|
|
95
|
-
path,
|
|
96
|
-
async stop() {
|
|
97
|
-
child.stdin.write("q");
|
|
98
|
-
child.stdin.end();
|
|
99
|
-
await exit;
|
|
100
|
-
return lastLevel;
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|