feat: Implement initial tuner application with core logic, audio processing, and presentation components.

This commit is contained in:
2025-12-02 13:54:52 +01:00
commit cc5f96c9e3
51 changed files with 4182 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
/**
* Real-time waveform display component using Canvas.
*/
import { useEffect, useRef } from 'react';
import type { AudioCaptureService } from '../../infrastructure/audio-capture';
interface WaveformDisplayProps {
audioService: AudioCaptureService | null;
isActive: boolean;
}
export function WaveformDisplay({ audioService, isActive }: WaveformDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafIdRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !audioService || !isActive) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
const analyser = audioService.getAnalyser();
if (!analyser) return;
const bufferLength = analyser.fftSize;
const dataArray = new Float32Array(bufferLength);
// Set canvas size
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const draw = () => {
if (!isActive) return;
// Get waveform data
analyser.getFloatTimeDomainData(dataArray);
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
// Clear canvas with gradient background
const bgGradient = ctx.createLinearGradient(0, 0, 0, height);
bgGradient.addColorStop(0, 'hsla(200, 60%, 30%, 0.2)');
bgGradient.addColorStop(1, 'hsla(200, 60%, 20%, 0.3)');
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, width, height);
// Draw waveform
const sliceWidth = width / bufferLength;
let x = 0;
// Create gradient for waveform
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'hsl(180, 80%, 60%)');
gradient.addColorStop(0.5, 'hsl(200, 80%, 50%)');
gradient.addColorStop(1, 'hsl(220, 80%, 40%)');
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.shadowBlur = 4;
ctx.shadowColor = 'hsla(200, 80%, 50%, 0.5)';
ctx.beginPath();
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i];
const y = (v + 1) * height / 2; // Convert from [-1, 1] to [0, height]
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.stroke();
ctx.shadowBlur = 0;
rafIdRef.current = requestAnimationFrame(draw);
};
draw();
return () => {
window.removeEventListener('resize', resizeCanvas);
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [audioService, isActive]);
if (!isActive) {
return null;
}
return (
<div className="mx-auto w-[calc(100%-2rem)] md:w-md">
<div className="bg-white/40 backdrop-blur-[15px] rounded-xl border border-white/50 shadow-[0_4px_16px_hsla(0,0%,0%,0.08),inset_0_1px_0_hsla(0,0%,100%,0.5)] overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-20 block"
style={{ imageRendering: 'auto' }}
/>
</div>
</div>
);
}