page.tsx (11800B)
1 "use client"; 2 3 import { useEffect, useRef, useState } from "react"; 4 5 export default function AudioAnalysisPage() { 6 type InputType = "file" | "mic" | "rec"; 7 const [inputType, setInputType] = useState<InputType>("file"); 8 const [isAnalyzing, setIsAnalyzing] = useState(false); 9 const [pitchResult, setPitchResult] = useState<{ 10 frequency: number; 11 period: number; 12 } | null>(null); 13 14 const audioContextRef = useRef<AudioContext | null>(null); 15 const analyserRef = useRef<AnalyserNode | null>(null); 16 const oscCanvasRef = useRef<HTMLCanvasElement>(null); 17 const specCanvasRef = useRef<HTMLCanvasElement>(null); 18 const singlePeriodCanvasRef = useRef<HTMLCanvasElement>(null); 19 const fileInputRef = useRef<HTMLInputElement>(null); 20 21 const updateInputType = (event: React.ChangeEvent<HTMLSelectElement>) => { 22 setIsAnalyzing(false); 23 setPitchResult(null); 24 setInputType(event.target.value as InputType); 25 }; 26 27 const performPitchAnalysis = () => { 28 const analyser = analyserRef.current; 29 const context = audioContextRef.current; 30 if (!analyser || !context) return; 31 32 const timeDomainBuffer = new Float32Array(analyser.fftSize); 33 analyser.getFloatTimeDomainData(timeDomainBuffer); 34 35 const sampleRate = context.sampleRate; 36 let bestOffset = -1; 37 let maxCorrelation = 0; 38 const minPeriodSamples = sampleRate / 1000; 39 const maxPeriodSamples = sampleRate / 50; 40 41 for ( 42 let offset = Math.floor(minPeriodSamples); 43 offset < maxPeriodSamples; 44 offset++ 45 ) { 46 let correlation = 0; 47 for (let i = 0; i < timeDomainBuffer.length - offset; i++) { 48 correlation += timeDomainBuffer[i] * timeDomainBuffer[i + offset]; 49 } 50 if (correlation > maxCorrelation) { 51 maxCorrelation = correlation; 52 bestOffset = offset; 53 } 54 } 55 56 if (bestOffset > 0) { 57 const detectedFrequency = sampleRate / bestOffset; 58 setPitchResult({ frequency: detectedFrequency, period: bestOffset }); 59 60 const canvas = singlePeriodCanvasRef.current; 61 if (canvas) { 62 const ctx = canvas.getContext("2d"); 63 if (ctx) { 64 ctx.fillStyle = "#141414"; 65 ctx.fillRect(0, 0, canvas.width, canvas.height); 66 ctx.strokeStyle = "#00ffff"; 67 ctx.lineWidth = 3; 68 ctx.beginPath(); 69 70 let startSampleIndex = 0; 71 for (let i = 1; i < timeDomainBuffer.length / 2; i++) { 72 if (timeDomainBuffer[i - 1] < 0 && timeDomainBuffer[i] >= 0) { 73 startSampleIndex = i; 74 break; 75 } 76 } 77 78 const xStep = canvas.width / bestOffset; 79 for (let i = 0; i < bestOffset; i++) { 80 const sampleValue = timeDomainBuffer[startSampleIndex + i] || 0; 81 const x = i * xStep; 82 const y = (1 - sampleValue) * (canvas.height / 2); 83 i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); 84 } 85 ctx.stroke(); 86 } 87 } 88 } 89 }; 90 91 useEffect(() => { 92 if (!isAnalyzing) return; 93 94 const canvases = [oscCanvasRef.current, specCanvasRef.current]; 95 canvases.forEach((canvas) => { 96 if (canvas) { 97 const ctx = canvas.getContext("2d"); 98 if (ctx) { 99 ctx.fillStyle = "#141414"; 100 ctx.fillRect(0, 0, canvas.width, canvas.height); 101 } 102 } 103 }); 104 105 let animationFrameId: number; 106 let audioSourceNode: AudioNode | null = null; 107 let mediaStream: MediaStream | null = null; 108 let audioUrl: string | null = null; 109 const internalAudioElement = new Audio(); 110 111 const startAudioSystem = async () => { 112 if (!audioContextRef.current) { 113 const AudioContextClass = 114 window.AudioContext || (window as any).webkitAudioContext; 115 audioContextRef.current = new AudioContextClass(); 116 } 117 const context = audioContextRef.current; 118 if (context.state === "suspended") await context.resume(); 119 120 const analyser = context.createAnalyser(); 121 analyser.fftSize = 4096; 122 analyserRef.current = analyser; 123 124 if (inputType === "file" && fileInputRef.current?.files?.[0]) { 125 audioUrl = URL.createObjectURL(fileInputRef.current.files[0]); 126 internalAudioElement.src = audioUrl; 127 internalAudioElement.onended = () => setIsAnalyzing(false); 128 internalAudioElement.play(); 129 audioSourceNode = 130 context.createMediaElementSource(internalAudioElement); 131 audioSourceNode.connect(analyser); 132 analyser.connect(context.destination); 133 } else if (inputType === "mic") { 134 try { 135 mediaStream = await navigator.mediaDevices.getUserMedia({ 136 audio: true, 137 }); 138 audioSourceNode = context.createMediaStreamSource(mediaStream); 139 audioSourceNode.connect(analyser); 140 } catch (error) { 141 console.error(error); 142 setIsAnalyzing(false); 143 return; 144 } 145 } else { 146 setIsAnalyzing(false); 147 return; 148 } 149 150 const drawLoop = () => { 151 const oscCanvas = oscCanvasRef.current; 152 const specCanvas = specCanvasRef.current; 153 154 if (oscCanvas) { 155 const ctx = oscCanvas.getContext("2d"); 156 if (ctx) { 157 const timeData = new Uint8Array(analyser.frequencyBinCount); 158 analyser.getByteTimeDomainData(timeData); 159 ctx.fillStyle = "#141414"; 160 ctx.fillRect(0, 0, oscCanvas.width, oscCanvas.height); 161 ctx.lineWidth = 2; 162 ctx.strokeStyle = "#00ff00"; 163 ctx.beginPath(); 164 const sliceWidth = oscCanvas.width / timeData.length; 165 let x = 0; 166 for (let i = 0; i < timeData.length; i++) { 167 const y = (timeData[i] / 128.0) * (oscCanvas.height / 2); 168 i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); 169 x += sliceWidth; 170 } 171 ctx.lineTo(oscCanvas.width, oscCanvas.height / 2); 172 ctx.stroke(); 173 } 174 } 175 176 if (specCanvas) { 177 const ctx = specCanvas.getContext("2d", { willReadFrequently: true }); 178 if (ctx) { 179 const freqData = new Uint8Array(analyser.frequencyBinCount); 180 analyser.getByteFrequencyData(freqData); 181 ctx.drawImage( 182 specCanvas, 183 0, 184 0, 185 specCanvas.width, 186 specCanvas.height - 1, 187 0, 188 1, 189 specCanvas.width, 190 specCanvas.height - 1, 191 ); 192 193 const currentSampleRate = context.sampleRate; 194 const nyquistFrequency = currentSampleRate / 2; 195 const displayMaxFrequency = 8000; 196 const binsToDisplay = Math.floor( 197 (displayMaxFrequency / nyquistFrequency) * freqData.length, 198 ); 199 200 const barWidth = specCanvas.width / binsToDisplay; 201 for (let i = 0; i < binsToDisplay; i++) { 202 const value = freqData[i]; 203 const hue = 240 - (value / 255) * 240; 204 ctx.fillStyle = value > 0 ? `hsl(${hue}, 100%, 50%)` : "black"; 205 ctx.fillRect(i * barWidth, 0, barWidth + 1, 1); 206 } 207 208 ctx.fillStyle = "rgba(255, 255, 255, 0.6)"; 209 for ( 210 let frequency = 440; 211 frequency < displayMaxFrequency; 212 frequency += 440 213 ) { 214 const xPosition = 215 (frequency / displayMaxFrequency) * specCanvas.width; 216 ctx.fillRect(xPosition, 0, 1, 1); 217 } 218 } 219 } 220 animationFrameId = requestAnimationFrame(drawLoop); 221 }; 222 223 animationFrameId = requestAnimationFrame(drawLoop); 224 }; 225 226 startAudioSystem(); 227 228 return () => { 229 cancelAnimationFrame(animationFrameId); 230 audioSourceNode?.disconnect(); 231 mediaStream?.getTracks().forEach((track) => track.stop()); 232 internalAudioElement.pause(); 233 internalAudioElement.src = ""; 234 if (audioUrl) URL.revokeObjectURL(audioUrl); 235 analyserRef.current = null; 236 }; 237 }, [isAnalyzing, inputType]); 238 239 useEffect(() => { 240 return () => { 241 if (audioContextRef.current) audioContextRef.current.close(); 242 }; 243 }, []); 244 245 return ( 246 <main style={{ padding: "20px", maxWidth: "900px", margin: "0 auto" }}> 247 <h1>Audio Analyser</h1> 248 <section 249 style={{ 250 marginBottom: "20px", 251 background: "#f5f5f5", 252 padding: "15px", 253 borderRadius: "8px", 254 }} 255 > 256 <h2>Select Input</h2> 257 <div 258 style={{ 259 display: "flex", 260 alignItems: "center", 261 gap: "10px", 262 flexWrap: "wrap", 263 }} 264 > 265 <select 266 onChange={updateInputType} 267 value={inputType} 268 style={{ padding: "5px" }} 269 > 270 <option value="file">File</option> 271 <option value="mic">Microphone</option> 272 <option value="rec">Record (Not Implemented)</option> 273 </select> 274 {inputType === "file" && ( 275 <input type="file" ref={fileInputRef} accept="audio/*" /> 276 )} 277 <div style={{ marginLeft: "auto", display: "flex", gap: "10px" }}> 278 <button 279 onClick={() => setIsAnalyzing(!isAnalyzing)} 280 style={{ 281 padding: "8px 16px", 282 backgroundColor: isAnalyzing ? "#ff0000" : "#0070f3", 283 color: "white", 284 border: "none", 285 borderRadius: "4px", 286 cursor: "pointer", 287 }} 288 > 289 {isAnalyzing ? "Stop Analysis" : "Start Analysis"} 290 </button> 291 {isAnalyzing && ( 292 <button 293 onClick={performPitchAnalysis} 294 style={{ 295 padding: "8px 16px", 296 backgroundColor: "#28a745", 297 color: "white", 298 border: "none", 299 borderRadius: "4px", 300 cursor: "pointer", 301 }} 302 > 303 Analyze Pitch & Single Period 304 </button> 305 )} 306 </div> 307 </div> 308 </section> 309 310 {pitchResult && ( 311 <article 312 style={{ 313 marginBottom: "30px", 314 padding: "15px", 315 border: "2px solid #28a745", 316 borderRadius: "8px", 317 }} 318 > 319 <h2>Pitch Analysis Result</h2> 320 <p style={{ fontSize: "1.2rem", fontWeight: "bold" }}> 321 Detected Frequency:{" "} 322 <span style={{ color: "#28a745" }}> 323 {pitchResult.frequency.toFixed(2)} Hz 324 </span> 325 </p> 326 <h3>Single Period Oscilloscope</h3> 327 <canvas 328 ref={singlePeriodCanvasRef} 329 width="800" 330 height="150" 331 style={{ 332 border: "1px solid #333", 333 background: "#141414", 334 width: "100%", 335 height: "auto", 336 }} 337 /> 338 </article> 339 )} 340 341 <article style={{ marginBottom: "20px" }}> 342 <h2>Oscilloscope (Real-time)</h2> 343 <canvas 344 ref={oscCanvasRef} 345 width="800" 346 height="200" 347 style={{ 348 border: "1px solid #333", 349 background: "#141414", 350 width: "100%", 351 height: "auto", 352 }} 353 /> 354 </article> 355 356 <article> 357 <h2>Spectrogram (Real-time)</h2> 358 <canvas 359 ref={specCanvasRef} 360 width="800" 361 height="400" 362 style={{ 363 border: "1px solid #333", 364 background: "#141414", 365 width: "100%", 366 height: "auto", 367 }} 368 /> 369 </article> 370 </main> 371 ); 372 }