@daugasauron/dolly-host-microphone 0.0.0-stage → 0.1.4-rc.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/README.md CHANGED
@@ -1,3 +1,4 @@
1
- # Temporary Holding Version
1
+ # @daugasauron/dolly-host-microphone
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
+ Dolly's `microphone@0` host module: the default microphone, behind the browser's permission prompt. Give the URL of its
4
+ `module.json` to `boot()`'s `modules`; @daugasauron/dolly's README says how.
package/abi.mjs ADDED
@@ -0,0 +1,20 @@
1
+ // Generated from host/microphone/dolly-microphone-0.wat, host/microphone/microphone.h, include/dolly/process.h.
2
+ export const DOLLY_MICROPHONE_VERSION = 0;
3
+ export const DOLLY_MICROPHONE_PROCESS_OP = 130;
4
+ export const DOLLY_MICROPHONE_SLOTS = 1;
5
+ export const DOLLY_MICROPHONE_REPLY_BYTES = 16392;
6
+ export const DOLLY_MICROPHONE_PACKET_BYTES = 40;
7
+ export const DOLLY_MICROPHONE_RATE = 48000;
8
+ export const DOLLY_MICROPHONE_CHANNELS = 1;
9
+ export const DOLLY_MICROPHONE_MAX_FRAMES = 4096;
10
+ export const DOLLY_MICROPHONE_QUEUE_FRAMES = 96000;
11
+ export const DOLLY_MICROPHONE_OPEN = 1;
12
+ export const DOLLY_MICROPHONE_READ = 2;
13
+ export const DOLLY_MICROPHONE_STATUS = 3;
14
+ export const DOLLY_MICROPHONE_CLOSE = 4;
15
+ export const DOLLY_MICROPHONE_WAITING = 1;
16
+ export const DOLLY_MICROPHONE_CAPTURING = 2;
17
+ export const DOLLY_MICROPHONE_DENIED = 3;
18
+ export const DOLLY_MICROPHONE_UNAVAILABLE = 4;
19
+ export const DOLLY_ERRNO = Object.freeze({ E2BIG: 1, EACCES: 2, EADDRINUSE: 3, EADDRNOTAVAIL: 4, EAFNOSUPPORT: 5, EAGAIN: 6, EALREADY: 7, EBADF: 8, EBADMSG: 9, EBUSY: 10, ECANCELED: 11, ECHILD: 12, ECONNABORTED: 13, ECONNREFUSED: 14, ECONNRESET: 15, EDEADLK: 16, EDESTADDRREQ: 17, EDOM: 18, EDQUOT: 19, EEXIST: 20, EFAULT: 21, EFBIG: 22, EHOSTUNREACH: 23, EIDRM: 24, EILSEQ: 25, EINPROGRESS: 26, EINTR: 27, EINVAL: 28, EIO: 29, EISCONN: 30, EISDIR: 31, ELOOP: 32, EMFILE: 33, EMLINK: 34, EMSGSIZE: 35, EMULTIHOP: 36, ENAMETOOLONG: 37, ENETDOWN: 38, ENETRESET: 39, ENETUNREACH: 40, ENFILE: 41, ENOBUFS: 42, ENODEV: 43, ENOENT: 44, ENOEXEC: 45, ENOLCK: 46, ENOLINK: 47, ENOMEM: 48, ENOMSG: 49, ENOPROTOOPT: 50, ENOSPC: 51, ENOSYS: 52, ENOTCONN: 53, ENOTDIR: 54, ENOTEMPTY: 55, ENOTRECOVERABLE: 56, ENOTSOCK: 57, ENOTTY: 59, ENXIO: 60, EOVERFLOW: 61, EOWNERDEAD: 62, EPERM: 63, EPIPE: 64, EPROTO: 65, EPROTONOSUPPORT: 66, EPROTOTYPE: 67, ERANGE: 68, EROFS: 69, ESPIPE: 70, ESRCH: 71, ESTALE: 72, ETIMEDOUT: 73, ETXTBSY: 74, EXDEV: 75, ENODATA: 116, ENOTSUP: 138 });
20
+ export const DOLLY_MICROPHONE_ABI_DIGEST = "1e1a29020c1d2f288048903eb53d1807819f52264e6366a8f9a326483054e5b4";
package/microphone.mjs ADDED
@@ -0,0 +1,63 @@
1
+ import { createMicrophoneProvider } from "./provider.mjs";
2
+ import * as M from "./abi.mjs";
3
+ import { DOLLY_ERRNO as E } from "./abi.mjs";
4
+ export { DOLLY_MICROPHONE_ABI_DIGEST as digest } from "./abi.mjs";
5
+
6
+ // The device as the lease bridge sees it.
7
+ export const lease = { type: "microphone", slots: M.DOLLY_MICROPHONE_SLOTS, packetBytes: M.DOLLY_MICROPHONE_PACKET_BYTES,
8
+ replyBytes: M.DOLLY_MICROPHONE_REPLY_BYTES, open: M.DOLLY_MICROPHONE_OPEN, close: M.DOLLY_MICROPHONE_CLOSE };
9
+
10
+ export function check() {
11
+ return typeof navigator.mediaDevices?.getUserMedia === "function" && typeof globalThis.AudioWorkletNode === "function"
12
+ ? null : "Microphone capture is unavailable in this browser";
13
+ }
14
+
15
+ // Trusted page DOM over the display, beside the browser's own recording mark:
16
+ // shown for as long as a program has asked for or holds the microphone.
17
+ function indicate(indicators, state) {
18
+ let element = document.querySelector("#microphone-status");
19
+ if (!element) {
20
+ element = document.body.appendChild(document.createElement("div"));
21
+ element.id = "microphone-status";
22
+ element.className = "page-indicator";
23
+ element.setAttribute("role", "status");
24
+ element.style.cssText = "position:fixed;left:0.5rem;bottom:2rem;z-index:8;max-width:calc(100vw - 1rem);" +
25
+ "padding:0.15rem 0.45rem;background:#262626;border:1px solid #e98773;color:#e98773;font-size:13px;pointer-events:none";
26
+ }
27
+ const text = { waiting: "Microphone: waiting for permission", capturing: "Microphone on" }[state];
28
+ element.hidden = !text;
29
+ element.textContent = text ?? "";
30
+ indicators.hold(element, Boolean(text));
31
+ }
32
+
33
+ export function browser({ send, indicators }) {
34
+ const provider = createMicrophoneProvider(() => indicate(indicators, provider.status().state));
35
+ return {
36
+ page: { get microphone() { return provider.status(); } },
37
+ messages: {
38
+ "microphone-request"(message) {
39
+ const result = provider.dispatch(message.packet);
40
+ send({ type: "microphone-complete", scope: message.scope, sequence: message.sequence, ...result },
41
+ [result.bytes.buffer]);
42
+ },
43
+ "microphone-revoke"(message) {
44
+ provider.release(message.scope);
45
+ send({ type: "microphone-complete", scope: message.scope, revoked: true });
46
+ },
47
+ },
48
+ dispose() { provider.close(); },
49
+ };
50
+ }
51
+
52
+ export function worker({ send, get }) {
53
+ let bridge;
54
+ return {
55
+ bindings: { "env.dolly_microphone_dispatch": (address, bytes) =>
56
+ bridge ? bridge.dispatch({ address, bytes }) : -E.ENOSYS },
57
+ start({ kernel }) {
58
+ bridge = get("runtime").leaseBridge(lease, Number(kernel.dolly_microphone_mailbox_address()), send);
59
+ },
60
+ messages: { "microphone-complete": message => bridge?.acknowledge(message) },
61
+ dispose() { bridge?.dispose(); bridge = undefined; },
62
+ };
63
+ }
package/module.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "microphone",
3
+ "version": 0,
4
+ "gives": "the default microphone, behind the browser's permission prompt",
5
+ "dependencies": ["runtime@0"],
6
+ "phase": "kernel",
7
+ "imports": ["env.dolly_microphone_dispatch"],
8
+ "host": "microphone.mjs",
9
+ "contracts": ["dolly-microphone-0.wat"],
10
+ "process": [],
11
+ "headers": ["microphone.h", "microphone-abi.h"],
12
+ "kernel": ["kernel.c"],
13
+ "client": ["client.c"],
14
+ "package": "libdolly-microphone"
15
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
+ "version": "0.1.4-rc.0",
3
+ "type": "module",
2
4
  "name": "@daugasauron/dolly-host-microphone",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
