1import { Pattern, clamp } from '@strudel/core';
2import { getDrawContext, getTheme } from '@strudel/draw';
3import { analysers, getAnalyzerData } from 'superdough';
4
5export function drawTimeScope(
6  analyser,
7  {
8    align = true,
9    color = 'white',
10    thickness = 3,
11    scale = 0.25,
12    pos = 0.75,
13    trigger = 0,
14    ctx = getDrawContext(),
15    id = 1,
16  } = {},
17) {
18  ctx.lineWidth = thickness;
19  ctx.strokeStyle = color;
20  let canvas = ctx.canvas;
21
22  if (!analyser) {
23    // if analyser is undefined, draw straight line
24    // it may be undefined when no sound has been played yet
25    ctx.beginPath();
26    let y = pos * canvas.height;
27    ctx.moveTo(0, y);
28    ctx.lineTo(canvas.width, y);
29    ctx.stroke();
30    return;
31  }
32  const dataArray = getAnalyzerData('time', id);
33
34  ctx.beginPath();
35
36  const bufferSize = analyser.frequencyBinCount;
37  let triggerIndex = align
38    ? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] > -trigger && v <= -trigger)
39    : 0;
40  triggerIndex = Math.max(triggerIndex, 0); // fallback to 0 when no trigger is found
41
42  const sliceWidth = (canvas.width * 1.0) / bufferSize;
43  let x = 0;
44  for (let i = triggerIndex; i < bufferSize; i++) {
45    const v = dataArray[i] + 1;
46    const y = (pos - scale * (v - 1)) * canvas.height;
47
48    if (i === 0) {
49      ctx.moveTo(x, y);
50    } else {
51      ctx.lineTo(x, y);
52    }
53    x += sliceWidth;
54  }
55  ctx.stroke();
56}
57
58export function drawFrequencyScope(
59  analyser,
60  { color = 'white', scale = 0.25, pos = 0.75, lean = 0.5, min = -150, max = 0, ctx = getDrawContext(), id = 1 } = {},
61) {
62  if (!analyser) {
63    ctx.beginPath();
64    let y = pos * canvas.height;
65    ctx.moveTo(0, y);
66    ctx.lineTo(canvas.width, y);
67    ctx.stroke();
68    return;
69  }
70  const dataArray = getAnalyzerData('frequency', id);
71  const canvas = ctx.canvas;
72
73  ctx.fillStyle = color;
74  const bufferSize = analyser.frequencyBinCount;
75  const sliceWidth = (canvas.width * 1.0) / bufferSize;
76
77  let x = 0;
78  for (let i = 0; i < bufferSize; i++) {
79    const normalized = clamp((dataArray[i] - min) / (max - min), 0, 1);
80    const v = normalized * scale;
81    const h = v * canvas.height;
82    const y = (pos - v * lean) * canvas.height;
83
84    ctx.fillRect(x, y, Math.max(sliceWidth, 1), h);
85    x += sliceWidth;
86  }
87}
88
89function clearScreen(smear = 0, smearRGB = `0,0,0`, ctx = getDrawContext()) {
90  if (!smear) {
91    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
92  } else {
93    ctx.fillStyle = `rgba(${smearRGB},${1 - smear})`;
94    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
95  }
96}
97
98/**
99 * Renders an oscilloscope for the frequency domain of the audio signal.
100 * @name fscope
101 * @tags visualization
102 * @param {string} color line color as hex or color name. defaults to white.
103 * @param {number} scale scales the y-axis. Defaults to 0.25
104 * @param {number} pos y-position relative to screen height. 0 = top, 1 = bottom of screen
105 * @param {number} lean y-axis alignment where 0 = top and 1 = bottom
106 * @param {number} min min value
107 * @param {number} max max value
108 * @example
109 * s("sawtooth").fscope()
110 */
111Pattern.prototype.fscope = function (config = {}) {
112  let id = config.id ?? 1;
113  return this.analyze(id).draw(
114    () => {
115      clearScreen(config.smear, '0,0,0', config.ctx);
116      analysers[id] && drawFrequencyScope(analysers[id], config);
117    },
118    { id },
119  );
120};
121
122/**
123 * Renders an oscilloscope for the time domain of the audio signal.
124 * @name scope
125 * @synonyms tscope
126 * @tags visualization
127 * @param {object} config optional config with options:
128 * @param {boolean} align if 1, the scope will be aligned to the first zero crossing. defaults to 1
129 * @param {string} color line color as hex or color name. defaults to white.
130 * @param {number} thickness line thickness. defaults to 3
131 * @param {number} scale scales the y-axis. Defaults to 0.25
132 * @param {number} pos y-position relative to screen height. 0 = top, 1 = bottom of screen
133 * @param {number} trigger amplitude value that is used to align the scope. defaults to 0.
134 * @example
135 * s("sawtooth")._scope()
136 */
137let latestColor = {};
138Pattern.prototype.tscope = function (config = {}) {
139  let id = config.id ?? 1;
140  return this.analyze(id).draw(
141    (haps) => {
142      config.color = haps[0]?.value?.color || getTheme().foreground;
143      latestColor[id] = config.color;
144      clearScreen(config.smear, '0,0,0', config.ctx);
145      drawTimeScope(analysers[id], config);
146    },
147    { id },
148  );
149};
150
151Pattern.prototype.scope = Pattern.prototype.tscope;