@makerbi/remodex 2.0.0 → 2.3.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,15 +1,36 @@
1
1
  // FILE: voice-handler.js
2
- // Purpose: Handles bridge-owned voice transcription requests without exposing auth tokens to iPhone.
2
+ // Purpose: Handles bridge-owned voice transcription and prewarm requests without exposing auth tokens to iPhone.
3
3
  // Layer: Bridge handler
4
4
  // Exports: createVoiceHandler, resolveVoiceAuth
5
- // Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest
5
+ // Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest, ./voice-audio
6
+
7
+ const {
8
+ hasConsistentVoiceWavLayout,
9
+ isSupportedVoiceWavFormat,
10
+ readM4AInfo,
11
+ readWavInfo,
12
+ wavDurationMs,
13
+ } = require("./voice-audio");
6
14
 
7
- const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions";
8
15
  const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
9
- const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
10
16
  const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
11
17
  const MAX_DURATION_SECONDS = 150;
12
18
  const MAX_DURATION_MS = MAX_DURATION_SECONDS * 1_000;
19
+ const DEFAULT_TRANSCRIPTION_TIMEOUT_MS = 175_000;
20
+ const MAX_DURATION_DRIFT_MS = 2_000;
21
+ const AUTH_CACHE_TTL_MS = 60_000;
22
+ const PRECONNECT_MIN_INTERVAL_MS = 2_000;
23
+ const PRECONNECT_TIMEOUT_MS = 10_000;
24
+ // Live endpoint probes confirmed subscription uploads accept AAC m4a alongside WAV.
25
+ const VOICE_AUDIO_FORMATS = ["wav", "m4a"];
26
+ const VOICE_WAV_MIME_TYPE = "audio/wav";
27
+ const VOICE_M4A_MIME_TYPE = "audio/mp4";
28
+ // Cloudflare rejects Node's default fetch identity on chatgpt.com with an HTML 403,
29
+ // which used to fail every bridge upload and force the slow phone-direct fallback.
30
+ // If the accepted identity changes again (symptom: transcription turns slow because
31
+ // every upload falls back to the phone), override it without a release via env.
32
+ const VOICE_UPLOAD_USER_AGENT = process.env.REMODEX_VOICE_UPLOAD_USER_AGENT
33
+ || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15";
13
34
 
