@wirecat/cli-core 0.498.0 → 0.499.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/CHANGELOG.md +47 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/messenger/hearing-command.js +1 -1
- package/dist/cli/messenger/models-command.js +2 -2
- package/dist/cli/messenger/transcribe-command.js +14 -10
- package/dist/cli/runs/command.js +2 -1
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +8 -0
- package/dist/meetings/transcription.d.ts +5 -0
- package/dist/meetings/transcription.js +27 -6
- package/dist/speech/decode.d.ts +11 -1
- package/dist/speech/decode.js +20 -15
- package/dist/speech/hearing.d.ts +3 -1
- package/dist/speech/hearing.js +4 -4
- package/dist/speech/index.d.ts +5 -2
- package/dist/speech/index.js +4 -2
- package/dist/speech/local.d.ts +8 -0
- package/dist/speech/local.js +22 -1
- package/dist/speech/models.d.ts +2 -0
- package/dist/speech/models.js +2 -0
- package/dist/speech/openai.d.ts +54 -0
- package/dist/speech/openai.js +199 -0
- package/dist/speech/recognize.d.ts +25 -1
- package/dist/speech/recognize.js +52 -21
- package/dist/speech/transcribe.d.ts +25 -9
- package/dist/speech/transcribe.js +72 -13
- package/dist/speech/wav.d.ts +2 -0
- package/dist/speech/wav.js +20 -0
- package/package.json +1 -1
package/dist/speech/models.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface SpeechModel {
|
|
|
24
24
|
featureDim: number;
|
|
25
25
|
}
|
|
26
26
|
export declare const MODELS: SpeechModel[];
|
|
27
|
+
/** OpenAI's recommended transcription model (speech-to-text guide, read 2026-10-31). */
|
|
28
|
+
export declare const OPENAI_TRANSCRIBE_MODEL = "gpt-transcribe";
|
|
27
29
|
/** Cuts speech at pauses. Every model needs it: five minutes in one piece crash the WebAssembly build. */
|
|
28
30
|
export declare const VAD: ModelFile;
|
|
29
31
|
/**
|
package/dist/speech/models.js
CHANGED
|
@@ -105,6 +105,8 @@ export const MODELS = [
|
|
|
105
105
|
}),
|
|
106
106
|
},
|
|
107
107
|
];
|
|
108
|
+
/** OpenAI's recommended transcription model (speech-to-text guide, read 2026-10-31). */
|
|
109
|
+
export const OPENAI_TRANSCRIBE_MODEL = "gpt-transcribe";
|
|
108
110
|
/** Cuts speech at pauses. Every model needs it: five minutes in one piece crash the WebAssembly build. */
|
|
109
111
|
export const VAD = {
|
|
110
112
|
name: "silero_vad.onnx",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type RetryConfig } from "@wirecat/cli-base";
|
|
2
|
+
import { type Fetch } from "../embeddings/remote.js";
|
|
3
|
+
import type { Pcm } from "./decode.js";
|
|
4
|
+
import { type SpeechCutter, type TimedText } from "./recognize.js";
|
|
5
|
+
/**
|
|
6
|
+
* Ten minutes of 16 kHz PCM16 is 19.2 MB: under OpenAI's 25 MB per file (speech-to-text guide, read 2026-10-31)
|
|
7
|
+
* whichever megabyte it means, with room for the multipart body around it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LONGEST_REQUEST_SECONDS = 600;
|
|
10
|
+
export interface OpenAITranscription {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** `gpt-transcribe` when not given. */
|
|
13
|
+
model?: string;
|
|
14
|
+
/** ISO-639-1 codes the speech may be in. Sent as `languages`; the singular `language` is never sent with it. */
|
|
15
|
+
languages?: readonly string[];
|
|
16
|
+
prompt?: string;
|
|
17
|
+
/** Literal terms expected in the speech; OpenAI refuses the whole request if one holds `<`, `>` or a line break. */
|
|
18
|
+
keywords?: readonly string[];
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
fetch?: Fetch;
|
|
22
|
+
/**
|
|
23
|
+
* Fewer, longer requests for a caller that wants the text and not the turns: a recording of up to ten minutes in
|
|
24
|
+
* one request, a longer one in pieces of up to ten minutes joined across pauses. Each piece then spans the silence
|
|
25
|
+
* between its utterances, so two channels heard this way do not merge into a dialogue.
|
|
26
|
+
*/
|
|
27
|
+
bundle?: boolean;
|
|
28
|
+
/** The voice detector model; the shared models directory's when not given. */
|
|
29
|
+
vadModel?: string;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
/** Seams for tests: the detector, the retry policy and the clock. */
|
|
32
|
+
cutter?: (vadModel: string) => SpeechCutter;
|
|
33
|
+
retry?: RetryConfig;
|
|
34
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
35
|
+
random?: () => number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Each stretch of speech the detector found is a piece of its own: on one channel of a call, a pause is often the
|
|
39
|
+
* other person talking. Two stretches the detector split without a pause stay one. With `limit`, neighbours are also
|
|
40
|
+
* joined across pauses while the piece stays within it.
|
|
41
|
+
*/
|
|
42
|
+
export declare const piecesOf: (spans: readonly {
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
}[], limit?: number) => {
|
|
46
|
+
start: number;
|
|
47
|
+
end: number;
|
|
48
|
+
}[];
|
|
49
|
+
/**
|
|
50
|
+
* Speech to text by OpenAI's `/audio/transcriptions`, from one channel's samples: one piece per stretch of speech the
|
|
51
|
+
* voice detector found, each sent alone, with where it lies in seconds. Two channels heard this way merge into a
|
|
52
|
+
* dialogue by `start`. `gpt-transcribe` gives no timestamps of its own, so the times are the detector's.
|
|
53
|
+
*/
|
|
54
|
+
export declare const transcribeOpenAI: (audio: Pcm, options: OpenAITranscription) => Promise<TimedText[]>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { backoffMs, CliError, DEFAULT_RETRY } from "@wirecat/cli-base";
|
|
3
|
+
import { providerWaitMs } from "@wirecat/cli-base/http";
|
|
4
|
+
import { OPENAI_URL } from "../embeddings/remote.js";
|
|
5
|
+
import { filesPresent, modelsDirectory, vadPath } from "./install.js";
|
|
6
|
+
import { OPENAI_TRANSCRIBE_MODEL, VAD } from "./models.js";
|
|
7
|
+
import { SAMPLE_RATE, toModelRate } from "./recognize.js";
|
|
8
|
+
import { encodeWav } from "./wav.js";
|
|
9
|
+
/**
|
|
10
|
+
* Ten minutes of 16 kHz PCM16 is 19.2 MB: under OpenAI's 25 MB per file (speech-to-text guide, read 2026-10-31)
|
|
11
|
+
* whichever megabyte it means, with room for the multipart body around it.
|
|
12
|
+
*/
|
|
13
|
+
export const LONGEST_REQUEST_SECONDS = 600;
|
|
14
|
+
/**
|
|
15
|
+
* The detector cuts speech that runs past 20 seconds with no pause; on real speech that cut left 0.074 s between the
|
|
16
|
+
* two stretches, and the shortest real pause it found was 0.43 s. Below this the two are one stretch, cut mid-word.
|
|
17
|
+
*/
|
|
18
|
+
const JOINED_SECONDS = 0.2;
|
|
19
|
+
/** Kept around each piece so its first and last sounds are not clipped; never reaches into the next piece. */
|
|
20
|
+
const PAD_SECONDS = 0.2;
|
|
21
|
+
const LEAD = SAMPLE_RATE / 2;
|
|
22
|
+
/** OpenAI's codes for money or limits run out (error-codes guide, read 2026-10-31): waiting does not help. */
|
|
23
|
+
const EXHAUSTED = new Set([
|
|
24
|
+
"insufficient_quota",
|
|
25
|
+
"credit_balance_exhausted",
|
|
26
|
+
"organization_spend_limit_exceeded",
|
|
27
|
+
"project_spend_limit_exceeded",
|
|
28
|
+
"organization_usage_limit_exceeded",
|
|
29
|
+
]);
|
|
30
|
+
const RETRY = { ...DEFAULT_RETRY, retries: 4 };
|
|
31
|
+
const interrupted = (signal) => {
|
|
32
|
+
if (!signal?.aborted)
|
|
33
|
+
return;
|
|
34
|
+
if (signal.reason instanceof CliError)
|
|
35
|
+
throw signal.reason;
|
|
36
|
+
const timeout = signal.reason instanceof Error && signal.reason.name === "TimeoutError";
|
|
37
|
+
throw new CliError(timeout ? "timeout" : "cancelled", "the transcription was interrupted");
|
|
38
|
+
};
|
|
39
|
+
const sleeping = (ms, signal) => wait(ms, undefined, signal ? { signal } : {}).then(() => undefined, (error) => {
|
|
40
|
+
interrupted(signal);
|
|
41
|
+
throw error;
|
|
42
|
+
});
|
|
43
|
+
const formOf = (wav, options) => {
|
|
44
|
+
const form = new FormData();
|
|
45
|
+
form.append("file", new Blob([wav], { type: "audio/wav" }), "audio.wav");
|
|
46
|
+
form.append("model", options.model ?? OPENAI_TRANSCRIBE_MODEL);
|
|
47
|
+
// Repeated `name[]` fields: how OpenAI's own SDK sends an array in a multipart body.
|
|
48
|
+
for (const language of options.languages ?? [])
|
|
49
|
+
form.append("languages[]", language);
|
|
50
|
+
for (const keyword of options.keywords ?? [])
|
|
51
|
+
form.append("keywords[]", keyword);
|
|
52
|
+
if (options.prompt)
|
|
53
|
+
form.append("prompt", options.prompt);
|
|
54
|
+
return form;
|
|
55
|
+
};
|
|
56
|
+
const safe = (value) => typeof value === "string" && /^[\w.-]{1,64}$/.test(value) ? value : undefined;
|
|
57
|
+
/** OpenAI's error code and type, never its message: a message can quote the request. */
|
|
58
|
+
const failureOf = async (response) => {
|
|
59
|
+
try {
|
|
60
|
+
const body = (await response.json());
|
|
61
|
+
const code = safe(body.error?.code);
|
|
62
|
+
const type = safe(body.error?.type);
|
|
63
|
+
return { ...(code ? { code } : {}), ...(type ? { type } : {}) };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/** One file, one request. 429, 408 and server errors are tried again; the provider's `Retry-After` beats our backoff. */
|
|
70
|
+
const send = async (wav, options) => {
|
|
71
|
+
const post = options.fetch ?? fetch;
|
|
72
|
+
const retry = options.retry ?? RETRY;
|
|
73
|
+
const sleep = options.sleep ?? sleeping;
|
|
74
|
+
const random = options.random ?? Math.random;
|
|
75
|
+
const url = `${(options.baseUrl ?? OPENAI_URL).replace(/\/+$/, "")}/audio/transcriptions`;
|
|
76
|
+
for (let attempt = 1;; attempt++) {
|
|
77
|
+
interrupted(options.signal);
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await post(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
83
|
+
body: formOf(wav, options),
|
|
84
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
interrupted(options.signal);
|
|
89
|
+
if (attempt > retry.retries)
|
|
90
|
+
throw new CliError("network_error", "OpenAI could not be reached", { reason: "network.failed" });
|
|
91
|
+
await sleep(backoffMs(attempt, retry, random), options.signal);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (response.ok) {
|
|
95
|
+
const body = (await response.json().catch(() => interrupted(options.signal)));
|
|
96
|
+
if (typeof body?.text !== "string")
|
|
97
|
+
throw new CliError("invalid_response", "OpenAI answered without a transcript", {
|
|
98
|
+
reason: "provider.invalid-response",
|
|
99
|
+
});
|
|
100
|
+
return body.text.trim();
|
|
101
|
+
}
|
|
102
|
+
const { code, type } = await failureOf(response);
|
|
103
|
+
const status = response.status;
|
|
104
|
+
const said = `HTTP ${status}${code ? `, ${code}` : type ? `, ${type}` : ""}`;
|
|
105
|
+
if (status === 401)
|
|
106
|
+
throw new CliError("authentication_error", `OpenAI refused the key (${said}) — set it again with \`models keys set openai\``, { reason: "auth.failed" });
|
|
107
|
+
if (status === 403)
|
|
108
|
+
throw new CliError("permission_error", `OpenAI refused the request (${said}) — it answers this when the country or region is not supported, or the key's project may not use the model`, { reason: "access.denied" });
|
|
109
|
+
if (status === 413)
|
|
110
|
+
throw new CliError("validation_error", `OpenAI refused the audio as too large (${said})`, {
|
|
111
|
+
reason: "transcription.too-large",
|
|
112
|
+
});
|
|
113
|
+
if (EXHAUSTED.has(code ?? "") || EXHAUSTED.has(type ?? ""))
|
|
114
|
+
throw new CliError("permission_error", `OpenAI refused: the account's credit, spend limit or usage limit is used up (${said}) — see the billing page`, { reason: "transcription.quota-exhausted" });
|
|
115
|
+
const again = status === 408 || status === 429 || status >= 500;
|
|
116
|
+
const asked = providerWaitMs(response.headers, () => new Date());
|
|
117
|
+
if (again && attempt <= retry.retries && (asked === undefined || asked <= retry.maxRetryAfterMs)) {
|
|
118
|
+
await sleep(asked ?? backoffMs(attempt, retry, random), options.signal);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (status === 429)
|
|
122
|
+
throw new CliError("rate_limited", `OpenAI answered ${said}`, {
|
|
123
|
+
reason: "provider.rate-limited",
|
|
124
|
+
...(asked === undefined ? {} : { retry_after_ms: asked }),
|
|
125
|
+
});
|
|
126
|
+
if (status >= 500)
|
|
127
|
+
throw new CliError([502, 503, 504].includes(status) ? "provider_unavailable" : "provider_error", `OpenAI answered ${said}`, { reason: [502, 503, 504].includes(status) ? "provider.unavailable" : "provider.refused" });
|
|
128
|
+
throw new CliError("provider_error", `OpenAI answered ${said}`, { reason: "provider.refused" });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Each stretch of speech the detector found is a piece of its own: on one channel of a call, a pause is often the
|
|
133
|
+
* other person talking. Two stretches the detector split without a pause stay one. With `limit`, neighbours are also
|
|
134
|
+
* joined across pauses while the piece stays within it.
|
|
135
|
+
*/
|
|
136
|
+
export const piecesOf = (spans, limit) => {
|
|
137
|
+
const pieces = [];
|
|
138
|
+
for (const span of spans) {
|
|
139
|
+
const last = pieces.at(-1);
|
|
140
|
+
const joined = last !== undefined && span.start - last.end < JOINED_SECONDS;
|
|
141
|
+
const fits = last !== undefined && limit !== undefined && span.end - last.start <= limit;
|
|
142
|
+
if (last && (fits || (joined && span.end - last.start <= LONGEST_REQUEST_SECONDS)))
|
|
143
|
+
last.end = span.end;
|
|
144
|
+
else
|
|
145
|
+
pieces.push({ start: span.start, end: span.end });
|
|
146
|
+
}
|
|
147
|
+
return pieces;
|
|
148
|
+
};
|
|
149
|
+
const openCutter = async (vadModel) => {
|
|
150
|
+
const { openSpeechCutter } = await import("./recognize.js");
|
|
151
|
+
return openSpeechCutter(vadModel);
|
|
152
|
+
};
|
|
153
|
+
const checkKeywords = (keywords = []) => {
|
|
154
|
+
const bad = keywords.find((keyword) => typeof keyword !== "string" || !keyword.trim() || /[<>\r\n]/.test(keyword));
|
|
155
|
+
if (bad !== undefined)
|
|
156
|
+
throw new CliError("validation_error", "a keyword must be a non-empty single line without < or > — OpenAI refuses the whole request otherwise");
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Speech to text by OpenAI's `/audio/transcriptions`, from one channel's samples: one piece per stretch of speech the
|
|
160
|
+
* voice detector found, each sent alone, with where it lies in seconds. Two channels heard this way merge into a
|
|
161
|
+
* dialogue by `start`. `gpt-transcribe` gives no timestamps of its own, so the times are the detector's.
|
|
162
|
+
*/
|
|
163
|
+
export const transcribeOpenAI = async (audio, options) => {
|
|
164
|
+
if (!options.apiKey)
|
|
165
|
+
throw new CliError("authentication_error", "no OpenAI key — `models keys set openai`, or OPENAI_API_KEY", {
|
|
166
|
+
reason: "transcription.no-key",
|
|
167
|
+
});
|
|
168
|
+
checkKeywords(options.keywords);
|
|
169
|
+
interrupted(options.signal);
|
|
170
|
+
const pcm = toModelRate(audio.samples, audio.rate);
|
|
171
|
+
const duration = (pcm.length - LEAD) / SAMPLE_RATE;
|
|
172
|
+
if (options.bundle && duration <= LONGEST_REQUEST_SECONDS) {
|
|
173
|
+
const text = await send(encodeWav(pcm.subarray(LEAD)), options);
|
|
174
|
+
return text ? [{ start: 0, end: duration, text }] : [];
|
|
175
|
+
}
|
|
176
|
+
const vadModel = options.vadModel ?? vadPath(modelsDirectory(options.env));
|
|
177
|
+
if (!options.cutter && !filesPresent([[VAD, vadModel]]))
|
|
178
|
+
throw new CliError("not_found", "the voice detector that finds the speech is not on this machine — it comes with any speech model: `models download`", { reason: "model.not-downloaded", model: "silero_vad" });
|
|
179
|
+
const cutter = await (options.cutter ?? openCutter)(vadModel);
|
|
180
|
+
let spans;
|
|
181
|
+
try {
|
|
182
|
+
spans = cutter.cut(pcm);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
cutter.free();
|
|
186
|
+
}
|
|
187
|
+
const pieces = piecesOf(spans, options.bundle ? LONGEST_REQUEST_SECONDS : undefined);
|
|
188
|
+
const heard = [];
|
|
189
|
+
for (const [index, piece] of pieces.entries()) {
|
|
190
|
+
const end = Math.min(piece.end, duration);
|
|
191
|
+
const from = Math.max(piece.start - PAD_SECONDS, pieces[index - 1]?.end ?? 0);
|
|
192
|
+
const to = Math.min(end + PAD_SECONDS, pieces[index + 1]?.start ?? duration);
|
|
193
|
+
const samples = pcm.subarray(LEAD + Math.floor(from * SAMPLE_RATE), LEAD + Math.ceil(to * SAMPLE_RATE));
|
|
194
|
+
const text = await send(encodeWav(samples), options);
|
|
195
|
+
if (text)
|
|
196
|
+
heard.push({ start: Math.min(piece.start, end), end, text });
|
|
197
|
+
}
|
|
198
|
+
return heard;
|
|
199
|
+
};
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { SpeechModel } from "./models.js";
|
|
2
|
+
/** Seconds from the start of the recording. */
|
|
3
|
+
export interface TimedText {
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
2
8
|
/** What transcription needs from a speech engine — small enough for a test to stand in for it. */
|
|
3
9
|
export interface Recognizer {
|
|
4
10
|
recognize(pcm: Float32Array): string;
|
|
11
|
+
/** Each stretch of speech the voice detector cut, with where it lies; the same input as `recognize`. */
|
|
12
|
+
pieces?(pcm: Float32Array): TimedText[];
|
|
5
13
|
free(): void;
|
|
6
14
|
}
|
|
7
15
|
export declare const SAMPLE_RATE = 16000;
|
|
@@ -13,7 +21,7 @@ export declare const toModelRate: (samples: Float32Array, rate: number) => Float
|
|
|
13
21
|
export declare const decodeOgg: (bytes: Uint8Array) => Promise<{
|
|
14
22
|
samples: Float32Array;
|
|
15
23
|
rate: number;
|
|
16
|
-
}>;
|
|
24
|
+
}[]>;
|
|
17
25
|
interface SherpaStream {
|
|
18
26
|
acceptWaveform(rate: number, samples: Float32Array): void;
|
|
19
27
|
free(): void;
|
|
@@ -31,6 +39,7 @@ interface Sherpa {
|
|
|
31
39
|
acceptWaveform(samples: Float32Array): void;
|
|
32
40
|
isEmpty(): boolean;
|
|
33
41
|
front(): {
|
|
42
|
+
start: number;
|
|
34
43
|
samples: Float32Array;
|
|
35
44
|
};
|
|
36
45
|
pop(): void;
|
|
@@ -55,6 +64,21 @@ export declare const detector: (vadModel: string) => {
|
|
|
55
64
|
debug: number;
|
|
56
65
|
bufferSizeInSeconds: number;
|
|
57
66
|
};
|
|
67
|
+
export interface SpeechSpan {
|
|
68
|
+
start: number;
|
|
69
|
+
end: number;
|
|
70
|
+
samples: Float32Array;
|
|
71
|
+
}
|
|
72
|
+
export interface SpeechCutter {
|
|
73
|
+
/** Where the speech is, in seconds of the recording; `pcm` as `toModelRate` made it. */
|
|
74
|
+
cut(pcm: Float32Array): {
|
|
75
|
+
start: number;
|
|
76
|
+
end: number;
|
|
77
|
+
}[];
|
|
78
|
+
free(): void;
|
|
79
|
+
}
|
|
80
|
+
/** The voice detector alone, for a recognizer that is not on this machine. */
|
|
81
|
+
export declare const openSpeechCutter: (vadModel: string) => SpeechCutter;
|
|
58
82
|
/**
|
|
59
83
|
* The engine's WebAssembly writes its logs (a growing buffer says "Overflow! … No data loss!") with
|
|
60
84
|
* `printf`, which its file system sends straight to file descriptor 1 and so into `--json` output.
|
package/dist/speech/recognize.js
CHANGED
|
@@ -31,10 +31,9 @@ export const decodeOgg = async (bytes) => {
|
|
|
31
31
|
await decoder.ready;
|
|
32
32
|
try {
|
|
33
33
|
const { channelData, sampleRate, samplesDecoded } = await decoder.decodeFile(bytes);
|
|
34
|
-
|
|
35
|
-
if (!samples || samplesDecoded === 0)
|
|
34
|
+
if (!channelData[0] || samplesDecoded === 0)
|
|
36
35
|
throw new Error("no Ogg Opus audio in it");
|
|
37
|
-
return { samples, rate: sampleRate };
|
|
36
|
+
return channelData.map((samples) => ({ samples, rate: sampleRate }));
|
|
38
37
|
}
|
|
39
38
|
finally {
|
|
40
39
|
decoder.free();
|
|
@@ -59,6 +58,43 @@ export const detector = (vadModel) => ({
|
|
|
59
58
|
debug: 0,
|
|
60
59
|
bufferSizeInSeconds: 30,
|
|
61
60
|
});
|
|
61
|
+
/**
|
|
62
|
+
* `pcm` as `toModelRate` made it: the detector's sample offsets count its half-second lead, the recording does not.
|
|
63
|
+
* Each stretch goes to `each` as it is cut, so an hour of speech is never held twice.
|
|
64
|
+
*/
|
|
65
|
+
const cut = (vad, pcm, each) => {
|
|
66
|
+
// One detector can hear several recordings in a row; nothing of the last may leak into this one.
|
|
67
|
+
vad.reset();
|
|
68
|
+
const last = Math.max(0, pcm.length - LEAD);
|
|
69
|
+
const drain = () => {
|
|
70
|
+
for (; !vad.isEmpty(); vad.pop()) {
|
|
71
|
+
const { start, samples } = vad.front();
|
|
72
|
+
each({
|
|
73
|
+
start: Math.min(last, Math.max(0, start - LEAD)) / SAMPLE_RATE,
|
|
74
|
+
end: Math.min(last, Math.max(0, start + samples.length - LEAD)) / SAMPLE_RATE,
|
|
75
|
+
samples,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
for (let i = 0; i + WINDOW <= pcm.length; i += WINDOW) {
|
|
80
|
+
vad.acceptWaveform(pcm.subarray(i, i + WINDOW));
|
|
81
|
+
drain();
|
|
82
|
+
}
|
|
83
|
+
vad.flush();
|
|
84
|
+
drain();
|
|
85
|
+
};
|
|
86
|
+
/** The voice detector alone, for a recognizer that is not on this machine. */
|
|
87
|
+
export const openSpeechCutter = (vadModel) => {
|
|
88
|
+
const vad = loadSherpa().createVad(detector(vadModel));
|
|
89
|
+
return {
|
|
90
|
+
cut: (pcm) => {
|
|
91
|
+
const spans = [];
|
|
92
|
+
cut(vad, pcm, ({ start, end }) => spans.push({ start, end }));
|
|
93
|
+
return spans;
|
|
94
|
+
},
|
|
95
|
+
free: () => vad.free(),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
62
98
|
/**
|
|
63
99
|
* The model, and a voice detector that cuts the audio at pauses into pieces of at most 20 seconds.
|
|
64
100
|
* The cutting is not optional: five minutes in one piece crash the WebAssembly build (measured).
|
|
@@ -107,25 +143,20 @@ export const openRecognizer = (model, path, vadModel) => {
|
|
|
107
143
|
stream.free();
|
|
108
144
|
}
|
|
109
145
|
};
|
|
146
|
+
const pieces = (pcm) => {
|
|
147
|
+
const heard = [];
|
|
148
|
+
cut(vad, pcm, ({ start, end, samples }) => {
|
|
149
|
+
const text = piece(samples);
|
|
150
|
+
if (text)
|
|
151
|
+
heard.push({ start, end, text });
|
|
152
|
+
});
|
|
153
|
+
return heard;
|
|
154
|
+
};
|
|
110
155
|
return {
|
|
111
|
-
recognize: (pcm) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const drain = () => {
|
|
116
|
-
while (!vad.isEmpty()) {
|
|
117
|
-
parts.push(piece(vad.front().samples));
|
|
118
|
-
vad.pop();
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
for (let i = 0; i + WINDOW <= pcm.length; i += WINDOW) {
|
|
122
|
-
vad.acceptWaveform(pcm.subarray(i, i + WINDOW));
|
|
123
|
-
drain();
|
|
124
|
-
}
|
|
125
|
-
vad.flush();
|
|
126
|
-
drain();
|
|
127
|
-
return parts.filter(Boolean).join(" ");
|
|
128
|
-
},
|
|
156
|
+
recognize: (pcm) => pieces(pcm)
|
|
157
|
+
.map(({ text }) => text)
|
|
158
|
+
.join(" "),
|
|
159
|
+
pieces,
|
|
129
160
|
free: () => {
|
|
130
161
|
vad.free();
|
|
131
162
|
engine.free();
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import { CliError } from "@wirecat/cli-base";
|
|
1
|
+
import { CliError, type KeyringStore } from "@wirecat/cli-base";
|
|
2
2
|
import type { Messenger } from "../cli/messenger/context.js";
|
|
3
3
|
import { type Settings } from "../cli/settings.js";
|
|
4
4
|
import { type MessengerAdapter, type RemoteFile, type Transcript } from "../ports/messenger.js";
|
|
5
5
|
import type { Ffmpeg } from "./decode.js";
|
|
6
6
|
import { type SpeechModel } from "./models.js";
|
|
7
7
|
import type { Recognizer } from "./recognize.js";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the
|
|
10
|
+
* second. `openai`: OpenAI's API, only when asked by name — `auto` never sends audio off the machine.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TRANSCRIBE_WITH: readonly ["auto", "messenger", "local", "openai"];
|
|
10
13
|
export type TranscribeWith = (typeof TRANSCRIBE_WITH)[number];
|
|
11
14
|
export interface Heard extends Transcript {
|
|
12
15
|
messageId: string;
|
|
13
|
-
/** The messenger's provider, or `
|
|
16
|
+
/** The messenger's provider, `local` or `openai`. */
|
|
14
17
|
via: string;
|
|
15
|
-
/** The local model, when it was one. */
|
|
18
|
+
/** The local or OpenAI model, when it was one. */
|
|
16
19
|
model?: string;
|
|
17
20
|
}
|
|
18
21
|
export type OpenRecognizer = (model: SpeechModel, path: (name: string) => string, vadModel: string) => Recognizer;
|
|
@@ -24,17 +27,26 @@ export interface Choice {
|
|
|
24
27
|
/** The local recognizer, where a test hands one in. */
|
|
25
28
|
open?: OpenRecognizer;
|
|
26
29
|
ffmpeg?: Ffmpeg;
|
|
30
|
+
/** The profile's `transcribeModel` for `openai`, else `gpt-transcribe`. */
|
|
31
|
+
transcribeModel?: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
/** Why nothing can be heard: an unknown `transcribeWith`, or for `openai` no key or a read-only process. */
|
|
34
|
+
refusal?: CliError;
|
|
35
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
36
|
+
/** The command's own cancel and deadline, for a request that leaves the machine. */
|
|
37
|
+
signal?: AbortSignal;
|
|
27
38
|
}
|
|
28
39
|
/** A voice note, or an audio file someone sent — an iPhone recording reaches WhatsApp as `audio/mp4`. */
|
|
29
40
|
export declare const isAudioFile: ({ kind, mime }: {
|
|
30
41
|
kind: string;
|
|
31
42
|
mime?: string | undefined;
|
|
32
43
|
}) => boolean;
|
|
33
|
-
/** The flag, then the profile's `transcribeWith` and `
|
|
44
|
+
/** The flag, then the profile's `transcribeWith`, `speechModel` and `transcribeModel`, then `auto` and the CLI's first model. */
|
|
34
45
|
export declare const choose: (messenger: Messenger, settings: Pick<Settings, "configured" | "shared">, flags: {
|
|
35
46
|
local?: boolean;
|
|
36
47
|
model?: string;
|
|
37
|
-
|
|
48
|
+
with?: TranscribeWith;
|
|
49
|
+
}, env?: NodeJS.ProcessEnv, keyring?: KeyringStore) => Choice;
|
|
38
50
|
/** Never a download: a model is hundreds of MB, and whether to fetch one is the owner's call. */
|
|
39
51
|
export declare const notDownloaded: (messenger: Messenger, model: SpeechModel) => CliError;
|
|
40
52
|
/**
|
|
@@ -43,5 +55,9 @@ export declare const notDownloaded: (messenger: Messenger, model: SpeechModel) =
|
|
|
43
55
|
* `read` gets the recording once its bytes are all in, for a reference the messenger renewed while they were read.
|
|
44
56
|
*/
|
|
45
57
|
export declare const hearOnline: (messenger: Messenger, connection: MessengerAdapter, chat: string, messageId: string, choice: Choice, read?: (file: RemoteFile) => Promise<void>) => Promise<Heard | Uint8Array>;
|
|
46
|
-
/** The local model, on the bytes `hearOnline` fetched. */
|
|
47
|
-
export declare const hearLocally: (bytes: Uint8Array, messageId: string,
|
|
58
|
+
/** The local model, or OpenAI when chosen, on the bytes `hearOnline` fetched. */
|
|
59
|
+
export declare const hearLocally: (bytes: Uint8Array, messageId: string, choice: Choice) => Promise<Heard>;
|
|
60
|
+
/** Where a kept transcript came from, as `Kept` stores it: the messenger's provider, a local model, or `openai:<model>`. */
|
|
61
|
+
export declare const keptSource: (heard: Heard) => string;
|
|
62
|
+
/** The reverse of `keptSource`. */
|
|
63
|
+
export declare const heardVia: (source: string, provider: string) => Pick<Heard, "via" | "model">;
|
|
@@ -1,26 +1,52 @@
|
|
|
1
1
|
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
2
2
|
import { fromFile } from "../cli/settings.js";
|
|
3
|
+
import { embeddingKeys } from "../embeddings/keys.js";
|
|
4
|
+
import { readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
|
|
3
5
|
import { capability } from "../ports/messenger.js";
|
|
4
6
|
import { typedCommand } from "../runs/scope.js";
|
|
5
7
|
import { installedBytes, isInstalled, megabytes, modelPath, modelsDirectory, vadPath } from "./install.js";
|
|
6
|
-
import { orderedModels, speechModel } from "./models.js";
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
import { OPENAI_TRANSCRIBE_MODEL, orderedModels, speechModel } from "./models.js";
|
|
9
|
+
/**
|
|
10
|
+
* `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the
|
|
11
|
+
* second. `openai`: OpenAI's API, only when asked by name — `auto` never sends audio off the machine.
|
|
12
|
+
*/
|
|
13
|
+
export const TRANSCRIBE_WITH = ["auto", "messenger", "local", "openai"];
|
|
9
14
|
/** A recording is read into memory whole; minutes of Opus are a few megabytes. */
|
|
10
15
|
const LARGEST_VOICE = 32 * 1024 * 1024;
|
|
11
16
|
/** A voice note, or an audio file someone sent — an iPhone recording reaches WhatsApp as `audio/mp4`. */
|
|
12
17
|
export const isAudioFile = ({ kind, mime }) => kind === "voice" || kind === "audio" || (mime?.startsWith("audio/") ?? false);
|
|
13
|
-
/** The flag, then the profile's `transcribeWith` and `
|
|
14
|
-
export const choose = (messenger, settings, flags, env = process.env) => {
|
|
18
|
+
/** The flag, then the profile's `transcribeWith`, `speechModel` and `transcribeModel`, then `auto` and the CLI's first model. */
|
|
19
|
+
export const choose = (messenger, settings, flags, env = process.env, keyring) => {
|
|
15
20
|
const model = flags.model ?? fromFile(settings, "speechModel", undefined).value;
|
|
21
|
+
const chosen = flags.local || flags.model !== undefined
|
|
22
|
+
? "local"
|
|
23
|
+
: (flags.with ?? fromFile(settings, "transcribeWith", "auto").value);
|
|
24
|
+
// Refused only where something is heard: `models list` reads the same profile for its model alone.
|
|
25
|
+
const known = TRANSCRIBE_WITH.includes(chosen);
|
|
16
26
|
return {
|
|
17
27
|
directory: modelsDirectory(env),
|
|
18
|
-
with:
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
with: known ? chosen : "auto",
|
|
29
|
+
...(known
|
|
30
|
+
? {}
|
|
31
|
+
: {
|
|
32
|
+
refusal: new CliError("validation_error", `the profile's transcribeWith is one of ${TRANSCRIBE_WITH.join(", ")}`),
|
|
33
|
+
}),
|
|
21
34
|
model: model === undefined ? orderedModels(messenger.speechModels)[0] : speechModel(model),
|
|
35
|
+
transcribeModel: fromFile(settings, "transcribeModel", OPENAI_TRANSCRIBE_MODEL).value,
|
|
36
|
+
...(known && chosen === "openai" ? openaiAccess(messenger, env, keyring) : {}),
|
|
22
37
|
};
|
|
23
38
|
};
|
|
39
|
+
const openaiAccess = (messenger, env, keyring) => {
|
|
40
|
+
const lock = readOnlyLockOf(messenger.app, env);
|
|
41
|
+
if (lock)
|
|
42
|
+
return { refusal: readOnlyRefusal(lock, "send audio to OpenAI") };
|
|
43
|
+
const apiKey = embeddingKeys(messenger.app, env, keyring).read("openai")?.key;
|
|
44
|
+
return apiKey
|
|
45
|
+
? { apiKey }
|
|
46
|
+
: {
|
|
47
|
+
refusal: new CliError("authentication_error", `no OpenAI key — \`${typedCommand(messenger.app.command, "models keys set openai")}\`, or OPENAI_API_KEY`, { reason: "transcription.no-key" }),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
24
50
|
/** Never a download: a model is hundreds of MB, and whether to fetch one is the owner's call. */
|
|
25
51
|
export const notDownloaded = (messenger, model) => new CliError("not_found", `the speech model ${model.id} is not on this machine — the owner downloads it once with ` +
|
|
26
52
|
`\`${typedCommand(messenger.app.command, `models download ${model.id}`)}\` (${megabytes(installedBytes(model))})`, { reason: "model.not-downloaded", model: model.id });
|
|
@@ -30,7 +56,9 @@ export const notDownloaded = (messenger, model) => new CliError("not_found", `th
|
|
|
30
56
|
* `read` gets the recording once its bytes are all in, for a reference the messenger renewed while they were read.
|
|
31
57
|
*/
|
|
32
58
|
export const hearOnline = async (messenger, connection, chat, messageId, choice, read) => {
|
|
33
|
-
if (choice.
|
|
59
|
+
if (choice.refusal)
|
|
60
|
+
throw choice.refusal;
|
|
61
|
+
if (choice.with === "auto" || choice.with === "messenger") {
|
|
34
62
|
const canAsk = typeof connection.transcribe === "function";
|
|
35
63
|
if (choice.with === "messenger" || canAsk) {
|
|
36
64
|
try {
|
|
@@ -44,7 +72,7 @@ export const hearOnline = async (messenger, connection, chat, messageId, choice,
|
|
|
44
72
|
}
|
|
45
73
|
}
|
|
46
74
|
}
|
|
47
|
-
if (!isInstalled(choice.model, choice.directory))
|
|
75
|
+
if (choice.with !== "openai" && !isInstalled(choice.model, choice.directory))
|
|
48
76
|
throw notDownloaded(messenger, choice.model);
|
|
49
77
|
const { files } = await capability(connection, "download", "download attachments")(chat, messageId);
|
|
50
78
|
const voice = files.find(isAudioFile);
|
|
@@ -54,11 +82,34 @@ export const hearOnline = async (messenger, connection, chat, messageId, choice,
|
|
|
54
82
|
await read?.(voice);
|
|
55
83
|
return bytes;
|
|
56
84
|
};
|
|
57
|
-
/** The local model, on the bytes `hearOnline` fetched. */
|
|
58
|
-
export const hearLocally = async (bytes, messageId,
|
|
59
|
-
const {
|
|
85
|
+
/** The local model, or OpenAI when chosen, on the bytes `hearOnline` fetched. */
|
|
86
|
+
export const hearLocally = async (bytes, messageId, choice) => {
|
|
87
|
+
const { model, directory, open, ffmpeg } = choice;
|
|
60
88
|
const { decodeAudio } = await import("./decode.js");
|
|
61
89
|
const { samples, rate } = await decodeAudio(bytes, ffmpeg);
|
|
90
|
+
if (choice.with === "openai") {
|
|
91
|
+
if (choice.refusal)
|
|
92
|
+
throw choice.refusal;
|
|
93
|
+
const { transcribeOpenAI } = await import("./openai.js");
|
|
94
|
+
const transcribeModel = choice.transcribeModel ?? OPENAI_TRANSCRIBE_MODEL;
|
|
95
|
+
const pieces = await transcribeOpenAI({ samples, rate }, {
|
|
96
|
+
apiKey: choice.apiKey ?? "",
|
|
97
|
+
model: transcribeModel,
|
|
98
|
+
vadModel: vadPath(directory),
|
|
99
|
+
// A voice message wants its text, not its turns: one request, and no voice detector needed.
|
|
100
|
+
bundle: true,
|
|
101
|
+
...(choice.fetch ? { fetch: choice.fetch } : {}),
|
|
102
|
+
...(choice.signal ? { signal: choice.signal } : {}),
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
messageId,
|
|
106
|
+
text: pieces.map(({ text }) => text).join(" "),
|
|
107
|
+
pending: false,
|
|
108
|
+
via: "openai",
|
|
109
|
+
model: transcribeModel,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const { openRecognizer, toModelRate } = await import("./recognize.js");
|
|
62
113
|
const recognizer = (open ?? openRecognizer)(model, modelPath(directory, model), vadPath(directory));
|
|
63
114
|
try {
|
|
64
115
|
return {
|
|
@@ -73,6 +124,14 @@ export const hearLocally = async (bytes, messageId, { model, directory, open, ff
|
|
|
73
124
|
recognizer.free();
|
|
74
125
|
}
|
|
75
126
|
};
|
|
127
|
+
/** Where a kept transcript came from, as `Kept` stores it: the messenger's provider, a local model, or `openai:<model>`. */
|
|
128
|
+
export const keptSource = (heard) => heard.via === "openai" ? `openai:${heard.model ?? OPENAI_TRANSCRIBE_MODEL}` : (heard.model ?? heard.via);
|
|
129
|
+
/** The reverse of `keptSource`. */
|
|
130
|
+
export const heardVia = (source, provider) => source === provider
|
|
131
|
+
? { via: source }
|
|
132
|
+
: source.startsWith("openai:")
|
|
133
|
+
? { via: "openai", model: source.slice("openai:".length) }
|
|
134
|
+
: { via: "local", model: source };
|
|
76
135
|
const readAll = async (file) => {
|
|
77
136
|
const chunks = [];
|
|
78
137
|
let total = 0;
|
package/dist/speech/wav.d.ts
CHANGED
|
@@ -25,3 +25,5 @@ export declare const openWavAudio: (plan: WavAudioPlan, input: WavAudioOptions)
|
|
|
25
25
|
windows(): AsyncIterable<WavPcmWindow>;
|
|
26
26
|
close(): Promise<void>;
|
|
27
27
|
}>;
|
|
28
|
+
/** PCM16 mono WAV of samples in [-1, 1], for a recognizer that takes a file. */
|
|
29
|
+
export declare const encodeWav: (samples: Float32Array, sampleRate?: number) => Uint8Array;
|
package/dist/speech/wav.js
CHANGED
|
@@ -196,3 +196,23 @@ export const openWavAudio = async (plan, input) => {
|
|
|
196
196
|
throw error;
|
|
197
197
|
}
|
|
198
198
|
};
|
|
199
|
+
/** PCM16 mono WAV of samples in [-1, 1], for a recognizer that takes a file. */
|
|
200
|
+
export const encodeWav = (samples, sampleRate = 16000) => {
|
|
201
|
+
const bytes = Buffer.alloc(44 + samples.length * 2);
|
|
202
|
+
bytes.write("RIFF", 0, "ascii");
|
|
203
|
+
bytes.writeUInt32LE(bytes.length - 8, 4);
|
|
204
|
+
bytes.write("WAVEfmt ", 8, "ascii");
|
|
205
|
+
bytes.writeUInt32LE(16, 16);
|
|
206
|
+
bytes.writeUInt16LE(1, 20);
|
|
207
|
+
bytes.writeUInt16LE(1, 22);
|
|
208
|
+
bytes.writeUInt32LE(sampleRate, 24);
|
|
209
|
+
bytes.writeUInt32LE(sampleRate * 2, 28);
|
|
210
|
+
bytes.writeUInt16LE(2, 32);
|
|
211
|
+
bytes.writeUInt16LE(16, 34);
|
|
212
|
+
bytes.write("data", 36, "ascii");
|
|
213
|
+
bytes.writeUInt32LE(samples.length * 2, 40);
|
|
214
|
+
samples.forEach((sample, index) => {
|
|
215
|
+
bytes.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample || 0)) * 32767), 44 + index * 2);
|
|
216
|
+
});
|
|
217
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
218
|
+
};
|