diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index b8edd51c..78e517dc 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -146,6 +146,9 @@ const generic_params = [ */ ['bank'], + ['analyze'], // analyser node send amount 0 - 1 (used by scope) + ['fft'], // fftSize of analyser + /** * 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..d1cdd7be 100644 --- a/packages/core/draw.mjs +++ b/packages/core/draw.mjs @@ -9,25 +9,26 @@ import { Pattern, getTime, State, TimeSpan } from './index.mjs'; export const getDrawContext = (id = 'test-canvas') => { let canvas = document.querySelector('#' + id); if (!canvas) { + const scale = 2; // 2 = crisp on retina screens canvas = document.createElement('canvas'); canvas.id = id; - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + canvas.width = window.innerWidth * scale; + canvas.height = window.innerHeight * scale; canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0'; document.body.prepend(canvas); let timeout; window.addEventListener('resize', () => { timeout && clearTimeout(timeout); timeout = setTimeout(() => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + canvas.width = window.innerWidth * scale; + canvas.height = window.innerHeight * scale; }, 200); }); } 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); } @@ -59,7 +60,7 @@ Pattern.prototype.draw = function (callback, { from, to, onQuery }) { export const cleanupDraw = (clearScreen = true) => { const ctx = getDrawContext(); - clearScreen && ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.width); if (window.strudelAnimation) { cancelAnimationFrame(window.strudelAnimation); } diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 1279000c..d5a2ca3f 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -121,6 +121,36 @@ 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); + analyserData = new Float32Array(analyser.frequencyBinCount); + } + if (analyser /* s[orbit] */.fftSize !== fftSize) { + analyser /* s[orbit] */.fftSize = fftSize; + //analyserData = new Uint8Array(analyser.frequencyBinCount); + analyserData = new Float32Array(analyser.frequencyBinCount); + } + return analyser /* s[orbit] */; +} + +export function getAnalyzerData(type = 'time') { + const getter = { + time: () => analyser?.getFloatTimeDomainData(analyserData), + frequency: () => analyser?.getFloatFrequencyData(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 +197,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 +273,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); diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs index 563a367e..a425e683 100644 --- a/packages/webaudio/index.mjs +++ b/packages/webaudio/index.mjs @@ -5,4 +5,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 'superdough'; diff --git a/packages/webaudio/scope.mjs b/packages/webaudio/scope.mjs new file mode 100644 index 00000000..cfde80ce --- /dev/null +++ b/packages/webaudio/scope.mjs @@ -0,0 +1,88 @@ +import { Pattern, getDrawContext, clamp } from '@strudel.cycles/core'; +import { analyser, getAnalyzerData } from 'superdough'; + +export function drawTimeScope( + analyser, + { align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, next = 1, trigger = 0 } = {}, +) { + const ctx = getDrawContext(); + const dataArray = getAnalyzerData('time'); + + ctx.lineWidth = thickness; + ctx.strokeStyle = color; + + ctx.beginPath(); + let canvas = ctx.canvas; + + const bufferSize = analyser.frequencyBinCount; + let triggerIndex = align + ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] > -trigger && v <= -trigger) + : 0; + triggerIndex = Math.max(triggerIndex, 0); // fallback to 0 when no trigger is found + + const sliceWidth = (canvas.width * 1.0) / bufferSize; + let x = 0; + + for (let i = triggerIndex; i < bufferSize; i++) { + const v = dataArray[i] + 1; + const y = (scale * (v - 1) + pos) * canvas.height; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + x += sliceWidth; + } + ctx.stroke(); +} + +export function drawFrequencyScope( + analyser, + { color = 'white', scale = 0.25, pos = 0.75, lean = 0.5, min = -150, max = 0 } = {}, +) { + const dataArray = getAnalyzerData('frequency'); + const ctx = getDrawContext(); + const canvas = ctx.canvas; + + ctx.fillStyle = color; + const bufferSize = analyser.frequencyBinCount; + const sliceWidth = (canvas.width * 1.0) / bufferSize; + + let x = 0; + for (let i = 0; i < bufferSize; i++) { + const normalized = clamp((dataArray[i] - min) / (max - min), 0, 1); + const v = normalized * scale; + const h = v * canvas.height; + const y = (pos - v * lean) * canvas.height; + + ctx.fillRect(x, y, Math.max(sliceWidth, 1), h); + x += sliceWidth; + } +} + +function clearScreen(smear = 0, smearRGB = `0,0,0`) { + const ctx = getDrawContext(); + if (!smear) { + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } else { + ctx.fillStyle = `rgba(${smearRGB},${1 - smear})`; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } +} + +Pattern.prototype.fscope = function (config = {}) { + return this.analyze(1).draw(() => { + clearScreen(config.smear); + analyser && drawFrequencyScope(analyser, config); + }); +}; + +Pattern.prototype.tscope = function (config = {}) { + return this.analyze(1).draw(() => { + clearScreen(config.smear); + analyser && drawTimeScope(analyser, config); + }); +}; + +Pattern.prototype.scope = Pattern.prototype.tscope;