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}