@makerbi/remodex 2.0.1 → 2.3.1
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/package.json +2 -2
- package/src/account-status.js +5 -4
- package/src/bridge.js +1813 -670
- package/src/codex-desktop-refresher.js +35 -7
- package/src/cursor-acp-client.js +242 -0
- package/src/cursor-models.js +134 -0
- package/src/cursor-provider.js +1197 -0
- package/src/desktop-ipc-action-follower.js +2331 -964
- package/src/desktop-ipc-conversation-adapter.js +1132 -0
- package/src/desktop-ipc-conversation-projector.js +1169 -0
- package/src/desktop-ipc-live-owner.js +1790 -0
- package/src/desktop-ipc-owner-transport.js +750 -0
- package/src/desktop-ipc-shared.js +473 -0
- package/src/desktop-ipc-state-patches.js +218 -0
- package/src/opencode-models.js +108 -0
- package/src/opencode-provider.js +1151 -0
- package/src/project-handler.js +50 -7
- package/src/project-registry.js +466 -0
- package/src/push-notification-tracker.js +4 -4
- package/src/rollout-live-mirror.js +930 -218
- package/src/rollout-turn-semantics.js +20 -0
- package/src/runtime-provider-models.js +164 -0
- package/src/runtime-provider-router.js +365 -0
- package/src/scripts/codex-refresh.applescript +26 -15
- package/src/secure-transport.js +204 -9
- package/src/session-jsonl-history.js +429 -39
- package/src/thread-context-handler.js +8 -6
- package/src/thread-runtime-settings-store.js +247 -0
- package/src/voice-audio.js +344 -0
- package/src/voice-handler.js +363 -173
|
@@ -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
|
+
};
|