From 7370f41fa0556406109be259cd24387d4a8bff78 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 09:45:30 +0200 Subject: [PATCH 1/8] 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); From 68ea086e2aec4360652e8f84202979d5007137f8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 12:39:38 +0200 Subject: [PATCH 2/8] improve canvas quality on retina --- packages/core/draw.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/draw.mjs b/packages/core/draw.mjs index 8be45e03..d1cdd7be 100644 --- a/packages/core/draw.mjs +++ b/packages/core/draw.mjs @@ -9,18 +9,19 @@ 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); }); } @@ -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); } From 965794712e435bcd5d157dfaf6eace8a03a999df Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 12:39:47 +0200 Subject: [PATCH 3/8] fscope + scaling options --- packages/core/scope.mjs | 66 ++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/core/scope.mjs b/packages/core/scope.mjs index 62b08e62..908ed254 100644 --- a/packages/core/scope.mjs +++ b/packages/core/scope.mjs @@ -2,8 +2,12 @@ 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 } = {}) { +export function drawTimeScope( + analyser, + { align = true, color = 'white', thickness = 3, scale = 1, pos = 0.75, next = 1 } = {}, +) { const canvasCtx = getDrawContext(); + const dataArray = getAnalyzerData('time'); canvasCtx.lineWidth = thickness; canvasCtx.strokeStyle = color; @@ -13,16 +17,18 @@ export function drawTimeScope(analyser, dataArray, { align = true, color = 'whit const bufferSize = analyser.frequencyBinCount; const triggerValue = 256 / 2; - const triggerIndex = align + let triggerIndex = align ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] < triggerValue && v >= triggerValue) : 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] / 128.0; - const y = (v * (canvas.height / 2)) / 2 + canvas.height / 2; + const y = (scale * (v - 1) + pos) * canvas.height; + if (i === 0) { canvasCtx.moveTo(x, y); } else { @@ -33,16 +39,48 @@ export function drawTimeScope(analyser, dataArray, { align = true, color = 'whit 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); +export function drawFrequencyScope(analyser, { color = 'white', scale = 1, pos = 0.75, lean = 0.5 } = {}) { + const dataArray = getAnalyzerData('frequency'); + const canvasCtx = getDrawContext(); + const canvas = canvasCtx.canvas; + + canvasCtx.fillStyle = color; + const bufferSize = analyser.frequencyBinCount; + const sliceWidth = (canvas.width * 1.0) / bufferSize; + + let x = 0; + for (let i = 0; i < bufferSize; i++) { + const v = (dataArray[i] / 256.0) * scale; + const h = v * canvas.height; + const y = (pos - v * lean) * canvas.height; + + canvasCtx.fillRect(x, y, Math.max(sliceWidth, 1), h); + x += sliceWidth; + } +} + +function clearScreen(smear = 0, smearRGB = `0,0,0`) { + const canvasCtx = getDrawContext(); + if (!smear) { + canvasCtx.clearRect(0, 0, canvasCtx.canvas.width, canvasCtx.canvas.height); + } else { + canvasCtx.fillStyle = `rgba(${smearRGB},${1 - smear})`; + canvasCtx.fillRect(0, 0, canvasCtx.canvas.width, canvasCtx.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; From 988bd8ccdc186a03a952364904f72b6dc88d4cf4 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 12:40:04 +0200 Subject: [PATCH 4/8] rename stuff --- packages/core/scope.mjs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/scope.mjs b/packages/core/scope.mjs index 908ed254..4f4a1d2d 100644 --- a/packages/core/scope.mjs +++ b/packages/core/scope.mjs @@ -6,14 +6,14 @@ export function drawTimeScope( analyser, { align = true, color = 'white', thickness = 3, scale = 1, pos = 0.75, next = 1 } = {}, ) { - const canvasCtx = getDrawContext(); + const ctx = getDrawContext(); const dataArray = getAnalyzerData('time'); - canvasCtx.lineWidth = thickness; - canvasCtx.strokeStyle = color; + ctx.lineWidth = thickness; + ctx.strokeStyle = color; - canvasCtx.beginPath(); - let canvas = canvasCtx.canvas; + ctx.beginPath(); + let canvas = ctx.canvas; const bufferSize = analyser.frequencyBinCount; const triggerValue = 256 / 2; @@ -30,21 +30,21 @@ export function drawTimeScope( const y = (scale * (v - 1) + pos) * canvas.height; if (i === 0) { - canvasCtx.moveTo(x, y); + ctx.moveTo(x, y); } else { - canvasCtx.lineTo(x, y); + ctx.lineTo(x, y); } x += sliceWidth; } - canvasCtx.stroke(); + ctx.stroke(); } export function drawFrequencyScope(analyser, { color = 'white', scale = 1, pos = 0.75, lean = 0.5 } = {}) { const dataArray = getAnalyzerData('frequency'); - const canvasCtx = getDrawContext(); - const canvas = canvasCtx.canvas; + const ctx = getDrawContext(); + const canvas = ctx.canvas; - canvasCtx.fillStyle = color; + ctx.fillStyle = color; const bufferSize = analyser.frequencyBinCount; const sliceWidth = (canvas.width * 1.0) / bufferSize; @@ -54,18 +54,18 @@ export function drawFrequencyScope(analyser, { color = 'white', scale = 1, pos = const h = v * canvas.height; const y = (pos - v * lean) * canvas.height; - canvasCtx.fillRect(x, y, Math.max(sliceWidth, 1), h); + ctx.fillRect(x, y, Math.max(sliceWidth, 1), h); x += sliceWidth; } } function clearScreen(smear = 0, smearRGB = `0,0,0`) { - const canvasCtx = getDrawContext(); + const ctx = getDrawContext(); if (!smear) { - canvasCtx.clearRect(0, 0, canvasCtx.canvas.width, canvasCtx.canvas.height); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); } else { - canvasCtx.fillStyle = `rgba(${smearRGB},${1 - smear})`; - canvasCtx.fillRect(0, 0, canvasCtx.canvas.width, canvasCtx.canvas.height); + ctx.fillStyle = `rgba(${smearRGB},${1 - smear})`; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); } } From 2e31c8695ae9e87bf7f8651c12582b8523aa146c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 25 Aug 2023 16:29:41 +0200 Subject: [PATCH 5/8] use Float32 for higher precision --- packages/core/scope.mjs | 15 ++++++++++----- packages/superdough/superdough.mjs | 10 ++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core/scope.mjs b/packages/core/scope.mjs index 4f4a1d2d..392ea859 100644 --- a/packages/core/scope.mjs +++ b/packages/core/scope.mjs @@ -1,10 +1,11 @@ import { Pattern } from './pattern.mjs'; import { getDrawContext } from './draw.mjs'; import { analyser } from '@strudel.cycles/webaudio'; +import { clamp } from './util.mjs'; export function drawTimeScope( analyser, - { align = true, color = 'white', thickness = 3, scale = 1, pos = 0.75, next = 1 } = {}, + { align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, next = 1 } = {}, ) { const ctx = getDrawContext(); const dataArray = getAnalyzerData('time'); @@ -16,7 +17,7 @@ export function drawTimeScope( let canvas = ctx.canvas; const bufferSize = analyser.frequencyBinCount; - const triggerValue = 256 / 2; + const triggerValue = 0; let triggerIndex = align ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] < triggerValue && v >= triggerValue) : 0; @@ -26,7 +27,7 @@ export function drawTimeScope( let x = 0; for (let i = triggerIndex; i < bufferSize; i++) { - const v = dataArray[i] / 128.0; + const v = dataArray[i] + 1; const y = (scale * (v - 1) + pos) * canvas.height; if (i === 0) { @@ -39,7 +40,10 @@ export function drawTimeScope( ctx.stroke(); } -export function drawFrequencyScope(analyser, { color = 'white', scale = 1, pos = 0.75, lean = 0.5 } = {}) { +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; @@ -50,7 +54,8 @@ export function drawFrequencyScope(analyser, { color = 'white', scale = 1, pos = let x = 0; for (let i = 0; i < bufferSize; i++) { - const v = (dataArray[i] / 256.0) * scale; + 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; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 07b32eec..d5a2ca3f 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -128,19 +128,21 @@ export function getAnalyser(/* orbit, */ fftSize = 2048) { analyserNode.fftSize = fftSize; // getDestination().connect(analyserNode); analyser /* s[orbit] */ = analyserNode; - analyserData = new Uint8Array(analyser.frequencyBinCount); + //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 Uint8Array(analyser.frequencyBinCount); + analyserData = new Float32Array(analyser.frequencyBinCount); } return analyser /* s[orbit] */; } export function getAnalyzerData(type = 'time') { const getter = { - time: () => analyser?.getByteTimeDomainData(analyserData), - frequency: () => analyser?.getByteFrequencyData(analyserData), + 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(', ')}`); From 43efe6921d03d6c12db224d435b7baba41b557e8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 26 Aug 2023 21:11:24 +0200 Subject: [PATCH 6/8] move scope to webaudio package --- packages/core/index.mjs | 1 - packages/webaudio/index.mjs | 1 + packages/{core => webaudio}/scope.mjs | 11 ++++------- 3 files changed, 5 insertions(+), 8 deletions(-) rename packages/{core => webaudio}/scope.mjs (89%) diff --git a/packages/core/index.mjs b/packages/core/index.mjs index ceffdcbe..bed63f9a 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -22,7 +22,6 @@ 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/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/core/scope.mjs b/packages/webaudio/scope.mjs similarity index 89% rename from packages/core/scope.mjs rename to packages/webaudio/scope.mjs index 392ea859..b3b4b8bd 100644 --- a/packages/core/scope.mjs +++ b/packages/webaudio/scope.mjs @@ -1,11 +1,9 @@ -import { Pattern } from './pattern.mjs'; -import { getDrawContext } from './draw.mjs'; -import { analyser } from '@strudel.cycles/webaudio'; -import { clamp } from './util.mjs'; +import { Pattern, getDrawContext, clamp } from '@strudel.cycles/core'; +import { analyser } from 'superdough'; export function drawTimeScope( analyser, - { align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, next = 1 } = {}, + { align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, next = 1, trigger = 0 } = {}, ) { const ctx = getDrawContext(); const dataArray = getAnalyzerData('time'); @@ -17,9 +15,8 @@ export function drawTimeScope( let canvas = ctx.canvas; const bufferSize = analyser.frequencyBinCount; - const triggerValue = 0; let triggerIndex = align - ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] < triggerValue && v >= triggerValue) + ? 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 From 4dadc18a3a8e9725ac654acdb084b1b68aa5c357 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 26 Aug 2023 21:12:02 +0200 Subject: [PATCH 7/8] fix: import --- packages/webaudio/scope.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webaudio/scope.mjs b/packages/webaudio/scope.mjs index b3b4b8bd..cfde80ce 100644 --- a/packages/webaudio/scope.mjs +++ b/packages/webaudio/scope.mjs @@ -1,5 +1,5 @@ import { Pattern, getDrawContext, clamp } from '@strudel.cycles/core'; -import { analyser } from 'superdough'; +import { analyser, getAnalyzerData } from 'superdough'; export function drawTimeScope( analyser, From a7728e3d81fb7a0a2dff9f2f4bd9e313ddf138cd Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 26 Aug 2023 21:16:46 +0200 Subject: [PATCH 8/8] comment new controls --- packages/core/controls.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index a02a5805..78e517dc 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -146,8 +146,8 @@ const generic_params = [ */ ['bank'], - ['analyze'], // sends - ['fft'], + ['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.