@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.
@@ -0,0 +1,247 @@
1
+ // FILE: thread-runtime-settings-store.js
2
+ // Purpose: Persists the last accepted model, reasoning effort, and service tier for each local thread.
3
+ // Layer: CLI helper
4
+ // Exports: createThreadRuntimeSettingsStore, runtimeSettingsFromTurnParams
5
+ // Depends on: fs, os, path
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const STORE_VERSION = 1;
12
+ const DEFAULT_MAX_THREADS = 500;
13
+ const DEFAULT_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1_000;
14
+ const DEFAULT_STORE_DIR = path.join(os.homedir(), ".remodex");
15
+
16
+ function createThreadRuntimeSettingsStore({
17
+ storeFile = process.env.REMODEX_THREAD_RUNTIME_STATE_FILE
18
+ || path.join(process.env.REMODEX_DEVICE_STATE_DIR || DEFAULT_STORE_DIR, "thread-runtime-settings.json"),
19
+ fsImpl = fs,
20
+ now = () => Date.now(),
21
+ maxThreads = DEFAULT_MAX_THREADS,
22
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
23
+ } = {}) {
24
+ let state = readState({ storeFile, fsImpl });
25
+
26
+ function get(threadId) {
27
+ const normalizedThreadId = normalizeString(threadId);
28
+ if (!normalizedThreadId) {
29
+ return null;
30
+ }
31
+ return cloneSettings(state.threads[normalizedThreadId]);
32
+ }
33
+
34
+ function commit(threadId, turnParams, { source = "unknown", turnId = "" } = {}) {
35
+ const normalizedThreadId = normalizeString(threadId);
36
+ const nextSource = normalizeString(source) || "unknown";
37
+ // Runtime choices are intentionally one-way: the phone may configure the
38
+ // runtime that executes its turn, while Desktop choices stay local.
39
+ if (!normalizedThreadId || nextSource !== "phone") {
40
+ return null;
41
+ }
42
+ const previous = state.threads[normalizedThreadId] || null;
43
+ const nextValues = runtimeSettingsFromTurnParams(turnParams, previous);
44
+ if (!nextValues.model && !nextValues.reasoningEffort && !previous) {
45
+ return null;
46
+ }
47
+
48
+ const normalizedTurnId = normalizeString(turnId);
49
+ if (previous
50
+ && previous.turnId === normalizedTurnId
51
+ && previous.model === nextValues.model
52
+ && previous.reasoningEffort === nextValues.reasoningEffort
53
+ && previous.serviceTier === nextValues.serviceTier) {
54
+ return cloneSettings(previous);
55
+ }
56
+
57
+ const next = {
58
+ model: nextValues.model || null,
59
+ reasoningEffort: nextValues.reasoningEffort || null,
60
+ serviceTier: nextValues.serviceTier,
61
+ revision: Math.max(0, Number(previous?.revision) || 0) + 1,
62
+ updatedAt: now(),
63
+ source: nextSource,
64
+ turnId: normalizedTurnId || null,
65
+ };
66
+ state.threads[normalizedThreadId] = next;
67
+ pruneState(state, { now: now(), maxThreads, maxAgeMs });
68
+ writeState(state, { storeFile, fsImpl });
69
+ return cloneSettings(next);
70
+ }
71
+
72
+ function attachToConversation(threadId, conversation) {
73
+ if (!conversation || typeof conversation !== "object") {
74
+ return conversation;
75
+ }
76
+ const settings = get(threadId);
77
+ if (!settings) {
78
+ return conversation;
79
+ }
80
+ conversation.remodexRuntimeSettings = settings;
81
+ if (settings.model) {
82
+ conversation.latestModel = settings.model;
83
+ }
84
+ if (settings.reasoningEffort) {
85
+ conversation.latestReasoningEffort = settings.reasoningEffort;
86
+ }
87
+ conversation.latestServiceTier = settings.serviceTier;
88
+ conversation.latestThreadSettings = {
89
+ ...(conversation.latestThreadSettings && typeof conversation.latestThreadSettings === "object"
90
+ ? conversation.latestThreadSettings
91
+ : {}),
92
+ model: settings.model,
93
+ effort: settings.reasoningEffort,
94
+ serviceTier: settings.serviceTier,
95
+ };
96
+ const collaborationSettings = conversation.latestCollaborationMode?.settings;
97
+ conversation.latestCollaborationMode = {
98
+ mode: conversation.latestCollaborationMode?.mode || "default",
99
+ settings: {
100
+ ...(collaborationSettings && typeof collaborationSettings === "object"
101
+ ? collaborationSettings
102
+ : { developer_instructions: null }),
103
+ model: settings.model || collaborationSettings?.model || "",
104
+ reasoning_effort: settings.reasoningEffort
105
+ || collaborationSettings?.reasoning_effort
106
+ || null,
107
+ },
108
+ };
109
+ return conversation;
110
+ }
111
+
112
+ function enrichResponse(method, envelope) {
113
+ if (!envelope || typeof envelope !== "object" || envelope.error) {
114
+ return envelope;
115
+ }
116
+ const result = envelope.result;
117
+ if (!result || typeof result !== "object") {
118
+ return envelope;
119
+ }
120
+ if (method === "thread/read" || method === "thread/resume") {
121
+ if (result.thread && typeof result.thread === "object") {
122
+ attachToThread(result.thread);
123
+ }
124
+ return envelope;
125
+ }
126
+ if (method === "thread/list") {
127
+ const key = ["data", "items", "threads"].find((candidate) => Array.isArray(result[candidate]));
128
+ for (const thread of key ? result[key] : []) {
129
+ attachToThread(thread);
130
+ }
131
+ }
132
+ return envelope;
133
+ }
134
+
135
+ function attachToThread(thread) {
136
+ const threadId = normalizeString(thread?.id) || normalizeString(thread?.threadId);
137
+ const settings = get(threadId);
138
+ if (!settings || !thread || typeof thread !== "object") {
139
+ return thread;
140
+ }
141
+ thread.model = settings.model || thread.model || null;
142
+ thread.reasoningEffort = settings.reasoningEffort;
143
+ thread.serviceTier = settings.serviceTier;
144
+ thread.runtimeSettingsRevision = settings.revision;
145
+ thread.runtimeSettingsUpdatedAt = settings.updatedAt;
146
+ thread.runtimeSettingsSource = settings.source;
147
+ return thread;
148
+ }
149
+
150
+ return {
151
+ get,
152
+ commit,
153
+ attachToConversation,
154
+ attachToThread,
155
+ enrichResponse,
156
+ };
157
+ }
158
+
159
+ function runtimeSettingsFromTurnParams(turnParams, previous = null) {
160
+ const params = turnParams && typeof turnParams === "object" ? turnParams : {};
161
+ const collaborationSettings = params.collaborationMode?.settings
162
+ || params.collaboration_mode?.settings
163
+ || {};
164
+ const model = normalizeString(params.model)
165
+ || normalizeString(collaborationSettings.model)
166
+ || normalizeString(previous?.model)
167
+ || null;
168
+ const reasoningEffort = normalizeString(params.effort)
169
+ || normalizeString(params.reasoningEffort)
170
+ || normalizeString(collaborationSettings.reasoning_effort)
171
+ || normalizeString(collaborationSettings.reasoningEffort)
172
+ || normalizeString(previous?.reasoningEffort)
173
+ || null;
174
+ const serviceTier = normalizeString(params.serviceTier)
175
+ || normalizeString(params.service_tier)
176
+ || null;
177
+ return { model, reasoningEffort, serviceTier };
178
+ }
179
+
180
+ function readState({ storeFile, fsImpl }) {
181
+ try {
182
+ const parsed = JSON.parse(fsImpl.readFileSync(storeFile, "utf8"));
183
+ return normalizeState(parsed);
184
+ } catch {
185
+ return { version: STORE_VERSION, threads: {} };
186
+ }
187
+ }
188
+
189
+ function normalizeState(rawState) {
190
+ const rawThreads = rawState?.threads && typeof rawState.threads === "object"
191
+ ? rawState.threads
192
+ : {};
193
+ const threads = {};
194
+ for (const [threadId, rawSettings] of Object.entries(rawThreads)) {
195
+ const normalizedThreadId = normalizeString(threadId);
196
+ const source = normalizeString(rawSettings?.source) || "unknown";
197
+ // Drop records written by older bidirectional builds so a Desktop choice
198
+ // cannot be replayed to the phone after upgrading.
199
+ if (!normalizedThreadId || !rawSettings || typeof rawSettings !== "object" || source !== "phone") {
200
+ continue;
201
+ }
202
+ threads[normalizedThreadId] = {
203
+ model: normalizeString(rawSettings.model) || null,
204
+ reasoningEffort: normalizeString(rawSettings.reasoningEffort) || null,
205
+ serviceTier: normalizeString(rawSettings.serviceTier) || null,
206
+ revision: Math.max(0, Number(rawSettings.revision) || 0),
207
+ updatedAt: Math.max(0, Number(rawSettings.updatedAt) || 0),
208
+ source,
209
+ turnId: normalizeString(rawSettings.turnId) || null,
210
+ };
211
+ }
212
+ return { version: STORE_VERSION, threads };
213
+ }
214
+
215
+ function pruneState(storeState, { now, maxThreads, maxAgeMs }) {
216
+ const entries = Object.entries(storeState.threads)
217
+ .filter(([, settings]) => !maxAgeMs || now - settings.updatedAt <= maxAgeMs)
218
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
219
+ .slice(0, Math.max(1, maxThreads));
220
+ storeState.threads = Object.fromEntries(entries);
221
+ }
222
+
223
+ function writeState(storeState, { storeFile, fsImpl }) {
224
+ const directory = path.dirname(storeFile);
225
+ const temporaryFile = `${storeFile}.tmp`;
226
+ fsImpl.mkdirSync(directory, { recursive: true });
227
+ fsImpl.writeFileSync(temporaryFile, JSON.stringify(storeState, null, 2), { mode: 0o600 });
228
+ fsImpl.renameSync(temporaryFile, storeFile);
229
+ try {
230
+ fsImpl.chmodSync(storeFile, 0o600);
231
+ } catch {
232
+ // Best-effort only on filesystems without POSIX permissions.
233
+ }
234
+ }
235
+
236
+ function cloneSettings(settings) {
237
+ return settings ? { ...settings } : null;
238
+ }
239
+
240
+ function normalizeString(value) {
241
+ return typeof value === "string" ? value.trim() : "";
242
+ }
243
+
244
+ module.exports = {
245
+ createThreadRuntimeSettingsStore,
246
+ runtimeSettingsFromTurnParams,
247
+ };
@@ -0,0 +1,344 @@
1
+ // FILE: voice-audio.js
2
+ // Purpose: Pure WAV and M4A container parsing plus format checks for Remodex voice clips.
3
+ // Layer: Bridge helper
4
+ // Exports: hasConsistentVoiceWavLayout, isSupportedVoiceWavFormat, readM4AInfo, readWavInfo, wavDurationMs
5
+ // Depends on: Buffer
6
+
7
+ // ─── WAV parsing ─────────────────────────────────────────────────
8
+
9
+ function hasRiffWaveHeader(buffer) {
10
+ return buffer.length >= 44
11
+ && buffer.toString("ascii", 0, 4) === "RIFF"
12
+ && buffer.toString("ascii", 8, 12) === "WAVE";
13
+ }
14
+
15
+ // Parses chunked WAV metadata so extra chunks before fmt/data do not break valid clips.
16
+ function readWavInfo(buffer) {
17
+ if (!hasRiffWaveHeader(buffer)) {
18
+ return null;
19
+ }
20
+
21
+ let offset = 12;
22
+ let info = null;
23
+ let hasData = false;
24
+ let dataByteCount = 0;
25
+ while (offset + 8 <= buffer.length) {
26
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
27
+ const chunkSize = buffer.readUInt32LE(offset + 4);
28
+ const payloadStart = offset + 8;
29
+ const payloadEnd = payloadStart + chunkSize;
30
+ if (payloadEnd > buffer.length) {
31
+ return null;
32
+ }
33
+
34
+ if (chunkId === "fmt ") {
35
+ if (chunkSize < 16) {
36
+ return null;
37
+ }
38
+ info = {
39
+ audioFormat: buffer.readUInt16LE(payloadStart),
40
+ channelCount: buffer.readUInt16LE(payloadStart + 2),
41
+ sampleRateHz: buffer.readUInt32LE(payloadStart + 4),
42
+ byteRate: buffer.readUInt32LE(payloadStart + 8),
43
+ blockAlign: buffer.readUInt16LE(payloadStart + 12),
44
+ bitsPerSample: buffer.readUInt16LE(payloadStart + 14),
45
+ };
46
+ } else if (chunkId === "data") {
47
+ hasData = chunkSize > 0;
48
+ dataByteCount = chunkSize;
49
+ }
50
+
51
+ offset = payloadEnd + (chunkSize % 2);
52
+ }
53
+
54
+ if (info && hasData) {
55
+ info.dataByteCount = dataByteCount;
56
+ return info;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function isSupportedVoiceWavFormat(wavInfo) {
62
+ return wavInfo.audioFormat === 1
63
+ && wavInfo.channelCount === 1
64
+ && wavInfo.sampleRateHz === 24_000
65
+ && wavInfo.bitsPerSample === 16;
66
+ }
67
+
68
+ // Rejects forged WAV layout fields before using data length for duration checks.
69
+ function hasConsistentVoiceWavLayout(wavInfo) {
70
+ return wavInfo.blockAlign === expectedVoiceWavBlockAlign(wavInfo)
71
+ && wavInfo.byteRate === expectedVoiceWavByteRate(wavInfo);
72
+ }
73
+
74
+ function expectedVoiceWavBlockAlign(wavInfo) {
75
+ return wavInfo.channelCount * (wavInfo.bitsPerSample / 8);
76
+ }
77
+
78
+ function expectedVoiceWavByteRate(wavInfo) {
79
+ return wavInfo.sampleRateHz * expectedVoiceWavBlockAlign(wavInfo);
80
+ }
81
+
82
+ function wavDurationMs(wavInfo) {
83
+ const byteRate = expectedVoiceWavByteRate(wavInfo);
84
+ if (!Number.isFinite(byteRate) || byteRate <= 0) {
85
+ return NaN;
86
+ }
87
+
88
+ return (Number(wavInfo.dataByteCount || 0) / byteRate) * 1_000;
89
+ }
90
+
91
+ // ─── M4A parsing ─────────────────────────────────────────────────
92
+
93
+ // Parses the MP4/M4A container enough to validate Remodex-generated AAC clips
94
+ // before proxying them to ChatGPT.
95
+ function readM4AInfo(buffer) {
96
+ if (!Buffer.isBuffer(buffer) || buffer.length < 16) {
97
+ return null;
98
+ }
99
+
100
+ let hasM4ABrand = false;
101
+ let hasMediaData = false;
102
+ let durationMs = NaN;
103
+ let audioTrackInfo = null;
104
+ for (const box of readMp4Boxes(buffer, 0, buffer.length)) {
105
+ if (box.type === "ftyp") {
106
+ hasM4ABrand = isM4AFileTypeBox(buffer, box);
107
+ } else if (box.type === "mdat") {
108
+ hasMediaData = box.payloadEnd > box.payloadStart;
109
+ } else if (box.type === "moov") {
110
+ durationMs = readMovieDurationMs(buffer, box.payloadStart, box.payloadEnd);
111
+ audioTrackInfo = readAudioTrackInfo(buffer, box.payloadStart, box.payloadEnd);
112
+ }
113
+ }
114
+
115
+ if (!hasM4ABrand
116
+ || !hasMediaData
117
+ || !Number.isFinite(durationMs)
118
+ || durationMs <= 0
119
+ || !isSupportedM4AAudioTrack(audioTrackInfo)) {
120
+ return null;
121
+ }
122
+ return { durationMs: Math.max(durationMs, audioTrackInfo.mdhdDurationMs) };
123
+ }
124
+
125
+ function* readMp4Boxes(buffer, start, end) {
126
+ let offset = start;
127
+ while (offset + 8 <= end) {
128
+ const size32 = buffer.readUInt32BE(offset);
129
+ const type = buffer.toString("ascii", offset + 4, offset + 8);
130
+ let headerSize = 8;
131
+ let size = size32;
132
+ if (size32 === 1) {
133
+ if (offset + 16 > end) {
134
+ return;
135
+ }
136
+ const size64 = buffer.readBigUInt64BE(offset + 8);
137
+ if (size64 > BigInt(Number.MAX_SAFE_INTEGER)) {
138
+ return;
139
+ }
140
+ size = Number(size64);
141
+ headerSize = 16;
142
+ } else if (size32 === 0) {
143
+ size = end - offset;
144
+ }
145
+
146
+ if (size < headerSize || offset + size > end) {
147
+ return;
148
+ }
149
+
150
+ yield {
151
+ type,
152
+ start: offset,
153
+ end: offset + size,
154
+ payloadStart: offset + headerSize,
155
+ payloadEnd: offset + size,
156
+ };
157
+ offset += size;
158
+ }
159
+ }
160
+
161
+ function isM4AFileTypeBox(buffer, box) {
162
+ if (box.payloadEnd - box.payloadStart < 8) {
163
+ return false;
164
+ }
165
+ const brands = [
166
+ buffer.toString("ascii", box.payloadStart, box.payloadStart + 4),
167
+ ];
168
+ for (let offset = box.payloadStart + 8; offset + 4 <= box.payloadEnd; offset += 4) {
169
+ brands.push(buffer.toString("ascii", offset, offset + 4));
170
+ }
171
+ return brands.includes("M4A ");
172
+ }
173
+
174
+ function readMovieDurationMs(buffer, start, end) {
175
+ for (const box of readMp4Boxes(buffer, start, end)) {
176
+ if (box.type !== "mvhd") {
177
+ continue;
178
+ }
179
+ return readMovieHeaderDurationMs(buffer, box.payloadStart, box.payloadEnd);
180
+ }
181
+ return NaN;
182
+ }
183
+
184
+ function readAudioTrackInfo(buffer, start, end) {
185
+ for (const box of readMp4Boxes(buffer, start, end)) {
186
+ if (box.type !== "trak") {
187
+ continue;
188
+ }
189
+ const trackInfo = readTrackInfo(buffer, box.payloadStart, box.payloadEnd);
190
+ if (trackInfo.handlerType === "soun") {
191
+ return trackInfo;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function readTrackInfo(buffer, start, end) {
198
+ let mdhdTimescale = NaN;
199
+ let mdhdDurationMs = NaN;
200
+ let handlerType = "";
201
+ let sampleEntryInfo = null;
202
+ for (const box of readMp4Boxes(buffer, start, end)) {
203
+ if (box.type !== "mdia") {
204
+ continue;
205
+ }
206
+ for (const mediaBox of readMp4Boxes(buffer, box.payloadStart, box.payloadEnd)) {
207
+ if (mediaBox.type === "mdhd") {
208
+ const mediaHeader = readMediaHeaderInfo(buffer, mediaBox.payloadStart, mediaBox.payloadEnd);
209
+ mdhdTimescale = mediaHeader.timescale;
210
+ mdhdDurationMs = mediaHeader.durationMs;
211
+ } else if (mediaBox.type === "hdlr") {
212
+ handlerType = readHandlerType(buffer, mediaBox.payloadStart, mediaBox.payloadEnd);
213
+ } else if (mediaBox.type === "minf") {
214
+ sampleEntryInfo = readAudioSampleEntryInfo(buffer, mediaBox.payloadStart, mediaBox.payloadEnd);
215
+ }
216
+ }
217
+ }
218
+ return { mdhdTimescale, mdhdDurationMs, handlerType, sampleEntryInfo };
219
+ }
220
+
221
+ function readMediaHeaderInfo(buffer, start, end) {
222
+ if (start + 4 > end) {
223
+ return { timescale: NaN, durationMs: NaN };
224
+ }
225
+ const version = buffer.readUInt8(start);
226
+ if (version === 0) {
227
+ if (start + 20 > end) {
228
+ return { timescale: NaN, durationMs: NaN };
229
+ }
230
+ const timescale = buffer.readUInt32BE(start + 12);
231
+ const duration = buffer.readUInt32BE(start + 16);
232
+ return { timescale, durationMs: movieDurationMs(timescale, duration) };
233
+ }
234
+ if (version === 1) {
235
+ if (start + 32 > end) {
236
+ return { timescale: NaN, durationMs: NaN };
237
+ }
238
+ const timescale = buffer.readUInt32BE(start + 20);
239
+ const duration = buffer.readBigUInt64BE(start + 24);
240
+ if (duration > BigInt(Number.MAX_SAFE_INTEGER)) {
241
+ return { timescale: NaN, durationMs: NaN };
242
+ }
243
+ return { timescale, durationMs: movieDurationMs(timescale, Number(duration)) };
244
+ }
245
+ return { timescale: NaN, durationMs: NaN };
246
+ }
247
+
248
+ function readHandlerType(buffer, start, end) {
249
+ if (start + 12 > end) {
250
+ return "";
251
+ }
252
+ return buffer.toString("ascii", start + 8, start + 12);
253
+ }
254
+
255
+ function readAudioSampleEntryInfo(buffer, start, end) {
256
+ for (const minfBox of readMp4Boxes(buffer, start, end)) {
257
+ if (minfBox.type !== "stbl") {
258
+ continue;
259
+ }
260
+ for (const stblBox of readMp4Boxes(buffer, minfBox.payloadStart, minfBox.payloadEnd)) {
261
+ if (stblBox.type !== "stsd") {
262
+ continue;
263
+ }
264
+ const sampleEntry = readFirstSampleEntry(buffer, stblBox.payloadStart, stblBox.payloadEnd);
265
+ if (sampleEntry) {
266
+ return sampleEntry;
267
+ }
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ function readFirstSampleEntry(buffer, start, end) {
274
+ if (start + 8 > end) {
275
+ return null;
276
+ }
277
+ const entryCount = buffer.readUInt32BE(start + 4);
278
+ if (entryCount < 1) {
279
+ return null;
280
+ }
281
+ const [sampleEntry] = readMp4Boxes(buffer, start + 8, end);
282
+ if (!sampleEntry || sampleEntry.type !== "mp4a" || sampleEntry.payloadStart + 28 > sampleEntry.payloadEnd) {
283
+ return null;
284
+ }
285
+ return {
286
+ codec: sampleEntry.type,
287
+ channelCount: buffer.readUInt16BE(sampleEntry.payloadStart + 16),
288
+ sampleRateHz: buffer.readUInt32BE(sampleEntry.payloadStart + 24) >>> 16,
289
+ };
290
+ }
291
+
292
+ function isSupportedM4AAudioTrack(trackInfo) {
293
+ return trackInfo?.handlerType === "soun"
294
+ && trackInfo.mdhdTimescale === 24_000
295
+ && Number.isFinite(trackInfo.mdhdDurationMs)
296
+ && trackInfo.mdhdDurationMs > 0
297
+ && trackInfo.sampleEntryInfo?.codec === "mp4a"
298
+ // CoreAudio writes channelCount=2 in the mp4a sample entry even for mono AAC
299
+ // (the real channel config lives in the esds), so accept both values here.
300
+ && (trackInfo.sampleEntryInfo?.channelCount === 1 || trackInfo.sampleEntryInfo?.channelCount === 2)
301
+ && trackInfo.sampleEntryInfo?.sampleRateHz === 24_000;
302
+ }
303
+
304
+ function readMovieHeaderDurationMs(buffer, start, end) {
305
+ if (start + 4 > end) {
306
+ return NaN;
307
+ }
308
+ const version = buffer.readUInt8(start);
309
+ if (version === 0) {
310
+ if (start + 20 > end) {
311
+ return NaN;
312
+ }
313
+ const timescale = buffer.readUInt32BE(start + 12);
314
+ const duration = buffer.readUInt32BE(start + 16);
315
+ return movieDurationMs(timescale, duration);
316
+ }
317
+ if (version === 1) {
318
+ if (start + 32 > end) {
319
+ return NaN;
320
+ }
321
+ const timescale = buffer.readUInt32BE(start + 20);
322
+ const duration = buffer.readBigUInt64BE(start + 24);
323
+ if (duration > BigInt(Number.MAX_SAFE_INTEGER)) {
324
+ return NaN;
325
+ }
326
+ return movieDurationMs(timescale, Number(duration));
327
+ }
328
+ return NaN;
329
+ }
330
+
331
+ function movieDurationMs(timescale, duration) {
332
+ if (!Number.isFinite(timescale) || timescale <= 0 || !Number.isFinite(duration) || duration <= 0) {
333
+ return NaN;
334
+ }
335
+ return (duration / timescale) * 1_000;
336
+ }
337
+
338
+ module.exports = {
339
+ hasConsistentVoiceWavLayout,
340
+ isSupportedVoiceWavFormat,
341
+ readM4AInfo,
342
+ readWavInfo,
343
+ wavDurationMs,
344
+ };