@mlx-node/asr 0.0.12 → 0.0.15

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.
Files changed (2) hide show
  1. package/package.json +5 -3
  2. package/src/index.ts +298 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlx-node/asr",
3
- "version": "0.0.12",
3
+ "version": "0.0.15",
4
4
  "homepage": "https://github.com/mlx-node/mlx-node",
5
5
  "bugs": {
6
6
  "url": "https://github.com/mlx-node/mlx-node/issues"
@@ -12,13 +12,15 @@
12
12
  "directory": "packages/asr"
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "src"
16
17
  ],
17
18
  "type": "module",
18
19
  "main": "./dist/index.js",
19
20
  "types": "./dist/index.d.ts",
20
21
  "exports": {
21
22
  ".": {
23
+ "@mlx-node/source": "./src/index.ts",
22
24
  "types": "./dist/index.d.ts",
23
25
  "import": "./dist/index.js"
24
26
  }
@@ -27,7 +29,7 @@
27
29
  "build": "tsc -b"
28
30
  },
29
31
  "dependencies": {
30
- "@mlx-node/core": "0.0.12"
32
+ "@mlx-node/core": "0.0.15"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@types/node": "^26.4.0"
package/src/index.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Qwen3-ASR inference and low-latency Core Audio capture on Apple Silicon.
3
+ */
4
+ import {
5
+ Qwen3AsrCapture,
6
+ Qwen3AsrCaptureSource,
7
+ Qwen3AsrModel,
8
+ Qwen3AsrStream,
9
+ qwen3AsrAudioDevices,
10
+ qwen3AsrInputDevices,
11
+ type Qwen3AsrAudioDevice,
12
+ type Qwen3AsrCaptureOptions,
13
+ type Qwen3AsrCaptureStats,
14
+ type Qwen3AsrInputDevice,
15
+ type Qwen3AsrResult,
16
+ type Qwen3AsrStreamOptions,
17
+ type Qwen3AsrTranscribeOptions,
18
+ } from '@mlx-node/core';
19
+
20
+ export {
21
+ Qwen3AsrCapture,
22
+ Qwen3AsrCaptureSource,
23
+ Qwen3AsrModel,
24
+ Qwen3AsrStream,
25
+ qwen3AsrAudioDevices,
26
+ qwen3AsrInputDevices,
27
+ type Qwen3AsrAudioDevice,
28
+ type Qwen3AsrCaptureOptions,
29
+ type Qwen3AsrCaptureStats,
30
+ type Qwen3AsrInputDevice,
31
+ type Qwen3AsrResult,
32
+ type Qwen3AsrStreamOptions,
33
+ type Qwen3AsrTranscribeOptions,
34
+ };
35
+
36
+ export interface Qwen3AsrRealtimeOptions {
37
+ /** Rolling decode cadence, language, and prompting options. */
38
+ stream?: Qwen3AsrStreamOptions;
39
+ /** Core Audio source, device, application filter, and callback-ring options. */
40
+ capture?: Qwen3AsrCaptureOptions;
41
+ /** Called for every rolling transcription revision. */
42
+ onResult: (result: Qwen3AsrResult) => void;
43
+ /** Called for asynchronous capture or model-worker errors. */
44
+ onError?: (error: Error) => void;
45
+ }
46
+
47
+ export interface Qwen3AsrRealtimeFinal {
48
+ result: Qwen3AsrResult;
49
+ capture: Qwen3AsrCaptureStats;
50
+ }
51
+
52
+ /**
53
+ * Owns one model stream and one Core Audio source. Call `stop()` to drain the
54
+ * lock-free capture ring and receive the final, non-provisional transcript.
55
+ */
56
+ export class Qwen3AsrRealtimeSession {
57
+ readonly stream: Qwen3AsrStream;
58
+ readonly capture: Qwen3AsrCapture;
59
+
60
+ #stopPromise: Promise<Qwen3AsrRealtimeFinal> | undefined;
61
+ private readonly getLastError: () => Error | undefined;
62
+
63
+ private constructor(stream: Qwen3AsrStream, capture: Qwen3AsrCapture, getLastError: () => Error | undefined) {
64
+ this.stream = stream;
65
+ this.capture = capture;
66
+ this.getLastError = getLastError;
67
+ }
68
+
69
+ static async start(model: Qwen3AsrModel, options: Qwen3AsrRealtimeOptions): Promise<Qwen3AsrRealtimeSession> {
70
+ const stream = await model.createStream(options.stream);
71
+ let lastError: Error | undefined;
72
+ let capture: Qwen3AsrCapture;
73
+ try {
74
+ capture = stream.startCapture(options.capture, (error, result) => {
75
+ if (error) {
76
+ lastError = error;
77
+ options.onError?.(error);
78
+ return;
79
+ }
80
+ options.onResult(result);
81
+ });
82
+ } catch (error) {
83
+ await stream.finish().catch(() => undefined);
84
+ throw error;
85
+ }
86
+ return new Qwen3AsrRealtimeSession(stream, capture, () => lastError);
87
+ }
88
+
89
+ get deviceName(): string {
90
+ return this.capture.deviceName;
91
+ }
92
+
93
+ get source(): Qwen3AsrCaptureSource {
94
+ return this.capture.source;
95
+ }
96
+
97
+ get sampleRate(): number {
98
+ return this.capture.sampleRate;
99
+ }
100
+
101
+ get lastError(): Error | undefined {
102
+ return this.getLastError();
103
+ }
104
+
105
+ pause(): void {
106
+ this.capture.pause();
107
+ }
108
+
109
+ resume(): void {
110
+ this.capture.resume();
111
+ }
112
+
113
+ stop(): Promise<Qwen3AsrRealtimeFinal> {
114
+ this.#stopPromise ??= (async () => {
115
+ let capture: Qwen3AsrCaptureStats;
116
+ try {
117
+ capture = await this.capture.stop();
118
+ } catch (error) {
119
+ await this.stream.finish().catch(() => undefined);
120
+ throw error;
121
+ }
122
+ const result = await this.stream.finish();
123
+ const error = this.lastError;
124
+ if (error) throw error;
125
+ return { result, capture };
126
+ })();
127
+ return this.#stopPromise;
128
+ }
129
+ }
130
+
131
+ export function startRealtimeTranscription(
132
+ model: Qwen3AsrModel,
133
+ options: Qwen3AsrRealtimeOptions,
134
+ ): Promise<Qwen3AsrRealtimeSession> {
135
+ return Qwen3AsrRealtimeSession.start(model, options);
136
+ }
137
+
138
+ type CaptureTimingOptions = Pick<Qwen3AsrCaptureOptions, 'feedMilliseconds' | 'ringSeconds'>;
139
+
140
+ export interface Qwen3AsrMicrophoneOptions extends CaptureTimingOptions {
141
+ /** Stable input-device UID from `qwen3AsrAudioDevices()`. */
142
+ deviceId?: string;
143
+ /** Input-device name. Prefer `deviceId` when persisting a selection. */
144
+ deviceName?: string;
145
+ }
146
+
147
+ export interface Qwen3AsrSystemAudioOptions extends CaptureTimingOptions {
148
+ /** Stable output-device UID from `qwen3AsrAudioDevices()`. */
149
+ deviceId?: string;
150
+ /** Output-device name. Prefer `deviceId` when persisting a selection. */
151
+ deviceName?: string;
152
+ /** Capture only these applications. Omit to capture all system output. */
153
+ applicationBundleIds?: string[];
154
+ }
155
+
156
+ export type Qwen3AsrMeetingSource = 'microphone' | 'systemAudio';
157
+
158
+ export interface Qwen3AsrMeetingResultEvent {
159
+ source: Qwen3AsrMeetingSource;
160
+ result: Qwen3AsrResult;
161
+ }
162
+
163
+ export interface Qwen3AsrMeetingErrorEvent {
164
+ source: Qwen3AsrMeetingSource;
165
+ error: Error;
166
+ }
167
+
168
+ export interface Qwen3AsrMeetingOptions {
169
+ /** Shared rolling decode cadence, language, and prompting options. */
170
+ stream?: Qwen3AsrStreamOptions;
171
+ /** Microphone capture options. `false` disables this track. Default enabled. */
172
+ microphone?: false | Qwen3AsrMicrophoneOptions;
173
+ /** System/output audio options. `false` disables this track. Default enabled. */
174
+ systemAudio?: false | Qwen3AsrSystemAudioOptions;
175
+ /** Called for every rolling revision, tagged with its audio source. */
176
+ onResult: (event: Qwen3AsrMeetingResultEvent) => void;
177
+ /** Called for asynchronous capture or model-worker errors. */
178
+ onError?: (event: Qwen3AsrMeetingErrorEvent) => void;
179
+ }
180
+
181
+ export interface Qwen3AsrMeetingFinal {
182
+ microphone?: Qwen3AsrRealtimeFinal;
183
+ systemAudio?: Qwen3AsrRealtimeFinal;
184
+ }
185
+
186
+ /**
187
+ * A meeting owns one independently clocked transcription track per enabled
188
+ * source. Results stay source-tagged so speaker-mic audio and remote/system
189
+ * audio are never silently mixed or ordered by unrelated device clocks.
190
+ */
191
+ export class Qwen3AsrMeetingSession {
192
+ readonly microphone?: Qwen3AsrRealtimeSession;
193
+ readonly systemAudio?: Qwen3AsrRealtimeSession;
194
+
195
+ #stopPromise: Promise<Qwen3AsrMeetingFinal> | undefined;
196
+
197
+ private constructor(tracks: { microphone?: Qwen3AsrRealtimeSession; systemAudio?: Qwen3AsrRealtimeSession }) {
198
+ this.microphone = tracks.microphone;
199
+ this.systemAudio = tracks.systemAudio;
200
+ }
201
+
202
+ static async start(model: Qwen3AsrModel, options: Qwen3AsrMeetingOptions): Promise<Qwen3AsrMeetingSession> {
203
+ if (options.microphone === false && options.systemAudio === false) {
204
+ throw new Error('At least one meeting audio source must be enabled');
205
+ }
206
+
207
+ const tracks: {
208
+ microphone?: Qwen3AsrRealtimeSession;
209
+ systemAudio?: Qwen3AsrRealtimeSession;
210
+ } = {};
211
+ const started: Qwen3AsrRealtimeSession[] = [];
212
+
213
+ const startTrack = async (
214
+ source: Qwen3AsrMeetingSource,
215
+ capture: Qwen3AsrCaptureOptions,
216
+ ): Promise<Qwen3AsrRealtimeSession> => {
217
+ const session = await Qwen3AsrRealtimeSession.start(model, {
218
+ stream: options.stream,
219
+ capture,
220
+ onResult: (result) => options.onResult({ source, result }),
221
+ onError: (error) => options.onError?.({ source, error }),
222
+ });
223
+ started.push(session);
224
+ return session;
225
+ };
226
+
227
+ try {
228
+ // Ask for system-audio permission before opening the microphone. This
229
+ // avoids leaving a live mic running while the system permission sheet is
230
+ // waiting for a response.
231
+ if (options.systemAudio !== false) {
232
+ tracks.systemAudio = await startTrack('systemAudio', {
233
+ ...options.systemAudio,
234
+ source: 'systemAudio' as Qwen3AsrCaptureSource,
235
+ });
236
+ }
237
+ if (options.microphone !== false) {
238
+ tracks.microphone = await startTrack('microphone', {
239
+ ...options.microphone,
240
+ source: 'microphone' as Qwen3AsrCaptureSource,
241
+ });
242
+ }
243
+ } catch (error) {
244
+ await Promise.allSettled(started.map((session) => session.stop()));
245
+ throw error;
246
+ }
247
+
248
+ return new Qwen3AsrMeetingSession(tracks);
249
+ }
250
+
251
+ pause(): void {
252
+ this.forEachTrack((track) => track.pause());
253
+ }
254
+
255
+ resume(): void {
256
+ this.forEachTrack((track) => track.resume());
257
+ }
258
+
259
+ stop(): Promise<Qwen3AsrMeetingFinal> {
260
+ this.#stopPromise ??= (async () => {
261
+ const microphone = this.microphone?.stop();
262
+ const systemAudio = this.systemAudio?.stop();
263
+ const settled = await Promise.allSettled(
264
+ [microphone, systemAudio].filter((promise): promise is Promise<Qwen3AsrRealtimeFinal> => promise !== undefined),
265
+ );
266
+ const failure = settled.find((result): result is PromiseRejectedResult => result.status === 'rejected');
267
+ if (failure) throw failure.reason;
268
+
269
+ const final: Qwen3AsrMeetingFinal = {};
270
+ let index = 0;
271
+ if (microphone) final.microphone = (settled[index++] as PromiseFulfilledResult<Qwen3AsrRealtimeFinal>).value;
272
+ if (systemAudio) final.systemAudio = (settled[index] as PromiseFulfilledResult<Qwen3AsrRealtimeFinal>).value;
273
+ return final;
274
+ })();
275
+ return this.#stopPromise;
276
+ }
277
+
278
+ private forEachTrack(action: (track: Qwen3AsrRealtimeSession) => void): void {
279
+ let firstError: unknown;
280
+ for (const track of [this.microphone, this.systemAudio]) {
281
+ if (!track) continue;
282
+ try {
283
+ action(track);
284
+ } catch (error) {
285
+ firstError ??= error;
286
+ }
287
+ }
288
+ if (firstError) throw firstError;
289
+ }
290
+ }
291
+
292
+ /** Start microphone and system-audio transcription as one meeting session. */
293
+ export function startMeetingTranscription(
294
+ model: Qwen3AsrModel,
295
+ options: Qwen3AsrMeetingOptions,
296
+ ): Promise<Qwen3AsrMeetingSession> {
297
+ return Qwen3AsrMeetingSession.start(model, options);
298
+ }