Skip to content

Commit 56fde71

Browse files
committed
Use SharedArrayBuffer for recording buffers
1 parent 7f69dfc commit 56fde71

File tree

6 files changed

+248
-50
lines changed

6 files changed

+248
-50
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ Try it here: https://andersnm.github.io/modulyzer/
1111
- DX7 patches: https://yamahablackboxes.com/collection/yamaha-dx7-synthesizer/patches/
1212
- Drumkits: https://filedn.com/lovhTbsn9Pz4AJR3FQeqxCF/Buzz/drumkits/ (for [Drumkit Manager 3](https://dkm3.sourceforge.net/))
1313
- Icons: https://hugeicons.com/
14+
- `SharedArrayBuffer` on localhost and GitHub Pages: https://github.com/gzuidhof/coi-serviceworker

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<!doctype html>
22
<html lang="en">
33
<head>
4+
<script src="coi-serviceworker.js"></script>
45
<script>
56
</script>
67
<meta charset="UTF-8" />

public/coi-serviceworker.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
2+
let coepCredentialless = false;
3+
if (typeof window === 'undefined') {
4+
self.addEventListener("install", () => self.skipWaiting());
5+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
6+
7+
self.addEventListener("message", (ev) => {
8+
if (!ev.data) {
9+
return;
10+
} else if (ev.data.type === "deregister") {
11+
self.registration
12+
.unregister()
13+
.then(() => {
14+
return self.clients.matchAll();
15+
})
16+
.then(clients => {
17+
clients.forEach((client) => client.navigate(client.url));
18+
});
19+
} else if (ev.data.type === "coepCredentialless") {
20+
coepCredentialless = ev.data.value;
21+
}
22+
});
23+
24+
self.addEventListener("fetch", function (event) {
25+
const r = event.request;
26+
if (r.cache === "only-if-cached" && r.mode !== "same-origin") {
27+
return;
28+
}
29+
30+
const request = (coepCredentialless && r.mode === "no-cors")
31+
? new Request(r, {
32+
credentials: "omit",
33+
})
34+
: r;
35+
event.respondWith(
36+
fetch(request)
37+
.then((response) => {
38+
if (response.status === 0) {
39+
return response;
40+
}
41+
42+
const newHeaders = new Headers(response.headers);
43+
newHeaders.set("Cross-Origin-Embedder-Policy",
44+
coepCredentialless ? "credentialless" : "require-corp"
45+
);
46+
if (!coepCredentialless) {
47+
newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin");
48+
}
49+
newHeaders.set("Cross-Origin-Opener-Policy", "same-origin");
50+
51+
return new Response(response.body, {
52+
status: response.status,
53+
statusText: response.statusText,
54+
headers: newHeaders,
55+
});
56+
})
57+
.catch((e) => console.error(e))
58+
);
59+
});
60+
61+
} else {
62+
(() => {
63+
const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf");
64+
window.sessionStorage.removeItem("coiReloadedBySelf");
65+
const coepDegrading = (reloadedBySelf == "coepdegrade");
66+
67+
// You can customize the behavior of this script through a global `coi` variable.
68+
const coi = {
69+
shouldRegister: () => !reloadedBySelf,
70+
shouldDeregister: () => false,
71+
coepCredentialless: () => true,
72+
coepDegrade: () => true,
73+
doReload: () => window.location.reload(),
74+
quiet: false,
75+
...window.coi
76+
};
77+
78+
const n = navigator;
79+
const controlling = n.serviceWorker && n.serviceWorker.controller;
80+
81+
// Record the failure if the page is served by serviceWorker.
82+
if (controlling && !window.crossOriginIsolated) {
83+
window.sessionStorage.setItem("coiCoepHasFailed", "true");
84+
}
85+
const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed");
86+
87+
if (controlling) {
88+
// Reload only on the first failure.
89+
const reloadToDegrade = coi.coepDegrade() && !(
90+
coepDegrading || window.crossOriginIsolated
91+
);
92+
n.serviceWorker.controller.postMessage({
93+
type: "coepCredentialless",
94+
value: (reloadToDegrade || coepHasFailed && coi.coepDegrade())
95+
? false
96+
: coi.coepCredentialless(),
97+
});
98+
if (reloadToDegrade) {
99+
!coi.quiet && console.log("Reloading page to degrade COEP.");
100+
window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade");
101+
coi.doReload("coepdegrade");
102+
}
103+
104+
if (coi.shouldDeregister()) {
105+
n.serviceWorker.controller.postMessage({ type: "deregister" });
106+
}
107+
}
108+
109+
// If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are
110+
// already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here.
111+
if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return;
112+
113+
if (!window.isSecureContext) {
114+
!coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required.");
115+
return;
116+
}
117+
118+
// In some environments (e.g. Firefox private mode) this won't be available
119+
if (!n.serviceWorker) {
120+
!coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode.");
121+
return;
122+
}
123+
124+
n.serviceWorker.register(window.document.currentScript.src).then(
125+
(registration) => {
126+
!coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope);
127+
128+
registration.addEventListener("updatefound", () => {
129+
!coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker.");
130+
window.sessionStorage.setItem("coiReloadedBySelf", "updatefound");
131+
coi.doReload();
132+
});
133+
134+
// If the registration is active, but it's not controlling the page
135+
if (registration.active && !n.serviceWorker.controller) {
136+
!coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker.");
137+
window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling");
138+
coi.doReload();
139+
}
140+
},
141+
(err) => {
142+
!coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err);
143+
}
144+
);
145+
})();
146+
}

