@tychenjiajun/pi-voice 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +134 -2
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +68 -4
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio-errors.ts +14 -0
- package/src/audio.ts +109 -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 +315 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +135 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +119 -0
- package/src/languages.ts +22 -0
- package/src/log.ts +171 -0
- package/src/microphone-picker.ts +95 -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 +319 -0
- package/src/openai-asr.ts +247 -0
- package/src/pcm-chunker.ts +46 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +524 -0
- package/src/settings-menu.ts +638 -0
- package/src/settings-path.ts +19 -0
- package/src/settings.ts +273 -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 +570 -0
- package/src/transcription.ts +203 -0
- package/src/try-it.ts +318 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +271 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tychenjiajun/pi-voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Speech-to-text for Pi (local model or OpenAI-compatible remote ASR)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tychenjiajun/pi-voice.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tychenjiajun/pi-voice#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tychenjiajun/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
|
+
"@handy-computer/recorder": "0.1.0",
|
|
65
|
+
"@huggingface/hub": "2.15.0",
|
|
66
|
+
"openai": "^7.31.0",
|
|
67
|
+
"opencc-js": "^1.4.1",
|
|
68
|
+
"transcribe-cpp": "0.2.4"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorder errors, matched structurally so eagerly loaded modules need not
|
|
3
|
+
* load the native recorder that audio.ts imports.
|
|
4
|
+
*/
|
|
5
|
+
function recorderErrorCode(error: unknown): string | undefined {
|
|
6
|
+
if (!(error instanceof Error) || error.name !== "RecorderError") return undefined;
|
|
7
|
+
const { code } = error as Error & { code?: unknown };
|
|
8
|
+
return typeof code === "string" ? code : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** macOS refused the terminal microphone access; the fix is in System Settings. */
|
|
12
|
+
export function isMacOSPermissionDenied(error: unknown): boolean {
|
|
13
|
+
return process.platform === "darwin" && recorderErrorCode(error) === "PermissionDenied";
|
|
14
|
+
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listInputDevices,
|
|
3
|
+
permissionStatus,
|
|
4
|
+
Recorder,
|
|
5
|
+
setLogHandler,
|
|
6
|
+
type InputDevice,
|
|
7
|
+
type Permission,
|
|
8
|
+
type Recording,
|
|
9
|
+
} from "@handy-computer/recorder";
|
|
10
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
11
|
+
import type { Capture, CaptureSinks } from "./dictation-controller.js";
|
|
12
|
+
import { logLevel, writeLog } from "./log.js";
|
|
13
|
+
import type { MicrophoneSetting } from "./settings.js";
|
|
14
|
+
|
|
15
|
+
export type { InputDevice, Permission };
|
|
16
|
+
|
|
17
|
+
// The recorder's own log into Pi Voice's, stamped with when each line was
|
|
18
|
+
// written: lines from a blocked event loop arrive late but sort correctly.
|
|
19
|
+
const level = logLevel();
|
|
20
|
+
if (level) {
|
|
21
|
+
setLogHandler(
|
|
22
|
+
(record) => writeLog(record.level === "trace" ? "debug" : record.level, "recorder", record.message, record.timeMs),
|
|
23
|
+
{ level: level === "debug" ? "debug" : "info" },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 32 ms chunks: a power of two, so the meter's FFT uses every sample. */
|
|
28
|
+
const FRAMES_PER_CHUNK = 512;
|
|
29
|
+
|
|
30
|
+
export class MicrophoneUnavailableError extends Error {
|
|
31
|
+
constructor(name: string) {
|
|
32
|
+
super(`Selected microphone is unavailable: ${name}. Open /voice-settings and choose another microphone.`);
|
|
33
|
+
this.name = "MicrophoneUnavailableError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Input devices to offer, without PulseAudio monitors of outputs. */
|
|
38
|
+
export async function getAvailableMicrophones(): Promise<InputDevice[]> {
|
|
39
|
+
return (await listInputDevices()).filter((device) => !device.isMonitor);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A synchronous read that never prompts. */
|
|
43
|
+
export function microphonePermission(): Permission {
|
|
44
|
+
return permissionStatus();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findMicrophone(
|
|
48
|
+
devices: readonly InputDevice[],
|
|
49
|
+
selected: { name: string; occurrence: number },
|
|
50
|
+
): InputDevice | undefined {
|
|
51
|
+
return devices.find((device) => device.name === selected.name && device.occurrence === selected.occurrence);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function interruption(recording: Recording): Error | undefined {
|
|
55
|
+
const { endReason } = recording;
|
|
56
|
+
if (endReason.kind === "recorderFailed") {
|
|
57
|
+
// Access revoked mid-recording (or refused at the first prompt): what
|
|
58
|
+
// was captured is silence, so fail the take and let the user fix it.
|
|
59
|
+
if (endReason.error.code === "PermissionDenied") throw endReason.error;
|
|
60
|
+
return endReason.error;
|
|
61
|
+
}
|
|
62
|
+
if (endReason.kind === "sinkPanicked") return new Error(endReason.message);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Opens the microphone and starts recording 16 kHz mono into `sinks`. */
|
|
67
|
+
export async function openMicrophone(microphone: MicrophoneSetting, sinks: CaptureSinks): Promise<Capture> {
|
|
68
|
+
let device: InputDevice | undefined;
|
|
69
|
+
if (microphone.type === "device") {
|
|
70
|
+
device = findMicrophone(await listInputDevices(), microphone);
|
|
71
|
+
if (!device) throw new MicrophoneUnavailableError(microphone.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const recorder = await Recorder.open({
|
|
75
|
+
device: device?.id,
|
|
76
|
+
sampleRate: CAPTURE_SAMPLE_RATE,
|
|
77
|
+
channels: "mono",
|
|
78
|
+
framesPerChunk: FRAMES_PER_CHUNK,
|
|
79
|
+
onChunk: ({ samples }) => sinks.onAudio(samples),
|
|
80
|
+
onFailure: (error) => sinks.onFailure(error),
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const { format } = recorder.info;
|
|
84
|
+
if (format.sampleRate !== CAPTURE_SAMPLE_RATE || format.channels !== 1) {
|
|
85
|
+
throw new Error(`Recorder delivers ${format.sampleRate} Hz × ${format.channels}; expected ${CAPTURE_SAMPLE_RATE} Hz mono`);
|
|
86
|
+
}
|
|
87
|
+
recorder.start();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await recorder.close().catch(() => undefined);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async stop() {
|
|
95
|
+
try {
|
|
96
|
+
const recording = await recorder.stop();
|
|
97
|
+
return {
|
|
98
|
+
pcm: recording.samples,
|
|
99
|
+
interruption: interruption(recording),
|
|
100
|
+
droppedFrames: recording.droppedFrames,
|
|
101
|
+
};
|
|
102
|
+
} finally {
|
|
103
|
+
// The recording is already in hand; a close that times out only
|
|
104
|
+
// means the platform holds the device until Pi exits.
|
|
105
|
+
await recorder.close().catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|