@wirecat/cli-core 0.497.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.
@@ -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
- /** `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the second. */
8
- export const TRANSCRIBE_WITH = ["auto", "messenger", "local"];
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 `speechModel`, then `auto` and the CLI's first model. */
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: flags.local || flags.model !== undefined
19
- ? "local"
20
- : fromFile(settings, "transcribeWith", "auto").value,
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.with !== "local") {
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, { model, directory, open, ffmpeg }) => {
59
- const { openRecognizer, toModelRate } = await import("./recognize.js");
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;
@@ -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;
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.497.0",
3
+ "version": "0.499.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",