jevstrudel.git / website / src / components / PitchSlider.jsx
PitchSlider.jsx268 lines · 8.0 KB · raw
1import useEvent from '@src/useEvent.mjs';
2import useFrame from '@src/useFrame.mjs';
3import { getAudioContext } from '@strudel/webaudio';
4import { midi2note } from '@strudel/core';
5import { useState, useRef, useEffect } from 'react';
6import Claviature from '@components/Claviature';
7
8let Button = (props) => <button {...props} className="bg-lineHighlight p-2 rounded-md color-foreground" />;
9
10function plotValues(ctx, values, min, max, color) {
11  let { width, height } = ctx.canvas;
12  ctx.strokeStyle = color;
13  const thickness = 8;
14  ctx.lineWidth = thickness;
15  ctx.beginPath();
16
17  let x = (f) => ((f - min) / (max - min)) * width;
18  let y = (i) => (1 - i / values.length) * height;
19  values.forEach((f, i, a) => {
20    ctx.lineTo(x(f), y(i));
21  });
22  ctx.stroke();
23}
24
25function getColor(cssVariable) {
26  if (typeof document === 'undefined') {
27    return 'white';
28  }
29  const dummyElement = document.createElement('div');
30  dummyElement.style.color = cssVariable;
31  // Append the dummy element to the document body
32  document.body.appendChild(dummyElement);
33  // Get the computed style of the dummy element
34  const styles = getComputedStyle(dummyElement);
35  // Get the value of the CSS variable
36  const color = styles.getPropertyValue(cssVariable);
37  document.body.removeChild(dummyElement);
38  return color;
39}
40
41let pitchColor = '#eab308';
42let frequencyColor = '#3b82f6';
43
44export function PitchSlider({
45  buttons = [],
46  animatable = false,
47  plot = false,
48  showPitchSlider = false,
49  showFrequencySlider = false,
50  pitchStep = '0.001',
51  min = 55,
52  max = 7040,
53  initial = 220,
54  baseFrequency = min,
55  zeroOffset = 0,
56  claviature,
57}) {
58  const oscRef = useRef();
59  const activeRef = useRef();
60  const freqRef = useRef(initial);
61  const historyRef = useRef([freqRef.current]);
62  const frameRef = useRef();
63  const canvasRef = useRef();
64  const [hz, setHz] = useState(freqRef.current);
65
66  useEffect(() => {
67    freqRef.current = hz;
68  }, [hz]);
69
70  useEvent('mouseup', () => {
71    oscRef.current?.stop();
72    activeRef.current = false;
73  });
74
75  let freqSlider2freq = (progress) => min + progress * (max - min);
76  let pitchSlider2freq = (progress) => min * 2 ** (progress * Math.log2(max / min));
77  let freq2freqSlider = (freq) => (freq - min) / (max - min);
78  let freq2pitchSlider = (freq) => {
79    const [minOct, maxOct] = [Math.log2(min), Math.log2(max)];
80    return (Math.log2(freq) - minOct) / (maxOct - minOct);
81  };
82
83  const freqSlider = freq2freqSlider(hz);
84  const pitchSlider = freq2pitchSlider(hz);
85
86  let startOsc = (hz) => {
87    if (oscRef.current) {
88      oscRef.current.stop();
89    }
90    oscRef.current = getAudioContext().createOscillator();
91    oscRef.current.frequency.value = hz;
92    oscRef.current.connect(getAudioContext().destination);
93    oscRef.current.start();
94    activeRef.current = true;
95    setHz(hz);
96  };
97
98  let startSweep = (exp = false) => {
99    let f = min;
100    startOsc(f);
101    const frame = () => {
102      if (f < max) {
103        if (!exp) {
104          f += 10;
105        } else {
106          f *= 1.01;
107        }
108        oscRef.current.frequency.value = f;
109        frameRef.current = requestAnimationFrame(frame);
110      } else {
111        oscRef.current.stop();
112        cancelAnimationFrame(frameRef.current);
113      }
114      setHz(f);
115    };
116    requestAnimationFrame(frame);
117  };
118
119  useFrame(() => {
120    historyRef.current.push(freqRef.current);
121    historyRef.current = historyRef.current.slice(-1000);
122    if (canvasRef.current) {
123      let ctx = canvasRef.current.getContext('2d');
124      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
125      if (showFrequencySlider) {
126        plotValues(ctx, historyRef.current, min, max, frequencyColor);
127      }
128      if (showPitchSlider) {
129        const [minOct, maxOct] = [Math.log2(min), Math.log2(max)];
130        let perceptual = historyRef.current.map((v) => Math.log2(v));
131        plotValues(ctx, perceptual, minOct, maxOct, pitchColor);
132      }
133    }
134  }, plot);
135
136  let handleChangeFrequency = (f) => {
137    setHz(f);
138    if (oscRef.current) {
139      oscRef.current.frequency.value = f;
140    }
141  };
142  let handleMouseDown = () => {
143    cancelAnimationFrame(frameRef.current);
144    startOsc(hz);
145  };
146
147  let exponent, activeNote, activeNoteLabel;
148  if (showPitchSlider) {
149    const expOffset = baseFrequency ? Math.log2(baseFrequency / min) : 0;
150    exponent = freq2pitchSlider(hz) * Math.log2(max / min) - expOffset;
151    let semitones = parseFloat((exponent * 12).toFixed(2));
152    if (zeroOffset) {
153      semitones = semitones + zeroOffset;
154      const isWhole = Math.round(semitones) === semitones;
155      activeNote = midi2note(Math.round(semitones));
156      activeNoteLabel = (!isWhole ? '~' : '') + activeNote;
157      semitones = !isWhole ? semitones.toFixed(2) : semitones;
158      exponent = (
159        <>
160          (<span className="text-yellow-500">{semitones}</span> - {zeroOffset})/12
161        </>
162      );
163    } else if (semitones % 12 === 0) {
164      exponent = <span className="text-yellow-500">{semitones / 12}</span>;
165    } else if (semitones % 1 === 0) {
166      exponent = (
167        <>
168          <span className="text-yellow-500">{semitones}</span>/12
169        </>
170      );
171    } else {
172      exponent = <span className="text-yellow-500">{exponent.toFixed(2)}</span>;
173    }
174  }
175  return (
176    <>
177      <span className="font-mono">
178        {showFrequencySlider && <span className="text-blue-500">{hz.toFixed(0)}Hz</span>}
179        {showFrequencySlider && showPitchSlider && <> = </>}
180        {showPitchSlider && (
181          <>
182            {baseFrequency || min}Hz * 2<sup>{exponent}</sup>
183          </>
184        )}
185      </span>
186      {claviature && (
187        <>
188          {' '}
189          = <span className="text-yellow-500">{activeNoteLabel}</span>
190        </>
191      )}
192      <div>
193        {showFrequencySlider && (
194          <div className="flex space-x-1 items-center">
195            <input
196              type="range"
197              value={freqSlider}
198              min={0}
199              max={1}
200              step={0.001}
201              onMouseDown={handleMouseDown}
202              className={`block w-full max-w-[600px] accent-blue-500 `}
203              onChange={(e) => {
204                const f = freqSlider2freq(parseFloat(e.target.value));
205                handleChangeFrequency(f);
206              }}
207            />
208          </div>
209        )}
210        {showPitchSlider && (
211          <div>
212            <input
213              type="range"
214              value={pitchSlider}
215              min={0}
216              max={1}
217              //step=".001"
218              step={pitchStep}
219              onMouseDown={handleMouseDown}
220              className={`block w-full max-w-[600px] accent-yellow-500`}
221              onChange={(e) => {
222                const f = pitchSlider2freq(parseFloat(e.target.value));
223                handleChangeFrequency(f);
224              }}
225            />
226          </div>
227        )}
228      </div>
229      <div className="px-2">
230        {plot && <canvas ref={canvasRef} className="w-full max-w-[584px] h-[300px]" height="600" width={800} />}
231      </div>
232      <div className="space-x-2">
233        {animatable && (
234          <Button onClick={() => startSweep()}>
235            <span style={{ color: '#3b82f6' }}>Frequency Sweep</span>
236          </Button>
237        )}
238        {animatable && (
239          <Button onClick={() => startSweep(true)}>
240            <span style={{ color: '#eab308' }}>Pitch Sweep</span>
241          </Button>
242        )}
243        {buttons.map((f, i) => (
244          <Button key={(f, i)} onMouseDown={() => startOsc(f)}>
245            {f}Hz
246          </Button>
247        ))}
248      </div>
249      {claviature && (
250        <Claviature
251          onMouseDown={(note) => {
252            const f = 440 * 2 ** ((note - 69) / 12);
253            handleChangeFrequency(f);
254            cancelAnimationFrame(frameRef.current);
255            startOsc(f);
256          }}
257          options={{
258            range: ['A1', 'A5'],
259            scaleY: 0.75,
260            scaleX: 0.86,
261            colorize: activeNote ? [{ keys: [activeNote], color: '#eab308' }] : [],
262            labels: activeNote ? { [activeNote]: activeNote } : {},
263          }}
264        />
265      )}
266    </>
267  );
268}