diff --git a/packages/codemirror/widget.mjs b/packages/codemirror/widget.mjs
index 72b4ec65d..42d3b1512 100644
--- a/packages/codemirror/widget.mjs
+++ b/packages/codemirror/widget.mjs
@@ -133,3 +133,10 @@ registerWidget('_pitchwheel', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.pitchwheel({ ...options, ctx, id });
});
+
+registerWidget('_spectrum', (id, options = {}, pat) => {
+ let _size = options.size || 200;
+ options = { width: _size, height: _size, ...options, size: _size / 5 };
+ const ctx = getCanvasWidget(id, options).getContext('2d');
+ return pat.spectrum({ ...options, ctx, id });
+});
diff --git a/packages/draw/draw.mjs b/packages/draw/draw.mjs
index e37376003..0576c297b 100644
--- a/packages/draw/draw.mjs
+++ b/packages/draw/draw.mjs
@@ -26,7 +26,7 @@ export const getDrawContext = (id = 'test-canvas', options) => {
}, 200);
});
}
- return canvas.getContext(contextType);
+ return canvas.getContext(contextType, { willReadFrequently: true });
};
let animationFrames = {};
diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs
index 7d57b5bbb..b61f42637 100644
--- a/packages/superdough/superdough.mjs
+++ b/packages/superdough/superdough.mjs
@@ -270,11 +270,12 @@ function getReverb(orbit, duration, fade, lp, dim, ir) {
export let analysers = {},
analysersData = {};
-export function getAnalyserById(id, fftSize = 1024) {
+export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
if (!analysers[id]) {
// make sure this doesn't happen too often as it piles up garbage
const analyserNode = getAudioContext().createAnalyser();
analyserNode.fftSize = fftSize;
+ analyserNode.smoothingTimeConstant = smoothingTimeConstant;
// getDestination().connect(analyserNode);
analysers[id] = analyserNode;
analysersData[id] = new Float32Array(analysers[id].frequencyBinCount);
diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs
index a425e6835..59672b617 100644
--- a/packages/webaudio/index.mjs
+++ b/packages/webaudio/index.mjs
@@ -6,4 +6,5 @@ This program is free software: you can redistribute it and/or modify it under th
export * from './webaudio.mjs';
export * from './scope.mjs';
+export * from './spectrum.mjs';
export * from 'superdough';
diff --git a/packages/webaudio/spectrum.mjs b/packages/webaudio/spectrum.mjs
new file mode 100644
index 000000000..2ddd214fa
--- /dev/null
+++ b/packages/webaudio/spectrum.mjs
@@ -0,0 +1,69 @@
+import { Pattern, clamp } from '@strudel/core';
+import { getDrawContext, getTheme } from '@strudel/draw';
+import { analysers, getAnalyzerData } from 'superdough';
+
+/**
+ * Renders a spectrum analyzer for the incoming audio signal.
+ * @name spectrum
+ * @param {object} config optional config with options:
+ * @param {integer} thickness line thickness in px (default 3)
+ * @param {integer} speed scroll speed (default 1)
+ * @param {integer} min min db (default -80)
+ * @param {integer} max max db (default 0)
+ * @example
+ * n("<0 4 <2 3> 1>*3")
+ * .off(1/8, add(n(5)))
+ * .off(1/5, add(n(7)))
+ * .scale("d3:minor:pentatonic")
+ * .s('sine')
+ * .dec(.3).room(.5)
+ * ._spectrum()
+ */
+let latestColor = {};
+Pattern.prototype.spectrum = function (config = {}) {
+ let id = config.id ?? 1;
+ return this.analyze(id).draw(
+ (haps) => {
+ config.color = haps[0]?.value?.color || latestColor[id] || getTheme().foreground;
+ latestColor[id] = config.color;
+ drawSpectrum(analysers[id], config);
+ },
+ { id },
+ );
+};
+
+Pattern.prototype.scope = Pattern.prototype.tscope;
+
+const lastFrames = new Map();
+
+function drawSpectrum(
+ analyser,
+ { thickness = 3, speed = 1, min = -80, max = 0, ctx = getDrawContext(), id = 1, color } = {},
+) {
+ ctx.lineWidth = thickness;
+ ctx.strokeStyle = color;
+
+ if (!analyser) {
+ // if analyser is undefined, draw straight line
+ // it may be undefined when no sound has been played yet
+ return;
+ }
+ const scrollSize = speed;
+ const dataArray = getAnalyzerData('frequency', id);
+ const canvas = ctx.canvas;
+ ctx.fillStyle = color;
+ const bufferSize = analyser.frequencyBinCount;
+ let imageData = lastFrames.get(id) || ctx.getImageData(0, 0, canvas.width, canvas.height);
+ lastFrames.set(id, imageData);
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ ctx.putImageData(imageData, -scrollSize, 0);
+ let q = canvas.width - speed;
+ for (let i = 0; i < bufferSize; i++) {
+ const normalized = clamp((dataArray[i] - min) / (max - min), 0, 1);
+ ctx.globalAlpha = normalized;
+ const next = (Math.log(i + 1) / Math.log(bufferSize)) * canvas.height;
+ const size = 2; //next - pos;
+ ctx.fillRect(q, canvas.height - next, scrollSize, size);
+ }
+ lastFrames.set(id, ctx.getImageData(0, 0, canvas.width, canvas.height));
+}
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 3ad6ea01e..7c21d4641 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -7508,6 +7508,75 @@ exports[`runs examples > example "sometimesBy" example index 0 1`] = `
]
`;
+exports[`runs examples > example "spectrum" example index 0 1`] = `
+[
+ "[ -5/24 ⇜ (0/1 → 1/8) | note:F4 s:sine decay:0.3 room:0.5 ]",
+ "[ -2/15 ⇜ (0/1 → 1/5) | note:A4 s:sine decay:0.3 room:0.5 ]",
+ "[ -1/120 ⇜ (0/1 → 1/5) ⇝ 13/40 | note:A5 s:sine decay:0.3 room:0.5 ]",
+ "[ 0/1 → 1/3 | note:D3 s:sine decay:0.3 room:0.5 ]",
+ "[ 1/8 → 11/24 | note:D4 s:sine decay:0.3 room:0.5 ]",
+ "[ -1/120 ⇜ (1/5 → 13/40) | note:A5 s:sine decay:0.3 room:0.5 ]",
+ "[ 1/5 → 8/15 | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ 13/40 → 79/120 | note:G5 s:sine decay:0.3 room:0.5 ]",
+ "[ 1/3 → 2/3 | note:C4 s:sine decay:0.3 room:0.5 ]",
+ "[ 11/24 → 19/24 | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ 8/15 → 13/15 | note:F5 s:sine decay:0.3 room:0.5 ]",
+ "[ 79/120 → 119/120 | note:F6 s:sine decay:0.3 room:0.5 ]",
+ "[ 2/3 → 1/1 | note:G3 s:sine decay:0.3 room:0.5 ]",
+ "[ (19/24 → 1/1) ⇝ 9/8 | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ (13/15 → 1/1) ⇝ 6/5 | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ (119/120 → 1/1) ⇝ 53/40 | note:C6 s:sine decay:0.3 room:0.5 ]",
+ "[ 19/24 ⇜ (1/1 → 9/8) | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ 13/15 ⇜ (1/1 → 6/5) | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ 119/120 ⇜ (1/1 → 6/5) ⇝ 53/40 | note:C6 s:sine decay:0.3 room:0.5 ]",
+ "[ 1/1 → 4/3 | note:F3 s:sine decay:0.3 room:0.5 ]",
+ "[ 9/8 → 35/24 | note:F4 s:sine decay:0.3 room:0.5 ]",
+ "[ 119/120 ⇜ (6/5 → 53/40) | note:C6 s:sine decay:0.3 room:0.5 ]",
+ "[ 6/5 → 23/15 | note:A4 s:sine decay:0.3 room:0.5 ]",
+ "[ 53/40 → 199/120 | note:A5 s:sine decay:0.3 room:0.5 ]",
+ "[ 4/3 → 5/3 | note:D3 s:sine decay:0.3 room:0.5 ]",
+ "[ 35/24 → 43/24 | note:D4 s:sine decay:0.3 room:0.5 ]",
+ "[ 23/15 → 28/15 | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ 199/120 → 239/120 | note:G5 s:sine decay:0.3 room:0.5 ]",
+ "[ 5/3 → 2/1 | note:C4 s:sine decay:0.3 room:0.5 ]",
+ "[ (43/24 → 2/1) ⇝ 17/8 | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ (28/15 → 2/1) ⇝ 11/5 | note:F5 s:sine decay:0.3 room:0.5 ]",
+ "[ (239/120 → 2/1) ⇝ 93/40 | note:F6 s:sine decay:0.3 room:0.5 ]",
+ "[ 43/24 ⇜ (2/1 → 17/8) | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ 28/15 ⇜ (2/1 → 11/5) | note:F5 s:sine decay:0.3 room:0.5 ]",
+ "[ 239/120 ⇜ (2/1 → 11/5) ⇝ 93/40 | note:F6 s:sine decay:0.3 room:0.5 ]",
+ "[ 2/1 → 7/3 | note:A3 s:sine decay:0.3 room:0.5 ]",
+ "[ 17/8 → 59/24 | note:A4 s:sine decay:0.3 room:0.5 ]",
+ "[ 239/120 ⇜ (11/5 → 93/40) | note:F6 s:sine decay:0.3 room:0.5 ]",
+ "[ 11/5 → 38/15 | note:D5 s:sine decay:0.3 room:0.5 ]",
+ "[ 93/40 → 319/120 | note:D6 s:sine decay:0.3 room:0.5 ]",
+ "[ 7/3 → 8/3 | note:F3 s:sine decay:0.3 room:0.5 ]",
+ "[ 59/24 → 67/24 | note:F4 s:sine decay:0.3 room:0.5 ]",
+ "[ 38/15 → 43/15 | note:A4 s:sine decay:0.3 room:0.5 ]",
+ "[ 319/120 → 359/120 | note:A5 s:sine decay:0.3 room:0.5 ]",
+ "[ 8/3 → 3/1 | note:D3 s:sine decay:0.3 room:0.5 ]",
+ "[ (67/24 → 3/1) ⇝ 25/8 | note:D4 s:sine decay:0.3 room:0.5 ]",
+ "[ (43/15 → 3/1) ⇝ 16/5 | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ (359/120 → 3/1) ⇝ 133/40 | note:G5 s:sine decay:0.3 room:0.5 ]",
+ "[ 67/24 ⇜ (3/1 → 25/8) | note:D4 s:sine decay:0.3 room:0.5 ]",
+ "[ 43/15 ⇜ (3/1 → 16/5) | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ 359/120 ⇜ (3/1 → 16/5) ⇝ 133/40 | note:G5 s:sine decay:0.3 room:0.5 ]",
+ "[ 3/1 → 10/3 | note:C4 s:sine decay:0.3 room:0.5 ]",
+ "[ 25/8 → 83/24 | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ 359/120 ⇜ (16/5 → 133/40) | note:G5 s:sine decay:0.3 room:0.5 ]",
+ "[ 16/5 → 53/15 | note:F5 s:sine decay:0.3 room:0.5 ]",
+ "[ 133/40 → 439/120 | note:F6 s:sine decay:0.3 room:0.5 ]",
+ "[ 10/3 → 11/3 | note:G3 s:sine decay:0.3 room:0.5 ]",
+ "[ 83/24 → 91/24 | note:G4 s:sine decay:0.3 room:0.5 ]",
+ "[ 53/15 → 58/15 | note:C5 s:sine decay:0.3 room:0.5 ]",
+ "[ 439/120 → 479/120 | note:C6 s:sine decay:0.3 room:0.5 ]",
+ "[ 11/3 → 4/1 | note:F3 s:sine decay:0.3 room:0.5 ]",
+ "[ (91/24 → 4/1) ⇝ 33/8 | note:F4 s:sine decay:0.3 room:0.5 ]",
+ "[ (58/15 → 4/1) ⇝ 21/5 | note:A4 s:sine decay:0.3 room:0.5 ]",
+ "[ (479/120 → 4/1) ⇝ 173/40 | note:A5 s:sine decay:0.3 room:0.5 ]",
+]
+`;
+
exports[`runs examples > example "speed" example index 0 1`] = `
[
"[ 0/1 → 1/6 | s:bd speed:1 ]",
diff --git a/test/runtime.mjs b/test/runtime.mjs
index a8f37a34e..18d4a29e8 100644
--- a/test/runtime.mjs
+++ b/test/runtime.mjs
@@ -134,6 +134,9 @@ strudel.Pattern.prototype._pitchwheel = function () {
strudel.Pattern.prototype._pianoroll = function () {
return this;
};
+strudel.Pattern.prototype._spectrum = function () {
+ return this;
+};
strudel.Pattern.prototype.markcss = function () {
return this;
};
diff --git a/website/src/pages/learn/visual-feedback.mdx b/website/src/pages/learn/visual-feedback.mdx
index ec275c6e2..35202ca6e 100644
--- a/website/src/pages/learn/visual-feedback.mdx
+++ b/website/src/pages/learn/visual-feedback.mdx
@@ -99,6 +99,10 @@ What follows is the API doc of all the options you can pass:
+## Spectrum
+
+
+
## markcss