@mafdet/cli 0.4.13 → 1.4.29
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.
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
2
|
+
import { type CommandIo } from "./runtime.js";
|
|
3
|
+
export declare function transcribeCommand(argv: readonly string[], io: CommandIo): Promise<ExitCode>;
|
|
4
|
+
/** Container magic numbers. Returns null for anything not recognised. */
|
|
5
|
+
export declare function sniffFormat(buffer: Buffer): string | null;
|
|
6
|
+
//# sourceMappingURL=transcribe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transcribe.d.ts","sourceRoot":"","sources":["../../src/commands/transcribe.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAsBtB,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAoFnB;AAqED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAczD"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parseApiData } from "../api/validate.js";
|
|
4
|
+
import { ApiError } from "../api/errors.js";
|
|
5
|
+
import { assertSurface, fetchCatalog, SurfaceMismatchError, } from "../api/models.js";
|
|
6
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
7
|
+
import { buildRuntime, reportError, writeHumanError, requireApiKey, } from "./runtime.js";
|
|
8
|
+
const USAGE = `Usage:
|
|
9
|
+
mafdet transcribe <file> [--model <id>] [--prompt <text>] [--json]
|
|
10
|
+
|
|
11
|
+
Turns a recording into text. The audio is sent inline, so it must fit the
|
|
12
|
+
gateway's 25 MB request limit — base64 inflates it by a third, which caps the
|
|
13
|
+
file at about 18 MB (roughly 10 minutes of 16 kHz mono WAV). Longer recordings
|
|
14
|
+
have to be split; the model's own 60-minute limit is not reachable this way.
|
|
15
|
+
`;
|
|
16
|
+
/** DEFAULT_MODEL is the file-transcription model, not the streaming one. */
|
|
17
|
+
const DEFAULT_MODEL = "gemini-3.5-transcribe";
|
|
18
|
+
/**
|
|
19
|
+
* 18 MiB. nginx allows 25 MB on `location /v1/` and base64 costs a third, so
|
|
20
|
+
* this is the largest raw file whose encoded body still fits, with room for the
|
|
21
|
+
* JSON envelope. Checked locally so a 40 MB file fails in a millisecond with a
|
|
22
|
+
* sentence, rather than after a long upload with a 413.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_BYTES = 18 * 1024 * 1024;
|
|
25
|
+
export async function transcribeCommand(argv, io) {
|
|
26
|
+
try {
|
|
27
|
+
const runtime = buildRuntime(io);
|
|
28
|
+
requireApiKey(runtime.config);
|
|
29
|
+
let model = DEFAULT_MODEL;
|
|
30
|
+
let prompt = "Transcribe this audio.";
|
|
31
|
+
let json = false;
|
|
32
|
+
const positional = [];
|
|
33
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
34
|
+
const arg = argv[i];
|
|
35
|
+
const next = () => argv[(i += 1)];
|
|
36
|
+
if (arg === "--model")
|
|
37
|
+
model = next() ?? "";
|
|
38
|
+
else if (arg === "--prompt")
|
|
39
|
+
prompt = next() ?? "";
|
|
40
|
+
else if (arg === "--json")
|
|
41
|
+
json = true;
|
|
42
|
+
else if (arg.startsWith("--")) {
|
|
43
|
+
io.stderr(`mafdet transcribe: unknown option '${arg}'\n${USAGE}`);
|
|
44
|
+
return ExitCode.Usage;
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
positional.push(arg);
|
|
48
|
+
}
|
|
49
|
+
if (positional.length !== 1) {
|
|
50
|
+
io.stderr(`mafdet transcribe: one audio file is required\n${USAGE}`);
|
|
51
|
+
return ExitCode.Usage;
|
|
52
|
+
}
|
|
53
|
+
if (!model) {
|
|
54
|
+
io.stderr("mafdet transcribe: --model needs a value\n");
|
|
55
|
+
return ExitCode.Usage;
|
|
56
|
+
}
|
|
57
|
+
const path = positional[0];
|
|
58
|
+
const audio = readAudio(path, io);
|
|
59
|
+
if (audio === null)
|
|
60
|
+
return ExitCode.Usage;
|
|
61
|
+
const catalog = await fetchCatalog(runtime.accountPlane).catch(() => []);
|
|
62
|
+
assertSurface(catalog, model, "CHAT_COMPLETIONS");
|
|
63
|
+
const body = {
|
|
64
|
+
model,
|
|
65
|
+
messages: [
|
|
66
|
+
{
|
|
67
|
+
role: "user",
|
|
68
|
+
content: [
|
|
69
|
+
{ type: "text", text: prompt },
|
|
70
|
+
{
|
|
71
|
+
type: "input_audio",
|
|
72
|
+
input_audio: { data: audio.base64, format: audio.format },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const { data: raw } = await runtime.dataPlane.postJson("/chat/completions", body);
|
|
79
|
+
const data = parseApiData(responseSchema, raw, "transcribe API");
|
|
80
|
+
const text = data.choices[0]?.message.content;
|
|
81
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
82
|
+
io.stderr("mafdet transcribe: the model returned no text. That usually means no speech was detected in the file.\n");
|
|
83
|
+
return ExitCode.Unavailable;
|
|
84
|
+
}
|
|
85
|
+
io.stdout(json ? `${JSON.stringify({ text }, null, 2)}\n` : `${text}\n`);
|
|
86
|
+
return ExitCode.Ok;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof SurfaceMismatchError) {
|
|
90
|
+
writeHumanError(io, `mafdet transcribe: ${error.message}\n`);
|
|
91
|
+
return ExitCode.Usage;
|
|
92
|
+
}
|
|
93
|
+
if (error instanceof ApiError && error.status === 413) {
|
|
94
|
+
writeHumanError(io, "mafdet transcribe: the gateway rejected the upload as too large. Split the recording and transcribe each part.\n");
|
|
95
|
+
return ExitCode.Usage;
|
|
96
|
+
}
|
|
97
|
+
return reportError(error, io);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Deliberately NOT the shared chat schemas. Both of them insist on at least one
|
|
102
|
+
* choice, and this model legitimately returns `choices: []` when the recording
|
|
103
|
+
* holds no speech (verified against Vertex directly, 2026-08-29) — so reusing
|
|
104
|
+
* them turns an ordinary outcome into "returned an invalid response at choices",
|
|
105
|
+
* which is both wrong and unactionable.
|
|
106
|
+
*/
|
|
107
|
+
const responseSchema = z
|
|
108
|
+
.object({
|
|
109
|
+
choices: z.array(z
|
|
110
|
+
.object({
|
|
111
|
+
message: z
|
|
112
|
+
.object({ content: z.string().nullable().optional() })
|
|
113
|
+
.passthrough(),
|
|
114
|
+
})
|
|
115
|
+
.passthrough()),
|
|
116
|
+
usage: z.unknown().optional(),
|
|
117
|
+
})
|
|
118
|
+
.passthrough();
|
|
119
|
+
/**
|
|
120
|
+
* Reads the file and names its format from the bytes, never the extension — a
|
|
121
|
+
* `.wav` holding an MP3 is common and the extension would send the wrong label.
|
|
122
|
+
*
|
|
123
|
+
* The label barely matters in practice: the provider sniffs the content and
|
|
124
|
+
* ignores the declared type (measured 2026-09-03 — the same WAV bytes labelled
|
|
125
|
+
* wav, mp3 and flac all returned an identical 25 audio tokens). It is sent
|
|
126
|
+
* honestly anyway, and an unrecognised header is refused here so a text file
|
|
127
|
+
* cannot be uploaded and billed as audio.
|
|
128
|
+
*/
|
|
129
|
+
function readAudio(path, io) {
|
|
130
|
+
let size;
|
|
131
|
+
try {
|
|
132
|
+
size = statSync(path).size;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
io.stderr(`mafdet transcribe: cannot read '${path}'\n`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (size === 0) {
|
|
139
|
+
io.stderr(`mafdet transcribe: '${path}' is empty\n`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (size > MAX_BYTES) {
|
|
143
|
+
io.stderr(`mafdet transcribe: '${path}' is ${mib(size)} MiB; the limit is ${mib(MAX_BYTES)} MiB. Split the recording and transcribe each part.\n`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const buffer = readFileSync(path);
|
|
147
|
+
const format = sniffFormat(buffer);
|
|
148
|
+
if (format === null) {
|
|
149
|
+
io.stderr(`mafdet transcribe: '${path}' does not look like audio (unrecognised file header)\n`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return { base64: buffer.toString("base64"), format };
|
|
153
|
+
}
|
|
154
|
+
/** Container magic numbers. Returns null for anything not recognised. */
|
|
155
|
+
export function sniffFormat(buffer) {
|
|
156
|
+
const ascii = (start, end) => buffer.subarray(start, end).toString("latin1");
|
|
157
|
+
if (buffer.length < 12)
|
|
158
|
+
return null;
|
|
159
|
+
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE")
|
|
160
|
+
return "wav";
|
|
161
|
+
if (ascii(0, 4) === "fLaC")
|
|
162
|
+
return "flac";
|
|
163
|
+
if (ascii(0, 4) === "OggS")
|
|
164
|
+
return "ogg";
|
|
165
|
+
if (ascii(4, 8) === "ftyp")
|
|
166
|
+
return "m4a";
|
|
167
|
+
if (ascii(0, 3) === "ID3")
|
|
168
|
+
return "mp3";
|
|
169
|
+
// MPEG audio frame sync: 11 set bits. 0xFFF1/0xFFF9 are ADTS AAC.
|
|
170
|
+
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
|
|
171
|
+
return (buffer[1] & 0x16) === 0x10 ? "aac" : "mp3";
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
function mib(bytes) {
|
|
176
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
177
|
+
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAgBA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAoElD,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4GpE"}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { configCommand } from "./commands/config.js";
|
|
|
9
9
|
import { doctorCommand } from "./commands/doctor.js";
|
|
10
10
|
import { embedCommand } from "./commands/embed.js";
|
|
11
11
|
import { speechCommand } from "./commands/speech.js";
|
|
12
|
+
import { transcribeCommand } from "./commands/transcribe.js";
|
|
12
13
|
import { modelsCommand } from "./commands/models.js";
|
|
13
14
|
import { sessionsCommand } from "./commands/sessions.js";
|
|
14
15
|
import { ExitCode } from "./output/exit-codes.js";
|
|
@@ -34,6 +35,7 @@ Commands:
|
|
|
34
35
|
chat Send a prompt to a model
|
|
35
36
|
embed Turn text into a vector
|
|
36
37
|
speech Turn text into an audio file
|
|
38
|
+
transcribe Turn a recording into text
|
|
37
39
|
models list List the models this key can call
|
|
38
40
|
models show Describe one model
|
|
39
41
|
account balance Wallet balance and bonus
|
|
@@ -153,6 +155,9 @@ export async function run(argv) {
|
|
|
153
155
|
if (command === "embed") {
|
|
154
156
|
return embedCommand(argv.slice(1), { ...io, readStdin });
|
|
155
157
|
}
|
|
158
|
+
if (command === "transcribe") {
|
|
159
|
+
return transcribeCommand(argv.slice(1), io);
|
|
160
|
+
}
|
|
156
161
|
if (command === "speech") {
|
|
157
162
|
return speechCommand(argv.slice(1), { ...io, readStdin });
|
|
158
163
|
}
|