@octane-xplat/recorder 0.0.0-stage → 0.11.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.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/native/index.js +16 -0
- package/dist/native/index2.js +46 -0
- package/dist/native/index3.js +330 -0
- package/dist/native/index4.js +257 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/index.js +2 -0
- package/dist/web/index2.js +41 -0
- package/dist/web/index3.js +329 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +79 -3
- package/platforms/android/AndroidManifest.xml +4 -0
- package/platforms/ios/Info.plist +8 -0
- package/src/index.ts +41 -0
- package/src/index.web.ts +2 -0
- package/src/recorder.android.ts +516 -0
- package/src/recorder.ios.ts +401 -0
- package/src/recorder.web.ts +477 -0
- package/src/types.ts +95 -0
- package/src/wav.ts +76 -0
- package/types/index.d.ts +1 -0
- package/types/types.d.ts +87 -0
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
|
-
#
|
|
1
|
+
# `@octane-xplat/recorder`
|
|
2
2
|
|
|
3
|
-
|
|
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 };
|