14
35
  function createVoiceHandler({
15
36
  sendCodexRequest,
@@ -17,17 +38,93 @@ function createVoiceHandler({
17
38
  FormDataImpl = globalThis.FormData,
18
39
  BlobImpl = globalThis.Blob,
19
40
  logPrefix = "[remodex]",
20
- env = process.env,
41
+ logger = console,
42
+ transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
21
43
  } = {}) {
22
- function handleVoiceRequest(rawMessage, sendResponse) {
23
- let parsed;
24
- try {
25
- parsed = JSON.parse(rawMessage);
26
- } catch {
44
+ // Keeps a short-lived auth context plus TLS preconnect so the post-recording
45
+ // transcription request skips the token refresh and handshake round trips.
46
+ const warmState = {
47
+ authPromise: null,
48
+ authLoadedAt: 0,
49
+ lastPreconnectAt: 0,
50
+ };
51
+
52
+ function cacheAuthPromise(promise) {
53
+ warmState.authPromise = promise;
54
+ warmState.authLoadedAt = Date.now();
55
+ promise.catch(() => {
56
+ if (warmState.authPromise === promise) {
57
+ warmState.authPromise = null;
58
+ }
59
+ });
60
+ return promise;
61
+ }
62
+
63
+ function loadAuth() {
64
+ if (warmState.authPromise && Date.now() - warmState.authLoadedAt < AUTH_CACHE_TTL_MS) {
65
+ return warmState.authPromise;
66
+ }
67
+ return cacheAuthPromise(loadAuthContext(sendCodexRequest, { refreshToken: false }));
68
+ }
69
+
70
+ function refreshAuth() {
71
+ return cacheAuthPromise(loadAuthContext(sendCodexRequest, { refreshToken: true }));
72
+ }
73
+
74
+ // Opens (or revives) the HTTPS connection to the provider so the upload fetch reuses it.
75
+ function preconnectTranscriptionOrigin() {
76
+ if (typeof fetchImpl !== "function") {
77
+ return;
78
+ }
79
+ const now = Date.now();
80
+ if (now - warmState.lastPreconnectAt < PRECONNECT_MIN_INTERVAL_MS) {
81
+ return;
82
+ }
83
+ warmState.lastPreconnectAt = now;
84
+
85
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
86
+ const timeoutID = controller
87
+ ? setTimeout(() => controller.abort(new Error("voice preconnect timed out")), PRECONNECT_TIMEOUT_MS)
88
+ : null;
89
+ timeoutID?.unref?.();
90
+
91
+ Promise.resolve()
92
+ .then(() => fetchImpl(new URL("/", CHATGPT_TRANSCRIPTIONS_URL).toString(), {
93
+ method: "HEAD",
94
+ headers: { "User-Agent": VOICE_UPLOAD_USER_AGENT },
95
+ signal: controller?.signal,
96
+ }))
97
+ .then((response) => {
98
+ response?.body?.cancel?.()?.catch?.(() => {});
99
+ })
100
+ .catch(() => {})
101
+ .finally(() => {
102
+ if (timeoutID) {
103
+ clearTimeout(timeoutID);
104
+ }
105
+ });
106
+ }
107
+
108
+ function handlePrewarmRequest(id, sendResponse) {
109
+ preconnectTranscriptionOrigin();
110
+ loadAuth().catch(() => {});
111
+ logVoiceEvent(logger, "log", logPrefix, "prewarm requested");
112
+ if (id != null) {
113
+ sendResponse(JSON.stringify({ id, result: { ok: true, formats: VOICE_AUDIO_FORMATS } }));
114
+ }
115
+ }
116
+
117
+ function handleVoiceRequest(rawMessage, sendResponse, parsedMessage = null) {
118
+ const parsed = parsedMessage || parseJsonMessage(rawMessage);
119
+ if (!parsed) {
27
120
  return false;
28
121
  }
29
122
 
30
123
  const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
124
+ if (method === "voice/prewarm") {
125
+ handlePrewarmRequest(parsed.id, sendResponse);
126
+ return true;
127
+ }
31
128
  if (method !== "voice/transcribe") {
32
129
  return false;
33
130
  }
@@ -40,21 +137,25 @@ function createVoiceHandler({
40
137
  fetchImpl,
41
138
  FormDataImpl,
42
139
  BlobImpl,
43
- env,
140
+ logger,
141
+ logPrefix,
142
+ transcriptionTimeoutMs,
143
+ loadAuth,
144
+ refreshAuth,
44
145
  })
45
146
  .then((result) => {
46
147
  sendResponse(JSON.stringify({ id, result }));
47
148
  })
48
149
  .catch((error) => {
49
- console.error(`${logPrefix} voice transcription failed: ${error.message}`);
150
+ logVoiceEvent(logger, "error", logPrefix, "failed", {
151
+ errorCode: error.errorCode || "voice_transcription_failed",
152
+ });
50
153
  sendResponse(JSON.stringify({
51
154
  id,
52
155
  error: {
53
156
  code: -32000,
54
157
  message: error.userMessage || error.message || "Voice transcription failed.",
55
- data: {
56
- errorCode: error.errorCode || "voice_transcription_failed",
57
- },
158
+ data: voiceErrorData(error),
58
159
  },
59
160
  }));
60
161
  });
@@ -67,12 +168,30 @@ function createVoiceHandler({
67
168
  };
68
169
  }
69
170
 
171
+ function parseJsonMessage(rawMessage) {
172
+ try {
173
+ return JSON.parse(rawMessage);
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
70
179
  // ─── Audio validation helpers ───────────────────────────────
71
180
 
72
181
  // Validates iPhone-owned audio input and proxies it to the official transcription endpoint.
73
182
  async function transcribeVoice(
74
183
  params,
75
- { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl, env = process.env }
184
+ {
185
+ sendCodexRequest,
186
+ fetchImpl,
187
+ FormDataImpl,
188
+ BlobImpl,
189
+ logger = console,
190
+ logPrefix = "[remodex]",
191
+ transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
192
+ loadAuth = () => loadAuthContext(sendCodexRequest, { refreshToken: false }),
193
+ refreshAuth = () => loadAuthContext(sendCodexRequest, { refreshToken: true }),
194
+ }
76
195
  ) {
77
196
  if (typeof sendCodexRequest !== "function") {
78
197
  throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
@@ -82,8 +201,8 @@ async function transcribeVoice(
82
201
  }
83
202
 
84
203
  const mimeType = readString(params.mimeType);
85
- if (mimeType !== "audio/wav") {
86
- throw voiceError("unsupported_mime_type", "Only WAV audio is supported for voice transcription.");
204
+ if (!isSupportedVoiceMimeType(mimeType)) {
205
+ throw voiceError("unsupported_mime_type", "Only WAV or M4A audio is supported for voice transcription.");
87
206
  }
88
207
 
89
208
  const sampleRateHz = readPositiveNumber(params.sampleRateHz);
@@ -103,94 +222,156 @@ async function transcribeVoice(
103
222
  if (audioBuffer.length > MAX_AUDIO_BYTES) {
104
223
  throw voiceError("audio_too_large", "Voice messages are limited to 10 MB.");
105
224
  }
106
- const wavInfo = readWavInfo(audioBuffer);
107
- if (!wavInfo) {
108
- throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
225
+ const audioInfo = readVoiceAudioInfo(audioBuffer, mimeType);
226
+ const actualDurationMs = audioInfo.durationMs;
227
+ if (!Number.isFinite(actualDurationMs) || actualDurationMs <= 0) {
228
+ throw voiceError("invalid_audio", "The recorded audio is not a valid audio file.");
109
229
  }
110
- if (wavInfo.audioFormat !== 1
111
- || wavInfo.channelCount !== 1
112
- || wavInfo.sampleRateHz !== 24_000
113
- || wavInfo.bitsPerSample !== 16) {
114
- throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
230
+ if (actualDurationMs > MAX_DURATION_MS) {
231
+ throw voiceError("duration_too_long", `Voice messages are limited to ${MAX_DURATION_SECONDS} seconds.`);
115
232
  }
233
+ if (actualDurationMs > durationMs + MAX_DURATION_DRIFT_MS) {
234
+ throw voiceError("duration_mismatch", "The recorded audio duration did not match the voice request.");
235
+ }
236
+
237
+ logVoiceEvent(logger, "log", logPrefix, "request received", {
238
+ durationMs,
239
+ actualDurationMs: Math.round(actualDurationMs),
240
+ audioBytes: audioBuffer.length,
241
+ });
116
242
 
117
- const authContext = await loadAuthContext(sendCodexRequest, { env });
243
+ const authContext = await loadAuth();
118
244
  return requestTranscription({
119
245
  authContext,
120
246
  audioBuffer,
121
247
  mimeType,
248
+ filename: audioInfo.filename,
122
249
  fetchImpl,
123
250
  FormDataImpl,
124
251
  BlobImpl,
125
- sendCodexRequest,
126
- env,
252
+ refreshAuth,
253
+ logger,
254
+ logPrefix,
255
+ transcriptionTimeoutMs,
127
256
  });
128
257
  }
129
258
 
259
+ // Posts the validated clip to the active transcription provider and logs only safe metadata.
130
260
  async function requestTranscription({
131
261
  authContext,
132
262
  audioBuffer,
133
263
  mimeType,
264
+ filename = "voice.wav",
134
265
  fetchImpl,
135
266
  FormDataImpl,
136
267
  BlobImpl,
137
- sendCodexRequest,
138
- env,
268
+ refreshAuth,
269
+ logger = console,
270
+ logPrefix = "[remodex]",
271
+ transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
139
272
  }) {
140
- const makeAttempt = async (activeAuthContext) => {
273
+ const makeAttempt = async (activeAuthContext, attempt) => {
274
+ logVoiceEvent(logger, "log", logPrefix, "auth selected", {
275
+ attempt,
276
+ source: activeAuthContext.authSource,
277
+ method: activeAuthContext.authMethodClass,
278
+ provider: activeAuthContext.provider,
279
+ });
280
+
141
281
  const formData = new FormDataImpl();
142
- formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), "voice.wav");
143
- if (!activeAuthContext.isChatGPT) {
144
- formData.append("model", DEFAULT_TRANSCRIPTION_MODEL);
145
- }
282
+ formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), filename);
146
283
 
147
284
  const headers = {
148
285
  Authorization: `Bearer ${activeAuthContext.token}`,
286
+ "User-Agent": VOICE_UPLOAD_USER_AGENT,
149
287
  };
150
288
 
151
- return fetchImpl(activeAuthContext.transcriptionURL, {
152
- method: "POST",
153
- headers,
154
- body: formData,
289
+ const timeoutMs = Number.isFinite(transcriptionTimeoutMs)
290
+ ? Math.max(0, Math.floor(transcriptionTimeoutMs))
291
+ : DEFAULT_TRANSCRIPTION_TIMEOUT_MS;
292
+ const controller = typeof AbortController === "function" && timeoutMs > 0
293
+ ? new AbortController()
294
+ : null;
295
+ const timeoutID = controller
296
+ ? setTimeout(() => {
297
+ controller.abort(createTranscriptionTimeoutError(timeoutMs));
298
+ }, timeoutMs)
299
+ : null;
300
+ timeoutID?.unref?.();
301
+
302
+ let response;
303
+ try {
304
+ response = await fetchImpl(activeAuthContext.transcriptionURL, {
305
+ method: "POST",
306
+ headers,
307
+ body: formData,
308
+ signal: controller?.signal,
309
+ });
310
+ } catch (error) {
311
+ if (isAbortError(error, controller)) {
312
+ logVoiceEvent(logger, "warn", logPrefix, "provider status", {
313
+ attempt,
314
+ provider: activeAuthContext.provider,
315
+ status: "timeout",
316
+ ok: false,
317
+ });
318
+ throw voiceError(
319
+ "transcription_timeout",
320
+ "Voice transcription timed out. Try a shorter clip or retry when the connection is stable.",
321
+ { provider: activeAuthContext.provider }
322
+ );
323
+ }
324
+
325
+ logVoiceEvent(logger, "warn", logPrefix, "provider status", {
326
+ attempt,
327
+ provider: activeAuthContext.provider,
328
+ status: "network_error",
329
+ ok: false,
330
+ });
331
+ throw voiceError(
332
+ "transcription_network_error",
333
+ "Voice transcription could not reach the provider.",
334
+ { provider: activeAuthContext.provider }
335
+ );
336
+ } finally {
337
+ if (timeoutID) {
338
+ clearTimeout(timeoutID);
339
+ }
340
+ }
341
+
342
+ logVoiceEvent(logger, response.ok ? "log" : "warn", logPrefix, "provider status", {
343
+ attempt,
344
+ provider: activeAuthContext.provider,
345
+ status: response.status,
346
+ ok: Boolean(response.ok),
155
347
  });
348
+ return response;
156
349
  };
157
350
 
158
351
  let activeAuthContext = authContext;
159
- let response = await makeAttempt(activeAuthContext);
352
+ let attempt = 1;
353
+ let response = await makeAttempt(activeAuthContext, attempt);
160
354
  if (response.status === 401 || response.status === 403) {
161
- activeAuthContext = await loadAuthContext(sendCodexRequest, { env });
162
- response = await makeAttempt(activeAuthContext);
163
- if (!response.ok
164
- && (response.status === 401 || response.status === 403)
165
- && activeAuthContext.isChatGPT) {
166
- const apiKeyContext = loadEnvApiKeyAuthContext(env);
167
- if (apiKeyContext) {
168
- activeAuthContext = apiKeyContext;
169
- response = await makeAttempt(activeAuthContext);
170
- }
171
- }
355
+ // First attempt runs on a cached/non-refreshed token, so force a refresh here.
356
+ activeAuthContext = await refreshAuth();
357
+ attempt += 1;
358
+ response = await makeAttempt(activeAuthContext, attempt);
172
359
  }
173
360
 
174
361
  if (!response.ok) {
175
- let errorMessage = `Transcription failed with status ${response.status}.`;
176
- try {
177
- const errorPayload = await response.json();
178
- const providerMessage = readString(errorPayload?.error?.message) || readString(errorPayload?.message);
179
- if (providerMessage) {
180
- errorMessage = providerMessage;
181
- }
182
- } catch {
183
- // Keep the generic message when the provider body is empty or non-JSON.
184
- }
185
-
186
362
  if (response.status === 401 || response.status === 403) {
187
- const message = activeAuthContext.isChatGPT
188
- ? "Your ChatGPT login has expired. Sign in again."
189
- : "Your OpenAI API key was rejected. Update the API key on the Mac, then try again.";
190
- throw voiceError("auth_rejected", message);
363
+ throw voiceError(
364
+ "auth_rejected",
365
+ "Your ChatGPT login has expired. Sign in again.",
366
+ { provider: activeAuthContext.provider }
367
+ );
191
368
  }
192
369
 
193
- throw voiceError("transcription_failed", errorMessage);
370
+ throw voiceError(
371
+ "transcription_failed",
372
+ `Voice transcription failed with provider status ${response.status}.`,
373
+ { provider: activeAuthContext.provider, status: response.status }
374
+ );
194
375
  }
195
376
 
196
377
  const payload = await response.json().catch(() => null);
@@ -199,56 +380,50 @@ async function requestTranscription({
199
380
  throw voiceError("transcription_invalid_response", "The transcription response did not include any text.");
200
381
  }
201
382
 
383
+ logVoiceEvent(logger, "log", logPrefix, "success", {
384
+ provider: activeAuthContext.provider,
385
+ status: response.status,
386
+ textLength: text.length,
387
+ });
388
+
202
389
  return { text };
203
390
  }
204
391
 
205
- // Reads the current bridge-owned auth state from the local codex app-server and refreshes if needed.
206
- async function loadAuthContext(sendCodexRequest, { env = process.env } = {}) {
207
- const authStatus = await readVoiceAuthStatus(sendCodexRequest);
392
+ // Reads the current bridge-owned ChatGPT auth state; refresh is reserved for 401/403 retries.
393
+ async function loadAuthContext(sendCodexRequest, { refreshToken = false } = {}) {
394
+ const authStatus = await readVoiceAuthStatus(sendCodexRequest, { refreshToken });
208
395
 
209
396
  const authMethod = readString(authStatus?.authMethod);
210
397
  const token = normalizeBearerToken(authStatus?.authToken);
211
398
  const isChatGPT = isChatGPTAuthMethod(authMethod);
212
399
 
213
400
  if (!token) {
214
- const apiKeyContext = loadEnvApiKeyAuthContext(env);
215
- if (apiKeyContext) {
216
- return apiKeyContext;
217
- }
218
- throw voiceError("not_authenticated", "Sign in with ChatGPT or configure an OpenAI API key before using voice transcription.");
401
+ throw voiceError("not_authenticated", "Sign in with ChatGPT before using voice transcription.");
219
402
  }
220
403
 
221
- return {
222
- authMethod,
223
- token,
224
- isChatGPT,
225
- transcriptionURL: isChatGPT ? CHATGPT_TRANSCRIPTIONS_URL : OPENAI_TRANSCRIPTIONS_URL,
226
- };
227
- }
228
-
229
- function loadEnvApiKeyAuthContext(env = process.env) {
230
- const token = normalizeBearerToken(env?.OPENAI_API_KEY);
231
- if (!token) {
232
- return null;
404
+ if (!isChatGPT) {
405
+ throw voiceError("not_chatgpt", "Voice transcription requires a ChatGPT account.");
233
406
  }
234
407
 
235
408
  return {
236
- authMethod: "apiKey",
409
+ authMethod,
410
+ authSource: "mac_runtime",
411
+ authMethodClass: "chatgpt",
412
+ provider: "chatgpt",
237
413
  token,
238
- isChatGPT: false,
239
- transcriptionURL: OPENAI_TRANSCRIPTIONS_URL,
414
+ transcriptionURL: CHATGPT_TRANSCRIPTIONS_URL,
240
415
  };
241
416
  }
242
417
 
243
- async function readVoiceAuthStatus(sendCodexRequest) {
418
+ async function readVoiceAuthStatus(sendCodexRequest, { refreshToken = true } = {}) {
244
419
  try {
245
420
  return await sendCodexRequest("getAuthStatus", {
246
421
  includeToken: true,
247
- refreshToken: true,
422
+ refreshToken,
248
423
  });
249
- } catch (err) {
250
- console.error(`[remodex] voice auth: getAuthStatus RPC failed: ${err.message}`);
251
- throw voiceError("auth_unavailable", "Could not read OpenAI auth from the Mac runtime. Is the bridge running?");
424
+ } catch {
425
+ console.error("[remodex] voice auth: getAuthStatus RPC failed");
426
+ throw voiceError("auth_unavailable", "Could not read ChatGPT auth from the Mac runtime. Is the bridge running?");
252
427
  }
253
428
  }
254
429
 
@@ -258,10 +433,6 @@ function decodeAudioBase64(value) {
258
433
  throw voiceError("missing_audio", "The voice request did not include any audio.");
259
434
  }
260
435
 
261
- if (!isLikelyBase64(normalized)) {
262
- throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
263
- }
264
-
265
436
  const audioBuffer = Buffer.from(normalized, "base64");
266
437
  if (!audioBuffer.length) {
267
438
  throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
@@ -271,10 +442,6 @@ function decodeAudioBase64(value) {
271
442
  throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
272
443
  }
273
444
 
274
- if (!hasRiffWaveHeader(audioBuffer)) {
275
- throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
276
- }
277
-
278
445
  return audioBuffer;
279
446
  }
280
447
 
@@ -283,81 +450,36 @@ function normalizeBase64(value) {
283
450
  return typeof value === "string" ? value.replace(/\s+/g, "").trim() : "";
284
451
  }
285
452
 
286
- function isLikelyBase64(value) {
287
- if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0) {
288
- return false;
289
- }
453
+ function isSupportedVoiceMimeType(mimeType) {
454
+ return mimeType === VOICE_WAV_MIME_TYPE || mimeType === VOICE_M4A_MIME_TYPE;
455
+ }
290
456
 
291
- const paddingStart = value.indexOf("=");
292
- if (paddingStart !== -1) {
293
- const paddingLength = value.length - paddingStart;
294
- if (paddingLength > 2) {
295
- return false;
457
+ function readVoiceAudioInfo(buffer, mimeType) {
458
+ if (mimeType === VOICE_WAV_MIME_TYPE) {
459
+ const wavInfo = readWavInfo(buffer);
460
+ if (!wavInfo) {
461
+ throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
296
462
  }
297
- for (let i = paddingStart; i < value.length; i += 1) {
298
- if (value[i] !== "=") {
299
- return false;
300
- }
463
+ if (!isSupportedVoiceWavFormat(wavInfo)) {
464
+ throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
301
465
  }
302
- }
303
-
304
- // Avoid one giant regex: V8 can overflow its stack on multi-MB voice clips.
305
- const dataEnd = paddingStart === -1 ? value.length : paddingStart;
306
- for (let i = 0; i < dataEnd; i += 1) {
307
- const code = value.charCodeAt(i);
308
- const isUppercase = code >= 65 && code <= 90;
309
- const isLowercase = code >= 97 && code <= 122;
310
- const isDigit = code >= 48 && code <= 57;
311
- if (!isUppercase && !isLowercase && !isDigit && value[i] !== "+" && value[i] !== "/") {
312
- return false;
466
+ if (!hasConsistentVoiceWavLayout(wavInfo)) {
467
+ throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
313
468
  }
469
+ return {
470
+ durationMs: wavDurationMs(wavInfo),
471
+ filename: "voice.wav",
472
+ };
314
473
  }
315
474
 
316
- return true;
317
- }
318
-
319
- function hasRiffWaveHeader(buffer) {
320
- return buffer.length >= 44
321
- && buffer.toString("ascii", 0, 4) === "RIFF"
322
- && buffer.toString("ascii", 8, 12) === "WAVE";
323
- }
324
-
325
- // Parses chunked WAV metadata so extra chunks before fmt/data do not break valid clips.
326
- function readWavInfo(buffer) {
327
- if (!hasRiffWaveHeader(buffer)) {
328
- return null;
475
+ const m4aInfo = readM4AInfo(buffer);
476
+ if (!m4aInfo) {
477
+ throw voiceError("invalid_audio", "The recorded audio is not a valid M4A file.");
329
478
  }
330
-
331
- let offset = 12;
332
- let info = null;
333
- let hasData = false;
334
- while (offset + 8 <= buffer.length) {
335
- const chunkId = buffer.toString("ascii", offset, offset + 4);
336
- const chunkSize = buffer.readUInt32LE(offset + 4);
337
- const payloadStart = offset + 8;
338
- const payloadEnd = payloadStart + chunkSize;
339
- if (payloadEnd > buffer.length) {
340
- return null;
341
- }
342
-
343
- if (chunkId === "fmt ") {
344
- if (chunkSize < 16) {
345
- return null;
346
- }
347
- info = {
348
- audioFormat: buffer.readUInt16LE(payloadStart),
349
- channelCount: buffer.readUInt16LE(payloadStart + 2),
350
- sampleRateHz: buffer.readUInt32LE(payloadStart + 4),
351
- bitsPerSample: buffer.readUInt16LE(payloadStart + 14),
352
- };
353
- } else if (chunkId === "data") {
354
- hasData = chunkSize > 0;
355
- }
356
-
357
- offset = payloadEnd + (chunkSize % 2);
358
- }
359
-
360
- return info && hasData ? info : null;
479
+ return {
480
+ durationMs: m4aInfo.durationMs,
481
+ filename: "voice.m4a",
482
+ };
361
483
  }
362
484
 
363
485
  function readString(value) {
@@ -378,15 +500,83 @@ function isChatGPTAuthMethod(value) {
378
500
  return normalized.includes("chatgpt");
379
501
  }
380
502
 
503
+ // Formats fixed safe fields only; callers must pass classifications, counts, and statuses.
504
+ function logVoiceEvent(logger, level, logPrefix, event, fields = {}) {
505
+ const writer = resolveLogWriter(logger, level);
506
+ const details = Object.entries(fields)
507
+ .map(([key, value]) => `${key}=${formatLogValue(value)}`)
508
+ .join(" ");
509
+ writer(`${logPrefix} voice transcribe ${event}${details ? ` ${details}` : ""}`);
510
+ }
511
+
512
+ function resolveLogWriter(logger, level) {
513
+ if (typeof logger?.[level] === "function") {
514
+ return logger[level].bind(logger);
515
+ }
516
+ if (level === "warn" && typeof logger?.log === "function") {
517
+ return logger.log.bind(logger);
518
+ }
519
+ if (level === "error" && typeof logger?.warn === "function") {
520
+ return logger.warn.bind(logger);
521
+ }
522
+ if (typeof console[level] === "function") {
523
+ return console[level].bind(console);
524
+ }
525
+ return console.log.bind(console);
526
+ }
527
+
528
+ function formatLogValue(value) {
529
+ if (typeof value === "number" && Number.isFinite(value)) {
530
+ return String(value);
531
+ }
532
+ if (typeof value === "boolean") {
533
+ return value ? "true" : "false";
534
+ }
535
+ return String(value).replace(/[^a-zA-Z0-9_.:-]/g, "_");
536
+ }
537
+
381
538
  function readPositiveNumber(value) {
382
539
  const numericValue = typeof value === "number" ? value : Number(value);
383
540
  return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
384
541
  }
385
542
 
386
- function voiceError(errorCode, userMessage) {
543
+ function createTranscriptionTimeoutError(timeoutMs) {
544
+ const error = new Error(`Voice transcription timed out after ${timeoutMs}ms`);
545
+ error.name = "AbortError";
546
+ error.code = "voice_transcription_timeout";
547
+ return error;
548
+ }
549
+
550
+ function isAbortError(error, controller) {
551
+ return Boolean(controller?.signal?.aborted)
552
+ || error?.name === "AbortError"
553
+ || error?.code === "ABORT_ERR"
554
+ || error?.code === "voice_transcription_timeout";
555
+ }
556
+
557
+ function voiceErrorData(error) {
558
+ const data = {
559
+ errorCode: error.errorCode || "voice_transcription_failed",
560
+ };
561
+ if (error.provider === "chatgpt") {
562
+ data.provider = error.provider;
563
+ }
564
+ if (Number.isInteger(error.status)) {
565
+ data.status = error.status;
566
+ }
567
+ return data;
568
+ }
569
+
570
+ function voiceError(errorCode, userMessage, details = {}) {
387
571
  const error = new Error(userMessage);
388
572
  error.errorCode = errorCode;
389
573
  error.userMessage = userMessage;
574
+ if (details.provider === "chatgpt") {
575
+ error.provider = details.provider;
576
+ }
577
+ if (Number.isInteger(details.status)) {
578
+ error.status = details.status;
579
+ }
390
580
  return error;
391
581
  }
392
582