@octane-xplat/recorder 0.0.0-stage → 0.11.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # `@octane-xplat/recorder`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```sh
4
+ pnpm add @octane-xplat/recorder
5
+ ```
6
+
7
+ Microphone recording for Octane xplat apps — one take produces a complete
8
+ 16-bit PCM **WAV** file (`audio/wav`) on every target. iOS drives
9
+ `AVAudioRecorder`, Android drives `AudioRecord`, and web captures through
10
+ `getUserMedia` + `AudioWorklet` (ScriptProcessor fallback) — no plugin
11
+ dependency. The leaf owns permission, capture, metering, and honest state
12
+ reporting; your app owns the interaction (hold-to-record, limits,
13
+ transcription, insertion).
14
+
15
+ ```ts
16
+ import { createAudioRecorder } from '@octane-xplat/recorder'
17
+
18
+ const recorder = createAudioRecorder()
19
+ const off = recorder.subscribe((snapshot) => {
20
+ // { state, permission, durationSeconds, meterLevel, error }
21
+ })
22
+
23
+ await recorder.start() // asks for the microphone if still undetermined
24
+ // ... record ...
25
+ const take = await recorder.stop() // { bytes, mimeType: 'audio/wav', durationSeconds, path? }
26
+ off()
27
+ recorder.dispose()
28
+ ```
29
+
30
+ `state` is explicit: `recording`, `paused` (app-paused), `interrupted` (the OS
31
+ suspended capture — a call, a silenced mic, a dead track; it becomes `paused`
32
+ when the interruption clears so the app can `resume()`), `idle`, and `error`
33
+ (`snapshot().error` carries the cause). `permission` reports
34
+ `undetermined | granted | denied | unavailable | unsupported` — a denied or
35
+ failed `start()` rejects _and_ updates the snapshot, never a silent no-op.
36
+
37
+ ```ts
38
+ if (recorder.capabilities().supported) {
39
+ await recorder.pause()
40
+ await recorder.resume() // also resumes a cleared interruption
41
+ await recorder.cancel() // discards the open take
42
+ }
43
+ ```
44
+
45
+ Notes and limits:
46
+
47
+ - `restart(options)` discards the open take and starts a new one.
48
+ - `options.sampleRate` (default 44100) and `options.channels` (1 or 2, default
49
+ 1. are requests — the result's `sampleRate`/`channels` report what was
50
+ actually written.
51
+ - Native takes also return `path` (a temp file your app owns and deletes);
52
+ `bytes` always carries the full WAV file.
53
+ - Android: background capture requires an app-owned foreground service — the
54
+ leaf does not start one.
55
+ - iOS: the leaf sets `AVAudioSessionCategoryPlayAndRecord` (mix + duck) while
56
+ a take is open and deactivates on stop/cancel.
57
+ - The native entry requires iOS or Android — other targets get the web
58
+ implementation or a thrown error, never a silent no-op.
59
+
60
+ For playback of the result see [`@octane-xplat/audio`](../audio/README.md).
61
+ Permission dispatch through `@octane-xplat/platform` answers
62
+ `permissions.ensure('microphone')`.
@@ -0,0 +1,16 @@
1
+ import { createAudioRecorder as createAudioRecorder$1, requestMicrophonePermission } from "./index3.js";
2
+ import { createAudioRecorder as createAudioRecorder$2, requestMicrophonePermission as requestMicrophonePermission$1 } from "./index4.js";
3
+ import { Application } from "@nativescript/core";
4
+ //#region src/index.ts
5
+ var createAudioRecorder = () => {
6
+ if (Application.android) return createAudioRecorder$1();
7
+ if (Application.ios) return createAudioRecorder$2();
8
+ throw new Error("@octane-xplat/recorder requires iOS or Android");
9
+ };
10
+ var permissionOwners = globalThis.__xplatPermissionOwners ??= {};
11
+ permissionOwners.microphone = async () => {
12
+ const state = Application.android ? await requestMicrophonePermission() : Application.ios ? await requestMicrophonePermission$1() : "unsupported";
13
+ return state === "granted" ? "granted" : state === "unsupported" ? "unsupported" : "denied";
14
+ };
15
+ //#endregion
16
+ export { createAudioRecorder };
@@ -0,0 +1,46 @@
1
+ //#region src/wav.ts
2
+ var ascii = (view, at, text) => {
3
+ for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i));
4
+ };
5
+ /** The 44-byte canonical WAV header for 16-bit PCM. `dataLength` is the
6
+ * number of PCM bytes that follow. */
7
+ function wavHeader(dataLength, sampleRate, channels) {
8
+ const bytes = /* @__PURE__ */ new Uint8Array(44);
9
+ const view = new DataView(bytes.buffer);
10
+ ascii(view, 0, "RIFF");
11
+ view.setUint32(4, 36 + dataLength, true);
12
+ ascii(view, 8, "WAVE");
13
+ ascii(view, 12, "fmt ");
14
+ view.setUint32(16, 16, true);
15
+ view.setUint16(20, 1, true);
16
+ view.setUint16(22, channels, true);
17
+ view.setUint32(24, sampleRate, true);
18
+ view.setUint32(28, sampleRate * channels * 2, true);
19
+ view.setUint16(32, channels * 2, true);
20
+ view.setUint16(34, 16, true);
21
+ ascii(view, 36, "data");
22
+ view.setUint32(40, dataLength, true);
23
+ return bytes;
24
+ }
25
+ /** Base64 → bytes. Native read-backs cross the bridge as base64 strings
26
+ * (one JNI/ObjC call instead of per-byte marshalling). */
27
+ function decodeBase64(value) {
28
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
29
+ const out = new Uint8Array(Math.ceil(value.length * 3 / 4));
30
+ let buffer = 0;
31
+ let bits = 0;
32
+ let at = 0;
33
+ for (const char of value) {
34
+ const index = alphabet.indexOf(char);
35
+ if (index < 0) continue;
36
+ buffer = buffer << 6 | index;
37
+ bits += 6;
38
+ if (bits >= 8) {
39
+ bits -= 8;
40
+ out[at++] = buffer >> bits & 255;
41
+ }
42
+ }
43
+ return out.subarray(0, at);
44
+ }
45
+ //#endregion
46
+ export { decodeBase64, wavHeader };
@@ -0,0 +1,330 @@
1
+ import { decodeBase64, wavHeader } from "./index2.js";
2
+ import { Application, ApplicationSettings, Utils } from "@nativescript/core";
3
+ //#region src/recorder.android.ts
4
+ var PERMISSION = "android.permission.RECORD_AUDIO";
5
+ var ASKED_KEY = "octane-xplat.recorder.asked";
6
+ var requestCode = 7921;
7
+ var appContext = () => Utils.android.getApplicationContext();
8
+ function microphonePresent() {
9
+ try {
10
+ return appContext()?.getPackageManager?.().hasSystemFeature?.("android.hardware.microphone") !== false;
11
+ } catch {
12
+ return true;
13
+ }
14
+ }
15
+ function microphonePermissionState() {
16
+ if (!microphonePresent()) return "unavailable";
17
+ const context = appContext();
18
+ if (!context?.checkSelfPermission) return "unsupported";
19
+ if (context.checkSelfPermission(PERMISSION) === android.content.pm.PackageManager.PERMISSION_GRANTED) return "granted";
20
+ return ApplicationSettings.getBoolean(ASKED_KEY, false) ? "denied" : "undetermined";
21
+ }
22
+ async function requestMicrophonePermission() {
23
+ if (!microphonePresent()) return "unavailable";
24
+ const context = appContext();
25
+ const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity;
26
+ if (!context?.checkSelfPermission || !activity?.requestPermissions) return "unsupported";
27
+ if (context.checkSelfPermission(PERMISSION) === android.content.pm.PackageManager.PERMISSION_GRANTED) return "granted";
28
+ ApplicationSettings.setBoolean(ASKED_KEY, true);
29
+ return await new Promise((resolve) => {
30
+ const code = requestCode++;
31
+ const onResult = (args) => {
32
+ if (args.requestCode !== code) return;
33
+ Application.android.off(Application.android.activityRequestPermissionsEvent, onResult);
34
+ resolve(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED ? "granted" : "denied");
35
+ };
36
+ Application.android.on(Application.android.activityRequestPermissionsEvent, onResult);
37
+ activity.requestPermissions([PERMISSION], code);
38
+ });
39
+ }
40
+ var asError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
41
+ /** Copy a Uint8Array into a Java byte[] for java.io writers. */
42
+ var toJavaBytes = (bytes) => {
43
+ const native = Array.create("byte", bytes.length);
44
+ for (let i = 0; i < bytes.length; i++) native[i] = bytes[i];
45
+ return native;
46
+ };
47
+ /** Patch the two RIFF size fields after streaming PCM — RandomAccessFile
48
+ * writes big-endian, so emit each little-endian byte directly. */
49
+ var patchWavSizes = (file, dataLength) => {
50
+ const raf = new java.io.RandomAccessFile(file, "rw");
51
+ try {
52
+ const sizes = /* @__PURE__ */ new Uint8Array(8);
53
+ const view = new DataView(sizes.buffer);
54
+ view.setUint32(0, 36 + dataLength, true);
55
+ view.setUint32(4, dataLength, true);
56
+ raf.seek(4);
57
+ raf.write(toJavaBytes(sizes.subarray(0, 4)));
58
+ raf.seek(40);
59
+ raf.write(toJavaBytes(sizes.subarray(4, 8)));
60
+ } finally {
61
+ raf.close();
62
+ }
63
+ };
64
+ var readFileBytes = (path) => {
65
+ const file = new java.io.File(path);
66
+ const stream = new java.io.FileInputStream(file);
67
+ try {
68
+ const bytes = Array.create("byte", file.length());
69
+ new java.io.DataInputStream(stream).readFully(bytes);
70
+ return decodeBase64(android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP));
71
+ } finally {
72
+ stream.close();
73
+ }
74
+ };
75
+ var createAudioRecorder = () => {
76
+ const listeners = /* @__PURE__ */ new Set();
77
+ let record;
78
+ let thread;
79
+ let recordingCallback;
80
+ let output;
81
+ let takePath;
82
+ let capturing = false;
83
+ let bytesWritten = 0;
84
+ let peak = 0;
85
+ let state = "idle";
86
+ let permission = microphonePermissionState();
87
+ let error;
88
+ let options = {
89
+ sampleRate: 44100,
90
+ channels: 1
91
+ };
92
+ let timer;
93
+ let opening = false;
94
+ let disposed = false;
95
+ const snapshot = () => ({
96
+ state,
97
+ permission,
98
+ durationSeconds: bytesWritten / (options.sampleRate * options.channels * 2),
99
+ meterLevel: state === "recording" ? peak / 32768 : null,
100
+ error
101
+ });
102
+ const emit = () => {
103
+ const value = snapshot();
104
+ for (const listener of listeners) listener(value);
105
+ };
106
+ const updatePeak = (buffer, length) => {
107
+ for (let i = 0; i + 1 < length; i += 2) {
108
+ const sample = Math.abs((buffer[i + 1] << 8 | buffer[i] & 255) << 16 >> 16);
109
+ if (sample > peak) peak = sample;
110
+ }
111
+ };
112
+ const readLoop = (audioRecord, out, chunkSize) => {
113
+ const chunk = Array.create("byte", chunkSize);
114
+ while (capturing) {
115
+ const read = audioRecord.read(chunk, 0, chunkSize);
116
+ if (read > 0) try {
117
+ out.write(chunk, 0, read);
118
+ bytesWritten += read;
119
+ updatePeak(chunk, read);
120
+ } catch {}
121
+ else if (read < 0) {
122
+ fail(/* @__PURE__ */ new Error(`AudioRecord read failed (${read})`));
123
+ return;
124
+ }
125
+ }
126
+ };
127
+ const suspendCapture = () => {
128
+ capturing = false;
129
+ try {
130
+ record?.stop();
131
+ } catch {}
132
+ try {
133
+ if (thread && java.lang.Thread.currentThread() !== thread) thread.join(500);
134
+ } catch {}
135
+ thread = void 0;
136
+ };
137
+ const resumeCapture = () => {
138
+ const audioRecord = record;
139
+ const out = output;
140
+ if (!audioRecord || !out) throw new Error("AudioRecorder has no open take");
141
+ audioRecord.startRecording();
142
+ capturing = true;
143
+ const chunkSize = Math.max(4096, audioRecord.getBufferSizeInFrames() * options.channels * 2);
144
+ thread = new java.lang.Thread(new java.lang.Runnable({ run: () => readLoop(audioRecord, out, chunkSize) }));
145
+ thread.start();
146
+ };
147
+ const fail = (cause) => {
148
+ error = cause;
149
+ suspendCapture();
150
+ state = "error";
151
+ emit();
152
+ };
153
+ const closeTake = (discard) => {
154
+ suspendCapture();
155
+ try {
156
+ output?.close();
157
+ } catch {}
158
+ output = void 0;
159
+ if (recordingCallback && record) try {
160
+ record.unregisterAudioRecordingCallback(recordingCallback);
161
+ } catch {}
162
+ recordingCallback = void 0;
163
+ try {
164
+ record?.release();
165
+ } catch {}
166
+ record = void 0;
167
+ if (discard && takePath) try {
168
+ new java.io.File(takePath).delete();
169
+ } catch {}
170
+ };
171
+ const start = async (next) => {
172
+ if (disposed) throw new Error("AudioRecorder is disposed");
173
+ if (state !== "idle" || opening) throw new Error(`AudioRecorder cannot start while ${opening ? "starting" : state}`);
174
+ opening = true;
175
+ try {
176
+ permission = await requestMicrophonePermission();
177
+ emit();
178
+ if (permission !== "granted") {
179
+ error = /* @__PURE__ */ new Error(`Microphone permission is ${permission}`);
180
+ emit();
181
+ throw error;
182
+ }
183
+ } finally {
184
+ opening = false;
185
+ }
186
+ options = {
187
+ sampleRate: next?.sampleRate ?? options.sampleRate,
188
+ channels: next?.channels ?? options.channels
189
+ };
190
+ error = void 0;
191
+ bytesWritten = 0;
192
+ peak = 0;
193
+ const context = appContext();
194
+ if (!context) throw new Error("AudioRecorder needs an Android application context");
195
+ const channelConfig = options.channels === 2 ? android.media.AudioFormat.CHANNEL_IN_STEREO : android.media.AudioFormat.CHANNEL_IN_MONO;
196
+ const encoding = android.media.AudioFormat.ENCODING_PCM_16BIT;
197
+ const minBuffer = android.media.AudioRecord.getMinBufferSize(options.sampleRate, channelConfig, encoding);
198
+ try {
199
+ if (minBuffer <= 0) throw new Error(`This device cannot capture ${options.sampleRate} Hz ${options.channels}-channel PCM`);
200
+ record = new android.media.AudioRecord(android.media.MediaRecorder.AudioSource.MIC, options.sampleRate, channelConfig, encoding, Math.max(minBuffer, 4096));
201
+ if (record.getState() !== android.media.AudioRecord.STATE_INITIALIZED) throw new Error("AudioRecord failed to initialize — no usable microphone input");
202
+ const file = java.io.File.createTempFile("octane-recorder-", ".wav", context.getCacheDir());
203
+ takePath = file.getAbsolutePath();
204
+ output = new java.io.FileOutputStream(file);
205
+ output.write(toJavaBytes(wavHeader(0, options.sampleRate, options.channels)));
206
+ const mainHandler = new android.os.Handler(android.os.Looper.getMainLooper());
207
+ const executor = new java.util.concurrent.Executor({ execute(command) {
208
+ mainHandler.post(command);
209
+ } });
210
+ recordingCallback = new android.media.AudioManager.AudioRecordingCallback.extend({ onRecordingConfigChanged(configs) {
211
+ let silenced = false;
212
+ for (let i = 0; configs && i < configs.size(); i++) silenced ||= configs.get(i).isClientSilenced() === true;
213
+ if (silenced && (state === "recording" || state === "paused")) {
214
+ suspendCapture();
215
+ state = "interrupted";
216
+ emit();
217
+ } else if (!silenced && state === "interrupted") {
218
+ state = "paused";
219
+ emit();
220
+ }
221
+ } })();
222
+ record.registerAudioRecordingCallback(executor, recordingCallback);
223
+ resumeCapture();
224
+ } catch (cause) {
225
+ closeTake(true);
226
+ error = asError(cause);
227
+ state = "error";
228
+ emit();
229
+ throw error;
230
+ }
231
+ state = "recording";
232
+ emit();
233
+ };
234
+ const pause = async () => {
235
+ if (state !== "recording") throw new Error(`AudioRecorder cannot pause while ${state}`);
236
+ suspendCapture();
237
+ state = "paused";
238
+ emit();
239
+ };
240
+ const resume = async () => {
241
+ if (state !== "paused") throw new Error(`AudioRecorder cannot resume while ${state}`);
242
+ try {
243
+ resumeCapture();
244
+ } catch (cause) {
245
+ fail(asError(cause));
246
+ throw error;
247
+ }
248
+ state = "recording";
249
+ emit();
250
+ };
251
+ const restart = async (next) => {
252
+ if (state !== "idle") await cancel();
253
+ await start(next);
254
+ };
255
+ const stop = async () => {
256
+ if (!record || !takePath || !output) throw new Error("AudioRecorder has no take to stop");
257
+ const takenPath = takePath;
258
+ const dataLength = bytesWritten;
259
+ closeTake(false);
260
+ patchWavSizes(new java.io.File(takenPath), dataLength);
261
+ const result = {
262
+ mimeType: "audio/wav",
263
+ bytes: readFileBytes(takenPath),
264
+ durationSeconds: dataLength / (options.sampleRate * options.channels * 2),
265
+ sampleRate: options.sampleRate,
266
+ channels: options.channels,
267
+ path: takenPath
268
+ };
269
+ takePath = void 0;
270
+ state = "idle";
271
+ emit();
272
+ return result;
273
+ };
274
+ const cancel = async () => {
275
+ if (state === "idle" && !record && !takePath) return;
276
+ closeTake(true);
277
+ takePath = void 0;
278
+ state = "idle";
279
+ error = void 0;
280
+ emit();
281
+ };
282
+ return {
283
+ capabilities: () => ({
284
+ supported: true,
285
+ pause: true,
286
+ metering: true,
287
+ userGestureRequired: false,
288
+ reason: "Foreground capture only — background microphone use needs an app-owned foreground service."
289
+ }),
290
+ snapshot,
291
+ subscribe: (listener) => {
292
+ listeners.add(listener);
293
+ listener(snapshot());
294
+ timer ??= setInterval(() => {
295
+ emit();
296
+ peak = 0;
297
+ }, 250);
298
+ return () => {
299
+ listeners.delete(listener);
300
+ if (!listeners.size && timer) {
301
+ clearInterval(timer);
302
+ timer = void 0;
303
+ }
304
+ };
305
+ },
306
+ requestPermission: async () => {
307
+ permission = await requestMicrophonePermission();
308
+ emit();
309
+ return permission;
310
+ },
311
+ start,
312
+ pause,
313
+ resume,
314
+ restart,
315
+ stop,
316
+ cancel,
317
+ dispose: () => {
318
+ if (disposed) return;
319
+ disposed = true;
320
+ closeTake(true);
321
+ takePath = void 0;
322
+ if (timer) clearInterval(timer);
323
+ timer = void 0;
324
+ listeners.clear();
325
+ state = "idle";
326
+ }
327
+ };
328
+ };
329
+ //#endregion
330
+ export { createAudioRecorder, microphonePermissionState, requestMicrophonePermission };