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