From 7370f41fa0556406109be259cd24387d4a8bff78 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 09:45:30 +0200 Subject: [PATCH] basic scope feature --- packages/core/controls.mjs | 3 ++ packages/core/draw.mjs | 2 +- packages/core/index.mjs | 1 + packages/core/scope.mjs | 48 ++++++++++++++++++++++++++++++ packages/superdough/superdough.mjs | 39 +++++++++++++++++++++++- 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 packages/core/scope.mjs diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index b8edd51c..a02a5805 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -146,6 +146,9 @@ const generic_params = [ */ ['bank'], + ['analyze'], // sends + ['fft'], + /** * Amplitude envelope decay time: the time it takes after the attack time to reach the sustain level. * Note that the decay is only audible if the sustain value is lower than 1. diff --git a/packages/core/draw.mjs b/packages/core/draw.mjs index 5ea0c591..8be45e03 100644 --- a/packages/core/draw.mjs +++ b/packages/core/draw.mjs @@ -27,7 +27,7 @@ export const getDrawContext = (id = 'test-canvas') => { return canvas.getContext('2d'); }; -Pattern.prototype.draw = function (callback, { from, to, onQuery }) { +Pattern.prototype.draw = function (callback, { from, to, onQuery } = {}) { if (window.strudelAnimation) { cancelAnimationFrame(window.strudelAnimation); } diff --git a/packages/core/index.mjs b/packages/core/index.mjs index bed63f9a..ceffdcbe 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -22,6 +22,7 @@ export * from './cyclist.mjs'; export * from './logger.mjs'; export * from './time.mjs'; export * from './draw.mjs'; +export * from './scope.mjs'; export * from './animate.mjs'; export * from './pianoroll.mjs'; export * from './spiral.mjs'; diff --git a/packages/core/scope.mjs b/packages/core/scope.mjs new file mode 100644 index 00000000..62b08e62 --- /dev/null +++ b/packages/core/scope.mjs @@ -0,0 +1,48 @@ +import { Pattern } from './pattern.mjs'; +import { getDrawContext } from './draw.mjs'; +import { analyser } from '@strudel.cycles/webaudio'; + +export function drawTimeScope(analyser, dataArray, { align = true, color = 'white', thickness = 2 } = {}) { + const canvasCtx = getDrawContext(); + + canvasCtx.lineWidth = thickness; + canvasCtx.strokeStyle = color; + + canvasCtx.beginPath(); + let canvas = canvasCtx.canvas; + + const bufferSize = analyser.frequencyBinCount; + const triggerValue = 256 / 2; + const triggerIndex = align + ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] < triggerValue && v >= triggerValue) + : 0; + + const sliceWidth = (canvas.width * 1.0) / bufferSize; + let x = 0; + + for (let i = triggerIndex; i < bufferSize; i++) { + const v = dataArray[i] / 128.0; + const y = (v * (canvas.height / 2)) / 2 + canvas.height / 2; + if (i === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + x += sliceWidth; + } + canvasCtx.stroke(); +} + +Pattern.prototype.scope = function (config = {}) { + return this.analyze(1).draw((ctx) => { + let data = getAnalyzerData('time'); + const { smear = 0 } = config; + if (!smear) { + ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + } else { + ctx.fillStyle = `rgba(0,0,0,${1 - smear})`; + ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); + } + data && drawTimeScope(analyser, data, config); + }); +}; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 1279000c..07b32eec 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -121,6 +121,34 @@ function getReverb(orbit, duration = 2) { return reverbs[orbit]; } +export let analyser, analyserData /* s = {} */; +export function getAnalyser(/* orbit, */ fftSize = 2048) { + if (!analyser /*s [orbit] */) { + const analyserNode = getAudioContext().createAnalyser(); + analyserNode.fftSize = fftSize; + // getDestination().connect(analyserNode); + analyser /* s[orbit] */ = analyserNode; + analyserData = new Uint8Array(analyser.frequencyBinCount); + } + if (analyser /* s[orbit] */.fftSize !== fftSize) { + analyser /* s[orbit] */.fftSize = fftSize; + analyserData = new Uint8Array(analyser.frequencyBinCount); + } + return analyser /* s[orbit] */; +} + +export function getAnalyzerData(type = 'time') { + const getter = { + time: () => analyser?.getByteTimeDomainData(analyserData), + frequency: () => analyser?.getByteFrequencyData(analyserData), + }[type]; + if (!getter) { + throw new Error(`getAnalyzerData: ${type} not supported. use one of ${Object.keys(getter).join(', ')}`); + } + getter(); + return analyserData; +} + function effectSend(input, effect, wet) { const send = gainNode(wet); input.connect(send); @@ -167,6 +195,8 @@ export const superdough = async (value, deadline, hapDuration) => { room, size = 2, velocity = 1, + analyze, // analyser wet + fft = 8, // fftSize 0 - 10 } = value; gain *= velocity; // legacy fix for velocity let toDisconnect = []; // audio nodes that will be disconnected when the source has ended @@ -241,12 +271,19 @@ export const superdough = async (value, deadline, hapDuration) => { reverbSend = effectSend(post, reverbNode, room); } + // analyser + let analyserSend; + if (analyze) { + const analyserNode = getAnalyser(/* orbit, */ 2 ** (fft + 5)); + analyserSend = effectSend(post, analyserNode, analyze); + } + // connect chain elements together chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); // toDisconnect = all the node that should be disconnected in onended callback // this is crucial for performance - toDisconnect = chain.concat([delaySend, reverbSend]); + toDisconnect = chain.concat([delaySend, reverbSend, analyserSend]); }; export const superdoughTrigger = (t, hap, ct, cps) => superdough(hap, t - ct, hap.duration / cps, cps);