webtools

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit e7d0a2f5df45685a0be61f38196849cd38acca2e
parent 3cd11705ec8203b9bfb642ff1ef9d781bb0e0d8a
Author: Minerva_juppiter <94231606+minerva-jupiter@users.noreply.github.com>
Date:   Sun, 21 Jun 2026 10:07:39 +0900

Audio (#73)

* feat(audio-analysis): add initial audio analysis page

Created a new page to handle audio input selection (file, microphone, or recording) and added placeholders for oscilloscope and spectrogram visualization components.

* feat(audio-analysis): implement real-time audio visualization and pitch detection

- Added AudioContext integration for microphone and file-based audio input.
- Implemented real-time Oscilloscope and Spectrogram visualizations using HTML5 Canvas.
- Added pitch detection functionality using autocorrelation.
- Updated the main navigation to include a link to the new audio analysis page.
Diffstat:
Aapp/audio-analysis/page.tsx | 372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/page.tsx | 3+++
2 files changed, 375 insertions(+), 0 deletions(-)

diff --git a/app/audio-analysis/page.tsx b/app/audio-analysis/page.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export default function AudioAnalysisPage() { + type InputType = "file" | "mic" | "rec"; + const [inputType, setInputType] = useState<InputType>("file"); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [pitchResult, setPitchResult] = useState<{ + frequency: number; + period: number; + } | null>(null); + + const audioContextRef = useRef<AudioContext | null>(null); + const analyserRef = useRef<AnalyserNode | null>(null); + const oscCanvasRef = useRef<HTMLCanvasElement>(null); + const specCanvasRef = useRef<HTMLCanvasElement>(null); + const singlePeriodCanvasRef = useRef<HTMLCanvasElement>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + + const updateInputType = (event: React.ChangeEvent<HTMLSelectElement>) => { + setIsAnalyzing(false); + setPitchResult(null); + setInputType(event.target.value as InputType); + }; + + const performPitchAnalysis = () => { + const analyser = analyserRef.current; + const context = audioContextRef.current; + if (!analyser || !context) return; + + const timeDomainBuffer = new Float32Array(analyser.fftSize); + analyser.getFloatTimeDomainData(timeDomainBuffer); + + const sampleRate = context.sampleRate; + let bestOffset = -1; + let maxCorrelation = 0; + const minPeriodSamples = sampleRate / 1000; + const maxPeriodSamples = sampleRate / 50; + + for ( + let offset = Math.floor(minPeriodSamples); + offset < maxPeriodSamples; + offset++ + ) { + let correlation = 0; + for (let i = 0; i < timeDomainBuffer.length - offset; i++) { + correlation += timeDomainBuffer[i] * timeDomainBuffer[i + offset]; + } + if (correlation > maxCorrelation) { + maxCorrelation = correlation; + bestOffset = offset; + } + } + + if (bestOffset > 0) { + const detectedFrequency = sampleRate / bestOffset; + setPitchResult({ frequency: detectedFrequency, period: bestOffset }); + + const canvas = singlePeriodCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#141414"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 3; + ctx.beginPath(); + + let startSampleIndex = 0; + for (let i = 1; i < timeDomainBuffer.length / 2; i++) { + if (timeDomainBuffer[i - 1] < 0 && timeDomainBuffer[i] >= 0) { + startSampleIndex = i; + break; + } + } + + const xStep = canvas.width / bestOffset; + for (let i = 0; i < bestOffset; i++) { + const sampleValue = timeDomainBuffer[startSampleIndex + i] || 0; + const x = i * xStep; + const y = (1 - sampleValue) * (canvas.height / 2); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + } + } + } + }; + + useEffect(() => { + if (!isAnalyzing) return; + + const canvases = [oscCanvasRef.current, specCanvasRef.current]; + canvases.forEach((canvas) => { + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#141414"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + } + }); + + let animationFrameId: number; + let audioSourceNode: AudioNode | null = null; + let mediaStream: MediaStream | null = null; + let audioUrl: string | null = null; + const internalAudioElement = new Audio(); + + const startAudioSystem = async () => { + if (!audioContextRef.current) { + const AudioContextClass = + window.AudioContext || (window as any).webkitAudioContext; + audioContextRef.current = new AudioContextClass(); + } + const context = audioContextRef.current; + if (context.state === "suspended") await context.resume(); + + const analyser = context.createAnalyser(); + analyser.fftSize = 4096; + analyserRef.current = analyser; + + if (inputType === "file" && fileInputRef.current?.files?.[0]) { + audioUrl = URL.createObjectURL(fileInputRef.current.files[0]); + internalAudioElement.src = audioUrl; + internalAudioElement.onended = () => setIsAnalyzing(false); + internalAudioElement.play(); + audioSourceNode = + context.createMediaElementSource(internalAudioElement); + audioSourceNode.connect(analyser); + analyser.connect(context.destination); + } else if (inputType === "mic") { + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + audioSourceNode = context.createMediaStreamSource(mediaStream); + audioSourceNode.connect(analyser); + } catch (error) { + console.error(error); + setIsAnalyzing(false); + return; + } + } else { + setIsAnalyzing(false); + return; + } + + const drawLoop = () => { + const oscCanvas = oscCanvasRef.current; + const specCanvas = specCanvasRef.current; + + if (oscCanvas) { + const ctx = oscCanvas.getContext("2d"); + if (ctx) { + const timeData = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteTimeDomainData(timeData); + ctx.fillStyle = "#141414"; + ctx.fillRect(0, 0, oscCanvas.width, oscCanvas.height); + ctx.lineWidth = 2; + ctx.strokeStyle = "#00ff00"; + ctx.beginPath(); + const sliceWidth = oscCanvas.width / timeData.length; + let x = 0; + for (let i = 0; i < timeData.length; i++) { + const y = (timeData[i] / 128.0) * (oscCanvas.height / 2); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + x += sliceWidth; + } + ctx.lineTo(oscCanvas.width, oscCanvas.height / 2); + ctx.stroke(); + } + } + + if (specCanvas) { + const ctx = specCanvas.getContext("2d", { willReadFrequently: true }); + if (ctx) { + const freqData = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(freqData); + ctx.drawImage( + specCanvas, + 0, + 0, + specCanvas.width, + specCanvas.height - 1, + 0, + 1, + specCanvas.width, + specCanvas.height - 1, + ); + + const currentSampleRate = context.sampleRate; + const nyquistFrequency = currentSampleRate / 2; + const displayMaxFrequency = 8000; + const binsToDisplay = Math.floor( + (displayMaxFrequency / nyquistFrequency) * freqData.length, + ); + + const barWidth = specCanvas.width / binsToDisplay; + for (let i = 0; i < binsToDisplay; i++) { + const value = freqData[i]; + const hue = 240 - (value / 255) * 240; + ctx.fillStyle = value > 0 ? `hsl(${hue}, 100%, 50%)` : "black"; + ctx.fillRect(i * barWidth, 0, barWidth + 1, 1); + } + + ctx.fillStyle = "rgba(255, 255, 255, 0.6)"; + for ( + let frequency = 440; + frequency < displayMaxFrequency; + frequency += 440 + ) { + const xPosition = + (frequency / displayMaxFrequency) * specCanvas.width; + ctx.fillRect(xPosition, 0, 1, 1); + } + } + } + animationFrameId = requestAnimationFrame(drawLoop); + }; + + animationFrameId = requestAnimationFrame(drawLoop); + }; + + startAudioSystem(); + + return () => { + cancelAnimationFrame(animationFrameId); + audioSourceNode?.disconnect(); + mediaStream?.getTracks().forEach((track) => track.stop()); + internalAudioElement.pause(); + internalAudioElement.src = ""; + if (audioUrl) URL.revokeObjectURL(audioUrl); + analyserRef.current = null; + }; + }, [isAnalyzing, inputType]); + + useEffect(() => { + return () => { + if (audioContextRef.current) audioContextRef.current.close(); + }; + }, []); + + return ( + <main style={{ padding: "20px", maxWidth: "900px", margin: "0 auto" }}> + <h1>Audio Analyser</h1> + <section + style={{ + marginBottom: "20px", + background: "#f5f5f5", + padding: "15px", + borderRadius: "8px", + }} + > + <h2>Select Input</h2> + <div + style={{ + display: "flex", + alignItems: "center", + gap: "10px", + flexWrap: "wrap", + }} + > + <select + onChange={updateInputType} + value={inputType} + style={{ padding: "5px" }} + > + <option value="file">File</option> + <option value="mic">Microphone</option> + <option value="rec">Record (Not Implemented)</option> + </select> + {inputType === "file" && ( + <input type="file" ref={fileInputRef} accept="audio/*" /> + )} + <div style={{ marginLeft: "auto", display: "flex", gap: "10px" }}> + <button + onClick={() => setIsAnalyzing(!isAnalyzing)} + style={{ + padding: "8px 16px", + backgroundColor: isAnalyzing ? "#ff0000" : "#0070f3", + color: "white", + border: "none", + borderRadius: "4px", + cursor: "pointer", + }} + > + {isAnalyzing ? "Stop Analysis" : "Start Analysis"} + </button> + {isAnalyzing && ( + <button + onClick={performPitchAnalysis} + style={{ + padding: "8px 16px", + backgroundColor: "#28a745", + color: "white", + border: "none", + borderRadius: "4px", + cursor: "pointer", + }} + > + Analyze Pitch & Single Period + </button> + )} + </div> + </div> + </section> + + {pitchResult && ( + <article + style={{ + marginBottom: "30px", + padding: "15px", + border: "2px solid #28a745", + borderRadius: "8px", + }} + > + <h2>Pitch Analysis Result</h2> + <p style={{ fontSize: "1.2rem", fontWeight: "bold" }}> + Detected Frequency:{" "} + <span style={{ color: "#28a745" }}> + {pitchResult.frequency.toFixed(2)} Hz + </span> + </p> + <h3>Single Period Oscilloscope</h3> + <canvas + ref={singlePeriodCanvasRef} + width="800" + height="150" + style={{ + border: "1px solid #333", + background: "#141414", + width: "100%", + height: "auto", + }} + /> + </article> + )} + + <article style={{ marginBottom: "20px" }}> + <h2>Oscilloscope (Real-time)</h2> + <canvas + ref={oscCanvasRef} + width="800" + height="200" + style={{ + border: "1px solid #333", + background: "#141414", + width: "100%", + height: "auto", + }} + /> + </article> + + <article> + <h2>Spectrogram (Real-time)</h2> + <canvas + ref={specCanvasRef} + width="800" + height="400" + style={{ + border: "1px solid #333", + background: "#141414", + width: "100%", + height: "auto", + }} + /> + </article> + </main> + ); +} diff --git a/app/page.tsx b/app/page.tsx @@ -13,6 +13,9 @@ export default function Home() { <li> <Link href="/time-translate">Time Translate</Link> </li> + <li> + <Link href="/audio-analysis">Audio Analysis</Link> + </li> </ul> </main> );