From c6aa13385bb149a0c469c78e2a8bcbb10df681a2 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Fri, 7 Jun 2024 17:41:02 +0100 Subject: [PATCH 01/29] [client] start work on visualizations --- clients/web/src/analysis.ts | 56 ++++++++++++++++++++++ clients/web/src/analysisWorker.js | 40 ++++++++++++++++ clients/web/src/audio.ts | 2 + clients/web/src/components/ThePlayer.vue | 4 ++ clients/web/src/components/TheWaveform.vue | 13 +++++ 5 files changed, 115 insertions(+) create mode 100644 clients/web/src/analysis.ts create mode 100644 clients/web/src/analysisWorker.js create mode 100644 clients/web/src/components/TheWaveform.vue diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts new file mode 100644 index 0000000..04a5d2f --- /dev/null +++ b/clients/web/src/analysis.ts @@ -0,0 +1,56 @@ +import { watchEffect, ref } from "vue"; +import WORKER from "./analysisWorker.js?worker"; + +// audio analyzers for people who need constant visual stimulation :D + +// === DRIVING STATE === + +export const enableAnalysis = ref(false); +const buf = ref(); + +export function setAnalysisBuf(b: AudioBuffer) { + buf.value = b; +} + +// === WORKER HANDLING CODE === + +const worker = new WORKER(); // wrong type +const pendingRequests: Record void> = {}; + +worker.onmessage = (e) => { + if (e.data[0] === "ERR") throw new Error(`error in analysis worker: ${e.data[1]}`); + if (!(e.data[0] in pendingRequests)) + return console.warn("unwanted response from analysis worker, dropping", e.data); + + pendingRequests[e.data[0]](...e.data.slice(1)); + delete pendingRequests[e.data[0]]; +}; + +function callWorker(cmd: string, args: unknown[]): Promise { + const id = Math.random().toString(16); + + worker.postMessage([cmd, id, ...args]); + return new Promise((res) => (pendingRequests[id] = res as any)); +} + +// === BINDINGS === + +// downscales buf to size, using naive sampling +const downscale = (buf: Float32Array, size: number) => + callWorker("downscale", [buf, size]); + +// assumes input is in the range [-1 ; 1] +const waveformToPath = (buf: Float32Array, startX = 0, startY = 1000, dx = 1, scaleY = 1000) => + callWorker("waveformToPath", [buf, startX, startY, dx, scaleY]); + +// === USEFUL REACTIVE STUFF === + +export const wavePath = ref(); + +watchEffect(async () => { + if (enableAnalysis.value && buf.value) { + wavePath.value = await downscale(buf.value.getChannelData(0), 10000).then(waveformToPath); + } else { + wavePath.value = undefined; + } +}); diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js new file mode 100644 index 0000000..ffed5ad --- /dev/null +++ b/clients/web/src/analysisWorker.js @@ -0,0 +1,40 @@ +/** @param {Float32Array} buf + * @param {number} size + * @returns Float32Array */ +function downscale(buf, size) { + const res = new Float32Array(size); + const scale = buf.length / size; + + for (let i = 0; i < size; i++) { + res[i] = buf[~~(i * scale)]; + } + + return res; +} + +/** @param {Float32Array} buf + * @param {number} startX + * @param {number} startY + * @param {number} dx + * @param {number} scaleY + * @returns string */ +function waveformToPath(buf, startX, startY, dx, scaleY) { + let path = `M${startX} ${startY}`; + + let pY = startY; + for (const v of buf) { + let y = startY + v * scaleY; + path += `l${dx} ${y - pY}`; + pY = y; + } + + return path; +} + +onmessage = (e) => { + // rip type safety + const func = { downscale, waveformToPath }[e.data[0]]; + if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); + + postMessage([e.data[1], func(...e.data.slice(2))]); +}; diff --git a/clients/web/src/audio.ts b/clients/web/src/audio.ts index 080636d..c285a65 100644 --- a/clients/web/src/audio.ts +++ b/clients/web/src/audio.ts @@ -2,6 +2,7 @@ import { ref, reactive, computed, watchEffect } from "vue"; import { setupMediaSession } from "./mediaSession"; import type { Song } from "./syncClient"; import { currentTimestamp } from "./util"; +import { setAnalysisBuf } from "./analysis"; export const audioCtx = new AudioContext(); export const audioAnalyser = new AnalyserNode(audioCtx); @@ -70,6 +71,7 @@ export async function play(song: Song, seek: number) { audioSource = new AudioBufferSourceNode(audioCtx, { buffer: await (songs[url] ?? preload(url)), }); + setAnalysisBuf(audioSource.buffer!); audioSource.connect(audioAnalyser).connect(audioGain).connect(audioCtx.destination); diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index 7c2946a..bf917b2 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -10,6 +10,8 @@ import { timePromise } from "@/util"; import { visualizerEnabled } from "@/visualizer"; import fallbackart from "@/assets/fallbackart_10x.png"; +import TheWaveform from "./TheWaveform.vue"; + const visualizerSupported = isButterchurnSupported(); const client = await timePromise.then(() => getClient()); @@ -53,6 +55,8 @@ const randomQuote = computed(() => :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" />
VOL
+ + diff --git a/clients/web/src/components/TheWaveform.vue b/clients/web/src/components/TheWaveform.vue new file mode 100644 index 0000000..f991e1a --- /dev/null +++ b/clients/web/src/components/TheWaveform.vue @@ -0,0 +1,13 @@ + + + + + From 58fa51ac6fa15724decafd30723caa0ecbc377cb Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Fri, 7 Jun 2024 18:44:59 +0100 Subject: [PATCH 02/29] [vis] improve waveform --- clients/web/src/analysis.ts | 1 + clients/web/src/analysisWorker.js | 35 +++++++++++++++++----- clients/web/src/components/ThePlayer.vue | 4 +-- clients/web/src/components/TheWaveform.vue | 2 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index 04a5d2f..f220ac8 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -49,6 +49,7 @@ export const wavePath = ref(); watchEffect(async () => { if (enableAnalysis.value && buf.value) { + wavePath.value = undefined; wavePath.value = await downscale(buf.value.getChannelData(0), 10000).then(waveformToPath); } else { wavePath.value = undefined; diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index ffed5ad..d0069dd 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -2,11 +2,23 @@ * @param {number} size * @returns Float32Array */ function downscale(buf, size) { - const res = new Float32Array(size); - const scale = buf.length / size; + // size of each chunk + const chunkSz = Math.ceil(buf.length / size); + const nChunks = Math.ceil(buf.length / chunkSz); - for (let i = 0; i < size; i++) { - res[i] = buf[~~(i * scale)]; + // this is at LEAST size, rounded up as necessary + const res = new Float32Array(nChunks); + + for (let ci = 0; ci < nChunks; ci++) { + const chunk = buf.slice(ci * chunkSz, (ci + 1) * chunkSz); + + let max = chunk[0]; /*, min = chunk[0]*/ + for (let i = 1; i < chunk.length; i++) { + max = Math.max(chunk[i], max); + //min = Math.min(chunk[i], min); + } + + res[ci] = max; } return res; @@ -21,14 +33,23 @@ function downscale(buf, size) { function waveformToPath(buf, startX, startY, dx, scaleY) { let path = `M${startX} ${startY}`; - let pY = startY; + let pY = 0; for (const v of buf) { - let y = startY + v * scaleY; + let y = -v * scaleY; path += `l${dx} ${y - pY}`; pY = y; } - return path; + path += `l0 ${2 * buf[buf.length - 1] * scaleY}`; + + // flip it over! + for (let i = buf.length - 1; i >= 0; i--) { + let y = buf[i] * scaleY; + path += `l${-dx} ${y - pY}`; + pY = y; + } + + return path + `Z`; } onmessage = (e) => { diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index bf917b2..a7b9741 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -54,9 +54,9 @@ const randomQuote = computed(() => class="h-px bg-white" :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> -
VOL
- + +
VOL
diff --git a/clients/web/src/components/TheWaveform.vue b/clients/web/src/components/TheWaveform.vue index f991e1a..7849d6d 100644 --- a/clients/web/src/components/TheWaveform.vue +++ b/clients/web/src/components/TheWaveform.vue @@ -6,7 +6,7 @@ enableAnalysis.value = true; From 738dd0864aaffb7ccc71a800eff7a96f612c4eeb Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Fri, 7 Jun 2024 19:09:48 +0100 Subject: [PATCH 03/29] [vis] allow storing buffer in the worker --- clients/web/src/analysis.ts | 11 ++++++++--- clients/web/src/analysisWorker.js | 14 +++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index f220ac8..c9fda56 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -35,12 +35,16 @@ function callWorker(cmd: string, args: unknown[]): Promise { // === BINDINGS === +// sets the current worker's default buffer to this one +// this avoids unnecessarily sending the same buffer multiple times +const uploadBuffer = (buf: Float32Array) => callWorker("uploadBuffer", [buf]); + // downscales buf to size, using naive sampling -const downscale = (buf: Float32Array, size: number) => +const downscale = (buf: undefined | Float32Array, size: number) => callWorker("downscale", [buf, size]); // assumes input is in the range [-1 ; 1] -const waveformToPath = (buf: Float32Array, startX = 0, startY = 1000, dx = 1, scaleY = 1000) => +const waveformToPath = (buf: undefined | Float32Array, startX = 0, startY = 1000, dx = 1, scaleY = 1000) => callWorker("waveformToPath", [buf, startX, startY, dx, scaleY]); // === USEFUL REACTIVE STUFF === @@ -50,7 +54,8 @@ export const wavePath = ref(); watchEffect(async () => { if (enableAnalysis.value && buf.value) { wavePath.value = undefined; - wavePath.value = await downscale(buf.value.getChannelData(0), 10000).then(waveformToPath); + await uploadBuffer(buf.value.getChannelData(0)); + wavePath.value = await downscale(undefined, 10000).then(waveformToPath); } else { wavePath.value = undefined; } diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index d0069dd..a9461b5 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,7 +1,17 @@ +// avoid uploading the same buffer a ton of times +let currentBuffer = new Float32Array(0); + +/** @pararm {Float32Array{ buf */ +function uploadBuffer(buf) { + currentBuffer = buf; +} + /** @param {Float32Array} buf * @param {number} size * @returns Float32Array */ function downscale(buf, size) { + buf ??= currentBuffer; + // size of each chunk const chunkSz = Math.ceil(buf.length / size); const nChunks = Math.ceil(buf.length / chunkSz); @@ -31,6 +41,8 @@ function downscale(buf, size) { * @param {number} scaleY * @returns string */ function waveformToPath(buf, startX, startY, dx, scaleY) { + buf ??= currentBuffer; + let path = `M${startX} ${startY}`; let pY = 0; @@ -54,7 +66,7 @@ function waveformToPath(buf, startX, startY, dx, scaleY) { onmessage = (e) => { // rip type safety - const func = { downscale, waveformToPath }[e.data[0]]; + const func = { uploadBuffer, downscale, waveformToPath }[e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); postMessage([e.data[1], func(...e.data.slice(2))]); From 6c8dee16aea8c03301d7bd45e9b827dda86e6d80 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sat, 8 Jun 2024 14:20:05 +0100 Subject: [PATCH 04/29] [vis] switch TheWaveform to use a canvas --- clients/web/src/analysis.ts | 12 ++---- clients/web/src/analysisWorker.js | 32 +------------- clients/web/src/components/TheWaveform.vue | 49 ++++++++++++++++++---- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index c9fda56..63e988e 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -43,20 +43,16 @@ const uploadBuffer = (buf: Float32Array) => callWorker("uploadBuffer", [buf]); const downscale = (buf: undefined | Float32Array, size: number) => callWorker("downscale", [buf, size]); -// assumes input is in the range [-1 ; 1] -const waveformToPath = (buf: undefined | Float32Array, startX = 0, startY = 1000, dx = 1, scaleY = 1000) => - callWorker("waveformToPath", [buf, startX, startY, dx, scaleY]); - // === USEFUL REACTIVE STUFF === -export const wavePath = ref(); +export const downscaled = ref(); watchEffect(async () => { if (enableAnalysis.value && buf.value) { - wavePath.value = undefined; + downscaled.value = undefined; await uploadBuffer(buf.value.getChannelData(0)); - wavePath.value = await downscale(undefined, 10000).then(waveformToPath); + downscaled.value = await downscale(undefined, 1000); } else { - wavePath.value = undefined; + downscaled.value = undefined; } }); diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index a9461b5..b682e72 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -34,39 +34,9 @@ function downscale(buf, size) { return res; } -/** @param {Float32Array} buf - * @param {number} startX - * @param {number} startY - * @param {number} dx - * @param {number} scaleY - * @returns string */ -function waveformToPath(buf, startX, startY, dx, scaleY) { - buf ??= currentBuffer; - - let path = `M${startX} ${startY}`; - - let pY = 0; - for (const v of buf) { - let y = -v * scaleY; - path += `l${dx} ${y - pY}`; - pY = y; - } - - path += `l0 ${2 * buf[buf.length - 1] * scaleY}`; - - // flip it over! - for (let i = buf.length - 1; i >= 0; i--) { - let y = buf[i] * scaleY; - path += `l${-dx} ${y - pY}`; - pY = y; - } - - return path + `Z`; -} - onmessage = (e) => { // rip type safety - const func = { uploadBuffer, downscale, waveformToPath }[e.data[0]]; + const func = { uploadBuffer, downscale }[e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); postMessage([e.data[1], func(...e.data.slice(2))]); diff --git a/clients/web/src/components/TheWaveform.vue b/clients/web/src/components/TheWaveform.vue index 7849d6d..6715750 100644 --- a/clients/web/src/components/TheWaveform.vue +++ b/clients/web/src/components/TheWaveform.vue @@ -1,13 +1,48 @@ - - From 149dde66af8f16d6a0fa1ef8e677bc7a423c2919 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sat, 8 Jun 2024 15:14:33 +0100 Subject: [PATCH 05/29] [vis] not yet working great attempts at showing one waveform --- clients/web/src/analysis.ts | 41 +++++++++- clients/web/src/analysisWorker.js | 78 ++++++++++++++++++- clients/web/src/components/ThePlayer.vue | 5 +- .../{TheWaveform.vue => WaveForm.vue} | 24 ++++-- 4 files changed, 132 insertions(+), 16 deletions(-) rename clients/web/src/components/{TheWaveform.vue => WaveForm.vue} (63%) diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index 63e988e..fe9bb11 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -1,5 +1,6 @@ -import { watchEffect, ref } from "vue"; +import { watchEffect, ref, type WatchStopHandle } from "vue"; import WORKER from "./analysisWorker.js?worker"; +import {seek} from "./audio" // audio analyzers for people who need constant visual stimulation :D @@ -26,7 +27,7 @@ worker.onmessage = (e) => { delete pendingRequests[e.data[0]]; }; -function callWorker(cmd: string, args: unknown[]): Promise { +function callWorker(cmd: number, args: unknown[]): Promise { const id = Math.random().toString(16); worker.postMessage([cmd, id, ...args]); @@ -37,21 +38,53 @@ function callWorker(cmd: string, args: unknown[]): Promise { // sets the current worker's default buffer to this one // this avoids unnecessarily sending the same buffer multiple times -const uploadBuffer = (buf: Float32Array) => callWorker("uploadBuffer", [buf]); +const uploadBuffer = (buf: Float32Array) => callWorker(0, [buf]); // downscales buf to size, using naive sampling const downscale = (buf: undefined | Float32Array, size: number) => - callWorker("downscale", [buf, size]); + callWorker(1, [buf, size]); + +/*// trims a buffer down to get only the next n zero-crossings +const getNCrossings = (buf: undefined | Float32Array, start: number, n: number) => + callWorker(2, [buf, start, n]);*/ + +/*// slice a buffer at the zero crossings +const sliceByCrossings = (buf: undefined | Float32Array, start?: number, end?: number) => + callWorker(4, [buf, start, end]);*/ + +/*// gets the buffer from the array with the maximum peak +const maxAbsPeakOf = (bufs: Float32Array[]) => callWorker(3, [bufs]);*/ + +// sliceByCrossings >> maxAbsPeakOf, saves copying unnecessary bufs +const sbcMax = (buf: undefined | Float32Array, start?: number, end?: number) => + callWorker(5, [buf, start, end]); // === USEFUL REACTIVE STUFF === export const downscaled = ref(); +export const singlePeriod = ref(); + +const innerCleanups: WatchStopHandle[] = []; watchEffect(async () => { if (enableAnalysis.value && buf.value) { + innerCleanups.forEach(c => c()); + downscaled.value = undefined; await uploadBuffer(buf.value.getChannelData(0)); downscaled.value = await downscale(undefined, 1000); + + innerCleanups.push( + watchEffect(async () => { + // 2 crossings for a full waveform + if (seek.value === undefined) return; + const seekSamples = seek.value * buf.value!.sampleRate; + // seek updates every 100ms + const seekEndSamples = (seek.value + 0.1) * buf.value!.sampleRate; + + singlePeriod.value = await sbcMax(undefined, seekSamples, seekEndSamples); + }), + ); } else { downscaled.value = undefined; } diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index b682e72..c880219 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,7 +1,7 @@ // avoid uploading the same buffer a ton of times let currentBuffer = new Float32Array(0); -/** @pararm {Float32Array{ buf */ +/** @param {Float32Array} buf */ function uploadBuffer(buf) { currentBuffer = buf; } @@ -34,9 +34,83 @@ function downscale(buf, size) { return res; } +/** @param {Float32Array} buf + * @param {number} start The number of samples into the buffer to start looking from + * @param {number} n The amount of zero crossings to include + * @returns Float32Array */ +/*function getNCrossings(buf, start, n) { + /!** @param {number} n + * @returns number*!/ + function nextCrossingIdx(n) { + let done = false; + do { + n++; + let signChanged = (buf[n - 1] > 0) !== (buf[n] > 0); + done = signChanged; + } while (!done); + return n; + } + + buf ??= currentBuffer; + + // find first crossing + let c1 = nextCrossingIdx(start); + + // find end + let end = c1; + for (let i = 0; i < n; i++) end = nextCrossingIdx(end); + + return buf.slice(c1, end); +}*/ + +/** @param {Float32Array} buf + * @param {number} start + * @param {number} end + * @returns Float32Array[] */ +function sliceByCrossings(buf, start, end) { + const buffers = []; + buf ??= currentBuffer; + start ??= 0; + end ??= buf.length; + + let startNext = start; // the start of the next buffer to be sliced + let isPos = buf[start] > 0; // was the last sample positive? + + for (let i = start + 1; i < end; i++) { + const thisSampPos = buf[i] > 0; + if (isPos !== thisSampPos) { + // flip! + buffers.push(buf.slice(startNext, i)); + startNext = i; + } + isPos = thisSampPos; + } + + return buffers; +} + +/** @param {Float32Array[]} bufs + * @returns Float32Array */ +function maxAbsPeakOf(bufs) { + let m = 0; + let mb = bufs[0]; + for (const buf of bufs) + for (const sample of buf) { + m = Math.max(m, Math.abs(sample)); + mb = buf; + } + + return mb; +} + +// maxAbsPeakOf(sliceByCrossings(...)) +function sbcMax(buf, start, end) { + return maxAbsPeakOf(sliceByCrossings(buf, start, end)); +} + onmessage = (e) => { // rip type safety - const func = { uploadBuffer, downscale }[e.data[0]]; + const func = [uploadBuffer, downscale, /*getNCrossings*/, /*maxAbsPeakOf*/, /*sliceByCrossings*/, sbcMax][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); postMessage([e.data[1], func(...e.data.slice(2))]); diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index a7b9741..c94ec0a 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -10,7 +10,8 @@ import { timePromise } from "@/util"; import { visualizerEnabled } from "@/visualizer"; import fallbackart from "@/assets/fallbackart_10x.png"; -import TheWaveform from "./TheWaveform.vue"; +import WaveForm from "./WaveForm.vue"; +import { downscaled, singlePeriod } from "@/analysis"; const visualizerSupported = isButterchurnSupported(); @@ -54,7 +55,7 @@ const randomQuote = computed(() => class="h-px bg-white" :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> - +
VOL
diff --git a/clients/web/src/components/TheWaveform.vue b/clients/web/src/components/WaveForm.vue similarity index 63% rename from clients/web/src/components/TheWaveform.vue rename to clients/web/src/components/WaveForm.vue index 6715750..cbe4d45 100644 --- a/clients/web/src/components/TheWaveform.vue +++ b/clients/web/src/components/WaveForm.vue @@ -1,9 +1,11 @@ From 1470ab5e06b8fa420ac944747165f67610e1c885 Mon Sep 17 00:00:00 2001 From: yellowsink Date: Sat, 8 Jun 2024 17:34:59 +0100 Subject: [PATCH 06/29] [vis] add spectrum analyzer --- clients/web/package.json | 1 + clients/web/pnpm-lock.yaml | 7 +++ clients/web/src/analysis.ts | 20 +++++-- clients/web/src/analysisWorker.js | 35 ++++++++++- clients/web/src/audio.ts | 2 +- clients/web/src/components/AudioSpectrum.vue | 63 ++++++++++++++++++++ clients/web/src/components/ThePlayer.vue | 5 +- clients/web/src/components/WaveForm.vue | 2 +- 8 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 clients/web/src/components/AudioSpectrum.vue diff --git a/clients/web/package.json b/clients/web/package.json index c37b59b..7f26695 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -17,6 +17,7 @@ "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", "can-autoplay": "^3.0.2", + "fft.js": "^4.0.4", "unocss": "^0.57.1", "vue": "^3.3.7" }, diff --git a/clients/web/pnpm-lock.yaml b/clients/web/pnpm-lock.yaml index 5ab3a37..29cbf86 100644 --- a/clients/web/pnpm-lock.yaml +++ b/clients/web/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: can-autoplay: specifier: ^3.0.2 version: 3.0.2 + fft.js: + specifier: ^4.0.4 + version: 4.0.4 unocss: specifier: ^0.57.1 version: 0.57.1(postcss@8.4.31)(vite@4.5.0) @@ -1759,6 +1762,10 @@ packages: tough-cookie: 4.1.3 dev: false + /fft.js@4.0.4: + resolution: {integrity: sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==} + dev: false + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index fe9bb11..ae01b2d 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -1,6 +1,6 @@ import { watchEffect, ref, type WatchStopHandle } from "vue"; import WORKER from "./analysisWorker.js?worker"; -import {seek} from "./audio" +import { seek } from "./audio"; // audio analyzers for people who need constant visual stimulation :D @@ -59,17 +59,23 @@ const maxAbsPeakOf = (bufs: Float32Array[]) => callWorker(3, [bufs const sbcMax = (buf: undefined | Float32Array, start?: number, end?: number) => callWorker(5, [buf, start, end]); +// computes the FFT of the buffer in the range +const fft = (buf: undefined | Float32Array, start?: number, end?: number, pad?: number) => + callWorker(6, [buf, start, end, pad]); + // === USEFUL REACTIVE STUFF === export const downscaled = ref(); export const singlePeriod = ref(); +export const fftd = ref(); + const innerCleanups: WatchStopHandle[] = []; watchEffect(async () => { if (enableAnalysis.value && buf.value) { - innerCleanups.forEach(c => c()); - + innerCleanups.forEach((c) => c()); + downscaled.value = undefined; await uploadBuffer(buf.value.getChannelData(0)); downscaled.value = await downscale(undefined, 1000); @@ -79,9 +85,11 @@ watchEffect(async () => { // 2 crossings for a full waveform if (seek.value === undefined) return; const seekSamples = seek.value * buf.value!.sampleRate; - // seek updates every 100ms - const seekEndSamples = (seek.value + 0.1) * buf.value!.sampleRate; - + // more samples = more accuracy, more padding = smoother plot + const seekEndSamples = seekSamples + 5000; + const pad = 0; + + fftd.value = (await fft(undefined, seekSamples, seekEndSamples, pad)).map(Math.abs); singlePeriod.value = await sbcMax(undefined, seekSamples, seekEndSamples); }), ); diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index c880219..c09738d 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,3 +1,5 @@ +import FFT from "fft.js"; + // avoid uploading the same buffer a ton of times let currentBuffer = new Float32Array(0); @@ -108,9 +110,40 @@ function sbcMax(buf, start, end) { return maxAbsPeakOf(sliceByCrossings(buf, start, end)); } +/** @param {Float32Array} buf + * @param {number} start + * @param {number} end + * @param {number} pad + * @returns {Float32Array} */ +function fft(buf, start, end, pad) { + buf ??= currentBuffer; + start ??= 0; + end ??= buf.length; + pad ??= 0; + + // min power of 2 that is <= buf.length + const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); + + buf = [...buf.slice(start, end), ...Array(size - (end - start)).fill(0)]; + + const fft = new FFT(size); + const output = fft.createComplexArray(); + fft.realTransform(output, buf); + + return new Float32Array(output.slice(0, size)); +} + onmessage = (e) => { // rip type safety - const func = [uploadBuffer, downscale, /*getNCrossings*/, /*maxAbsPeakOf*/, /*sliceByCrossings*/, sbcMax][e.data[0]]; + const func = [ + uploadBuffer, + downscale, + /*getNCrossings*/ undefined, + /*maxAbsPeakOf*/ undefined, + /*sliceByCrossings*/ undefined, + sbcMax, + fft, + ][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); postMessage([e.data[1], func(...e.data.slice(2))]); diff --git a/clients/web/src/audio.ts b/clients/web/src/audio.ts index c285a65..773c78c 100644 --- a/clients/web/src/audio.ts +++ b/clients/web/src/audio.ts @@ -39,7 +39,7 @@ export const prettySeek = computed(() => prettyFormatTime(seek.value!)); setInterval( () => (seek.value = Math.min(audioCtx.currentTime - startTime + startSeek, getDuration())), - 100, + 50, ); export const seekTo = (seek: number) => { diff --git a/clients/web/src/components/AudioSpectrum.vue b/clients/web/src/components/AudioSpectrum.vue new file mode 100644 index 0000000..ab43167 --- /dev/null +++ b/clients/web/src/components/AudioSpectrum.vue @@ -0,0 +1,63 @@ + + + diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index c94ec0a..e6bf25b 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -11,7 +11,8 @@ import { visualizerEnabled } from "@/visualizer"; import fallbackart from "@/assets/fallbackart_10x.png"; import WaveForm from "./WaveForm.vue"; -import { downscaled, singlePeriod } from "@/analysis"; +import AudioSpectrum from "@/components/AudioSpectrum.vue"; +import { downscaled, singlePeriod, fftd } from "@/analysis"; const visualizerSupported = isButterchurnSupported(); @@ -55,7 +56,7 @@ const randomQuote = computed(() => class="h-px bg-white" :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> - +
VOL
diff --git a/clients/web/src/components/WaveForm.vue b/clients/web/src/components/WaveForm.vue index cbe4d45..e6124b5 100644 --- a/clients/web/src/components/WaveForm.vue +++ b/clients/web/src/components/WaveForm.vue @@ -4,7 +4,7 @@ import { enableAnalysis } from "@/analysis"; enableAnalysis.value = true; -const props = defineProps<{ waveform: Float32Array | undefined, fill: boolean }>(); +const props = defineProps<{ waveform: Float32Array | undefined; fill: boolean }>(); onMounted(() => { const ctx = (document.getElementById("wfcanvas") as HTMLCanvasElement).getContext("2d")!; From 467322c06604f7dbb1f5f912415bb69825502b69 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sun, 9 Jun 2024 12:47:06 +0100 Subject: [PATCH 07/29] [vis] very unfinished work on switching to wasm --- clients/web/package.json | 4 +- clients/web/pnpm-lock.yaml | 286 ++++ clients/web/src/analysisWorker.js | 11 +- clients/web/src/components/AudioSpectrum.vue | 4 +- clients/web/src/components/ThePlayer.vue | 1 + clients/web/src/dsp-asm/asconfig.json | 23 + clients/web/src/dsp-asm/assembly/fft.ts | 551 +++++++ clients/web/src/dsp-asm/assembly/index.ts | 27 + .../web/src/dsp-asm/assembly/tsconfig.json | 6 + clients/web/src/dsp-asm/build/.gitignore | 2 + clients/web/src/dsp-asm/package.json | 20 + clients/web/src/dsp-asm/pnpm-lock.yaml | 1294 +++++++++++++++++ clients/web/src/dsp-asm/tests/index.ts | 26 + clients/web/vite.config.ts | 5 + 14 files changed, 2253 insertions(+), 7 deletions(-) create mode 100644 clients/web/src/dsp-asm/asconfig.json create mode 100644 clients/web/src/dsp-asm/assembly/fft.ts create mode 100644 clients/web/src/dsp-asm/assembly/index.ts create mode 100644 clients/web/src/dsp-asm/assembly/tsconfig.json create mode 100644 clients/web/src/dsp-asm/build/.gitignore create mode 100644 clients/web/src/dsp-asm/package.json create mode 100644 clients/web/src/dsp-asm/pnpm-lock.yaml create mode 100644 clients/web/src/dsp-asm/tests/index.ts diff --git a/clients/web/package.json b/clients/web/package.json index 7f26695..800b083 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -7,7 +7,8 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", - "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", + "postinstall": "pnpm i -C src/dsp-asm" }, "dependencies": { "@headlessui/vue": "^1.7.16", @@ -38,6 +39,7 @@ "prettier": "^3.0.3", "typescript": "~5.2.2", "vite": "^4.5.0", + "vite-plugin-assemblyscript-asc": "^1.0.5", "vue-tsc": "^1.8.22" } } diff --git a/clients/web/pnpm-lock.yaml b/clients/web/pnpm-lock.yaml index 29cbf86..686c12a 100644 --- a/clients/web/pnpm-lock.yaml +++ b/clients/web/pnpm-lock.yaml @@ -85,6 +85,9 @@ devDependencies: vite: specifier: ^4.5.0 version: 4.5.0(@types/node@20.8.10) + vite-plugin-assemblyscript-asc: + specifier: ^1.0.5 + version: 1.0.5(@types/node@20.8.10) vue-tsc: specifier: ^1.8.22 version: 1.8.22(typescript@5.2.2) @@ -153,6 +156,15 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm@0.15.18: + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm@0.18.20: resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -225,6 +237,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-loong64@0.15.18: + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-loong64@0.18.20: resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -1502,6 +1523,216 @@ packages: is-symbol: 1.0.4 dev: true + /esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + dev: true + /esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2707,6 +2938,14 @@ packages: glob: 7.2.3 dev: true + /rollup@2.79.1: + resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: true + /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -3147,6 +3386,53 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /vite-plugin-assemblyscript-asc@1.0.5(@types/node@20.8.10): + resolution: {integrity: sha512-qQwZzvREZMhkFX64mT94pUh+fydo9rYxENQuh3HZ7jNzEB2bQAXWWvaA2qkG7l53+8fNYT/MpJGtXXQ9OVckzw==} + dependencies: + vite: 3.2.10(@types/node@20.8.10) + transitivePeerDependencies: + - '@types/node' + - less + - sass + - stylus + - sugarss + - terser + dev: true + + /vite@3.2.10(@types/node@20.8.10): + resolution: {integrity: sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.8.10 + esbuild: 0.15.18 + postcss: 8.4.31 + resolve: 1.22.8 + rollup: 2.79.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vite@4.5.0(@types/node@20.8.10): resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} engines: {node: ^14.18.0 || >=16.0.0} diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index c09738d..eeb8ac4 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,3 +1,4 @@ +import { downscale as wasmDS } from "./dsp-asm/assembly/index"; import FFT from "fft.js"; // avoid uploading the same buffer a ton of times @@ -14,8 +15,10 @@ function uploadBuffer(buf) { function downscale(buf, size) { buf ??= currentBuffer; + return wasmDS(buf, size); + // size of each chunk - const chunkSz = Math.ceil(buf.length / size); + /*const chunkSz = Math.ceil(buf.length / size); const nChunks = Math.ceil(buf.length / chunkSz); // this is at LEAST size, rounded up as necessary @@ -24,7 +27,7 @@ function downscale(buf, size) { for (let ci = 0; ci < nChunks; ci++) { const chunk = buf.slice(ci * chunkSz, (ci + 1) * chunkSz); - let max = chunk[0]; /*, min = chunk[0]*/ + let max = chunk[0]; /!*, min = chunk[0]*!/ for (let i = 1; i < chunk.length; i++) { max = Math.max(chunk[i], max); //min = Math.min(chunk[i], min); @@ -33,7 +36,7 @@ function downscale(buf, size) { res[ci] = max; } - return res; + return res;*/ } /** @param {Float32Array} buf @@ -123,7 +126,7 @@ function fft(buf, start, end, pad) { // min power of 2 that is <= buf.length const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); - + buf = [...buf.slice(start, end), ...Array(size - (end - start)).fill(0)]; const fft = new FFT(size); diff --git a/clients/web/src/components/AudioSpectrum.vue b/clients/web/src/components/AudioSpectrum.vue index ab43167..a736307 100644 --- a/clients/web/src/components/AudioSpectrum.vue +++ b/clients/web/src/components/AudioSpectrum.vue @@ -16,7 +16,7 @@ function movingMax(buf: Float32Array, n: number) { } onMounted(() => { - const ctx = (document.getElementById("wfcanvas") as HTMLCanvasElement).getContext("2d")!; + const ctx = (document.getElementById("ascanvas") as HTMLCanvasElement).getContext("2d")!; const W = ctx.canvas.offsetWidth; const H = ctx.canvas.offsetHeight; @@ -59,5 +59,5 @@ onMounted(() => { diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index e6bf25b..095093f 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -56,6 +56,7 @@ const randomQuote = computed(() => class="h-px bg-white" :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> +
VOL
diff --git a/clients/web/src/dsp-asm/asconfig.json b/clients/web/src/dsp-asm/asconfig.json new file mode 100644 index 0000000..87e1e5c --- /dev/null +++ b/clients/web/src/dsp-asm/asconfig.json @@ -0,0 +1,23 @@ +{ + "targets": { + "debug": { + "outFile": "build/debug.wasm", + "textFile": "build/debug.wat", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/release.wasm", + "textFile": "build/release.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false + } + }, + "options": { + "bindings": "esm", + "enable": ["simd"] + } +} diff --git a/clients/web/src/dsp-asm/assembly/fft.ts b/clients/web/src/dsp-asm/assembly/fft.ts new file mode 100644 index 0000000..a6b9311 --- /dev/null +++ b/clients/web/src/dsp-asm/assembly/fft.ts @@ -0,0 +1,551 @@ +// an assemblyscript port of fft.js by Fedor Indutny +// which is licensed under the MIT license +// ported by Hazel Atkinson 2024 + +/* +Copyright (c) 2021 Fedor Indutny + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +// this is implemented with 32 bit floats for greater SIMD potential. + +export class FFT { + size: i32; + _csize: i32; + table: Float32Array; + _width: i32; + _bitrev: Float32Array; + _out: Float32Array | null; + _data: Float32Array | null; + _inv: i32; + + constructor(size: i32) { + this.size = size; + if (size <= 1 || (size & (size - 1)) !== 0) + throw new Error("FFT size must be a power of 2 and bigger than 1"); + + this._csize = size << 1; + + const table = new Float32Array(this.size * 2); + for (let i = 0; i < table.length; i += 2) { + const angle = (Math.PI * i) / this.size; + table[i] = Math.cos(angle); + table[i + 1] = -Math.sin(angle); + } + this.table = table; + + let power = 0; + for (let t = 0; this.size > t; t <<= 1) power++; + + // Calculate initial step's width: + // * If we are full radix-4 - it is 2x smaller to give inital len=8 + // * Otherwise it is the same as `power` to give len=4 + this._width = power % 2 === 0 ? power - 1 : power; + + this._bitrev = new Float32Array(1 << this._width); + for (let j = 0; j < this._bitrev.length; j++) + for (let shift = 0; shift < this._width; shift += 2) { + const revShift = this._width - shift - 2; + this._bitrev[j] |= ((j >>> shift) & 3) << revShift; + } + + this._out = null; + this._data = null; + this._inv = 0; + } + + /** create a complex array of the size needed for this FFT */ + createComplexArray(): Float32Array { + const res = new Float32Array(this._csize); + } +} + +/** get only the real values from an interleaved complex array */ +export function fromComplexArray(complex: Float32Array): Float32Array { + const inPtr = complex.dataStart; // UNSAFE + const res = new Float32Array(complex.length >>> 1); + const outPtr = res.dataStart; // UNSAFE + + // all inputs must be %2, but as we read 4 at a time, we must be careful not to OOB + const willOverread = complex.length % 4 !== 0; + const upperBound = (res.length >>> 1) - (willOverread ? 1 : 0); + + for (let i = 0; i < upperBound; i++) { + const comp = v128.load(inPtr + i * 16); + // comp contains [a; b; c; d] f32s, we want [a; c] + const swiz = v128.swizzle(comp, v128(0, 1, 2, 3, 8, 9, 10, 11, 0, 0, 0, 0, 0, 0, 0, 0)); + // store the first 64 bits (two floats) to the arr + v128.store64_lane(outPtr + i * 8, swiz, 0); + } + // handle last float separately to avoid reading out of bounds + if (willOverread) { + const i = res.length - 1; + res[i] = complex[2 * i]; + } + + return res; +} + +/* +FFT.prototype.createComplexArray = function createComplexArray() { + const res = new Array(this._csize); + for (var i = 0; i < res.length; i++) + res[i] = 0; + return res; +}; + +FFT.prototype.toComplexArray = function toComplexArray(input, storage) { + var res = storage || this.createComplexArray(); + for (var i = 0; i < res.length; i += 2) { + res[i] = input[i >>> 1]; + res[i + 1] = 0; + } + return res; +}; + +FFT.prototype.completeSpectrum = function completeSpectrum(spectrum) { + var size = this._csize; + var half = size >>> 1; + for (var i = 2; i < half; i += 2) { + spectrum[size - i] = spectrum[i]; + spectrum[size - i + 1] = -spectrum[i + 1]; + } +}; + +FFT.prototype.transform = function transform(out, data) { + if (out === data) + throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 0; + this._transform4(); + this._out = null; + this._data = null; +}; + +FFT.prototype.realTransform = function realTransform(out, data) { + if (out === data) + throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 0; + this._realTransform4(); + this._out = null; + this._data = null; +}; + +FFT.prototype.inverseTransform = function inverseTransform(out, data) { + if (out === data) + throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 1; + this._transform4(); + for (var i = 0; i < out.length; i++) + out[i] /= this.size; + this._out = null; + this._data = null; +}; + +// radix-4 implementation +// +// NOTE: Uses of `var` are intentional for older V8 version that do not +// support both `let compound assignments` and `const phi` +FFT.prototype._transform4 = function _transform4() { + var out = this._out; + var size = this._csize; + + // Initial step (permute and transform) + var width = this._width; + var step = 1 << width; + var len = (size / step) << 1; + + var outOff; + var t; + var bitrev = this._bitrev; + if (len === 4) { + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleTransform2(outOff, off, step); + } + } else { + // len === 8 + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleTransform4(outOff, off, step); + } + } + + // Loop through steps in decreasing order + var inv = this._inv ? -1 : 1; + var table = this.table; + for (step >>= 2; step >= 2; step >>= 2) { + len = (size / step) << 1; + var quarterLen = len >>> 2; + + // Loop through offsets in the data + for (outOff = 0; outOff < size; outOff += len) { + // Full case + var limit = outOff + quarterLen; + for (var i = outOff, k = 0; i < limit; i += 2, k += step) { + const A = i; + const B = A + quarterLen; + const C = B + quarterLen; + const D = C + quarterLen; + + // Original values + const Ar = out[A]; + const Ai = out[A + 1]; + const Br = out[B]; + const Bi = out[B + 1]; + const Cr = out[C]; + const Ci = out[C + 1]; + const Dr = out[D]; + const Di = out[D + 1]; + + // Middle values + const MAr = Ar; + const MAi = Ai; + + const tableBr = table[k]; + const tableBi = inv * table[k + 1]; + const MBr = Br * tableBr - Bi * tableBi; + const MBi = Br * tableBi + Bi * tableBr; + + const tableCr = table[2 * k]; + const tableCi = inv * table[2 * k + 1]; + const MCr = Cr * tableCr - Ci * tableCi; + const MCi = Cr * tableCi + Ci * tableCr; + + const tableDr = table[3 * k]; + const tableDi = inv * table[3 * k + 1]; + const MDr = Dr * tableDr - Di * tableDi; + const MDi = Dr * tableDi + Di * tableDr; + + // Pre-Final values + const T0r = MAr + MCr; + const T0i = MAi + MCi; + const T1r = MAr - MCr; + const T1i = MAi - MCi; + const T2r = MBr + MDr; + const T2i = MBi + MDi; + const T3r = inv * (MBr - MDr); + const T3i = inv * (MBi - MDi); + + // Final values + const FAr = T0r + T2r; + const FAi = T0i + T2i; + + const FCr = T0r - T2r; + const FCi = T0i - T2i; + + const FBr = T1r + T3i; + const FBi = T1i - T3r; + + const FDr = T1r - T3i; + const FDi = T1i + T3r; + + out[A] = FAr; + out[A + 1] = FAi; + out[B] = FBr; + out[B + 1] = FBi; + out[C] = FCr; + out[C + 1] = FCi; + out[D] = FDr; + out[D + 1] = FDi; + } + } + } +}; + +// radix-2 implementation +// +// NOTE: Only called for len=4 +FFT.prototype._singleTransform2 = function _singleTransform2(outOff, off, + step) { + const out = this._out; + const data = this._data; + + const evenR = data[off]; + const evenI = data[off + 1]; + const oddR = data[off + step]; + const oddI = data[off + step + 1]; + + const leftR = evenR + oddR; + const leftI = evenI + oddI; + const rightR = evenR - oddR; + const rightI = evenI - oddI; + + out[outOff] = leftR; + out[outOff + 1] = leftI; + out[outOff + 2] = rightR; + out[outOff + 3] = rightI; +}; + +// radix-4 +// +// NOTE: Only called for len=8 +FFT.prototype._singleTransform4 = function _singleTransform4(outOff, off, + step) { + const out = this._out; + const data = this._data; + const inv = this._inv ? -1 : 1; + const step2 = step * 2; + const step3 = step * 3; + + // Original values + const Ar = data[off]; + const Ai = data[off + 1]; + const Br = data[off + step]; + const Bi = data[off + step + 1]; + const Cr = data[off + step2]; + const Ci = data[off + step2 + 1]; + const Dr = data[off + step3]; + const Di = data[off + step3 + 1]; + + // Pre-Final values + const T0r = Ar + Cr; + const T0i = Ai + Ci; + const T1r = Ar - Cr; + const T1i = Ai - Ci; + const T2r = Br + Dr; + const T2i = Bi + Di; + const T3r = inv * (Br - Dr); + const T3i = inv * (Bi - Di); + + // Final values + const FAr = T0r + T2r; + const FAi = T0i + T2i; + + const FBr = T1r + T3i; + const FBi = T1i - T3r; + + const FCr = T0r - T2r; + const FCi = T0i - T2i; + + const FDr = T1r - T3i; + const FDi = T1i + T3r; + + out[outOff] = FAr; + out[outOff + 1] = FAi; + out[outOff + 2] = FBr; + out[outOff + 3] = FBi; + out[outOff + 4] = FCr; + out[outOff + 5] = FCi; + out[outOff + 6] = FDr; + out[outOff + 7] = FDi; +}; + +// Real input radix-4 implementation +FFT.prototype._realTransform4 = function _realTransform4() { + var out = this._out; + var size = this._csize; + + // Initial step (permute and transform) + var width = this._width; + var step = 1 << width; + var len = (size / step) << 1; + + var outOff; + var t; + var bitrev = this._bitrev; + if (len === 4) { + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform2(outOff, off >>> 1, step >>> 1); + } + } else { + // len === 8 + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform4(outOff, off >>> 1, step >>> 1); + } + } + + // Loop through steps in decreasing order + var inv = this._inv ? -1 : 1; + var table = this.table; + for (step >>= 2; step >= 2; step >>= 2) { + len = (size / step) << 1; + var halfLen = len >>> 1; + var quarterLen = halfLen >>> 1; + var hquarterLen = quarterLen >>> 1; + + // Loop through offsets in the data + for (outOff = 0; outOff < size; outOff += len) { + for (var i = 0, k = 0; i <= hquarterLen; i += 2, k += step) { + var A = outOff + i; + var B = A + quarterLen; + var C = B + quarterLen; + var D = C + quarterLen; + + // Original values + var Ar = out[A]; + var Ai = out[A + 1]; + var Br = out[B]; + var Bi = out[B + 1]; + var Cr = out[C]; + var Ci = out[C + 1]; + var Dr = out[D]; + var Di = out[D + 1]; + + // Middle values + var MAr = Ar; + var MAi = Ai; + + var tableBr = table[k]; + var tableBi = inv * table[k + 1]; + var MBr = Br * tableBr - Bi * tableBi; + var MBi = Br * tableBi + Bi * tableBr; + + var tableCr = table[2 * k]; + var tableCi = inv * table[2 * k + 1]; + var MCr = Cr * tableCr - Ci * tableCi; + var MCi = Cr * tableCi + Ci * tableCr; + + var tableDr = table[3 * k]; + var tableDi = inv * table[3 * k + 1]; + var MDr = Dr * tableDr - Di * tableDi; + var MDi = Dr * tableDi + Di * tableDr; + + // Pre-Final values + var T0r = MAr + MCr; + var T0i = MAi + MCi; + var T1r = MAr - MCr; + var T1i = MAi - MCi; + var T2r = MBr + MDr; + var T2i = MBi + MDi; + var T3r = inv * (MBr - MDr); + var T3i = inv * (MBi - MDi); + + // Final values + var FAr = T0r + T2r; + var FAi = T0i + T2i; + + var FBr = T1r + T3i; + var FBi = T1i - T3r; + + out[A] = FAr; + out[A + 1] = FAi; + out[B] = FBr; + out[B + 1] = FBi; + + // Output final middle point + if (i === 0) { + var FCr = T0r - T2r; + var FCi = T0i - T2i; + out[C] = FCr; + out[C + 1] = FCi; + continue; + } + + // Do not overwrite ourselves + if (i === hquarterLen) + continue; + + // In the flipped case: + // MAi = -MAi + // MBr=-MBi, MBi=-MBr + // MCr=-MCr + // MDr=MDi, MDi=MDr + var ST0r = T1r; + var ST0i = -T1i; + var ST1r = T0r; + var ST1i = -T0i; + var ST2r = -inv * T3i; + var ST2i = -inv * T3r; + var ST3r = -inv * T2i; + var ST3i = -inv * T2r; + + var SFAr = ST0r + ST2r; + var SFAi = ST0i + ST2i; + + var SFBr = ST1r + ST3i; + var SFBi = ST1i - ST3r; + + var SA = outOff + quarterLen - i; + var SB = outOff + halfLen - i; + + out[SA] = SFAr; + out[SA + 1] = SFAi; + out[SB] = SFBr; + out[SB + 1] = SFBi; + } + } + } +}; + +// radix-2 implementation +// +// NOTE: Only called for len=4 +FFT.prototype._singleRealTransform2 = function _singleRealTransform2(outOff, + off, + step) { + const out = this._out; + const data = this._data; + + const evenR = data[off]; + const oddR = data[off + step]; + + const leftR = evenR + oddR; + const rightR = evenR - oddR; + + out[outOff] = leftR; + out[outOff + 1] = 0; + out[outOff + 2] = rightR; + out[outOff + 3] = 0; +}; + +// radix-4 +// +// NOTE: Only called for len=8 +FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff, + off, + step) { + const out = this._out; + const data = this._data; + const inv = this._inv ? -1 : 1; + const step2 = step * 2; + const step3 = step * 3; + + // Original values + const Ar = data[off]; + const Br = data[off + step]; + const Cr = data[off + step2]; + const Dr = data[off + step3]; + + // Pre-Final values + const T0r = Ar + Cr; + const T1r = Ar - Cr; + const T2r = Br + Dr; + const T3r = inv * (Br - Dr); + + // Final values + const FAr = T0r + T2r; + + const FBr = T1r; + const FBi = -T3r; + + const FCr = T0r - T2r; + + const FDr = T1r; + const FDi = T3r; + + out[outOff] = FAr; + out[outOff + 1] = 0; + out[outOff + 2] = FBr; + out[outOff + 3] = FBi; + out[outOff + 4] = FCr; + out[outOff + 5] = 0; + out[outOff + 6] = FDr; + out[outOff + 7] = FDi; +}; +*/ diff --git a/clients/web/src/dsp-asm/assembly/index.ts b/clients/web/src/dsp-asm/assembly/index.ts new file mode 100644 index 0000000..a3d55a3 --- /dev/null +++ b/clients/web/src/dsp-asm/assembly/index.ts @@ -0,0 +1,27 @@ +export function downscale(buf: Float32Array, size: i32): Float32Array { + const fchunkSz = Math.ceil(buf.length / size); + const nChunks = Math.ceil(buf.length / fchunkSz); + const chunkSz = fchunkSz; + + const res = new Float32Array(nChunks); + for (let ci = 0; ci < nChunks; ci++) { + let max: f64 = 0; + for (let i = 1; i < chunkSz; i++) { + max = Math.max(buf[i + ci * chunkSz], max); + } + + res[ci] = max; + } + + return res; +} + +export * from "./fft"; + +function fft(buf: Float32Array, start: i32, end: i32, pad: i32): void { + if (start === -1) start = 0; + if (end === -1) end = buf.length; + if (pad === -1) pad = 0; + + const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); +} diff --git a/clients/web/src/dsp-asm/assembly/tsconfig.json b/clients/web/src/dsp-asm/assembly/tsconfig.json new file mode 100644 index 0000000..998f782 --- /dev/null +++ b/clients/web/src/dsp-asm/assembly/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../node_modules/.pnpm/assemblyscript@0.27.27/node_modules/assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/clients/web/src/dsp-asm/build/.gitignore b/clients/web/src/dsp-asm/build/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/clients/web/src/dsp-asm/build/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/clients/web/src/dsp-asm/package.json b/clients/web/src/dsp-asm/package.json new file mode 100644 index 0000000..6f59da4 --- /dev/null +++ b/clients/web/src/dsp-asm/package.json @@ -0,0 +1,20 @@ +{ + "scripts": { + "test": "pnpm run asbuild:release && deno test tests/index.ts --allow-read", + "asbuild:debug": "asc assembly/index.ts --target debug", + "asbuild:release": "asc assembly/index.ts --target release", + "asbuild": "pnpm run asbuild:debug && pnpm run asbuild:release", + "start": "npx serve ." + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "assemblyscript": "^0.27.27" + }, + "type": "module", + "exports": { + ".": { + "import": "./build/release.js", + "types": "./build/release.d.ts" + } + } +} diff --git a/clients/web/src/dsp-asm/pnpm-lock.yaml b/clients/web/src/dsp-asm/pnpm-lock.yaml new file mode 100644 index 0000000..984ea05 --- /dev/null +++ b/clients/web/src/dsp-asm/pnpm-lock.yaml @@ -0,0 +1,1294 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 + assemblyscript: + specifier: ^0.27.27 + version: 0.27.27 + +packages: + + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@babel/code-frame@7.24.7: + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.0.1 + dev: true + + /@babel/compat-data@7.24.7: + resolution: {integrity: sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.24.7: + resolution: {integrity: sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.24.7 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) + '@babel/helpers': 7.24.7 + '@babel/parser': 7.24.7 + '@babel/template': 7.24.7 + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.24.7: + resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: true + + /@babel/helper-compilation-targets@7.24.7: + resolution: {integrity: sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.7 + '@babel/helper-validator-option': 7.24.7 + browserslist: 4.23.1 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-environment-visitor@7.24.7: + resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-function-name@7.24.7: + resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-hoist-variables@7.24.7: + resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-module-imports@7.24.7: + resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-module-transforms@7.24.7(@babel/core@7.24.7): + resolution: {integrity: sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-plugin-utils@7.24.7: + resolution: {integrity: sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-simple-access@7.24.7: + resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-split-export-declaration@7.24.7: + resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-string-parser@7.24.7: + resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.24.7: + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.24.7: + resolution: {integrity: sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.24.7: + resolution: {integrity: sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.7 + dev: true + + /@babel/highlight@7.24.7: + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.1 + dev: true + + /@babel/parser@7.24.7: + resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.24.7): + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.24.7): + resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/template@7.24.7: + resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/parser': 7.24.7 + '@babel/types': 7.24.7 + dev: true + + /@babel/traverse@7.24.7: + resolution: {integrity: sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-hoist-variables': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/parser': 7.24.7 + '@babel/types': 7.24.7 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types@7.24.7: + resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + dev: true + + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + dev: true + + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + + /@jest/environment@29.7.0: + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.14.2 + jest-mock: 29.7.0 + dev: true + + /@jest/expect-utils@29.7.0: + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.6.3 + dev: true + + /@jest/expect@29.7.0: + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/fake-timers@29.7.0: + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.14.2 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: true + + /@jest/globals@29.7.0: + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + dev: true + + /@jest/transform@29.7.0: + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.24.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.7 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.14.2 + '@types/yargs': 17.0.32 + chalk: 4.1.2 + dev: true + + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + dev: true + + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.1 + dev: true + + /@types/graceful-fs@4.1.9: + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + dependencies: + '@types/node': 20.14.2 + dev: true + + /@types/istanbul-lib-coverage@2.0.6: + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + dev: true + + /@types/istanbul-lib-report@3.0.3: + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + dev: true + + /@types/istanbul-reports@3.0.4: + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + dependencies: + '@types/istanbul-lib-report': 3.0.3 + dev: true + + /@types/node@20.14.2: + resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + dependencies: + undici-types: 5.26.5 + dev: true + + /@types/stack-utils@2.0.3: + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + dev: true + + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + dev: true + + /@types/yargs@17.0.32: + resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + dependencies: + '@types/yargs-parser': 21.0.3 + dev: true + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /assemblyscript@0.27.27: + resolution: {integrity: sha512-z4ijXsjjk3uespEeCWpO1K2GQySc6bn+LL5dL0tsC2VXNYKFnKDmAh3wefcKazxXHFVhYlxqNfyv96ajaQyINQ==} + engines: {node: '>=16', npm: '>=7'} + hasBin: true + dependencies: + binaryen: 116.0.0-nightly.20240114 + long: 5.2.3 + dev: true + + /babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.24.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.7): + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7) + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binaryen@116.0.0-nightly.20240114: + resolution: {integrity: sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==} + hasBin: true + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.1.1 + dev: true + + /browserslist@4.23.1: + resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001629 + electron-to-chromium: 1.4.796 + node-releases: 2.0.14 + update-browserslist-db: 1.0.16(browserslist@4.23.1) + dev: true + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + dev: true + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /caniuse-lite@1.0.30001629: + resolution: {integrity: sha512-c3dl911slnQhmxUIT4HhYzT7wnBK/XYpGnYLOj4nJBaRiw52Ibe7YxlDaAeRECvA786zCuExhxIUJ2K7nHMrBw==} + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + dev: true + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /electron-to-chromium@1.4.796: + resolution: {integrity: sha512-NglN/xprcM+SHD2XCli4oC6bWe6kHoytcyLKCWXmRL854F0qhPhaYgUswUsglnPxYaNQIg2uMY4BvaomIf3kLA==} + dev: true + + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + dev: true + + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + dev: true + + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.24.7 + '@babel/parser': 7.24.7 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: true + + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.14.2 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.7 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: true + + /jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.24.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.7 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: true + + /jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.14.2 + jest-util: 29.7.0 + dev: true + + /jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.24.7 + '@babel/generator': 7.24.7 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.7) + '@babel/types': 7.24.7 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.14.2 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + dev: true + + /jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 20.14.2 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: true + + /long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + dev: true + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + dev: true + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + dev: true + + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: true + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + dev: true + + /react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + dev: true + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + dev: true + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + dev: true + + /semver@7.6.2: + resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + dev: true + + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + dev: true + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + dev: true + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + + /update-browserslist-db@1.0.16(browserslist@4.23.1): + resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.1 + escalade: 3.1.2 + picocolors: 1.0.1 + dev: true + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true diff --git a/clients/web/src/dsp-asm/tests/index.ts b/clients/web/src/dsp-asm/tests/index.ts new file mode 100644 index 0000000..f43564d --- /dev/null +++ b/clients/web/src/dsp-asm/tests/index.ts @@ -0,0 +1,26 @@ +// @ts-ignore lmao it doesnt like deno + +import {assertEquals} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { fromComplexArray } from "../build/release.js"; + +Deno.test("SIMD FFT", async (t) => { + await t.step("#fromComplexArray", async (t) => { + + await t.step("should work for %4 length arrays", () => { + const input = new Float32Array([ + 1, 6, 7, 4, 8, 5, 8, 4, 2, 8, 5, 8, + ]); + const expectedOut = [1, 7, 8, 8, 2, 5]; + assertEquals([...fromComplexArray(input)], expectedOut); + }) + + await t.step("should work for %2 but not %4 length arrays", () => { + const input = new Float32Array([ + 1, 6, 7, 4, 8, 5, 8, 4, 2, 8, + ]); + const expectedOut = [1, 7, 8, 8, 2]; + assertEquals([...fromComplexArray(input)], expectedOut); + }) + }); +}); + diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 4c395ae..a51467f 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -9,6 +9,10 @@ import transformerVariantGroup from "@unocss/transformer-variant-group"; import presetWebFonts from "@unocss/preset-web-fonts"; import transformerDirectives from "@unocss/transformer-directives"; +// @ts-expect-error this library has typings, but did not add them to exports {} +// your IDE should pick types up fine, but TSC will complain. +import asmScript from "vite-plugin-assemblyscript-asc"; + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ @@ -26,6 +30,7 @@ export default defineConfig({ safelist: ["underline"], }), vue(), + asmScript({ projectRoot: "src/dsp-asm" }), ], resolve: { alias: { From 19ed89d21e0b3e8cee651d3f5e59b9c960d4b81b Mon Sep 17 00:00:00 2001 From: yellowsink Date: Sun, 9 Jun 2024 13:15:38 +0100 Subject: [PATCH 08/29] [vis] [wasm] implement more fft with simd --- clients/web/src/dsp-asm/assembly/fft.ts | 80 ++++++++++++++++++------- clients/web/src/dsp-asm/tests/index.ts | 42 +++++++++---- 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/clients/web/src/dsp-asm/assembly/fft.ts b/clients/web/src/dsp-asm/assembly/fft.ts index a6b9311..0fcdb04 100644 --- a/clients/web/src/dsp-asm/assembly/fft.ts +++ b/clients/web/src/dsp-asm/assembly/fft.ts @@ -58,10 +58,23 @@ export class FFT { this._data = null; this._inv = 0; } - + /** create a complex array of the size needed for this FFT */ createComplexArray(): Float32Array { const res = new Float32Array(this._csize); + const ptr = res.dataStart; // UNSAFE + + // csize is always a multiple of 4, so this will line up perfectly + for (let i = 0; i < (this._csize / 4); i++) + v128.store(ptr + i * 16, f32x4.splat(0)); + + return res; + } + + /** computes the conjugates of the spectrum to complete it */ + completeSpectrum(): void { + // note that csize is always %4 + // TODO } } @@ -70,20 +83,26 @@ export function fromComplexArray(complex: Float32Array): Float32Array { const inPtr = complex.dataStart; // UNSAFE const res = new Float32Array(complex.length >>> 1); const outPtr = res.dataStart; // UNSAFE - - // all inputs must be %2, but as we read 4 at a time, we must be careful not to OOB - const willOverread = complex.length % 4 !== 0; - const upperBound = (res.length >>> 1) - (willOverread ? 1 : 0); - - for (let i = 0; i < upperBound; i++) { + + if (complex.length % 2 !== 0) throw new Error("complex input must have an even length"); + if (complex.length === 0) throw new Error("complex input must not be empty"); + + for (let i = 0; i < (res.length >>> 1); i++) { const comp = v128.load(inPtr + i * 16); // comp contains [a; b; c; d] f32s, we want [a; c] - const swiz = v128.swizzle(comp, v128(0, 1, 2, 3, 8, 9, 10, 11, 0, 0, 0, 0, 0, 0, 0, 0)); + const swiz = v128.swizzle(comp, v128( + 0, 1, 2, 3, + 8, 9, 10, 11, + 0, 0, 0, 0, + 0, 0, 0, 0, + )); // store the first 64 bits (two floats) to the arr v128.store64_lane(outPtr + i * 8, swiz, 0); } - // handle last float separately to avoid reading out of bounds - if (willOverread) { + + // all inputs must be %2, but as we read 4 at a time, we must be careful not to miss a last value + // we dont oob as >>> rounds down. + if (complex.length % 4 !== 0) { const i = res.length - 1; res[i] = complex[2 * i]; } @@ -91,22 +110,37 @@ export function fromComplexArray(complex: Float32Array): Float32Array { return res; } -/* -FFT.prototype.createComplexArray = function createComplexArray() { - const res = new Array(this._csize); - for (var i = 0; i < res.length; i++) - res[i] = 0; - return res; -}; +/** convert an array of real values to an interleaved complex array */ +export function toComplexArray(real: Float32Array): Float32Array { + const inPtr = real.dataStart; // UNSAFE + const res = new Float32Array(real.length * 2); + const outPtr = res.dataStart; // UNSAFE -FFT.prototype.toComplexArray = function toComplexArray(input, storage) { - var res = storage || this.createComplexArray(); - for (var i = 0; i < res.length; i += 2) { - res[i] = input[i >>> 1]; - res[i + 1] = 0; + // for every pair of real inputs + for (let i = 0; i < (real.length >>> 1); i++) { + // read two real f32s into a vector [a; b; 0; 0] + const reals = v128.load64_zero(inPtr + i * 8); + // to [a; 0; b; 0] + const swiz = v128.swizzle(reals, v128( + 0, 1, 2, 3, // lane 1 + 8, 9, 10, 11, // lane 3 + 4, 5, 6, 7, // lane 2 + 8, 9, 10, 11, // lane 3 again + )); + // write this into the output + v128.store(outPtr + i * 16, swiz); + } + // if theres an odd number of reals, the last one will get left + if (real.length % 2) { + const i = real.length - 1; + res[i * 2] = real[i]; } + return res; -}; +} + +/* + FFT.prototype.completeSpectrum = function completeSpectrum(spectrum) { var size = this._csize; diff --git a/clients/web/src/dsp-asm/tests/index.ts b/clients/web/src/dsp-asm/tests/index.ts index f43564d..40e0b74 100644 --- a/clients/web/src/dsp-asm/tests/index.ts +++ b/clients/web/src/dsp-asm/tests/index.ts @@ -1,26 +1,42 @@ -// @ts-ignore lmao it doesnt like deno +// @ts-ignore lol IDE doesnt like deno -import {assertEquals} from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { fromComplexArray } from "../build/release.js"; +import { assertEquals, assertThrows } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { fromComplexArray, toComplexArray } from "../build/release.js"; Deno.test("SIMD FFT", async (t) => { await t.step("#fromComplexArray", async (t) => { - await t.step("should work for %4 length arrays", () => { - const input = new Float32Array([ - 1, 6, 7, 4, 8, 5, 8, 4, 2, 8, 5, 8, - ]); + const input = new Float32Array([1, 6, 7, 4, 8, 5, 8, 4, 2, 8, 5, 8]); const expectedOut = [1, 7, 8, 8, 2, 5]; assertEquals([...fromComplexArray(input)], expectedOut); - }) - + }); + await t.step("should work for %2 but not %4 length arrays", () => { - const input = new Float32Array([ - 1, 6, 7, 4, 8, 5, 8, 4, 2, 8, - ]); + const input = new Float32Array([1, 6, 7, 4, 8, 5, 8, 4, 2, 8]); const expectedOut = [1, 7, 8, 8, 2]; assertEquals([...fromComplexArray(input)], expectedOut); + }); + + await t.step("should throw on odd input size", () => { + assertThrows(() => fromComplexArray(new Float32Array([1, 2, 3]))); + }); + + await t.step("should throw on empty array", () => { + assertThrows(() => fromComplexArray(new Float32Array([]))); }) }); -}); + + await t.step("#toComplexArray", async (t) => { + await t.step("should work for %2 arrays", () => { + const input = new Float32Array([1, 2, 3, 4]); + const expectedOut = [1, 0, 2, 0, 3, 0, 4, 0]; + assertEquals([...toComplexArray(input)], expectedOut); + }) + await t.step("should work for not-%2 arrays", () => { + const input = new Float32Array([1, 2, 3, 4, 5]); + const expectedOut = [1, 0, 2, 0, 3, 0, 4, 0, 5, 0]; + assertEquals([...toComplexArray(input)], expectedOut); + }) + }) +}); From ba5637e8692cd7c9ddf0881677fadbb3a0708bb0 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sun, 9 Jun 2024 15:37:34 +0100 Subject: [PATCH 09/29] [vis] [wasm] impl fft --- clients/web/package.json | 1 - clients/web/pnpm-lock.yaml | 7 - clients/web/src/analysisWorker.js | 20 +- clients/web/src/dsp-asm/assembly/fft.ts | 759 +++++++--------------- clients/web/src/dsp-asm/assembly/index.ts | 25 +- clients/web/src/dsp-asm/tests/index.ts | 71 +- 6 files changed, 285 insertions(+), 598 deletions(-) diff --git a/clients/web/package.json b/clients/web/package.json index 800b083..e95b254 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -18,7 +18,6 @@ "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", "can-autoplay": "^3.0.2", - "fft.js": "^4.0.4", "unocss": "^0.57.1", "vue": "^3.3.7" }, diff --git a/clients/web/pnpm-lock.yaml b/clients/web/pnpm-lock.yaml index 686c12a..6a8bcd1 100644 --- a/clients/web/pnpm-lock.yaml +++ b/clients/web/pnpm-lock.yaml @@ -26,9 +26,6 @@ dependencies: can-autoplay: specifier: ^3.0.2 version: 3.0.2 - fft.js: - specifier: ^4.0.4 - version: 4.0.4 unocss: specifier: ^0.57.1 version: 0.57.1(postcss@8.4.31)(vite@4.5.0) @@ -1993,10 +1990,6 @@ packages: tough-cookie: 4.1.3 dev: false - /fft.js@4.0.4: - resolution: {integrity: sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==} - dev: false - /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index eeb8ac4..d078713 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,5 +1,4 @@ -import { downscale as wasmDS } from "./dsp-asm/assembly/index"; -import FFT from "fft.js"; +import { downscale as wasmDS, fft as wasmFFT } from "./dsp-asm/assembly/index"; // avoid uploading the same buffer a ton of times let currentBuffer = new Float32Array(0); @@ -120,20 +119,11 @@ function sbcMax(buf, start, end) { * @returns {Float32Array} */ function fft(buf, start, end, pad) { buf ??= currentBuffer; - start ??= 0; - end ??= buf.length; - pad ??= 0; - - // min power of 2 that is <= buf.length - const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); - - buf = [...buf.slice(start, end), ...Array(size - (end - start)).fill(0)]; - - const fft = new FFT(size); - const output = fft.createComplexArray(); - fft.realTransform(output, buf); + start ??= -1; + end ??= -1; + pad ??= -1; - return new Float32Array(output.slice(0, size)); + return wasmFFT(buf, start, end, pad); } onmessage = (e) => { diff --git a/clients/web/src/dsp-asm/assembly/fft.ts b/clients/web/src/dsp-asm/assembly/fft.ts index 0fcdb04..e07e25e 100644 --- a/clients/web/src/dsp-asm/assembly/fft.ts +++ b/clients/web/src/dsp-asm/assembly/fft.ts @@ -1,6 +1,7 @@ -// an assemblyscript port of fft.js by Fedor Indutny +// a PARTIAL assemblyscript port of fft.js by Fedor Indutny // which is licensed under the MIT license // ported by Hazel Atkinson 2024 +// only implements realTransform. /* Copyright (c) 2021 Fedor Indutny @@ -12,574 +13,268 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// this is implemented with 32 bit floats for greater SIMD potential. - export class FFT { size: i32; - _csize: i32; + private _csize: i32; table: Float32Array; - _width: i32; - _bitrev: Float32Array; - _out: Float32Array | null; - _data: Float32Array | null; - _inv: i32; + private _width: i32; + private _bitrev: Int32Array; + private _out: Float32Array | null; + private _data: Float32Array | null; + private _inv: i32; constructor(size: i32) { + this._out = null; + this._data = null; + this._inv = 0; + this.size = size; - if (size <= 1 || (size & (size - 1)) !== 0) + if (size <= 1 || (size & (size - 1)) !== 0) { throw new Error("FFT size must be a power of 2 and bigger than 1"); + } this._csize = size << 1; - const table = new Float32Array(this.size * 2); + const table = new Float32Array(size * 2); for (let i = 0; i < table.length; i += 2) { - const angle = (Math.PI * i) / this.size; - table[i] = Math.cos(angle); - table[i + 1] = -Math.sin(angle); + const angle = (Mathf.PI * i) / size; + table[i] = Mathf.cos(angle); + table[i + 1] = -Mathf.sin(angle); } this.table = table; - + let power = 0; - for (let t = 0; this.size > t; t <<= 1) power++; + for (let t = 1; t < size; t <<= 1) power++; // Calculate initial step's width: // * If we are full radix-4 - it is 2x smaller to give inital len=8 // * Otherwise it is the same as `power` to give len=4 - this._width = power % 2 === 0 ? power - 1 : power; + const width = power % 2 === 0 ? power - 1 : power; + this._width = width; - this._bitrev = new Float32Array(1 << this._width); - for (let j = 0; j < this._bitrev.length; j++) + this._bitrev = new Int32Array(1 << width); + for (let j = 0; j < this._bitrev.length; j++) { for (let shift = 0; shift < this._width; shift += 2) { const revShift = this._width - shift - 2; this._bitrev[j] |= ((j >>> shift) & 3) << revShift; } - - this._out = null; - this._data = null; - this._inv = 0; + } } - /** create a complex array of the size needed for this FFT */ createComplexArray(): Float32Array { - const res = new Float32Array(this._csize); - const ptr = res.dataStart; // UNSAFE - - // csize is always a multiple of 4, so this will line up perfectly - for (let i = 0; i < (this._csize / 4); i++) - v128.store(ptr + i * 16, f32x4.splat(0)); - - return res; - } - - /** computes the conjugates of the spectrum to complete it */ - completeSpectrum(): void { - // note that csize is always %4 - // TODO + return new Float32Array(this._csize); } -} -/** get only the real values from an interleaved complex array */ -export function fromComplexArray(complex: Float32Array): Float32Array { - const inPtr = complex.dataStart; // UNSAFE - const res = new Float32Array(complex.length >>> 1); - const outPtr = res.dataStart; // UNSAFE - - if (complex.length % 2 !== 0) throw new Error("complex input must have an even length"); - if (complex.length === 0) throw new Error("complex input must not be empty"); - - for (let i = 0; i < (res.length >>> 1); i++) { - const comp = v128.load(inPtr + i * 16); - // comp contains [a; b; c; d] f32s, we want [a; c] - const swiz = v128.swizzle(comp, v128( - 0, 1, 2, 3, - 8, 9, 10, 11, - 0, 0, 0, 0, - 0, 0, 0, 0, - )); - // store the first 64 bits (two floats) to the arr - v128.store64_lane(outPtr + i * 8, swiz, 0); - } + realTransform(out: Float32Array, data: Float32Array): void { + if (out === data) { + throw new Error("Input and output buffers must be different"); + } - // all inputs must be %2, but as we read 4 at a time, we must be careful not to miss a last value - // we dont oob as >>> rounds down. - if (complex.length % 4 !== 0) { - const i = res.length - 1; - res[i] = complex[2 * i]; + this._out = out; + this._data = data; + this._inv = 0; + this._realTransform4(); + this._out = null; + this._data = null; } - return res; -} + // Real input radix-4 implementation + private _realTransform4(): void { + const out = this._out!; + const size = this._csize; + + // Initial step (permute and transform) + const width = this._width; + let step = 1 << width; + let len = (size / step) << 1; + + let outOff: i32; + let t: i32; + const bitrev = this._bitrev; + if (len === 4) { + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform2(outOff, off >>> 1, step >>> 1); + } + } else { + // len === 8 + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform4(outOff, off >>> 1, step >>> 1); + } + } -/** convert an array of real values to an interleaved complex array */ -export function toComplexArray(real: Float32Array): Float32Array { - const inPtr = real.dataStart; // UNSAFE - const res = new Float32Array(real.length * 2); - const outPtr = res.dataStart; // UNSAFE - - // for every pair of real inputs - for (let i = 0; i < (real.length >>> 1); i++) { - // read two real f32s into a vector [a; b; 0; 0] - const reals = v128.load64_zero(inPtr + i * 8); - // to [a; 0; b; 0] - const swiz = v128.swizzle(reals, v128( - 0, 1, 2, 3, // lane 1 - 8, 9, 10, 11, // lane 3 - 4, 5, 6, 7, // lane 2 - 8, 9, 10, 11, // lane 3 again - )); - // write this into the output - v128.store(outPtr + i * 16, swiz); - } - // if theres an odd number of reals, the last one will get left - if (real.length % 2) { - const i = real.length - 1; - res[i * 2] = real[i]; + // Loop through steps in decreasing order + const inv: f32 = this._inv ? -1 : 1; + const table = this.table; + for (step >>= 2; step >= 2; step >>= 2) { + len = (size / step) << 1; + const halfLen = len >>> 1; + const quarterLen = halfLen >>> 1; + const hquarterLen = quarterLen >>> 1; + + // Loop through offsets in the data + for (outOff = 0; outOff < size; outOff += len) { + for (let i = 0, k = 0; i <= hquarterLen; i += 2, k += step) { + const A = outOff + i; + const B = A + quarterLen; + const C = B + quarterLen; + const D = C + quarterLen; + + // Original values + const Ar = out[A]; + const Ai = out[A + 1]; + const Br = out[B]; + const Bi = out[B + 1]; + const Cr = out[C]; + const Ci = out[C + 1]; + const Dr = out[D]; + const Di = out[D + 1]; + + // Middle values + const MAr = Ar; + const MAi = Ai; + + const tableBr = table[k]; + const tableBi = inv * table[k + 1]; + const MBr = Br * tableBr - Bi * tableBi; + const MBi = Br * tableBi + Bi * tableBr; + + const tableCr = table[2 * k]; + const tableCi = inv * table[2 * k + 1]; + const MCr = Cr * tableCr - Ci * tableCi; + const MCi = Cr * tableCi + Ci * tableCr; + + const tableDr = table[3 * k]; + const tableDi = inv * table[3 * k + 1]; + const MDr = Dr * tableDr - Di * tableDi; + const MDi = Dr * tableDi + Di * tableDr; + + // Pre-Final values + const T0r = MAr + MCr; + const T0i = MAi + MCi; + const T1r = MAr - MCr; + const T1i = MAi - MCi; + const T2r = MBr + MDr; + const T2i = MBi + MDi; + const T3r = inv * (MBr - MDr); + const T3i = inv * (MBi - MDi); + + // Final values + const FAr = T0r + T2r; + const FAi = T0i + T2i; + + const FBr = T1r + T3i; + const FBi = T1i - T3r; + + out[A] = FAr; + out[A + 1] = FAi; + out[B] = FBr; + out[B + 1] = FBi; + + // Output final middle point + if (i === 0) { + const FCr = T0r - T2r; + const FCi = T0i - T2i; + out[C] = FCr; + out[C + 1] = FCi; + continue; + } + + // Do not overwrite ourselves + if (i === hquarterLen) { + continue; + } + + // In the flipped case: + // MAi = -MAi + // MBr=-MBi, MBi=-MBr + // MCr=-MCr + // MDr=MDi, MDi=MDr + const ST0r = T1r; + const ST0i = -T1i; + const ST1r = T0r; + const ST1i = -T0i; + const ST2r = -inv * T3i; + const ST2i = -inv * T3r; + const ST3r = -inv * T2i; + const ST3i = -inv * T2r; + + const SFAr = ST0r + ST2r; + const SFAi = ST0i + ST2i; + + const SFBr = ST1r + ST3i; + const SFBi = ST1i - ST3r; + + const SA = outOff + quarterLen - i; + const SB = outOff + halfLen - i; + + out[SA] = SFAr; + out[SA + 1] = SFAi; + out[SB] = SFBr; + out[SB + 1] = SFBi; + } + } + } } - - return res; -} -/* + // radix-2 implementation + // NOTE: Only called for len=4 + private _singleRealTransform2(outOff: i32, off: i32, step: i32): void { + const out = this._out!; + const data = this._data!; + const evenR = data[off]; + const oddR = data[off + step]; -FFT.prototype.completeSpectrum = function completeSpectrum(spectrum) { - var size = this._csize; - var half = size >>> 1; - for (var i = 2; i < half; i += 2) { - spectrum[size - i] = spectrum[i]; - spectrum[size - i + 1] = -spectrum[i + 1]; - } -}; - -FFT.prototype.transform = function transform(out, data) { - if (out === data) - throw new Error('Input and output buffers must be different'); - - this._out = out; - this._data = data; - this._inv = 0; - this._transform4(); - this._out = null; - this._data = null; -}; - -FFT.prototype.realTransform = function realTransform(out, data) { - if (out === data) - throw new Error('Input and output buffers must be different'); - - this._out = out; - this._data = data; - this._inv = 0; - this._realTransform4(); - this._out = null; - this._data = null; -}; - -FFT.prototype.inverseTransform = function inverseTransform(out, data) { - if (out === data) - throw new Error('Input and output buffers must be different'); - - this._out = out; - this._data = data; - this._inv = 1; - this._transform4(); - for (var i = 0; i < out.length; i++) - out[i] /= this.size; - this._out = null; - this._data = null; -}; - -// radix-4 implementation -// -// NOTE: Uses of `var` are intentional for older V8 version that do not -// support both `let compound assignments` and `const phi` -FFT.prototype._transform4 = function _transform4() { - var out = this._out; - var size = this._csize; - - // Initial step (permute and transform) - var width = this._width; - var step = 1 << width; - var len = (size / step) << 1; - - var outOff; - var t; - var bitrev = this._bitrev; - if (len === 4) { - for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { - const off = bitrev[t]; - this._singleTransform2(outOff, off, step); - } - } else { - // len === 8 - for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { - const off = bitrev[t]; - this._singleTransform4(outOff, off, step); - } - } + const leftR = evenR + oddR; + const rightR = evenR - oddR; - // Loop through steps in decreasing order - var inv = this._inv ? -1 : 1; - var table = this.table; - for (step >>= 2; step >= 2; step >>= 2) { - len = (size / step) << 1; - var quarterLen = len >>> 2; - - // Loop through offsets in the data - for (outOff = 0; outOff < size; outOff += len) { - // Full case - var limit = outOff + quarterLen; - for (var i = outOff, k = 0; i < limit; i += 2, k += step) { - const A = i; - const B = A + quarterLen; - const C = B + quarterLen; - const D = C + quarterLen; - - // Original values - const Ar = out[A]; - const Ai = out[A + 1]; - const Br = out[B]; - const Bi = out[B + 1]; - const Cr = out[C]; - const Ci = out[C + 1]; - const Dr = out[D]; - const Di = out[D + 1]; - - // Middle values - const MAr = Ar; - const MAi = Ai; - - const tableBr = table[k]; - const tableBi = inv * table[k + 1]; - const MBr = Br * tableBr - Bi * tableBi; - const MBi = Br * tableBi + Bi * tableBr; - - const tableCr = table[2 * k]; - const tableCi = inv * table[2 * k + 1]; - const MCr = Cr * tableCr - Ci * tableCi; - const MCi = Cr * tableCi + Ci * tableCr; - - const tableDr = table[3 * k]; - const tableDi = inv * table[3 * k + 1]; - const MDr = Dr * tableDr - Di * tableDi; - const MDi = Dr * tableDi + Di * tableDr; - - // Pre-Final values - const T0r = MAr + MCr; - const T0i = MAi + MCi; - const T1r = MAr - MCr; - const T1i = MAi - MCi; - const T2r = MBr + MDr; - const T2i = MBi + MDi; - const T3r = inv * (MBr - MDr); - const T3i = inv * (MBi - MDi); - - // Final values - const FAr = T0r + T2r; - const FAi = T0i + T2i; - - const FCr = T0r - T2r; - const FCi = T0i - T2i; - - const FBr = T1r + T3i; - const FBi = T1i - T3r; - - const FDr = T1r - T3i; - const FDi = T1i + T3r; - - out[A] = FAr; - out[A + 1] = FAi; - out[B] = FBr; - out[B + 1] = FBi; - out[C] = FCr; - out[C + 1] = FCi; - out[D] = FDr; - out[D + 1] = FDi; - } - } - } -}; - -// radix-2 implementation -// -// NOTE: Only called for len=4 -FFT.prototype._singleTransform2 = function _singleTransform2(outOff, off, - step) { - const out = this._out; - const data = this._data; - - const evenR = data[off]; - const evenI = data[off + 1]; - const oddR = data[off + step]; - const oddI = data[off + step + 1]; - - const leftR = evenR + oddR; - const leftI = evenI + oddI; - const rightR = evenR - oddR; - const rightI = evenI - oddI; - - out[outOff] = leftR; - out[outOff + 1] = leftI; - out[outOff + 2] = rightR; - out[outOff + 3] = rightI; -}; - -// radix-4 -// -// NOTE: Only called for len=8 -FFT.prototype._singleTransform4 = function _singleTransform4(outOff, off, - step) { - const out = this._out; - const data = this._data; - const inv = this._inv ? -1 : 1; - const step2 = step * 2; - const step3 = step * 3; - - // Original values - const Ar = data[off]; - const Ai = data[off + 1]; - const Br = data[off + step]; - const Bi = data[off + step + 1]; - const Cr = data[off + step2]; - const Ci = data[off + step2 + 1]; - const Dr = data[off + step3]; - const Di = data[off + step3 + 1]; - - // Pre-Final values - const T0r = Ar + Cr; - const T0i = Ai + Ci; - const T1r = Ar - Cr; - const T1i = Ai - Ci; - const T2r = Br + Dr; - const T2i = Bi + Di; - const T3r = inv * (Br - Dr); - const T3i = inv * (Bi - Di); - - // Final values - const FAr = T0r + T2r; - const FAi = T0i + T2i; - - const FBr = T1r + T3i; - const FBi = T1i - T3r; - - const FCr = T0r - T2r; - const FCi = T0i - T2i; - - const FDr = T1r - T3i; - const FDi = T1i + T3r; - - out[outOff] = FAr; - out[outOff + 1] = FAi; - out[outOff + 2] = FBr; - out[outOff + 3] = FBi; - out[outOff + 4] = FCr; - out[outOff + 5] = FCi; - out[outOff + 6] = FDr; - out[outOff + 7] = FDi; -}; - -// Real input radix-4 implementation -FFT.prototype._realTransform4 = function _realTransform4() { - var out = this._out; - var size = this._csize; - - // Initial step (permute and transform) - var width = this._width; - var step = 1 << width; - var len = (size / step) << 1; - - var outOff; - var t; - var bitrev = this._bitrev; - if (len === 4) { - for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { - const off = bitrev[t]; - this._singleRealTransform2(outOff, off >>> 1, step >>> 1); - } - } else { - // len === 8 - for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { - const off = bitrev[t]; - this._singleRealTransform4(outOff, off >>> 1, step >>> 1); - } + out[outOff] = leftR; + out[outOff + 1] = 0; + out[outOff + 2] = rightR; + out[outOff + 3] = 0; } - // Loop through steps in decreasing order - var inv = this._inv ? -1 : 1; - var table = this.table; - for (step >>= 2; step >= 2; step >>= 2) { - len = (size / step) << 1; - var halfLen = len >>> 1; - var quarterLen = halfLen >>> 1; - var hquarterLen = quarterLen >>> 1; - - // Loop through offsets in the data - for (outOff = 0; outOff < size; outOff += len) { - for (var i = 0, k = 0; i <= hquarterLen; i += 2, k += step) { - var A = outOff + i; - var B = A + quarterLen; - var C = B + quarterLen; - var D = C + quarterLen; - - // Original values - var Ar = out[A]; - var Ai = out[A + 1]; - var Br = out[B]; - var Bi = out[B + 1]; - var Cr = out[C]; - var Ci = out[C + 1]; - var Dr = out[D]; - var Di = out[D + 1]; - - // Middle values - var MAr = Ar; - var MAi = Ai; - - var tableBr = table[k]; - var tableBi = inv * table[k + 1]; - var MBr = Br * tableBr - Bi * tableBi; - var MBi = Br * tableBi + Bi * tableBr; - - var tableCr = table[2 * k]; - var tableCi = inv * table[2 * k + 1]; - var MCr = Cr * tableCr - Ci * tableCi; - var MCi = Cr * tableCi + Ci * tableCr; - - var tableDr = table[3 * k]; - var tableDi = inv * table[3 * k + 1]; - var MDr = Dr * tableDr - Di * tableDi; - var MDi = Dr * tableDi + Di * tableDr; - - // Pre-Final values - var T0r = MAr + MCr; - var T0i = MAi + MCi; - var T1r = MAr - MCr; - var T1i = MAi - MCi; - var T2r = MBr + MDr; - var T2i = MBi + MDi; - var T3r = inv * (MBr - MDr); - var T3i = inv * (MBi - MDi); - - // Final values - var FAr = T0r + T2r; - var FAi = T0i + T2i; - - var FBr = T1r + T3i; - var FBi = T1i - T3r; - - out[A] = FAr; - out[A + 1] = FAi; - out[B] = FBr; - out[B + 1] = FBi; - - // Output final middle point - if (i === 0) { - var FCr = T0r - T2r; - var FCi = T0i - T2i; - out[C] = FCr; - out[C + 1] = FCi; - continue; - } - - // Do not overwrite ourselves - if (i === hquarterLen) - continue; - - // In the flipped case: - // MAi = -MAi - // MBr=-MBi, MBi=-MBr - // MCr=-MCr - // MDr=MDi, MDi=MDr - var ST0r = T1r; - var ST0i = -T1i; - var ST1r = T0r; - var ST1i = -T0i; - var ST2r = -inv * T3i; - var ST2i = -inv * T3r; - var ST3r = -inv * T2i; - var ST3i = -inv * T2r; - - var SFAr = ST0r + ST2r; - var SFAi = ST0i + ST2i; - - var SFBr = ST1r + ST3i; - var SFBi = ST1i - ST3r; - - var SA = outOff + quarterLen - i; - var SB = outOff + halfLen - i; - - out[SA] = SFAr; - out[SA + 1] = SFAi; - out[SB] = SFBr; - out[SB + 1] = SFBi; - } - } + // radix-4 + // NOTE: Only called for len=8 + private _singleRealTransform4(outOff: i32, off: i32, step: i32): void { + const out = this._out!; + const data = this._data!; + const inv: f32 = this._inv ? -1 : 1; + const step2 = step * 2; + const step3 = step * 3; + + // Original values + const Ar = data[off]; + const Br = data[off + step]; + const Cr = data[off + step2]; + const Dr = data[off + step3]; + + // Pre-Final values + const T0r = Ar + Cr; + const T1r = Ar - Cr; + const T2r = Br + Dr; + const T3r = inv * (Br - Dr); + + // Final values + const FAr = T0r + T2r; + + const FBr = T1r; + const FBi = -T3r; + + const FCr = T0r - T2r; + + const FDr = T1r; + const FDi = T3r; + + out[outOff] = FAr; + out[outOff + 1] = 0; + out[outOff + 2] = FBr; + out[outOff + 3] = FBi; + out[outOff + 4] = FCr; + out[outOff + 5] = 0; + out[outOff + 6] = FDr; + out[outOff + 7] = FDi; } -}; - -// radix-2 implementation -// -// NOTE: Only called for len=4 -FFT.prototype._singleRealTransform2 = function _singleRealTransform2(outOff, - off, - step) { - const out = this._out; - const data = this._data; - - const evenR = data[off]; - const oddR = data[off + step]; - - const leftR = evenR + oddR; - const rightR = evenR - oddR; - - out[outOff] = leftR; - out[outOff + 1] = 0; - out[outOff + 2] = rightR; - out[outOff + 3] = 0; -}; - -// radix-4 -// -// NOTE: Only called for len=8 -FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff, - off, - step) { - const out = this._out; - const data = this._data; - const inv = this._inv ? -1 : 1; - const step2 = step * 2; - const step3 = step * 3; - - // Original values - const Ar = data[off]; - const Br = data[off + step]; - const Cr = data[off + step2]; - const Dr = data[off + step3]; - - // Pre-Final values - const T0r = Ar + Cr; - const T1r = Ar - Cr; - const T2r = Br + Dr; - const T3r = inv * (Br - Dr); - - // Final values - const FAr = T0r + T2r; - - const FBr = T1r; - const FBi = -T3r; - - const FCr = T0r - T2r; - - const FDr = T1r; - const FDi = T3r; - - out[outOff] = FAr; - out[outOff + 1] = 0; - out[outOff + 2] = FBr; - out[outOff + 3] = FBi; - out[outOff + 4] = FCr; - out[outOff + 5] = 0; - out[outOff + 6] = FDr; - out[outOff + 7] = FDi; -}; -*/ +} diff --git a/clients/web/src/dsp-asm/assembly/index.ts b/clients/web/src/dsp-asm/assembly/index.ts index a3d55a3..5e76949 100644 --- a/clients/web/src/dsp-asm/assembly/index.ts +++ b/clients/web/src/dsp-asm/assembly/index.ts @@ -1,3 +1,5 @@ +import { FFT } from "./fft"; + export function downscale(buf: Float32Array, size: i32): Float32Array { const fchunkSz = Math.ceil(buf.length / size); const nChunks = Math.ceil(buf.length / fchunkSz); @@ -16,12 +18,27 @@ export function downscale(buf: Float32Array, size: i32): Float32Array { return res; } -export * from "./fft"; - -function fft(buf: Float32Array, start: i32, end: i32, pad: i32): void { +export function fft(buf: Float32Array, start: i32, end: i32, pad: i32): Float32Array { if (start === -1) start = 0; if (end === -1) end = buf.length; if (pad === -1) pad = 0; - + + // min power of 2 that is <= buf.length const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); + + const fftInput = new Float32Array(size); + + // copy in buffer + memory.copy( + fftInput.dataStart, + buf.dataStart + start * Float32Array.BYTES_PER_ELEMENT, + (end - start) * Float32Array.BYTES_PER_ELEMENT, + ); + + const fft = new FFT(size); + const output = fft.createComplexArray(); + + fft.realTransform(output, fftInput); + + return output.subarray(0, size); } diff --git a/clients/web/src/dsp-asm/tests/index.ts b/clients/web/src/dsp-asm/tests/index.ts index 40e0b74..249e939 100644 --- a/clients/web/src/dsp-asm/tests/index.ts +++ b/clients/web/src/dsp-asm/tests/index.ts @@ -1,42 +1,35 @@ // @ts-ignore lol IDE doesnt like deno -import { assertEquals, assertThrows } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { fromComplexArray, toComplexArray } from "../build/release.js"; - -Deno.test("SIMD FFT", async (t) => { - await t.step("#fromComplexArray", async (t) => { - await t.step("should work for %4 length arrays", () => { - const input = new Float32Array([1, 6, 7, 4, 8, 5, 8, 4, 2, 8, 5, 8]); - const expectedOut = [1, 7, 8, 8, 2, 5]; - assertEquals([...fromComplexArray(input)], expectedOut); - }); - - await t.step("should work for %2 but not %4 length arrays", () => { - const input = new Float32Array([1, 6, 7, 4, 8, 5, 8, 4, 2, 8]); - const expectedOut = [1, 7, 8, 8, 2]; - assertEquals([...fromComplexArray(input)], expectedOut); - }); - - await t.step("should throw on odd input size", () => { - assertThrows(() => fromComplexArray(new Float32Array([1, 2, 3]))); - }); - - await t.step("should throw on empty array", () => { - assertThrows(() => fromComplexArray(new Float32Array([]))); - }) - }); - - await t.step("#toComplexArray", async (t) => { - await t.step("should work for %2 arrays", () => { - const input = new Float32Array([1, 2, 3, 4]); - const expectedOut = [1, 0, 2, 0, 3, 0, 4, 0]; - assertEquals([...toComplexArray(input)], expectedOut); - }) - - await t.step("should work for not-%2 arrays", () => { - const input = new Float32Array([1, 2, 3, 4, 5]); - const expectedOut = [1, 0, 2, 0, 3, 0, 4, 0, 5, 0]; - assertEquals([...toComplexArray(input)], expectedOut); - }) - }) +import { assertAlmostEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import FFT from "npm:fft.js"; +import { fft } from "../build/release.js"; + +function jsfft(buf: Float32Array) { + const end = buf.length; + const start = 0; + + const size = Math.pow(2, Math.ceil(Math.log2(end - start))); + + const input = [ + ...buf.slice(start, end), + ...Array(size - (end - start)).fill(0), + ]; + const fft = new FFT(size); + const output = fft.createComplexArray(); + fft.realTransform(output, input); + + return new Float32Array(output.slice(0, size)); +} + +Deno.test("FFT", () => { + const input = new Float32Array(100); + for (let i = 0; i < input.length; i++) input[i] = Math.random(); + + const expected = jsfft(input); + const actual = fft(input, -1, -1, -1); + + // wasm is using f32 for memory usage reasons + for (let i = 0; i < actual.length; i++) { + assertAlmostEquals(actual[i], expected[i], 5e-6); + } }); From c052d7b93f1bbae1f864faa73417e57b61fea2cf Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sun, 9 Jun 2024 16:07:57 +0100 Subject: [PATCH 10/29] [vis] [wasm] get wasm fully working for downscale and fft --- clients/web/{src => }/dsp-asm/asconfig.json | 2 +- clients/web/{src => }/dsp-asm/assembly/fft.ts | 0 .../web/{src => }/dsp-asm/assembly/index.ts | 8 +- .../{src => }/dsp-asm/assembly/tsconfig.json | 0 .../web/{src => }/dsp-asm/build/.gitignore | 0 clients/web/{src => }/dsp-asm/package.json | 0 clients/web/{src => }/dsp-asm/pnpm-lock.yaml | 0 clients/web/{src => }/dsp-asm/tests/index.ts | 8 +- clients/web/package.json | 5 +- clients/web/pnpm-lock.yaml | 286 ------------------ clients/web/src/analysis.ts | 5 + clients/web/src/analysisWorker.js | 43 ++- clients/web/vite.config.ts | 5 - 13 files changed, 36 insertions(+), 326 deletions(-) rename clients/web/{src => }/dsp-asm/asconfig.json (94%) rename clients/web/{src => }/dsp-asm/assembly/fft.ts (100%) rename clients/web/{src => }/dsp-asm/assembly/index.ts (87%) rename clients/web/{src => }/dsp-asm/assembly/tsconfig.json (100%) rename clients/web/{src => }/dsp-asm/build/.gitignore (100%) rename clients/web/{src => }/dsp-asm/package.json (100%) rename clients/web/{src => }/dsp-asm/pnpm-lock.yaml (100%) rename clients/web/{src => }/dsp-asm/tests/index.ts (84%) diff --git a/clients/web/src/dsp-asm/asconfig.json b/clients/web/dsp-asm/asconfig.json similarity index 94% rename from clients/web/src/dsp-asm/asconfig.json rename to clients/web/dsp-asm/asconfig.json index 87e1e5c..c19b265 100644 --- a/clients/web/src/dsp-asm/asconfig.json +++ b/clients/web/dsp-asm/asconfig.json @@ -17,7 +17,7 @@ } }, "options": { - "bindings": "esm", + "bindings": "raw", "enable": ["simd"] } } diff --git a/clients/web/src/dsp-asm/assembly/fft.ts b/clients/web/dsp-asm/assembly/fft.ts similarity index 100% rename from clients/web/src/dsp-asm/assembly/fft.ts rename to clients/web/dsp-asm/assembly/fft.ts diff --git a/clients/web/src/dsp-asm/assembly/index.ts b/clients/web/dsp-asm/assembly/index.ts similarity index 87% rename from clients/web/src/dsp-asm/assembly/index.ts rename to clients/web/dsp-asm/assembly/index.ts index 5e76949..95af787 100644 --- a/clients/web/src/dsp-asm/assembly/index.ts +++ b/clients/web/dsp-asm/assembly/index.ts @@ -8,8 +8,10 @@ export function downscale(buf: Float32Array, size: i32): Float32Array { const res = new Float32Array(nChunks); for (let ci = 0; ci < nChunks; ci++) { let max: f64 = 0; - for (let i = 1; i < chunkSz; i++) { - max = Math.max(buf[i + ci * chunkSz], max); + const chunk = buf.subarray(ci * chunkSz, (ci + 1) * chunkSz); + + for (let i = 1; i < chunk.length; i++) { + max = Math.max(chunk[i], max); } res[ci] = max; @@ -25,7 +27,7 @@ export function fft(buf: Float32Array, start: i32, end: i32, pad: i32): Float32A // min power of 2 that is <= buf.length const size = Math.pow(2, Math.ceil(Math.log2(pad + end - start))); - + const fftInput = new Float32Array(size); // copy in buffer diff --git a/clients/web/src/dsp-asm/assembly/tsconfig.json b/clients/web/dsp-asm/assembly/tsconfig.json similarity index 100% rename from clients/web/src/dsp-asm/assembly/tsconfig.json rename to clients/web/dsp-asm/assembly/tsconfig.json diff --git a/clients/web/src/dsp-asm/build/.gitignore b/clients/web/dsp-asm/build/.gitignore similarity index 100% rename from clients/web/src/dsp-asm/build/.gitignore rename to clients/web/dsp-asm/build/.gitignore diff --git a/clients/web/src/dsp-asm/package.json b/clients/web/dsp-asm/package.json similarity index 100% rename from clients/web/src/dsp-asm/package.json rename to clients/web/dsp-asm/package.json diff --git a/clients/web/src/dsp-asm/pnpm-lock.yaml b/clients/web/dsp-asm/pnpm-lock.yaml similarity index 100% rename from clients/web/src/dsp-asm/pnpm-lock.yaml rename to clients/web/dsp-asm/pnpm-lock.yaml diff --git a/clients/web/src/dsp-asm/tests/index.ts b/clients/web/dsp-asm/tests/index.ts similarity index 84% rename from clients/web/src/dsp-asm/tests/index.ts rename to clients/web/dsp-asm/tests/index.ts index 249e939..ddb7ba0 100644 --- a/clients/web/src/dsp-asm/tests/index.ts +++ b/clients/web/dsp-asm/tests/index.ts @@ -10,10 +10,7 @@ function jsfft(buf: Float32Array) { const size = Math.pow(2, Math.ceil(Math.log2(end - start))); - const input = [ - ...buf.slice(start, end), - ...Array(size - (end - start)).fill(0), - ]; + const input = [...buf.slice(start, end), ...Array(size - (end - start)).fill(0)]; const fft = new FFT(size); const output = fft.createComplexArray(); fft.realTransform(output, input); @@ -29,7 +26,6 @@ Deno.test("FFT", () => { const actual = fft(input, -1, -1, -1); // wasm is using f32 for memory usage reasons - for (let i = 0; i < actual.length; i++) { + for (let i = 0; i < actual.length; i++) assertAlmostEquals(actual[i], expected[i], 5e-6); - } }); diff --git a/clients/web/package.json b/clients/web/package.json index e95b254..2d12cb4 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -2,8 +2,9 @@ "name": "uwuradio-client", "version": "0.0.0", "scripts": { - "dev": "vite", - "build": "run-p type-check build-only", + "dev": "pnpm build:as && vite", + "build": "pnpm build:as && run-p type-check build-only", + "build:as": "cd dsp-asm && pnpm asbuild:release && cd ..", "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", diff --git a/clients/web/pnpm-lock.yaml b/clients/web/pnpm-lock.yaml index 6a8bcd1..5ab3a37 100644 --- a/clients/web/pnpm-lock.yaml +++ b/clients/web/pnpm-lock.yaml @@ -82,9 +82,6 @@ devDependencies: vite: specifier: ^4.5.0 version: 4.5.0(@types/node@20.8.10) - vite-plugin-assemblyscript-asc: - specifier: ^1.0.5 - version: 1.0.5(@types/node@20.8.10) vue-tsc: specifier: ^1.8.22 version: 1.8.22(typescript@5.2.2) @@ -153,15 +150,6 @@ packages: requiresBuild: true optional: true - /@esbuild/android-arm@0.15.18: - resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-arm@0.18.20: resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -234,15 +222,6 @@ packages: requiresBuild: true optional: true - /@esbuild/linux-loong64@0.15.18: - resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-loong64@0.18.20: resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -1520,216 +1499,6 @@ packages: is-symbol: 1.0.4 dev: true - /esbuild-android-64@0.15.18: - resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /esbuild-android-arm64@0.15.18: - resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /esbuild-darwin-64@0.15.18: - resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /esbuild-darwin-arm64@0.15.18: - resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /esbuild-freebsd-64@0.15.18: - resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-freebsd-arm64@0.15.18: - resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-32@0.15.18: - resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-64@0.15.18: - resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-arm64@0.15.18: - resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-arm@0.15.18: - resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-mips64le@0.15.18: - resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-ppc64le@0.15.18: - resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-riscv64@0.15.18: - resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-s390x@0.15.18: - resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-netbsd-64@0.15.18: - resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-openbsd-64@0.15.18: - resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-sunos-64@0.15.18: - resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-32@0.15.18: - resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-64@0.15.18: - resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-arm64@0.15.18: - resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild@0.15.18: - resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/android-arm': 0.15.18 - '@esbuild/linux-loong64': 0.15.18 - esbuild-android-64: 0.15.18 - esbuild-android-arm64: 0.15.18 - esbuild-darwin-64: 0.15.18 - esbuild-darwin-arm64: 0.15.18 - esbuild-freebsd-64: 0.15.18 - esbuild-freebsd-arm64: 0.15.18 - esbuild-linux-32: 0.15.18 - esbuild-linux-64: 0.15.18 - esbuild-linux-arm: 0.15.18 - esbuild-linux-arm64: 0.15.18 - esbuild-linux-mips64le: 0.15.18 - esbuild-linux-ppc64le: 0.15.18 - esbuild-linux-riscv64: 0.15.18 - esbuild-linux-s390x: 0.15.18 - esbuild-netbsd-64: 0.15.18 - esbuild-openbsd-64: 0.15.18 - esbuild-sunos-64: 0.15.18 - esbuild-windows-32: 0.15.18 - esbuild-windows-64: 0.15.18 - esbuild-windows-arm64: 0.15.18 - dev: true - /esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2931,14 +2700,6 @@ packages: glob: 7.2.3 dev: true - /rollup@2.79.1: - resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} - engines: {node: '>=10.0.0'} - hasBin: true - optionalDependencies: - fsevents: 2.3.3 - dev: true - /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -3379,53 +3140,6 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vite-plugin-assemblyscript-asc@1.0.5(@types/node@20.8.10): - resolution: {integrity: sha512-qQwZzvREZMhkFX64mT94pUh+fydo9rYxENQuh3HZ7jNzEB2bQAXWWvaA2qkG7l53+8fNYT/MpJGtXXQ9OVckzw==} - dependencies: - vite: 3.2.10(@types/node@20.8.10) - transitivePeerDependencies: - - '@types/node' - - less - - sass - - stylus - - sugarss - - terser - dev: true - - /vite@3.2.10(@types/node@20.8.10): - resolution: {integrity: sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - '@types/node': 20.8.10 - esbuild: 0.15.18 - postcss: 8.4.31 - resolve: 1.22.8 - rollup: 2.79.1 - optionalDependencies: - fsevents: 2.3.3 - dev: true - /vite@4.5.0(@types/node@20.8.10): resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} engines: {node: ^14.18.0 || >=16.0.0} diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index ae01b2d..952253d 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -34,6 +34,9 @@ function callWorker(cmd: number, args: unknown[]): Promise { return new Promise((res) => (pendingRequests[id] = res as any)); } +// init the wasm in the worker +const initPromise = callWorker(7, []); + // === BINDINGS === // sets the current worker's default buffer to this one @@ -74,6 +77,8 @@ export const fftd = ref(); const innerCleanups: WatchStopHandle[] = []; watchEffect(async () => { if (enableAnalysis.value && buf.value) { + await initPromise; + innerCleanups.forEach((c) => c()); downscaled.value = undefined; diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index d078713..5ae694f 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,4 +1,19 @@ -import { downscale as wasmDS, fft as wasmFFT } from "./dsp-asm/assembly/index"; +//import { downscale as wasmDS, fft as wasmFFT } from "../dsp-asm/build/release.js"; +// init wasm +import wasmUrl from "../dsp-asm/build/release.wasm?url"; +import { instantiate } from "../dsp-asm/build/release.js"; + +let wasmDS, wasmFFT, init; + +function wasmInit() { + if (init) return init; + return (init = WebAssembly.compileStreaming(fetch(wasmUrl)) + .then(instantiate) + .then(({ downscale, fft }) => { + wasmDS = downscale; + wasmFFT = fft; + })); +} // avoid uploading the same buffer a ton of times let currentBuffer = new Float32Array(0); @@ -15,27 +30,6 @@ function downscale(buf, size) { buf ??= currentBuffer; return wasmDS(buf, size); - - // size of each chunk - /*const chunkSz = Math.ceil(buf.length / size); - const nChunks = Math.ceil(buf.length / chunkSz); - - // this is at LEAST size, rounded up as necessary - const res = new Float32Array(nChunks); - - for (let ci = 0; ci < nChunks; ci++) { - const chunk = buf.slice(ci * chunkSz, (ci + 1) * chunkSz); - - let max = chunk[0]; /!*, min = chunk[0]*!/ - for (let i = 1; i < chunk.length; i++) { - max = Math.max(chunk[i], max); - //min = Math.min(chunk[i], min); - } - - res[ci] = max; - } - - return res;*/ } /** @param {Float32Array} buf @@ -136,8 +130,11 @@ onmessage = (e) => { /*sliceByCrossings*/ undefined, sbcMax, fft, + wasmInit, ][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); - postMessage([e.data[1], func(...e.data.slice(2))]); + const res = func(...e.data.slice(2)); + if (res instanceof Promise) res.then((r) => postMessage([e.data[1], r])) + else postMessage([e.data[1], res]); }; diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index a51467f..4c395ae 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -9,10 +9,6 @@ import transformerVariantGroup from "@unocss/transformer-variant-group"; import presetWebFonts from "@unocss/preset-web-fonts"; import transformerDirectives from "@unocss/transformer-directives"; -// @ts-expect-error this library has typings, but did not add them to exports {} -// your IDE should pick types up fine, but TSC will complain. -import asmScript from "vite-plugin-assemblyscript-asc"; - // https://vitejs.dev/config/ export default defineConfig({ plugins: [ @@ -30,7 +26,6 @@ export default defineConfig({ safelist: ["underline"], }), vue(), - asmScript({ projectRoot: "src/dsp-asm" }), ], resolve: { alias: { From 0a9c416c220f2e020a4d4f17825fabbb78bb4e21 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sun, 9 Jun 2024 23:13:49 +0100 Subject: [PATCH 11/29] [vis] [wasm] get the waveform working --- clients/web/dsp-asm/asconfig.json | 3 +- clients/web/dsp-asm/assembly/index.ts | 107 +++++++++++++++++-- clients/web/dsp-asm/tests/index.ts | 7 +- clients/web/src/analysis.ts | 37 +++---- clients/web/src/analysisWorker.js | 101 ++--------------- clients/web/src/components/AudioSpectrum.vue | 6 +- clients/web/src/components/ThePlayer.vue | 1 + clients/web/src/components/WaveForm.vue | 23 ++-- 8 files changed, 150 insertions(+), 135 deletions(-) diff --git a/clients/web/dsp-asm/asconfig.json b/clients/web/dsp-asm/asconfig.json index c19b265..a023a23 100644 --- a/clients/web/dsp-asm/asconfig.json +++ b/clients/web/dsp-asm/asconfig.json @@ -4,7 +4,8 @@ "outFile": "build/debug.wasm", "textFile": "build/debug.wat", "sourceMap": true, - "debug": true + "debug": true, + "bindings": "esm" }, "release": { "outFile": "build/release.wasm", diff --git a/clients/web/dsp-asm/assembly/index.ts b/clients/web/dsp-asm/assembly/index.ts index 95af787..f4980ee 100644 --- a/clients/web/dsp-asm/assembly/index.ts +++ b/clients/web/dsp-asm/assembly/index.ts @@ -1,26 +1,42 @@ import { FFT } from "./fft"; -export function downscale(buf: Float32Array, size: i32): Float32Array { - const fchunkSz = Math.ceil(buf.length / size); +// copying arrays across thread boundaries is very expensive, so provide a mechanism to reuse it. +let currentBuf: Float32Array | null; + +export function uploadBuf(buf: Float32Array): void { + currentBuf = buf; +} + +export function downscale(_buf: Float32Array | null, size: i32): Float32Array { + const buf = _buf ? _buf : currentBuf!; + // round chunk size up to a multiple of 4 + const fchunkSz = 4 * Math.ceil(buf.length / size / 4); const nChunks = Math.ceil(buf.length / fchunkSz); const chunkSz = fchunkSz; const res = new Float32Array(nChunks); for (let ci = 0; ci < nChunks; ci++) { - let max: f64 = 0; + let max = v128.splat(0); const chunk = buf.subarray(ci * chunkSz, (ci + 1) * chunkSz); + const ptr = chunk.dataStart; - for (let i = 1; i < chunk.length; i++) { - max = Math.max(chunk[i], max); + // 4 floats per vec + for (let i = 0; i < chunk.length; i += 4) { + const vec = v128.load(ptr + i * sizeof()); + max = f32x4.max(max, vec); } - res[ci] = max; + res[ci] = Mathf.max( + Mathf.max(f32x4.extract_lane(max, 0), f32x4.extract_lane(max, 1)), + Mathf.max(f32x4.extract_lane(max, 2), f32x4.extract_lane(max, 3)), + ); } return res; } -export function fft(buf: Float32Array, start: i32, end: i32, pad: i32): Float32Array { +export function fft(_buf: Float32Array | null, start: i32, end: i32, pad: i32): Float32Array { + const buf = _buf ? _buf : currentBuf!; if (start === -1) start = 0; if (end === -1) end = buf.length; if (pad === -1) pad = 0; @@ -44,3 +60,80 @@ export function fft(buf: Float32Array, start: i32, end: i32, pad: i32): Float32A return output.subarray(0, size); } + +function sliceByCrossings( + _buf: Float32Array | null, + start: i32, + end: i32, + n: i32, +): Array { + const buf = _buf ? _buf : currentBuf!; + const buffers = new Array(); + if (start === -1) start = 0; + if (end === -1) end = buf.length; + console.log(n.toString()); + if (n === -1) n = 1; + console.log(n.toString()); + + let startNext = start; // the start of the next buffer to be sliced + let isPos = buf[start] > 0; // was the last sample positive? + + let cutN = 0; // counter for `n` + + for (let i = start + 1; i < end; i++) { + const thisSampPos = buf[i] > 0; + if (isPos !== thisSampPos && ++cutN === n) { + // flip! + cutN = 0; + buffers.push(buf.subarray(startNext, i)); + startNext = i; + } + isPos = thisSampPos; + } + + if (cutN !== 0) { + // a lil bit left + buffers.push(buf.subarray(startNext, end - 1)); + } + + return buffers; +} + +function maxAbsPeakOf(bufs: Array): Float32Array { + let maxBuf = bufs[0]; + let max: f32 = 0; + + for (let bi = 0; bi < bufs.length; bi++) { + const b = bufs[bi]; + let maxV = f32x4.splat(0); + const ptr = b.dataStart; + + // max over chunks of 4 floats, very quickly + const len = 4 * (b.length / 4); + for (let i = 0; i < len; i += 4) { + const vec = f32x4.abs(v128.load(ptr + i * sizeof())); + maxV = f32x4.max(maxV, vec); + } + + let finalMax = Mathf.max( + Mathf.max(f32x4.extract_lane(maxV, 0), f32x4.extract_lane(maxV, 1)), + Mathf.max(f32x4.extract_lane(maxV, 2), f32x4.extract_lane(maxV, 3)), + ); + + // now just take the values we missed off due to only taking fours + for (let i = len; i < b.length; i++) { + finalMax = Mathf.max(finalMax, Mathf.abs(b[i])); + } + + if (finalMax < max) continue; + max = finalMax; + maxBuf = b; + } + + return maxBuf; +} + +// sliceByCrossings -> maxAbsPeakOf all in one go as an opt +export function sbcMax(_buf: Float32Array | null, start: i32, end: i32, n: i32): Float32Array { + return maxAbsPeakOf(sliceByCrossings(_buf, start, end, n)); +} diff --git a/clients/web/dsp-asm/tests/index.ts b/clients/web/dsp-asm/tests/index.ts index ddb7ba0..73f6248 100644 --- a/clients/web/dsp-asm/tests/index.ts +++ b/clients/web/dsp-asm/tests/index.ts @@ -1,8 +1,6 @@ -// @ts-ignore lol IDE doesnt like deno - import { assertAlmostEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import FFT from "npm:fft.js"; -import { fft } from "../build/release.js"; +import { fft } from "../build/debug.js"; function jsfft(buf: Float32Array) { const end = buf.length; @@ -26,6 +24,7 @@ Deno.test("FFT", () => { const actual = fft(input, -1, -1, -1); // wasm is using f32 for memory usage reasons - for (let i = 0; i < actual.length; i++) + for (let i = 0; i < actual.length; i++) { assertAlmostEquals(actual[i], expected[i], 5e-6); + } }); diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index 952253d..abc7c90 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -18,6 +18,9 @@ export function setAnalysisBuf(b: AudioBuffer) { const worker = new WORKER(); // wrong type const pendingRequests: Record void> = {}; +// init the wasm in the worker +const initPromise = callWorker(0, []); + worker.onmessage = (e) => { if (e.data[0] === "ERR") throw new Error(`error in analysis worker: ${e.data[1]}`); if (!(e.data[0] in pendingRequests)) @@ -27,44 +30,32 @@ worker.onmessage = (e) => { delete pendingRequests[e.data[0]]; }; -function callWorker(cmd: number, args: unknown[]): Promise { +async function callWorker(cmd: number, args: unknown[]): Promise { + if (cmd !== 0) await initPromise; + const id = Math.random().toString(16); worker.postMessage([cmd, id, ...args]); return new Promise((res) => (pendingRequests[id] = res as any)); } -// init the wasm in the worker -const initPromise = callWorker(7, []); - // === BINDINGS === // sets the current worker's default buffer to this one // this avoids unnecessarily sending the same buffer multiple times -const uploadBuffer = (buf: Float32Array) => callWorker(0, [buf]); +const uploadBuffer = (buf: Float32Array) => callWorker(1, [buf]); // downscales buf to size, using naive sampling const downscale = (buf: undefined | Float32Array, size: number) => - callWorker(1, [buf, size]); + callWorker(2, [buf, size]); -/*// trims a buffer down to get only the next n zero-crossings -const getNCrossings = (buf: undefined | Float32Array, start: number, n: number) => - callWorker(2, [buf, start, n]);*/ - -/*// slice a buffer at the zero crossings -const sliceByCrossings = (buf: undefined | Float32Array, start?: number, end?: number) => - callWorker(4, [buf, start, end]);*/ - -/*// gets the buffer from the array with the maximum peak -const maxAbsPeakOf = (bufs: Float32Array[]) => callWorker(3, [bufs]);*/ - -// sliceByCrossings >> maxAbsPeakOf, saves copying unnecessary bufs -const sbcMax = (buf: undefined | Float32Array, start?: number, end?: number) => - callWorker(5, [buf, start, end]); +// slices a buffer every n zero crossings, and returns the slice with the biggest peak amplitude +const sbcMax = (buf: undefined | Float32Array, start?: number, end?: number, n?: number) => + callWorker(3, [buf, start, end, n]); // computes the FFT of the buffer in the range const fft = (buf: undefined | Float32Array, start?: number, end?: number, pad?: number) => - callWorker(6, [buf, start, end, pad]); + callWorker(4, [buf, start, end, pad]); // === USEFUL REACTIVE STUFF === @@ -77,8 +68,6 @@ export const fftd = ref(); const innerCleanups: WatchStopHandle[] = []; watchEffect(async () => { if (enableAnalysis.value && buf.value) { - await initPromise; - innerCleanups.forEach((c) => c()); downscaled.value = undefined; @@ -95,7 +84,7 @@ watchEffect(async () => { const pad = 0; fftd.value = (await fft(undefined, seekSamples, seekEndSamples, pad)).map(Math.abs); - singlePeriod.value = await sbcMax(undefined, seekSamples, seekEndSamples); + singlePeriod.value = await sbcMax(undefined, seekSamples, seekEndSamples, 2); }), ); } else { diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index 5ae694f..567b2e1 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,136 +1,55 @@ -//import { downscale as wasmDS, fft as wasmFFT } from "../dsp-asm/build/release.js"; -// init wasm import wasmUrl from "../dsp-asm/build/release.wasm?url"; import { instantiate } from "../dsp-asm/build/release.js"; -let wasmDS, wasmFFT, init; +let wasmDS, wasmFFT, wasmUpload, wasmSbcMax, init; function wasmInit() { if (init) return init; return (init = WebAssembly.compileStreaming(fetch(wasmUrl)) .then(instantiate) - .then(({ downscale, fft }) => { + .then(({ downscale, fft, uploadBuf, sbcMax }) => { wasmDS = downscale; wasmFFT = fft; + wasmUpload = uploadBuf; + wasmSbcMax = sbcMax; })); } -// avoid uploading the same buffer a ton of times -let currentBuffer = new Float32Array(0); - /** @param {Float32Array} buf */ function uploadBuffer(buf) { - currentBuffer = buf; + wasmUpload(buf); } /** @param {Float32Array} buf * @param {number} size * @returns Float32Array */ function downscale(buf, size) { - buf ??= currentBuffer; - return wasmDS(buf, size); } -/** @param {Float32Array} buf - * @param {number} start The number of samples into the buffer to start looking from - * @param {number} n The amount of zero crossings to include - * @returns Float32Array */ -/*function getNCrossings(buf, start, n) { - /!** @param {number} n - * @returns number*!/ - function nextCrossingIdx(n) { - let done = false; - do { - n++; - let signChanged = (buf[n - 1] > 0) !== (buf[n] > 0); - done = signChanged; - } while (!done); - return n; - } - - buf ??= currentBuffer; - - // find first crossing - let c1 = nextCrossingIdx(start); - - // find end - let end = c1; - for (let i = 0; i < n; i++) end = nextCrossingIdx(end); - - return buf.slice(c1, end); -}*/ - -/** @param {Float32Array} buf - * @param {number} start - * @param {number} end - * @returns Float32Array[] */ -function sliceByCrossings(buf, start, end) { - const buffers = []; - buf ??= currentBuffer; - start ??= 0; - end ??= buf.length; - - let startNext = start; // the start of the next buffer to be sliced - let isPos = buf[start] > 0; // was the last sample positive? - - for (let i = start + 1; i < end; i++) { - const thisSampPos = buf[i] > 0; - if (isPos !== thisSampPos) { - // flip! - buffers.push(buf.slice(startNext, i)); - startNext = i; - } - isPos = thisSampPos; - } - - return buffers; -} - -/** @param {Float32Array[]} bufs - * @returns Float32Array */ -function maxAbsPeakOf(bufs) { - let m = 0; - let mb = bufs[0]; - for (const buf of bufs) - for (const sample of buf) { - m = Math.max(m, Math.abs(sample)); - mb = buf; - } - - return mb; -} - // maxAbsPeakOf(sliceByCrossings(...)) -function sbcMax(buf, start, end) { - return maxAbsPeakOf(sliceByCrossings(buf, start, end)); +function sbcMax(buf, start, end, n) { + return wasmSbcMax(buf, start ?? -1, end ?? -1, n ?? -1); } /** @param {Float32Array} buf * @param {number} start * @param {number} end * @param {number} pad + * @param {number} n * @returns {Float32Array} */ function fft(buf, start, end, pad) { - buf ??= currentBuffer; - start ??= -1; - end ??= -1; - pad ??= -1; - - return wasmFFT(buf, start, end, pad); + return wasmFFT(buf, start ?? -1, end ?? -1, pad ?? -1); } onmessage = (e) => { // rip type safety const func = [ + wasmInit, uploadBuffer, downscale, - /*getNCrossings*/ undefined, - /*maxAbsPeakOf*/ undefined, - /*sliceByCrossings*/ undefined, sbcMax, fft, - wasmInit, ][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); diff --git a/clients/web/src/components/AudioSpectrum.vue b/clients/web/src/components/AudioSpectrum.vue index a736307..2f8645d 100644 --- a/clients/web/src/components/AudioSpectrum.vue +++ b/clients/web/src/components/AudioSpectrum.vue @@ -2,6 +2,8 @@ import { onMounted, watchEffect } from "vue"; import { enableAnalysis } from "@/analysis"; +const id = Math.random().toString(16).split(".")[1]; + enableAnalysis.value = true; const props = defineProps<{ waveform: Float32Array | undefined }>(); @@ -16,7 +18,7 @@ function movingMax(buf: Float32Array, n: number) { } onMounted(() => { - const ctx = (document.getElementById("ascanvas") as HTMLCanvasElement).getContext("2d")!; + const ctx = (document.getElementById(id) as HTMLCanvasElement).getContext("2d")!; const W = ctx.canvas.offsetWidth; const H = ctx.canvas.offsetHeight; @@ -59,5 +61,5 @@ onMounted(() => { diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index 095093f..1757e6c 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -57,6 +57,7 @@ const randomQuote = computed(() => :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> +
VOL
diff --git a/clients/web/src/components/WaveForm.vue b/clients/web/src/components/WaveForm.vue index e6124b5..f9ca873 100644 --- a/clients/web/src/components/WaveForm.vue +++ b/clients/web/src/components/WaveForm.vue @@ -2,12 +2,14 @@ import { onMounted, watchEffect } from "vue"; import { enableAnalysis } from "@/analysis"; +const id = Math.random().toString(16).split(".")[1]; + enableAnalysis.value = true; const props = defineProps<{ waveform: Float32Array | undefined; fill: boolean }>(); onMounted(() => { - const ctx = (document.getElementById("wfcanvas") as HTMLCanvasElement).getContext("2d")!; + const ctx = (document.getElementById(id) as HTMLCanvasElement).getContext("2d")!; const W = ctx.canvas.offsetWidth; const H = ctx.canvas.offsetHeight; @@ -23,6 +25,7 @@ onMounted(() => { const len = buffer.length; const dx = W / len; + const scale = 1; /// Math.max(...buffer.map(Math.abs)); ctx.clearRect(0, 0, W, H); @@ -31,26 +34,34 @@ onMounted(() => { ctx.moveTo(0, H / 2); ctx.beginPath(); for (let i = 0; i < len; i++) { - ctx.lineTo(x, (H / 2) * (1 - buffer[i])); + ctx.lineTo(x, (H / 2) * (1 - buffer[i] * scale)); x += dx; } if (props.fill) { // draw bottom half, a mirror of the top half for (let i = len - 1; i >= 0; i--) { - ctx.lineTo(x, (H / 2) * (1 + buffer[i])); + ctx.lineTo(x, (H / 2) * (1 + buffer[i] * scale)); x -= dx; } ctx.closePath(); ctx.fill(); - } - else { + } else { + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(x, H/2); + ctx.lineTo(0, H/2); + + const prev = ctx.strokeStyle; + ctx.strokeStyle = "#333"; ctx.stroke(); + ctx.strokeStyle = prev; } }); }); From 789db3f66d9b235288c0cabb64379b0671f35229 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Sun, 9 Jun 2024 23:21:50 +0100 Subject: [PATCH 12/29] [vis] [wasm] housekeeping --- clients/web/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clients/web/package.json b/clients/web/package.json index 2d12cb4..fd84e18 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -9,7 +9,7 @@ "build-only": "vite build", "type-check": "vue-tsc --noEmit", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", - "postinstall": "pnpm i -C src/dsp-asm" + "postinstall": "pnpm i -C dsp-asm" }, "dependencies": { "@headlessui/vue": "^1.7.16", @@ -39,7 +39,6 @@ "prettier": "^3.0.3", "typescript": "~5.2.2", "vite": "^4.5.0", - "vite-plugin-assemblyscript-asc": "^1.0.5", "vue-tsc": "^1.8.22" } } From a72fc7bcbb4a00cf307ca1ba808affd3b570624b Mon Sep 17 00:00:00 2001 From: yellowsink Date: Mon, 10 Jun 2024 00:24:31 +0100 Subject: [PATCH 13/29] [vis] add fft persistence, tweak seek update scheduling, refactoring --- clients/web/dsp-asm/assembly/index.ts | 48 ++++++++++++++++++++++++--- clients/web/src/analysis.ts | 18 ++++++---- clients/web/src/analysisWorker.js | 38 ++++----------------- clients/web/src/audio.ts | 23 +++++++++---- 4 files changed, 80 insertions(+), 47 deletions(-) diff --git a/clients/web/dsp-asm/assembly/index.ts b/clients/web/dsp-asm/assembly/index.ts index f4980ee..49c486a 100644 --- a/clients/web/dsp-asm/assembly/index.ts +++ b/clients/web/dsp-asm/assembly/index.ts @@ -35,7 +35,15 @@ export function downscale(_buf: Float32Array | null, size: i32): Float32Array { return res; } -export function fft(_buf: Float32Array | null, start: i32, end: i32, pad: i32): Float32Array { +let lastFft: Float32Array | null; + +export function fft( + _buf: Float32Array | null, + start: i32, + end: i32, + pad: i32, + persistence: f32, +): Float32Array { const buf = _buf ? _buf : currentBuf!; if (start === -1) start = 0; if (end === -1) end = buf.length; @@ -58,7 +66,41 @@ export function fft(_buf: Float32Array | null, start: i32, end: i32, pad: i32): fft.realTransform(output, fftInput); - return output.subarray(0, size); + const result = output.subarray(0, size); + if (Mathf.abs(persistence) < 0.0001) return result; // no persistence, just return the raw fft + if (!lastFft || lastFft!.length !== result.length) { + lastFft = result; + return result; + } + + // blend last fft with this fft to reduce jitter + // round size down to 4 for simd purposes + const out = new Float32Array(lastFft!.length); + + // TODO: null errors. why? + /*const simdSafeSize = 4 * (out.length / 4); + const outPtr = out.dataStart; + const lastPtr = lastFft!.dataStart; + const resPtr = result.dataStart; + + for (let i = 0; i < 4 * (out.length / 4); i++) { + // out_i = max(res_i, last_i * persistence) + v128.store( + outPtr + i * sizeof(), + f32x4.max( + v128.load(resPtr + i * sizeof()), + f32x4.mul(v128.load(lastPtr + i * sizeof()), f32x4.splat(persistence)), + ), + ); + }*/ + + // do last few elements + for (let i = /*simdSafeSize*/ 0; i < out.length; i++) { + out[i] = result[i] + lastFft![i] * persistence; + } + + lastFft = out; + return out.subarray(0, lastFft!.length); } function sliceByCrossings( @@ -71,9 +113,7 @@ function sliceByCrossings( const buffers = new Array(); if (start === -1) start = 0; if (end === -1) end = buf.length; - console.log(n.toString()); if (n === -1) n = 1; - console.log(n.toString()); let startNext = start; // the start of the next buffer to be sliced let isPos = buf[start] > 0; // was the last sample positive? diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index abc7c90..fe90428 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -1,4 +1,4 @@ -import { watchEffect, ref, type WatchStopHandle } from "vue"; +import { ref, watchEffect, type WatchStopHandle } from "vue"; import WORKER from "./analysisWorker.js?worker"; import { seek } from "./audio"; @@ -54,8 +54,13 @@ const sbcMax = (buf: undefined | Float32Array, start?: number, end?: number, n?: callWorker(3, [buf, start, end, n]); // computes the FFT of the buffer in the range -const fft = (buf: undefined | Float32Array, start?: number, end?: number, pad?: number) => - callWorker(4, [buf, start, end, pad]); +const fft = ( + buf: undefined | Float32Array, + start?: number, + end?: number, + pad?: number, + persistence?: number, +) => callWorker(4, [buf, start, end, pad, persistence]); // === USEFUL REACTIVE STUFF === @@ -80,11 +85,12 @@ watchEffect(async () => { if (seek.value === undefined) return; const seekSamples = seek.value * buf.value!.sampleRate; // more samples = more accuracy, more padding = smoother plot - const seekEndSamples = seekSamples + 5000; const pad = 0; - fftd.value = (await fft(undefined, seekSamples, seekEndSamples, pad)).map(Math.abs); - singlePeriod.value = await sbcMax(undefined, seekSamples, seekEndSamples, 2); + fftd.value = (await fft(undefined, seekSamples, seekSamples + 10_000, pad, 0.93)).map( + Math.abs, + ); + singlePeriod.value = await sbcMax(undefined, seekSamples, seekSamples + 5000, 2); }), ); } else { diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index 567b2e1..49b2dda 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -15,45 +15,21 @@ function wasmInit() { })); } -/** @param {Float32Array} buf */ -function uploadBuffer(buf) { - wasmUpload(buf); -} +const uploadBuffer = (buf) => wasmUpload(buf); -/** @param {Float32Array} buf - * @param {number} size - * @returns Float32Array */ -function downscale(buf, size) { - return wasmDS(buf, size); -} +const downscale = (buf, size) => wasmDS(buf, size); -// maxAbsPeakOf(sliceByCrossings(...)) -function sbcMax(buf, start, end, n) { - return wasmSbcMax(buf, start ?? -1, end ?? -1, n ?? -1); -} +const sbcMax = (buf, start, end, n) => wasmSbcMax(buf, start ?? -1, end ?? -1, n ?? -1); -/** @param {Float32Array} buf - * @param {number} start - * @param {number} end - * @param {number} pad - * @param {number} n - * @returns {Float32Array} */ -function fft(buf, start, end, pad) { - return wasmFFT(buf, start ?? -1, end ?? -1, pad ?? -1); -} +const fft = (buf, start, end, pad, persistence) => + wasmFFT(buf, start ?? -1, end ?? -1, pad ?? -1, persistence ?? 0); onmessage = (e) => { // rip type safety - const func = [ - wasmInit, - uploadBuffer, - downscale, - sbcMax, - fft, - ][e.data[0]]; + const func = [wasmInit, uploadBuffer, downscale, sbcMax, fft][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); const res = func(...e.data.slice(2)); - if (res instanceof Promise) res.then((r) => postMessage([e.data[1], r])) + if (res instanceof Promise) res.then((r) => postMessage([e.data[1], r])); else postMessage([e.data[1], res]); }; diff --git a/clients/web/src/audio.ts b/clients/web/src/audio.ts index 773c78c..208edd9 100644 --- a/clients/web/src/audio.ts +++ b/clients/web/src/audio.ts @@ -33,23 +33,34 @@ watchEffect(() => { const prettyFormatTime = (time: number) => `${~~(time / 60)}:${(~~(time % 60)).toString().padStart(2, "0")}`; +export const getDuration = () => audioSource?.buffer?.duration ?? 0; +export const prettyDuration = () => prettyFormatTime(getDuration()); + const seek = ref(); export { seek }; export const prettySeek = computed(() => prettyFormatTime(seek.value!)); -setInterval( +/*setInterval( () => (seek.value = Math.min(audioCtx.currentTime - startTime + startSeek, getDuration())), - 50, -); + 20, +);*/ + +let i = 0; +const loop = () => { + if (++i === 2) { + seek.value = Math.min(audioCtx.currentTime - startTime + startSeek, getDuration()); + i = 0; + } + + requestAnimationFrame(loop); +}; +loop(); export const seekTo = (seek: number) => { startSeek = seek; audioSource?.start(0, seek); }; -export const getDuration = () => audioSource?.buffer?.duration ?? 0; -export const prettyDuration = () => prettyFormatTime(getDuration()); - async function loadAudio(url: string) { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); From 105d681137d02def8b248c5f311ec51344c524a4 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Mon, 10 Jun 2024 21:14:16 +0100 Subject: [PATCH 14/29] [vis] start on specific view for it --- UwuRadio.Server/DSP.cs | 2 +- clients/web/src/App.vue | 5 +++ clients/web/src/components/AudioSpectrum.vue | 11 +++-- clients/web/src/components/ThePlayer.vue | 25 ++++++----- .../web/src/components/TheRedundantInfo.vue | 42 +++++++++++++++++++ clients/web/src/components/WaveForm.vue | 11 ++--- 6 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 clients/web/src/components/TheRedundantInfo.vue diff --git a/UwuRadio.Server/DSP.cs b/UwuRadio.Server/DSP.cs index a8550ed..b30bbd9 100644 --- a/UwuRadio.Server/DSP.cs +++ b/UwuRadio.Server/DSP.cs @@ -48,7 +48,7 @@ public static async Task MeasureLoudness(string path) var peak = double.Parse(match.Groups[4].Value); //Console.Error.WriteLine(stdErr); //Console.Error.WriteLine($"measurement: {lufs} LUFS, {thres} threshold, {lra} LU range, {peak} dbFS peak"); - + return new LoudnessMeasurement(lufs, thres, lra, peak); } diff --git a/clients/web/src/App.vue b/clients/web/src/App.vue index 54bbe1c..e8f5430 100644 --- a/clients/web/src/App.vue +++ b/clients/web/src/App.vue @@ -5,9 +5,11 @@ import { defineAsyncComponent, ref } from "vue"; // @ts-expect-error this lib is not typed lol import canAutoplay from "can-autoplay"; import { visualizerEnabled } from "./visualizer"; +import { enableAnalysis } from "./analysis"; import { setupMediaSession } from "./mediaSession"; import { audioCtx } from "./audio"; const TheChurner = defineAsyncComponent(() => import("./components/TheChurner.vue")); +const TheRedundantInfo = defineAsyncComponent(() => import("./components/TheRedundantInfo.vue")); interface CanAutoplay { result: boolean; @@ -36,6 +38,9 @@ function handleEnterClick() { + + + diff --git a/clients/web/src/components/AudioSpectrum.vue b/clients/web/src/components/AudioSpectrum.vue index 2f8645d..e086ed2 100644 --- a/clients/web/src/components/AudioSpectrum.vue +++ b/clients/web/src/components/AudioSpectrum.vue @@ -1,11 +1,8 @@ diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index 1757e6c..b72ee97 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -8,12 +8,9 @@ import { getClient } from "@/syncClient"; import TheClients from "./TheClients.vue"; import { timePromise } from "@/util"; import { visualizerEnabled } from "@/visualizer"; +import { enableAnalysis } from "@/analysis"; import fallbackart from "@/assets/fallbackart_10x.png"; -import WaveForm from "./WaveForm.vue"; -import AudioSpectrum from "@/components/AudioSpectrum.vue"; -import { downscaled, singlePeriod, fftd } from "@/analysis"; - const visualizerSupported = isButterchurnSupported(); const client = await timePromise.then(() => getClient()); @@ -56,9 +53,6 @@ const randomQuote = computed(() => class="h-px bg-white" :style="{ width: (100 * (seek ?? 0)) / getDuration() + '%' }" /> - - -
VOL
@@ -67,11 +61,16 @@ const randomQuote = computed(() => {{ randomQuote }}
- +
+ + +
diff --git a/clients/web/src/components/TheRedundantInfo.vue b/clients/web/src/components/TheRedundantInfo.vue new file mode 100644 index 0000000..31518f5 --- /dev/null +++ b/clients/web/src/components/TheRedundantInfo.vue @@ -0,0 +1,42 @@ + + + diff --git a/clients/web/src/components/WaveForm.vue b/clients/web/src/components/WaveForm.vue index f9ca873..f9c1380 100644 --- a/clients/web/src/components/WaveForm.vue +++ b/clients/web/src/components/WaveForm.vue @@ -1,11 +1,8 @@ @@ -19,9 +19,11 @@ import { getDuration, seek } from "@/audio";
moodbar
-
+
+
+ -
scrolling wave
+
scrolling spectrogram
diff --git a/clients/web/src/components/WaveForm.vue b/clients/web/src/components/WaveForm.vue index f9c1380..bea850e 100644 --- a/clients/web/src/components/WaveForm.vue +++ b/clients/web/src/components/WaveForm.vue @@ -22,7 +22,7 @@ onMounted(() => { const len = buffer.length; const dx = W / len; - const scale = 1; /// Math.max(...buffer.map(Math.abs)); + const scale = 1 / Math.max(1, ...buffer.map(Math.abs)); ctx.clearRect(0, 0, W, H); @@ -31,15 +31,19 @@ onMounted(() => { ctx.moveTo(0, H / 2); ctx.beginPath(); for (let i = 0; i < len; i++) { + x = i * dx; + ctx.lineTo(x, (H / 2) * (1 - buffer[i] * scale)); - x += dx; + //x += dx; } if (props.fill) { // draw bottom half, a mirror of the top half for (let i = len - 1; i >= 0; i--) { + x = i * dx; + ctx.lineTo(x, (H / 2) * (1 + buffer[i] * scale)); - x -= dx; + //x -= dx; } ctx.closePath(); ctx.fill(); From ef7cce858589ad1035fbec1be0a19aa2580681c9 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Tue, 11 Jun 2024 22:36:23 +0100 Subject: [PATCH 16/29] [vis] wasm fastutils to better isolate unsafe code and add better asserts / checks this will improve debugging, and memory errors from reading / writing in the wrong place should manifest as assert errors instead of random assert failures inside the GC runtime now --- clients/web/dsp-asm/assembly/fastutils.ts | 126 ++++++++++++++++++++++ clients/web/dsp-asm/assembly/index.ts | 95 +++++----------- 2 files changed, 150 insertions(+), 71 deletions(-) create mode 100644 clients/web/dsp-asm/assembly/fastutils.ts diff --git a/clients/web/dsp-asm/assembly/fastutils.ts b/clients/web/dsp-asm/assembly/fastutils.ts new file mode 100644 index 0000000..837b880 --- /dev/null +++ b/clients/web/dsp-asm/assembly/fastutils.ts @@ -0,0 +1,126 @@ +// uwr fastutils for assemblyscript +// a small and application specific wrapper library for otherwise unsafe ops by hazel + +/** UNSAFE CONTRACT: len MUST be a multiple of 4, and within buffer bounds */ +export function listAbsMax_f32_UNCHECKED(buf: Float32Array, len: i32): f32 { + let running = v128.splat(0); + + for (let i = 0; i < len; i += sizeof() / sizeof()) { + // load up a vec + const vec = f32x4.abs(v128.load(buf.dataStart + i * sizeof())); + // pmax is faster than max due to not treating nulls etc + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/compiler/backend/x64/code-generator-x64.cc;drc=8ab75a56a24f34d4f582261c99939ffa1446a3b7;l=2712 + running = f32x4.pmax(running, vec); + } + + return Mathf.max( + Mathf.max(f32x4.extract_lane(running, 0), f32x4.extract_lane(running, 1)), + Mathf.max(f32x4.extract_lane(running, 2), f32x4.extract_lane(running, 3)), + ); +} + +/** UNSAFE CONTRACT: len MUST be a multiple of 4, and within buffer bounds */ +export function listMax_f32_UNCHECKED(buf: Float32Array, len: i32): f32 { + let running = v128.splat(f32.MIN_VALUE); + + for (let i = 0; i < len; i += sizeof() / sizeof()) { + // load up a vec + const vec = v128.load(buf.dataStart + i * sizeof()); + // pmax is faster than max due to not treating nulls etc + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/compiler/backend/x64/code-generator-x64.cc;drc=8ab75a56a24f34d4f582261c99939ffa1446a3b7;l=2712 + running = f32x4.pmax(running, vec); + } + + return Mathf.max( + Mathf.max(f32x4.extract_lane(running, 0), f32x4.extract_lane(running, 1)), + Mathf.max(f32x4.extract_lane(running, 2), f32x4.extract_lane(running, 3)), + ); +} + +/** finds the maximum element of the buffer with SIMD */ +export function listMax_f32(buf: Float32Array): f32 { + assert(buf.length, "cannot get max of an empty buffer"); + + const simdSafeLen = 4 * (buf.length / 4); + + // if buffer is smaller than 4, this will return f32.MIN_VALUE + let running = listMax_f32_UNCHECKED(buf, simdSafeLen); + + for (let i = simdSafeLen; i < buf.length; i++) { + running = Mathf.max(running, buf[i]); + } + + return running; +} + +/** finds the maximum element of the absolute values of the buffer with SIMD */ +export function listAbsMax_f32(buf: Float32Array): f32 { + assert(buf.length, "cannot get abs max of an empty buffer"); + + const simdSafeLen = 4 * (buf.length / 4); + + // if buffer is smaller than 4, this will return 0 + let running = listAbsMax_f32_UNCHECKED(buf, simdSafeLen); + + for (let i = simdSafeLen; i < buf.length; i++) { + running = Mathf.max(running, Mathf.abs(buf[i])); + } + + return running; +} + +/** checked fast memory copy between two buffers. + * pass two buffers, the bytes per element, the offsets for where to write to, and the number of elemments. + * a raw byte copy can be achieved with sz = 1. + * takes isize not usize for checking reasons. */ +export function memcpy( + src: ArrayBuffer, + dst: ArrayBuffer, + srcOset: isize, + dstOset: isize, + elSz: isize, + n: isize, +): void { + assert((srcOset + n) * elSz <= src.byteLength, "source will overflow"); + assert((dstOset + n) * elSz <= dst.byteLength, "destination will overflow"); + assert(elSz > 0, "element size must be >0"); + assert(srcOset >= 0, "source offset must be >=0"); + assert(dstOset >= 0, "destination offset must be >=0"); + assert(n >= 0, "elems copied must be >=0"); + + // https://github.com/AssemblyScript/assemblyscript/issues/743#issuecomment-518022808 + const srcPtr = changetype(src); + const dstPtr = changetype(dst); + + if (n !== 0) { + memory.copy(dstPtr + elSz * dstOset, srcPtr + elSz * srcOset, elSz * n); + } +} + +/** checked fast memory copy between two float32arrays */ +@inline +export function memcpy_f32( + src: Float32Array, + dst: Float32Array, + srcOset: isize, + dstOset: isize, + n: isize, +): void { + return memcpy(src.buffer, dst.buffer, srcOset, dstOset, Float32Array.BYTES_PER_ELEMENT, n); +} + +/** checked load f32x4 from Float32Array */ +export function f32x4_load(buf: Float32Array, i: isize): v128 { + assert(i >= 0, "index must be >=0"); + assert(i + 3 < buf.length, "index will overflow"); + + return v128.load(buf.dataStart + i * Float32Array.BYTES_PER_ELEMENT); +} + +/** checked store f32x4 to Float32Array */ +export function f32x4_store(v: v128, buf: Float32Array, i: isize): void { + assert(i >= 0, "index must be >=0"); + assert(i + 3 < buf.length, "index will overflow"); + + v128.store(buf.dataStart + i * Float32Array.BYTES_PER_ELEMENT, v); +} diff --git a/clients/web/dsp-asm/assembly/index.ts b/clients/web/dsp-asm/assembly/index.ts index 673bea6..9bfa76e 100644 --- a/clients/web/dsp-asm/assembly/index.ts +++ b/clients/web/dsp-asm/assembly/index.ts @@ -1,4 +1,11 @@ import { FFT } from "./fft"; +import { + f32x4_load, + f32x4_store, + listAbsMax_f32, + listMax_f32_UNCHECKED, + memcpy_f32, +} from "./fastutils"; // copying arrays across thread boundaries is very expensive, so provide a mechanism to reuse it. let currentBuf: Float32Array | null; @@ -14,25 +21,14 @@ export function downscale(_buf: Float32Array | null, size: i32): Float32Array { assert(buf, "cannot pass a null buffer without first uploading a buffer"); // round chunk up to a multiple of 4 const fchunkSz = 4 * Math.ceil(buf.length / size / 4); - const nChunks = Math.ceil(buf.length / fchunkSz); + const nChunks = Math.floor(buf.length / fchunkSz); const chunkSz = fchunkSz; const res = new Float32Array(nChunks); for (let ci = 0; ci < nChunks; ci++) { - let max = v128.splat(0); - const chunk = buf.subarray(ci * chunkSz, (ci + 1) * chunkSz); - const ptr = chunk.dataStart; - - // 4 floats per vec - for (let i = 0; i < chunk.length; i += 4) { - const vec = v128.load(ptr + i * sizeof()); - max = f32x4.max(max, vec); - } - - res[ci] = Mathf.max( - Mathf.max(f32x4.extract_lane(max, 0), f32x4.extract_lane(max, 1)), - Mathf.max(f32x4.extract_lane(max, 2), f32x4.extract_lane(max, 3)), - ); + const b = buf.subarray(ci * chunkSz, (ci + 1) * chunkSz); + // UNSAFE: the length of a chunk is always % 4 + res[ci] = listMax_f32_UNCHECKED(b, b.length); } return res; @@ -61,11 +57,7 @@ export function fft( const fftInput = new Float32Array(size); // copy in buffer - memory.copy( - fftInput.dataStart, - buf.dataStart + start * Float32Array.BYTES_PER_ELEMENT, - (end - start) * Float32Array.BYTES_PER_ELEMENT, - ); + memcpy_f32(buf, fftInput, start, 0, end - start); const fft = new FFT(size); const output = fft.createComplexArray(); @@ -83,25 +75,17 @@ export function fft( // round size down to 4 for simd purposes const out = new Float32Array(lastFft!.length); - // TODO: null errors. why? - /*const simdSafeSize = 4 * (out.length / 4); - const outPtr = out.dataStart; - const lastPtr = lastFft!.dataStart; - const resPtr = result.dataStart; - - for (let i = 0; i < 4 * (out.length / 4); i++) { + const simdSafeSize = 4 * (out.length / 4); + + for (let i = 0; i < simdSafeSize; i += 4) { // out_i = max(res_i, last_i * persistence) - v128.store( - outPtr + i * sizeof(), - f32x4.max( - v128.load(resPtr + i * sizeof()), - f32x4.mul(v128.load(lastPtr + i * sizeof()), f32x4.splat(persistence)), - ), - ); - }*/ + const persisted = f32x4.mul(f32x4_load(lastFft!, i), f32x4.splat(persistence)); + + f32x4_store(f32x4.max(f32x4_load(result, i), persisted), out, i); + } // do last few elements - for (let i = /*simdSafeSize*/ 0; i < out.length; i++) { + for (let i = simdSafeSize; i < out.length; i++) { out[i] = result[i] + lastFft![i] * persistence; } @@ -158,28 +142,11 @@ function maxAbsPeakOf(bufs: Array): Float32Array { for (let bi = 0; bi < bufs.length; bi++) { const b = bufs[bi]; - let maxV = f32x4.splat(0); - const ptr = b.dataStart; - - // max over chunks of 4 floats, very quickly - const len = 4 * (b.length / 4); - for (let i = 0; i < len; i += 4) { - const vec = f32x4.abs(v128.load(ptr + i * sizeof())); - maxV = f32x4.max(maxV, vec); - } - let finalMax = Mathf.max( - Mathf.max(f32x4.extract_lane(maxV, 0), f32x4.extract_lane(maxV, 1)), - Mathf.max(f32x4.extract_lane(maxV, 2), f32x4.extract_lane(maxV, 3)), - ); - - // now just take the values we missed off due to only taking fours - for (let i = len; i < b.length; i++) { - finalMax = Mathf.max(finalMax, Mathf.abs(b[i])); - } + const chunkMax = listAbsMax_f32(b); - if (finalMax < max) continue; - max = finalMax; + if (chunkMax < max) continue; + max = chunkMax; maxBuf = b; } @@ -214,21 +181,7 @@ export function centeredSlice( } else { // unhappy path - we need to pad it! padded = new Float32Array(width); - // setup copy - const inOset = start < 0 ? 0 : start; - // safe version for debugging in case fast version causes memory issues (rt asserts) - /*for (let i = 0; i < width - padE - padS; i++) { - assert(i + padS < padded.length, "(centeredSlice) dest must stay in range"); - assert(i + inOset < buf.length, - "(centeredSlice) src must stay in range (i: " + i.toString() + " width: " + width.toString() + " padS: " + padS.toString() + " padE: " + padE.toString() + ")" - ); - padded[i + padS] = buf[i + inOset]; - }*/ - const copyInStart = buf.dataStart + Float32Array.BYTES_PER_ELEMENT * (inOset); - const copyOutStart = padded.dataStart + Float32Array.BYTES_PER_ELEMENT * padS; - const copyLen = Float32Array.BYTES_PER_ELEMENT * (width - padE - padS); - - memory.copy(copyOutStart, copyInStart, copyLen); + memcpy_f32(buf, padded, start < 0 ? 0 : start, padS, width - padE - padS); } // TODO: fix shimmering due to downscaling From f20633d405ee2cb89b203aae3a0df7b78894b9b5 Mon Sep 17 00:00:00 2001 From: Hazel Atkinson Date: Wed, 12 Jun 2024 16:19:14 +0100 Subject: [PATCH 17/29] [vis] basic metering --- clients/web/dsp-asm/assembly/fastutils.ts | 32 ++++++++++++ clients/web/dsp-asm/assembly/index.ts | 21 +++++++- clients/web/src/analysis.ts | 39 +++++++++++++-- clients/web/src/analysisWorker.js | 12 +++-- .../web/src/components/TheRedundantInfo.vue | 50 +++++++++++++++++-- 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/clients/web/dsp-asm/assembly/fastutils.ts b/clients/web/dsp-asm/assembly/fastutils.ts index 837b880..7b33a3a 100644 --- a/clients/web/dsp-asm/assembly/fastutils.ts +++ b/clients/web/dsp-asm/assembly/fastutils.ts @@ -69,6 +69,38 @@ export function listAbsMax_f32(buf: Float32Array): f32 { return running; } +/** UNSAFE CONTRACT: len MUST be a multiple of 4, and within buffer bounds */ +export function listSqSum_f32_UNCHECKED(buf: Float32Array, len: i32): f32 { + let running = v128.splat(0); + + for (let i = 0; i < len; i += sizeof() / sizeof()) { + // load up a vec + const vec = v128.load(buf.dataStart + i * sizeof()); + running = f32x4.add(running, f32x4.mul(vec, vec)); + } + + return f32x4.extract_lane(running, 0) + + f32x4.extract_lane(running, 1) + + f32x4.extract_lane(running, 2) + + f32x4.extract_lane(running, 3); +} + +/** finds the sum of the squares of the buffer with SIMD */ +export function listSqSum_f32(buf: Float32Array): f32 { + assert(buf.length, "cannot get abs max of an empty buffer"); + + const simdSafeLen = 4 * (buf.length / 4); + + // if buffer is smaller than 4, this will return 0 + let running = listSqSum_f32_UNCHECKED(buf, simdSafeLen); + + for (let i = simdSafeLen; i < buf.length; i++) { + running += buf[i] * buf[i]; + } + + return running; +} + /** checked fast memory copy between two buffers. * pass two buffers, the bytes per element, the offsets for where to write to, and the number of elemments. * a raw byte copy can be achieved with sz = 1. diff --git a/clients/web/dsp-asm/assembly/index.ts b/clients/web/dsp-asm/assembly/index.ts index 9bfa76e..5a13c12 100644 --- a/clients/web/dsp-asm/assembly/index.ts +++ b/clients/web/dsp-asm/assembly/index.ts @@ -3,7 +3,7 @@ import { f32x4_load, f32x4_store, listAbsMax_f32, - listMax_f32_UNCHECKED, + listMax_f32_UNCHECKED, listSqSum_f32, memcpy_f32, } from "./fastutils"; @@ -188,3 +188,22 @@ export function centeredSlice( // increasing the downs size enough to remove shimmering while scrolling is too slow return downs == -1 ? padded : downscale(padded, downs); } + +export function samplePeak(_buf: Float32Array | null, start: i32, end: i32): f32 { + const buf = _buf ? _buf : currentBuf!; + assert(buf, "cannot pass a null buffer without first uploading a buffer"); + if (start < 1) start = 0; + if (end < 1) end = buf.length; + + return listAbsMax_f32(buf.subarray(start, end)); +} + +export function rms(_buf: Float32Array | null, start: i32, end: i32): f32 { + const buf = _buf ? _buf : currentBuf!; + assert(buf, "cannot pass a null buffer without first uploading a buffer"); + if (start < 1) start = 0; + if (end < 1) end = buf.length; + + // root-mean-square is [the root of [the mean of [the squares]]] + return Mathf.sqrt(listSqSum_f32(buf.subarray(start, end)) / (end - start)); +} diff --git a/clients/web/src/analysis.ts b/clients/web/src/analysis.ts index cab9261..8496941 100644 --- a/clients/web/src/analysis.ts +++ b/clients/web/src/analysis.ts @@ -11,6 +11,7 @@ const buf = ref(); export function setAnalysisBuf(b: AudioBuffer) { buf.value = b; + reset(); } // === WORKER HANDLING CODE === @@ -66,6 +67,12 @@ const fft = ( const centeredSlice = (buf: undefined | Float32Array, pos: number, width: number, downs?: number) => callWorker(5, [buf, pos, width, downs]); +const samplePeak = (buf: undefined | Float32Array, start?: number, end?: number) => + callWorker(6, [buf, start, end]); + +const rms = (buf: undefined | Float32Array, start?: number, end?: number) => + callWorker(7, [buf, start, end]); + // === USEFUL REACTIVE STUFF === export const downscaled = ref(); @@ -76,6 +83,21 @@ export const fftd = ref(); export const slice = ref(); +export const currentPeak = ref(0); +export const currentRms = ref(0); + +export const peakDbfs = () => 20 * Math.log10(currentPeak.value); +export const rmsDbfs = () => 20 * Math.log10(currentRms.value); + +function reset() { + downscaled.value = undefined; + singlePeriod.value = undefined; + fftd.value = undefined; + slice.value = undefined; + currentPeak.value = 0; + currentRms.value = 0; +} + const innerCleanups: WatchStopHandle[] = []; watchEffect(async () => { innerCleanups.forEach((c) => c()); @@ -97,17 +119,24 @@ watchEffect(async () => { Math.abs, ); singlePeriod.value = await sbcMax(undefined, seekSamples, seekSamples + 5000, 2); - } - else { + } else { singlePeriod.value = undefined; fftd.value = undefined; } + const s16m = ~~(buf.value!.sampleRate / 60); + const s300m = ~~(buf.value!.sampleRate * 0.3); + if (seekSamples - s16m >= 0) { + const pk = await samplePeak(undefined, seekSamples - s16m, seekSamples); + currentPeak.value = Math.max(currentPeak.value * 0.9, pk); + } + if (seekSamples - s300m >= 0) { + currentRms.value = await rms(undefined, seekSamples - s300m, seekSamples); + } + const sliceLen = 7.5 * buf.value!.sampleRate; slice.value = await centeredSlice(undefined, seekSamples, sliceLen, 5_000); }), ); - } else { - downscaled.value = undefined; - } + } else reset(); }); diff --git a/clients/web/src/analysisWorker.js b/clients/web/src/analysisWorker.js index dc0965f..aa7ba48 100644 --- a/clients/web/src/analysisWorker.js +++ b/clients/web/src/analysisWorker.js @@ -1,18 +1,20 @@ import wasmUrl from "../dsp-asm/build/release.wasm?url"; import { instantiate } from "../dsp-asm/build/release.js"; -let wasmDS, wasmFFT, wasmUpload, wasmSbcMax, wasmCentSlic, init; +let wasmDS, wasmFFT, wasmUpload, wasmSbcMax, wasmCentSlic, wasmSamPk, wasmRms, init; function wasmInit() { if (init) return init; return (init = WebAssembly.compileStreaming(fetch(wasmUrl)) .then(instantiate) - .then(({ downscale, fft, uploadBuf, sbcMax, centeredSlice }) => { + .then(({ downscale, fft, uploadBuf, sbcMax, centeredSlice, samplePeak, rms }) => { wasmDS = downscale; wasmFFT = fft; wasmUpload = uploadBuf; wasmSbcMax = sbcMax; wasmCentSlic = centeredSlice; + wasmSamPk = samplePeak; + wasmRms = rms; })); } @@ -27,9 +29,13 @@ const fft = (buf, start, end, pad, persistence) => const centeredSlice = (buf, pos, width, downs) => wasmCentSlic(buf, pos, width, downs ?? -1); +const samplePeak = (buf, start, end) => wasmSamPk(buf, start ?? -1, end ?? -1); + +const rms = (buf, start, end) => wasmRms(buf, start ?? -1, end ?? -1); + onmessage = (e) => { // rip type safety - const func = [wasmInit, uploadBuffer, downscale, sbcMax, fft, centeredSlice][e.data[0]]; + const func = [wasmInit, uploadBuffer, downscale, sbcMax, fft, centeredSlice, samplePeak, rms][e.data[0]]; if (!func) postMessage(["ERR", `${e.data[0]} is not a command`]); const res = func(...e.data.slice(2)); diff --git a/clients/web/src/components/TheRedundantInfo.vue b/clients/web/src/components/TheRedundantInfo.vue index 9f611de..df2fa25 100644 --- a/clients/web/src/components/TheRedundantInfo.vue +++ b/clients/web/src/components/TheRedundantInfo.vue @@ -1,8 +1,29 @@