@moxxy/plugin-stt-whisper 0.27.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/dist/audio.d.ts +52 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +129 -0
- package/dist/audio.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/whisper.d.ts +40 -0
- package/dist/whisper.d.ts.map +1 -0
- package/dist/whisper.js +161 -0
- package/dist/whisper.js.map +1 -0
- package/package.json +62 -0
- package/src/audio.test.ts +101 -0
- package/src/audio.ts +150 -0
- package/src/index.ts +73 -0
- package/src/whisper.test.ts +281 -0
- package/src/whisper.ts +212 -0
package/src/whisper.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import OpenAI, { APIError, APIConnectionError, APIUserAbortError } from 'openai';
|
|
2
|
+
import {
|
|
3
|
+
classifyHttpStatus,
|
|
4
|
+
classifyNetworkError,
|
|
5
|
+
MoxxyError,
|
|
6
|
+
type Transcriber,
|
|
7
|
+
type TranscribeOptions,
|
|
8
|
+
type TranscriptionResult,
|
|
9
|
+
} from '@moxxy/sdk';
|
|
10
|
+
import { normalizeWhisperUpload } from './audio.js';
|
|
11
|
+
|
|
12
|
+
export type WhisperModel = 'whisper-1' | 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe';
|
|
13
|
+
|
|
14
|
+
/** Provider tag attached to classified errors for logs/debug context. */
|
|
15
|
+
const WHISPER_PROVIDER_ID = 'openai';
|
|
16
|
+
|
|
17
|
+
export interface WhisperTranscriberOptions {
|
|
18
|
+
readonly apiKey?: string;
|
|
19
|
+
readonly baseURL?: string;
|
|
20
|
+
/** Defaults to `whisper-1`. */
|
|
21
|
+
readonly model?: WhisperModel;
|
|
22
|
+
/**
|
|
23
|
+
* Default language hint (BCP-47). Overridden per-call by
|
|
24
|
+
* `TranscribeOptions.language`. Omit to let Whisper auto-detect.
|
|
25
|
+
*/
|
|
26
|
+
readonly language?: string;
|
|
27
|
+
/** Inject a pre-built OpenAI client (tests pass a stub here). */
|
|
28
|
+
readonly client?: OpenAI;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `Transcriber` backed by OpenAI's audio.transcriptions endpoint
|
|
33
|
+
* (Whisper-1 by default). Requests `verbose_json` so we can return
|
|
34
|
+
* `language`, `durationSec`, and per-segment text without an extra call.
|
|
35
|
+
*
|
|
36
|
+
* Audio bytes come in as `Uint8Array | ArrayBuffer`; we wrap them in a
|
|
37
|
+
* Node `File` for upload (Node 20.10+ provides File / Blob globals).
|
|
38
|
+
*/
|
|
39
|
+
export class WhisperTranscriber implements Transcriber {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
private readonly client: OpenAI;
|
|
42
|
+
private readonly model: WhisperModel;
|
|
43
|
+
private readonly defaultLanguage: string | undefined;
|
|
44
|
+
|
|
45
|
+
constructor(opts: WhisperTranscriberOptions = {}) {
|
|
46
|
+
this.model = opts.model ?? 'whisper-1';
|
|
47
|
+
this.name = `openai-${this.model}`;
|
|
48
|
+
this.defaultLanguage = opts.language;
|
|
49
|
+
this.client =
|
|
50
|
+
opts.client ??
|
|
51
|
+
new OpenAI({
|
|
52
|
+
apiKey: opts.apiKey ?? process.env.OPENAI_API_KEY,
|
|
53
|
+
...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async transcribe(
|
|
58
|
+
audio: Uint8Array | ArrayBuffer,
|
|
59
|
+
opts: TranscribeOptions = {},
|
|
60
|
+
): Promise<TranscriptionResult> {
|
|
61
|
+
// Route through the shared Whisper-family preprocessor so the project's
|
|
62
|
+
// raw-PCM16 contract (MOXXY_PCM16_24KHZ_MIME) gets WAV-wrapped and filename
|
|
63
|
+
// inference stays in lockstep with the Codex sibling. Default the MIME to
|
|
64
|
+
// `audio/ogg` to preserve prior behavior for callers that omit it.
|
|
65
|
+
const upload = normalizeWhisperUpload(audio, opts.mimeType ?? 'audio/ogg');
|
|
66
|
+
// Node 20.10+ exposes File globally; the OpenAI SDK accepts it as
|
|
67
|
+
// an `Uploadable`.
|
|
68
|
+
const file = new File([upload.bytes], upload.filename, { type: upload.mimeType });
|
|
69
|
+
const language = opts.language ?? this.defaultLanguage;
|
|
70
|
+
// verbose_json is only supported by whisper-1; the gpt-4o family
|
|
71
|
+
// returns plain JSON. Branch so callers get rich segments when
|
|
72
|
+
// available, and a graceful text-only result when not.
|
|
73
|
+
if (this.model === 'whisper-1') {
|
|
74
|
+
const response = await this.run(() =>
|
|
75
|
+
this.client.audio.transcriptions.create(
|
|
76
|
+
{
|
|
77
|
+
model: this.model,
|
|
78
|
+
file,
|
|
79
|
+
response_format: 'verbose_json',
|
|
80
|
+
...(language ? { language } : {}),
|
|
81
|
+
...(opts.prompt ? { prompt: opts.prompt } : {}),
|
|
82
|
+
},
|
|
83
|
+
{ signal: opts.signal },
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
// OpenAI verbose-json response: { text, language, duration, segments[] }
|
|
87
|
+
const r = requireTextResponse(response) as {
|
|
88
|
+
text: string;
|
|
89
|
+
language?: unknown;
|
|
90
|
+
duration?: unknown;
|
|
91
|
+
segments?: unknown;
|
|
92
|
+
};
|
|
93
|
+
const result: {
|
|
94
|
+
text: string;
|
|
95
|
+
language?: string;
|
|
96
|
+
durationSec?: number;
|
|
97
|
+
segments?: Array<{ start: number; end: number; text: string }>;
|
|
98
|
+
} = { text: r.text };
|
|
99
|
+
if (typeof r.language === 'string') result.language = r.language;
|
|
100
|
+
if (typeof r.duration === 'number') result.durationSec = r.duration;
|
|
101
|
+
if (Array.isArray(r.segments)) {
|
|
102
|
+
// The vendor response is untrusted: a malformed/partial `segments`
|
|
103
|
+
// entry (`null`, a non-object, or one missing the numeric start/end /
|
|
104
|
+
// string text) would either crash the blind `.start` read or leak a
|
|
105
|
+
// value that violates the `TranscriptionSegment` type contract.
|
|
106
|
+
// Validate each element and drop the ones that don't conform rather
|
|
107
|
+
// than crashing or emitting `{ text: 123 }` downstream.
|
|
108
|
+
result.segments = sanitizeSegments(r.segments);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
const response = await this.run(() =>
|
|
113
|
+
this.client.audio.transcriptions.create(
|
|
114
|
+
{
|
|
115
|
+
model: this.model,
|
|
116
|
+
file,
|
|
117
|
+
...(language ? { language } : {}),
|
|
118
|
+
...(opts.prompt ? { prompt: opts.prompt } : {}),
|
|
119
|
+
},
|
|
120
|
+
{ signal: opts.signal },
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
return { text: requireTextResponse(response).text };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run an SDK transcription call, translating failures into structured
|
|
128
|
+
* `MoxxyError`s (network vs. HTTP status) to match the codex sibling.
|
|
129
|
+
* User aborts re-throw unchanged so cancellation isn't masked as an error.
|
|
130
|
+
*/
|
|
131
|
+
private async run<T>(call: () => Promise<T>): Promise<T> {
|
|
132
|
+
try {
|
|
133
|
+
return await call();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Intentional cancellation: propagate as-is so callers see the abort.
|
|
136
|
+
if (err instanceof APIUserAbortError) throw err;
|
|
137
|
+
const ctx = { provider: WHISPER_PROVIDER_ID, url: this.client.baseURL };
|
|
138
|
+
if (err instanceof APIConnectionError) {
|
|
139
|
+
const network = classifyNetworkError(err.cause ?? err, ctx);
|
|
140
|
+
if (network) throw network;
|
|
141
|
+
}
|
|
142
|
+
if (err instanceof APIError && typeof err.status === 'number') {
|
|
143
|
+
const classified = classifyHttpStatus(err.status, { ...ctx, body: err.message });
|
|
144
|
+
if (classified) throw classified;
|
|
145
|
+
throw new MoxxyError({
|
|
146
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
147
|
+
message: `OpenAI transcription returned HTTP ${err.status}.`,
|
|
148
|
+
context: { ...ctx, status: err.status },
|
|
149
|
+
cause: err,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const network = classifyNetworkError(err, ctx);
|
|
153
|
+
if (network) throw network;
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate that an OpenAI transcription response carries a string `text`
|
|
161
|
+
* field before we trust it. The SDK's union return type is wider than what
|
|
162
|
+
* we consume, so we narrow it here; a response missing `text` (or whose
|
|
163
|
+
* `text` isn't a string) is a real contract violation worth surfacing as a
|
|
164
|
+
* structured error rather than silently returning `{ text: undefined }`.
|
|
165
|
+
*/
|
|
166
|
+
function requireTextResponse(response: unknown): { text: string } & Record<string, unknown> {
|
|
167
|
+
if (
|
|
168
|
+
!response ||
|
|
169
|
+
typeof response !== 'object' ||
|
|
170
|
+
typeof (response as { text?: unknown }).text !== 'string'
|
|
171
|
+
) {
|
|
172
|
+
throw new MoxxyError({
|
|
173
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
174
|
+
message: 'OpenAI transcription response was missing a text field.',
|
|
175
|
+
context: { provider: WHISPER_PROVIDER_ID },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return response as { text: string } & Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Defensively narrow an untrusted `segments` array from a vendor response into
|
|
183
|
+
* the well-typed `TranscriptionSegment[]` contract. Each element is validated
|
|
184
|
+
* to be an object carrying finite numeric `start`/`end` and a string `text`;
|
|
185
|
+
* non-conforming entries (`null`, primitives, NaN/Infinity bounds, missing
|
|
186
|
+
* fields) are dropped so a hostile/partial response degrades to fewer (or zero)
|
|
187
|
+
* segments instead of crashing the caller or leaking off-contract values.
|
|
188
|
+
*/
|
|
189
|
+
function sanitizeSegments(
|
|
190
|
+
raw: readonly unknown[],
|
|
191
|
+
): Array<{ start: number; end: number; text: string }> {
|
|
192
|
+
const out: Array<{ start: number; end: number; text: string }> = [];
|
|
193
|
+
for (const seg of raw) {
|
|
194
|
+
if (!seg || typeof seg !== 'object') continue;
|
|
195
|
+
const s = seg as { start?: unknown; end?: unknown; text?: unknown };
|
|
196
|
+
if (
|
|
197
|
+
typeof s.start !== 'number' ||
|
|
198
|
+
typeof s.end !== 'number' ||
|
|
199
|
+
typeof s.text !== 'string' ||
|
|
200
|
+
!Number.isFinite(s.start) ||
|
|
201
|
+
!Number.isFinite(s.end)
|
|
202
|
+
) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
out.push({ start: s.start, end: s.end, text: s.text });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createWhisperTranscriber(opts: WhisperTranscriberOptions = {}): WhisperTranscriber {
|
|
211
|
+
return new WhisperTranscriber(opts);
|
|
212
|
+
}
|