5
+ "license": "MIT",
6
+ "dependencies": {
7
+ "@daugasauron/dolly": "0.1.4-rc.0"
8
+ },
9
+ "description": "Dolly's microphone@0 host module: the default microphone, behind the browser's permission prompt.",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/daugasauron/dolly.git"
13
+ }
14
+ }
package/provider.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import * as M from "./abi.mjs";
2
+ import { DOLLY_ERRNO as E } from "./abi.mjs";
3
+
4
+ const fail = errno => { throw Object.assign(new Error("Microphone request failed"), {errno}); };
5
+ const response = size => new Uint8Array(size);
6
+ const names = { [M.DOLLY_MICROPHONE_WAITING]: "waiting", [M.DOLLY_MICROPHONE_CAPTURING]: "capturing",
7
+ [M.DOLLY_MICROPHONE_DENIED]: "denied", [M.DOLLY_MICROPHONE_UNAVAILABLE]: "unavailable" };
8
+
9
+ // The page side of microphone@0: one capture of the browser's default input.
10
+ // OPEN asks the browser, whose own prompt decides; nothing here grants it.
11
+ // `changed` runs whenever the capture's state does.
12
+ export function createMicrophoneProvider(changed = () => {}) {
13
+ let capture, generation = 0;
14
+
15
+ function set(target, state) {
16
+ if (target.released || target.state === state) return;
17
+ target.state = state;
18
+ changed();
19
+ }
20
+ // Stops the tracks, which is what releases the device and ends the browser's own indicator.
21
+ function stop(target) {
22
+ if (target.node) { target.node.port.onmessage = null; target.node.disconnect(); }
23
+ target.source?.disconnect();
24
+ for (const track of target.stream?.getTracks() ?? []) track.stop();
25
+ if (target.context && target.context.state !== "closed") target.context.close().catch(() => {});
26
+ }
27
+ function queue(target, samples) {
28
+ if (target.released || !(samples instanceof Float32Array) || samples.length > target.samples.length) return;
29
+ const size = target.samples.length, overflow = Math.max(0, target.length + samples.length - size);
30
+ target.start = (target.start + overflow) % size;
31
+ target.length -= overflow; target.dropped += overflow;
32
+ const end = (target.start + target.length) % size, first = Math.min(samples.length, size - end);
33
+ target.samples.set(samples.subarray(0, first), end);
34
+ target.samples.set(samples.subarray(first), 0);
35
+ target.length += samples.length; target.captured += samples.length;
36
+ }
37
+ async function start(target) {
38
+ try {
39
+ target.stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
40
+ if (target.released) return stop(target);
41
+ target.context = new AudioContext({ sampleRate: M.DOLLY_MICROPHONE_RATE });
42
+ await target.context.audioWorklet.addModule(new URL("./worklet.mjs", import.meta.url));
43
+ if (target.released) return stop(target);
44
+ target.source = target.context.createMediaStreamSource(target.stream);
45
+ target.node = new AudioWorkletNode(target.context, "dolly-microphone", { numberOfInputs: 1, numberOfOutputs: 0,
46
+ channelCount: M.DOLLY_MICROPHONE_CHANNELS, channelCountMode: "explicit", channelInterpretation: "speakers" });
47
+ target.node.port.onmessage = event => queue(target, event.data);
48
+ target.source.connect(target.node);
49
+ for (const track of target.stream.getAudioTracks()) {
50
+ track.addEventListener("ended", () => set(target, M.DOLLY_MICROPHONE_UNAVAILABLE));
51
+ }
52
+ set(target, M.DOLLY_MICROPHONE_CAPTURING);
53
+ // A capture closed at once ends this promise in a rejection of its own making.
54
+ target.context.resume().catch(() => {});
55
+ } catch (error) {
56
+ const denied = error?.name === "NotAllowedError" || error?.name === "SecurityError";
57
+ if (!denied && error?.name !== "NotFoundError") console.warn("Dolly microphone:", error);
58
+ stop(target);
59
+ set(target, denied ? M.DOLLY_MICROPHONE_DENIED : M.DOLLY_MICROPHONE_UNAVAILABLE);
60
+ }
61
+ }
62
+ function release(scope) {
63
+ if (capture?.scope !== scope) return;
64
+ const target = capture;
65
+ capture = undefined;
66
+ target.released = true;
67
+ stop(target);
68
+ changed();
69
+ }
70
+ function status() {
71
+ return { active: capture ? 1 : 0, state: capture ? names[capture.state] : "closed",
72
+ queuedFrames: capture?.length ?? 0, capturedFrames: capture?.captured ?? 0, droppedFrames: capture?.dropped ?? 0,
73
+ liveTracks: capture?.stream?.getTracks().filter(track => track.readyState === "live").length ?? 0 };
74
+ }
75
+ function dispatch(packet) {
76
+ try {
77
+ if (!(packet instanceof Uint8Array) || packet.byteLength < 32 || packet.byteLength > M.DOLLY_MICROPHONE_PACKET_BYTES) fail(E.EINVAL);
78
+ packet = packet.slice();
79
+ const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
80
+ const op = view.getUint32(4, true), scope = view.getUint32(8, true), sequence = view.getUint32(16, true);
81
+ if (view.getUint32(0, true) !== M.DOLLY_MICROPHONE_VERSION || view.getUint32(12, true) || view.getUint32(20, true) ||
82
+ !scope || !sequence || view.getUint32(24, true) !== packet.length - 32 || view.getUint32(28, true)) fail(E.EINVAL);
83
+ let result;
84
+ if (op === M.DOLLY_MICROPHONE_OPEN) {
85
+ if (packet.length !== 32) fail(E.EINVAL);
86
+ if (scope <= generation) fail(E.ESTALE);
87
+ if (capture) fail(E.EBUSY);
88
+ if (typeof navigator.mediaDevices?.getUserMedia !== "function" || typeof AudioWorkletNode !== "function") fail(E.ENOSYS);
89
+ capture = { scope, sequence, state: M.DOLLY_MICROPHONE_WAITING, released: false,
90
+ samples: new Float32Array(M.DOLLY_MICROPHONE_QUEUE_FRAMES), start: 0, length: 0, captured: 0, dropped: 0 };
91
+ generation = scope;
92
+ void start(capture);
93
+ changed();
94
+ result = response(16);
95
+ const output = new DataView(result.buffer);
96
+ output.setBigUint64(0, BigInt(scope), true);
97
+ output.setUint32(8, M.DOLLY_MICROPHONE_RATE, true); output.setUint32(12, M.DOLLY_MICROPHONE_CHANNELS, true);
98
+ } else {
99
+ if (capture?.scope !== scope) fail(E.EBADF);
100
+ if (sequence <= capture.sequence) fail(E.ESTALE);
101
+ capture.sequence = sequence;
102
+ if (op === M.DOLLY_MICROPHONE_READ) {
103
+ if (packet.length !== 40) fail(E.EINVAL);
104
+ const frames = view.getUint32(32, true);
105
+ if (!frames || frames > M.DOLLY_MICROPHONE_MAX_FRAMES || view.getUint32(36, true)) fail(E.EINVAL);
106
+ const count = Math.min(frames, capture.length), size = capture.samples.length;
107
+ if (!count && capture.state === M.DOLLY_MICROPHONE_DENIED) fail(E.EACCES);
108
+ if (!count && capture.state === M.DOLLY_MICROPHONE_UNAVAILABLE) fail(E.ENODEV);
109
+ result = response(8 + count * 4);
110
+ const output = new DataView(result.buffer);
111
+ output.setUint32(0, count, true); output.setUint32(4, capture.state, true);
112
+ for (let i = 0; i < count; ++i) output.setFloat32(8 + i * 4, capture.samples[(capture.start + i) % size], true);
113
+ capture.start = (capture.start + count) % size; capture.length -= count;
114
+ } else if (op === M.DOLLY_MICROPHONE_STATUS) {
115
+ if (packet.length !== 32) fail(E.EINVAL);
116
+ result = response(24);
117
+ const output = new DataView(result.buffer);
118
+ output.setUint32(0, capture.length, true); output.setUint32(4, capture.state, true);
119
+ output.setBigUint64(8, BigInt(capture.captured), true); output.setBigUint64(16, BigInt(capture.dropped), true);
120
+ } else if (op === M.DOLLY_MICROPHONE_CLOSE) {
121
+ if (packet.length !== 32) fail(E.EINVAL);
122
+ release(scope); result = response(0);
123
+ } else fail(E.ENOSYS);
124
+ }
125
+ return {bytes: result, error: 0};
126
+ } catch (error) {
127
+ if (!error.errno) console.warn("Dolly microphone:", error);
128
+ return {bytes: response(0), error: error.errno ?? E.EIO};
129
+ }
130
+ }
131
+ return {dispatch, release, status, close() { if (capture) release(capture.scope); }};
132
+ }
package/worklet.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // The audio thread's side of microphone@0: hands the page the input's samples,
2
+ // 1024 frames at a time. The node mixes the input down to one channel.
3
+ class DollyMicrophone extends AudioWorkletProcessor {
4
+ block = new Float32Array(1024);
5
+ filled = 0;
6
+ process([[channel]]) {
7
+ for (let at = 0; channel && at < channel.length;) {
8
+ const count = Math.min(channel.length - at, this.block.length - this.filled);
9
+ this.block.set(channel.subarray(at, at + count), this.filled);
10
+ this.filled += count; at += count;
11
+ if (this.filled === this.block.length) {
12
+ this.port.postMessage(this.block, [this.block.buffer]);
13
+ this.block = new Float32Array(1024); this.filled = 0;
14
+ }
15
+ }
16
+ return true;
17
+ }
18
+ }
19
+ registerProcessor("dolly-microphone", DollyMicrophone);