src/audio/AudioDevice.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export class AudioDevice {
5151
}
5252

5353
async close() {
54-
// this.recorder.close();
5554
this.inputNode.disconnect(this.recorder.recordNode);
55+
this.recorder.destroy();
5656

5757
await this.context.close();
5858
this.context = null;

src/audio/Recorder.ts

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,68 +3,70 @@ export interface RecorderBuffer {
33
bufferPosition: number;
44
}
55

6-
const bufferSize = 8192 * 4;
7-
8-
function createBuffers() {
9-
return [
10-
new Float32Array(bufferSize),
11-
new Float32Array(bufferSize)
12-
];
13-
}
6+
const bufferSize = 8192 * 4; // size of the shared ringbuffer
7+
const outputSize = bufferSize / 4; // return quarter chunks of the ringbuffer as they're ready
148

159
export class Recorder extends EventTarget {
1610
context: AudioContext;
1711
recordNode: AudioWorkletNode;
12+
sharedState: SharedArrayBuffer;
13+
sharedBuffers: SharedArrayBuffer[];
14+
buffers: Float32Array[];
15+
state: Int32Array;
16+
pollingInterval: number;
17+
lastReadPosition: number = 0;
18+
outBuffers: Float32Array[];
1819

1920
constructor(context: AudioContext) {
2021
super();
2122
this.context = context;
2223

23-
this.recordNode = this.setupRecordNode();
24-
24+
this.sharedState = new SharedArrayBuffer(16); // write, 3 reserved * sizeof(int32)
25+
this.sharedBuffers = [
26+
new SharedArrayBuffer(bufferSize * 4), // * sizeof(float)
27+
new SharedArrayBuffer(bufferSize * 4)
28+
];
29+
30+
this.state = new Int32Array(this.sharedState);
31+
this.buffers = [
32+
new Float32Array(this.sharedBuffers[0]),
33+
new Float32Array(this.sharedBuffers[1]),
34+
];
35+
36+
this.outBuffers = [
37+
new Float32Array(outputSize),
38+
new Float32Array(outputSize)
39+
];
40+
41+
this.recordNode = new AudioWorkletNode(this.context, "record-processor");
42+
this.recordNode.port.postMessage({ type: "init", buffers: this.sharedBuffers, state: this.sharedState });
2543
this.recordNode.connect(context.destination);
26-
}
27-
28-
setupRecordNode() {
29-
const recordNode = new AudioWorkletNode(this.context, "record-processor");
3044

31-
let buffers = createBuffers();
32-
let bufferPosition = 0;
33-
34-
recordNode.port.onmessage = async (e) => {
35-
// always monitoring, discard unless recording
36-
const inputs: Float32Array[] = e.data;
37-
const inputLength = inputs[0].length;
38-
if (inputLength > bufferSize) {
39-
throw new Error("Recorder buffer size too small");
40-
}
41-
42-
let remaining = inputLength;
43-
let inputPosition = 0;
44-
while (remaining > 0) {
45-
const chunkSize = Math.min(bufferSize - bufferPosition, remaining);
45+
// TODO: compute a poll interval such that a quarter of the ringbuffer is always ready
46+
this.pollingInterval = window.setInterval(this.onPoll, 90);
47+
}
4648

47-
for (let i = 0; i < buffers.length; i++) {
48-
buffers[i].set(inputs[i % inputs.length].subarray(inputPosition, inputPosition + chunkSize), bufferPosition);
49-
}
49+
onPoll = () => {
50+
const writePosition = Atomics.load(this.state, 0);
51+
const available = (writePosition >= this.lastReadPosition)
52+
? writePosition - this.lastReadPosition
53+
: bufferSize - this.lastReadPosition + writePosition;
5054

51-
inputPosition += chunkSize;
55+
if (available < outputSize) return; // wait until enough data
5256

53-
if (bufferPosition === bufferSize) {
54-
// EOC - end of chunk, go for it
55-
this.dispatchEvent(new CustomEvent("input", { detail: buffers }))
57+
const left = this.buffers[0].subarray(this.lastReadPosition, this.lastReadPosition + outputSize);
58+
const right = this.buffers[1].subarray(this.lastReadPosition, this.lastReadPosition + outputSize);
5659

57-
buffers = createBuffers();
58-
bufferPosition = 0;
59-
remaining -= chunkSize;
60-
} else {
61-
bufferPosition += chunkSize;
62-
remaining -= chunkSize;
63-
}
64-
}
60+
this.lastReadPosition += outputSize;
6561

66-
};
62+
this.dispatchEvent(new CustomEvent("input", {
63+
detail: [left, right]
64+
}));
65+
}
6766

68-
return recordNode;
67+
destroy() {
68+
this.recordNode.port.postMessage({ type: "quit" });
69+
clearInterval(this.pollingInterval);
70+
this.pollingInterval = undefined;
6971
}
7072
}

src/audio/RecorderWorklet.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,65 @@
11
class RecordProcessor extends AudioWorkletProcessor {
2+
quit: boolean = false;
3+
buffers: Float32Array[];
4+
state: Int32Array;
5+
26
constructor() {
37
super();
8+
9+
this.port.addEventListener("message", this.onMessage);
10+
this.port.start();
411
}
512

6-
process(inputs, outputs, parameters) {
7-
// inputs: An array of inputs connected to the node, each item of which is, in turn, an array of channels.
13+
onMessage = (ev: MessageEvent<any>) => {
14+
if (ev.data.type === "init") {
15+
this.buffers = [
16+
new Float32Array(ev.data.buffers[0]),
17+
new Float32Array(ev.data.buffers[1]),
18+
];
19+
this.state = new Int32Array(ev.data.state);
20+
} else if (ev.data.type === "quit") {
21+
this.quit = true;
22+
} else {
23+
console.error("Unknown message: " + ev.data.type);
24+
}
25+
};
26+
27+
process(inputs: Float32Array[][], outputs, parameters) {
28+
if (this.quit) {
29+
return false;
30+
}
31+
32+
if (!this.buffers || !this.state) {
33+
return true;
34+
}
35+
836
if (!inputs.length) {
937
return true;
1038
}
1139

40+
let writePosition = Atomics.load(this.state, 0);
41+
1242
// Send all channels from the first input, assume only a microphone stream is connected
1343
const inputBuffers = inputs[0];
14-
this.port.postMessage(inputBuffers);
44+
const inputBufferSize = inputBuffers[0].length;
45+
const recordBufferSize = this.buffers[0].length;
46+
47+
if (writePosition + inputBufferSize <= recordBufferSize) {
48+
this.buffers[0].set(inputBuffers[0], writePosition);
49+
this.buffers[1].set(inputBuffers[1], writePosition);
50+
} else {
51+
// wrap
52+
const chunkSize = recordBufferSize - writePosition;
53+
this.buffers[0].set(inputBuffers[0].subarray(0, chunkSize), writePosition);
54+
this.buffers[1].set(inputBuffers[1].subarray(0, chunkSize), writePosition);
55+
56+
this.buffers[0].set(inputBuffers[0].subarray(chunkSize, inputBufferSize), 0);
57+
this.buffers[1].set(inputBuffers[1].subarray(chunkSize, inputBufferSize), 0);
58+
}
59+
60+
writePosition = (writePosition + inputBufferSize) % recordBufferSize;
61+
Atomics.store(this.state, 0, writePosition);
62+
1563
return true;
1664
}
1765
}

0 commit comments

Comments
 (0)