@earendil-works/pi-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 +86 -0
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +69 -0
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio.ts +193 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +204 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +114 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +118 -0
- package/src/languages.ts +22 -0
- package/src/microphone-picker.ts +99 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +314 -0
- package/src/pcm-chunker.ts +42 -0
- package/src/pcm.ts +19 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +501 -0
- package/src/settings-menu.ts +410 -0
- package/src/settings-path.ts +13 -0
- package/src/settings.ts +235 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +548 -0
- package/src/transcription.ts +186 -0
- package/src/try-it.ts +327 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +269 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@earendil-works/pi-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local speech-to-text for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/earendil-works/pi-voice.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/earendil-works/pi-voice#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/earendil-works/pi-voice/issues"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"index.ts",
|
|
18
|
+
"catalog/recommendations.json",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi-package",
|
|
27
|
+
"pi-extension",
|
|
28
|
+
"voice",
|
|
29
|
+
"dictation",
|
|
30
|
+
"transcription",
|
|
31
|
+
"speech-to-text"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"check": "tsc --noEmit",
|
|
38
|
+
"test": "npm run check && node scripts/run-tests.mjs",
|
|
39
|
+
"prepublishOnly": "npm test",
|
|
40
|
+
"catalog:generate": "node scripts/generate-catalog.mjs",
|
|
41
|
+
"recommendations:generate": "node scripts/generate-recommendations.mjs",
|
|
42
|
+
"catalog:verify": "node scripts/catalog-metadata.mjs",
|
|
43
|
+
"catalog:update-metadata": "node scripts/catalog-metadata.mjs --write"
|
|
44
|
+
},
|
|
45
|
+
"pi": {
|
|
46
|
+
"extensions": [
|
|
47
|
+
"./index.ts"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"@earendil-works/pi-tui": "*",
|
|
53
|
+
"typebox": "*"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
57
|
+
"@earendil-works/pi-tui": "^0.83.0",
|
|
58
|
+
"@huggingface/gguf": "^0.4.6",
|
|
59
|
+
"@types/node": "22.20.1",
|
|
60
|
+
"typebox": "^1.3.7",
|
|
61
|
+
"typescript": "5.9.3"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@huggingface/hub": "2.15.0",
|
|
65
|
+
"@picovoice/pvrecorder-node": "1.2.9",
|
|
66
|
+
"opencc-js": "^1.4.1",
|
|
67
|
+
"transcribe-cpp": "0.2.2"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
type Waiter = {
|
|
2
|
+
signal?: AbortSignal;
|
|
3
|
+
resolve: (release: () => void) => void;
|
|
4
|
+
reject: (error: unknown) => void;
|
|
5
|
+
onAbort?: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Bounds async work without starting queued tasks until a permit is available. */
|
|
9
|
+
export class AsyncLimiter {
|
|
10
|
+
private active = 0;
|
|
11
|
+
private readonly waiters: Waiter[] = [];
|
|
12
|
+
|
|
13
|
+
constructor(private readonly concurrency: number) {
|
|
14
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
15
|
+
throw new Error("AsyncLimiter concurrency must be a positive integer");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get saturated(): boolean {
|
|
20
|
+
return this.active >= this.concurrency;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
24
|
+
const release = await this.acquire(signal);
|
|
25
|
+
try {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
return await task();
|
|
28
|
+
} finally {
|
|
29
|
+
release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private acquire(signal?: AbortSignal): Promise<() => void> {
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
if (this.active < this.concurrency) {
|
|
36
|
+
this.active += 1;
|
|
37
|
+
return Promise.resolve(this.createRelease());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return new Promise<() => void>((resolve, reject) => {
|
|
41
|
+
const waiter: Waiter = { signal, resolve, reject };
|
|
42
|
+
if (signal) {
|
|
43
|
+
waiter.onAbort = () => {
|
|
44
|
+
const index = this.waiters.indexOf(waiter);
|
|
45
|
+
if (index >= 0) this.waiters.splice(index, 1);
|
|
46
|
+
reject(signal.reason ?? new Error("Operation cancelled"));
|
|
47
|
+
};
|
|
48
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
49
|
+
}
|
|
50
|
+
this.waiters.push(waiter);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private createRelease(): () => void {
|
|
55
|
+
let released = false;
|
|
56
|
+
return () => {
|
|
57
|
+
if (released) return;
|
|
58
|
+
released = true;
|
|
59
|
+
this.release();
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private release(): void {
|
|
64
|
+
while (this.waiters.length > 0) {
|
|
65
|
+
const waiter = this.waiters.shift()!;
|
|
66
|
+
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
67
|
+
if (waiter.signal?.aborted) {
|
|
68
|
+
waiter.reject(waiter.signal.reason ?? new Error("Operation cancelled"));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Transfer the active permit directly to the next waiter.
|
|
72
|
+
waiter.resolve(this.createRelease());
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.active -= 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CAPTURE_SAMPLE_RATE = 16_000;
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { PvRecorder } from "@picovoice/pvrecorder-node";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
5
|
+
import { convertFrames } from "./pcm.js";
|
|
6
|
+
import type { MicrophoneSetting } from "./settings.js";
|
|
7
|
+
|
|
8
|
+
export { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
9
|
+
|
|
10
|
+
const FRAME_LENGTH = 512;
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
interface CapturedAudio {
|
|
15
|
+
pcm: Float32Array;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type SelectedMicrophone = {
|
|
19
|
+
name: string;
|
|
20
|
+
occurrence: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class MicrophoneUnavailableError extends Error {
|
|
24
|
+
constructor(name: string) {
|
|
25
|
+
super(`Selected microphone is unavailable: ${name}. Open /voice-settings and choose another microphone.`);
|
|
26
|
+
this.name = "MicrophoneUnavailableError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getAvailableMicrophones(): string[] {
|
|
31
|
+
return PvRecorder.getAvailableDevices();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toError(value: unknown): Error {
|
|
35
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type MicPermissionResult =
|
|
39
|
+
| { status: "granted" }
|
|
40
|
+
| { status: "denied"; message: string }
|
|
41
|
+
| { status: "not-determined"; message: string }
|
|
42
|
+
| { status: "error"; message: string };
|
|
43
|
+
|
|
44
|
+
const JXA_PERMISSION_SCRIPT = [
|
|
45
|
+
"-l",
|
|
46
|
+
"JavaScript",
|
|
47
|
+
"-e",
|
|
48
|
+
[
|
|
49
|
+
"ObjC.import('Foundation');",
|
|
50
|
+
"ObjC.import('AVFoundation');",
|
|
51
|
+
"var captureDevice = $.NSClassFromString('AVCaptureDevice');",
|
|
52
|
+
"if (!captureDevice) throw new Error('AVCaptureDevice is unavailable');",
|
|
53
|
+
"var status = captureDevice.authorizationStatusForMediaType($.AVMediaTypeAudio);",
|
|
54
|
+
"status.toString();",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const MACOS_PERMISSION_STATUS = {
|
|
59
|
+
NOT_DETERMINED: 0,
|
|
60
|
+
RESTRICTED: 1,
|
|
61
|
+
DENIED: 2,
|
|
62
|
+
AUTHORIZED: 3,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export async function testMicrophonePermission(): Promise<MicPermissionResult> {
|
|
66
|
+
if (process.platform !== "darwin") return { status: "granted" };
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execFileAsync("osascript", JXA_PERMISSION_SCRIPT, {
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
});
|
|
72
|
+
const code = parseInt(stdout.trim(), 10);
|
|
73
|
+
|
|
74
|
+
switch (code) {
|
|
75
|
+
case MACOS_PERMISSION_STATUS.AUTHORIZED:
|
|
76
|
+
return { status: "granted" };
|
|
77
|
+
case MACOS_PERMISSION_STATUS.DENIED:
|
|
78
|
+
return { status: "denied", message: "Microphone access denied in System Settings" };
|
|
79
|
+
case MACOS_PERMISSION_STATUS.RESTRICTED:
|
|
80
|
+
return { status: "denied", message: "Microphone access restricted by system policy" };
|
|
81
|
+
case MACOS_PERMISSION_STATUS.NOT_DETERMINED:
|
|
82
|
+
return { status: "not-determined", message: "Microphone access not yet requested" };
|
|
83
|
+
default:
|
|
84
|
+
return { status: "error", message: `Unknown mic permission status code: ${code}` };
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
status: "error",
|
|
89
|
+
message: `Could not check mic permission: ${error instanceof Error ? error.message : String(error)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findDeviceIndex(devices: readonly string[], selected: SelectedMicrophone): number {
|
|
95
|
+
let occurrence = 0;
|
|
96
|
+
for (let index = 0; index < devices.length; index += 1) {
|
|
97
|
+
if (devices[index] !== selected.name) continue;
|
|
98
|
+
if (occurrence === selected.occurrence) return index;
|
|
99
|
+
occurrence += 1;
|
|
100
|
+
}
|
|
101
|
+
return -1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createMicrophoneCapture(microphone: MicrophoneSetting): MicrophoneCapture {
|
|
105
|
+
return new MicrophoneCapture(microphone.type === "device" ? microphone : undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class MicrophoneCapture {
|
|
109
|
+
private recorder: PvRecorder | undefined;
|
|
110
|
+
private frames: Int16Array[] = [];
|
|
111
|
+
private readLoop: Promise<void> | undefined;
|
|
112
|
+
private stopping = false;
|
|
113
|
+
private readError: Error | undefined;
|
|
114
|
+
onFrame?: (frame: Int16Array) => void;
|
|
115
|
+
|
|
116
|
+
constructor(private readonly selectedDevice?: SelectedMicrophone) {}
|
|
117
|
+
|
|
118
|
+
start(): void {
|
|
119
|
+
if (this.recorder) throw new Error("Microphone capture is already active");
|
|
120
|
+
|
|
121
|
+
const deviceIndex = this.selectedDevice
|
|
122
|
+
? findDeviceIndex(getAvailableMicrophones(), this.selectedDevice)
|
|
123
|
+
: -1;
|
|
124
|
+
if (this.selectedDevice && deviceIndex < 0) {
|
|
125
|
+
throw new MicrophoneUnavailableError(this.selectedDevice.name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// PvRecorder asks the native device for 16 kHz mono Int16 PCM. Its miniaudio
|
|
129
|
+
// layer performs any device-rate resampling and channel conversion.
|
|
130
|
+
const recorder = new PvRecorder(FRAME_LENGTH, deviceIndex);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (recorder.sampleRate !== CAPTURE_SAMPLE_RATE) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`PvRecorder reported ${recorder.sampleRate} Hz; expected ${CAPTURE_SAMPLE_RATE} Hz`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.frames = [];
|
|
140
|
+
this.stopping = false;
|
|
141
|
+
this.readError = undefined;
|
|
142
|
+
recorder.start();
|
|
143
|
+
this.recorder = recorder;
|
|
144
|
+
this.readLoop = this.readFrames(recorder);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
recorder.release();
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async stop(): Promise<CapturedAudio> {
|
|
152
|
+
const recorder = this.recorder;
|
|
153
|
+
if (!recorder) throw new Error("Microphone capture is not active");
|
|
154
|
+
|
|
155
|
+
this.stopping = true;
|
|
156
|
+
let stopError: Error | undefined;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
if (recorder.isRecording) recorder.stop();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
stopError = toError(error);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await this.readLoop;
|
|
166
|
+
} finally {
|
|
167
|
+
recorder.release();
|
|
168
|
+
this.recorder = undefined;
|
|
169
|
+
this.readLoop = undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (stopError) throw stopError;
|
|
173
|
+
if (this.readError) throw this.readError;
|
|
174
|
+
return { pcm: convertFrames(this.frames) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async readFrames(recorder: PvRecorder): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
while (!this.stopping && recorder.isRecording) {
|
|
180
|
+
const frame = await recorder.read();
|
|
181
|
+
if (this.stopping) continue;
|
|
182
|
+
this.frames.push(frame);
|
|
183
|
+
try {
|
|
184
|
+
this.onFrame?.(frame);
|
|
185
|
+
} catch {
|
|
186
|
+
// Visualizer updates must not fail the recording.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (!this.stopping) this.readError = toError(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|