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,36 @@
/**
* Simple, reusable button component with Tailwind styling.
* Pure presentation - no business logic.
*/
import type { ReactNode } from 'react';
interface ButtonProps {
onClick: () => void;
children: ReactNode;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({ onClick, children, variant = 'primary', disabled = false }: ButtonProps) {
const baseClasses = 'inline-flex items-center justify-center gap-2 px-8 py-4 rounded-full text-xl font-bold uppercase tracking-wide cursor-pointer transition-all duration-300 relative overflow-hidden outline-none select-none min-w-[200px] disabled:opacity-60 disabled:cursor-not-allowed shadow-[inset_0_1px_0_hsla(0,0%,100%,0.5),inset_0_-2px_0_hsla(0,0%,0%,0.15),0_8px_16px_hsla(0,0%,0%,0.2),0_4px_8px_hsla(0,0%,0%,0.1)] hover:not(:disabled):-translate-y-0.5 hover:not(:disabled):shadow-[inset_0_1px_0_hsla(0,0%,100%,0.5),inset_0_-2px_0_hsla(0,0%,0%,0.15),0_12px_24px_hsla(0,0%,0%,0.25),0_6px_12px_hsla(0,0%,0%,0.15)] active:not(:disabled):translate-y-0 active:not(:disabled):shadow-[inset_0_2px_4px_hsla(0,0%,0%,0.2),0_2px_4px_hsla(0,0%,0%,0.1)]';
const variantClasses = variant === 'primary'
? 'text-white'
: 'text-white';
return (
<button
className={`${baseClasses} ${variantClasses}`}
onClick={onClick}
disabled={disabled}
style={{
background: variant === 'primary' ? 'var(--gradient-button-blue)' : 'var(--gradient-button-green)',
textShadow: '0 1px 2px hsla(0, 0%, 0%, 0.3)'
}}
>
<span className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white/40 to-transparent rounded-t-full pointer-events-none" />
{children}
</button>
);
}

View File

@@ -0,0 +1,153 @@
/**
* Circular gauge component with Tailwind styling.
*/
import type { Note, TuningState } from '../../domain/types';
import { formatNote } from '../../domain/note-converter';
interface CircularGaugeProps {
note: Note | null;
tuningState: TuningState | null;
}
export function CircularGauge({ note, tuningState }: CircularGaugeProps) {
// Calculate needle rotation
const maxCents = 50;
const cents = tuningState?.cents || 0;
const clampedCents = Math.max(-maxCents, Math.min(maxCents, cents));
const rotation = (clampedCents / maxCents) * 90;
// Determine color based on tuning accuracy
const getColorForCents = (cents: number) => {
const absCents = Math.abs(cents);
if (absCents <= 5) {
// Perfect tune - bright green
return {
bg: 'linear-gradient(180deg, hsl(140, 70%, 45%) 0%, hsl(140, 75%, 35%) 100%)',
text: 'hsl(140, 90%, 85%)',
glow: '0 0 20px hsla(140, 80%, 50%, 0.6), 0 0 40px hsla(140, 80%, 50%, 0.3)'
};
} else if (absCents <= 15) {
// Close - yellow
return {
bg: 'linear-gradient(180deg, hsl(50, 90%, 55%) 0%, hsl(50, 85%, 45%) 100%)',
text: 'hsl(50, 95%, 90%)',
glow: '0 0 20px hsla(50, 80%, 50%, 0.4)'
};
} else if (absCents <= 30) {
// Getting further - orange
return {
bg: 'linear-gradient(180deg, hsl(30, 90%, 55%) 0%, hsl(30, 85%, 45%) 100%)',
text: 'hsl(30, 95%, 90%)',
glow: '0 0 20px hsla(30, 80%, 50%, 0.4)'
};
} else {
// Too far - red
return {
bg: 'linear-gradient(180deg, hsl(0, 85%, 60%) 0%, hsl(0, 80%, 50%) 100%)',
text: 'hsl(0, 95%, 95%)',
glow: '0 0 20px hsla(0, 80%, 50%, 0.4)'
};
}
};
const colors = note && tuningState ? getColorForCents(cents) : {
bg: 'linear-gradient(180deg, hsl(210, 60%, 35%) 0%, hsl(210, 65%, 25%) 100%)',
text: 'hsl(190, 80%, 70%)',
glow: 'none'
};
// Generate gauge marks
const marks = [];
for (let i = -50; i <= 50; i += 5) {
const angle = (i / 50) * 90;
const isMajor = i % 10 === 0;
marks.push(
<div
key={i}
className={`absolute top-2.5 left-1/2 origin-[50%_140px] -ml-px ${isMajor ? 'w-[3px] h-5 bg-[hsla(200,30%,30%,0.8)]' : 'w-0.5 h-3 bg-[hsla(200,30%,40%,0.6)]'}`}
style={{ transform: `rotate(${angle}deg)` }}
/>
);
}
// Generate labels
const labels = [
{ value: -20, angle: -36 },
{ value: 0, angle: 0 },
{ value: 20, angle: 36 },
{ value: 40, angle: 72 },
];
return (
<div className="w-[360px] h-[360px] my-1 mx-auto mb-2 relative md:w-[300px] md:h-[300px] md:my-2 max-md:w-[260px] max-md:h-[260px] max-md:my-0">
<div
className="w-full h-full rounded-full p-4 relative shadow-[inset_0_2px_4px_hsla(0,0%,0%,0.2),inset_0_-2px_4px_hsla(0,0%,100%,0.3),0_16px_32px_hsla(0,0%,0%,0.15)]"
style={{ background: 'var(--gradient-gauge-bezel)' }}
>
<div
className="w-full h-full rounded-full relative overflow-hidden shadow-[inset_0_4px_8px_hsla(0,0%,100%,0.3),inset_0_-2px_4px_hsla(0,0%,0%,0.1)]"
style={{ background: 'var(--gradient-gauge-face)' }}
>
{/* Gloss */}
<span className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white/40 to-transparent pointer-events-none" />
{/* Marks */}
<div className="absolute -inset-2 rounded-full">{marks}</div>
{/* Labels */}
<div className="absolute inset-0 pointer-events-none">
{labels.map(({ value, angle }) => {
const radius = 120;
const radian = (angle * Math.PI) / 180;
const x = 50 + radius * Math.sin(radian);
const y = 50 - radius * Math.cos(radian);
return (
<span
key={value}
className="absolute text-lg font-bold -translate-x-1/2 -translate-y-1/2"
style={{
left: `${x}%`,
top: `${y}%`,
color: 'hsla(200, 30%, 30%, 0.8)'
}}
>
{value}
</span>
);
})}
</div>
{/* Note display with color feedback */}
<div
className="absolute bottom-20 left-1/2 -translate-x-1/2 text-[2.5rem] font-extrabold px-6 py-2 rounded-xl min-w-[100px] text-center z-10 md:text-[2rem] md:bottom-[50px] max-md:text-[1.75rem] max-md:bottom-[50px] max-md:px-3 max-md:py-1 transition-all duration-300"
style={{
background: colors.bg,
color: colors.text,
boxShadow: `inset 0 1px 0 hsla(0, 0%, 100%, 0.1), inset 0 -2px 0 hsla(0, 0%, 0%, 0.3), 0 4px 8px hsla(0, 0%, 0%, 0.3), ${colors.glow}`
}}
>
{note ? formatNote(note) : '—'}
</div>
{/* Needle */}
<div
className="absolute bottom-1/2 left-1/2 w-1 h-[120px] -translate-x-1/2 rounded-t-sm transition-transform duration-500 shadow-[0_2px_4px_hsla(0,0%,0%,0.3)] z-[2] md:h-[100px] max-md:h-20"
style={{
background: 'linear-gradient(180deg, hsl(200, 80%, 45%) 0%, hsl(200, 75%, 40%) 80%, hsl(200, 70%, 35%) 100%)',
transform: `translateX(-50%) rotate(${rotation}deg)`,
transformOrigin: 'bottom center'
}}
/>
{/* Center cap */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-6 rounded-full shadow-[inset_0_2px_2px_hsla(0,0%,0%,0.3),0_2px_4px_hsla(0,0%,0%,0.2)] z-[3]"
style={{ background: 'linear-gradient(135deg, hsl(200, 20%, 60%) 0%, hsl(200, 15%, 50%) 100%)' }}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-100 to-cyan-50 p-4">
<div className="bg-white/80 backdrop-blur-xl p-8 rounded-2xl shadow-xl border border-white/50 max-w-md w-full text-center">
<div className="text-4xl mb-4">😵</div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Oops! Something went wrong.</h1>
<p className="text-gray-600 mb-6">
The tuner crashed. This might be due to a temporary glitch.
</p>
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm font-mono mb-6 text-left overflow-auto max-h-32">
{this.state.error?.message}
</div>
<button
onClick={() => window.location.reload()}
className="bg-gradient-to-b from-blue-400 to-blue-600 text-white font-bold py-2 px-6 rounded-full shadow-lg hover:shadow-xl active:scale-95 transition-all"
>
Reload App
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,37 @@
/**
* Error message component with Tailwind styling.
*/
import { Button } from './Button';
interface ErrorMessageProps {
error: Error;
onRetry?: () => void;
}
export function ErrorMessage({ error, onRetry }: ErrorMessageProps) {
const isPermissionError = error.message.includes('microphone') || error.message.includes('permission');
return (
<div className="bg-gradient-to-br from-red-50/90 to-red-100/90 border-2 border-red-300/40 rounded-2xl p-4 text-red-600 font-semibold shadow-sm backdrop-blur-lg">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div className="flex-1">
<p className="font-bold mb-1">
{isPermissionError ? 'Microphone Access Required' : 'Error'}
</p>
<p className="text-sm opacity-90">
{error.message}
</p>
{onRetry && (
<div className="mt-3">
<Button onClick={onRetry} variant="secondary">
Try Again
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
/**
* Displays the detected frequency in Hz.
*/
interface FrequencyDisplayProps {
frequency: number | null;
}
export function FrequencyDisplay({ frequency }: FrequencyDisplayProps) {
return (
<div className="text-center text-2xl font-semibold tabular-nums mt-0" style={{ color: 'var(--color-primary-dark)' }}>
{frequency ? `${frequency.toFixed(1)} Hz` : '- - -'}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { StatusIndicator } from "./StatusIndicator";
interface HeaderProps {
status: 'idle' | 'listening' | 'error';
}
export default function Header({ status }: HeaderProps) {
return <div
className="px-8 py-5 shadow-[inset_0_2px_0_hsla(0,0%,100%,0.4),inset_0_-2px_0_hsla(0,0%,0%,0.1),0_4px_16px_hsla(200,70%,20%,0.2)] flex items-center justify-between"
style={{ background: 'var(--gradient-header)' }}
>
<div className="flex items-center gap-4 text-4xl font-extrabold text-white drop-shadow-[0_2px_0_hsla(0,0%,0%,0.2)] md:text-3xl max-md:text-2xl">
<span className="text-5xl drop-shadow-[0_2px_4px_hsla(0,0%,0%,0.2)] md:text-4xl max-md:text-2xl">🌍</span>
Tuner
</div>
<StatusIndicator status={status} />
</div>;
}

View File

@@ -0,0 +1,43 @@
/**
* Instrument selector component with Tailwind styling.
* Allows switching between guitar, piano, and ukulele.
*/
import type { InstrumentType } from '../../domain/types';
interface InstrumentSelectorProps {
value: InstrumentType;
onChange: (type: InstrumentType) => void;
}
const instruments: { type: InstrumentType; label: string; emoji: string }[] = [
{ type: 'guitar', label: 'Guitar', emoji: '🎸' },
{ type: 'piano', label: 'Piano', emoji: '🎹' },
{ type: 'ukulele', label: 'Ukulele', emoji: '🎻' },
];
export function InstrumentSelector({ value, onChange }: InstrumentSelectorProps) {
return (
<div className="flex gap-4 justify-center my-4">
{instruments.map((instrument) => {
const isActive = value === instrument.type;
return (
<button
key={instrument.type}
className={`w-[90px] h-[90px] border-0 rounded-2xl text-[3.5rem] leading-none cursor-pointer transition-all duration-300 relative overflow-hidden flex items-center justify-center p-0 shadow-[inset_0_2px_0_hsla(0,0%,100%,0.4),inset_0_-3px_0_hsla(0,0%,0%,0.2),0_6px_12px_hsla(0,0%,0%,0.15)] hover:-translate-y-1 hover:shadow-[inset_0_2px_0_hsla(0,0%,100%,0.4),inset_0_-3px_0_hsla(0,0%,0%,0.2),0_10px_20px_hsla(0,0%,0%,0.2)] md:w-[75px] md:h-[75px] md:text-[2.8rem] max-md:w-[65px] max-md:h-[65px] max-md:text-[2.5rem]`}
onClick={() => onChange(instrument.type)}
aria-label={instrument.label}
title={instrument.label}
style={{
background: isActive ? 'var(--gradient-instrument-active)' : 'linear-gradient(135deg, hsl(200, 40%, 70%) 0%, hsl(200, 35%, 65%) 100%)',
boxShadow: isActive ? 'inset 0 2px 0 hsla(0, 0%, 100%, 0.5), inset 0 -3px 0 hsla(0, 0%, 0%, 0.25), 0 6px 12px hsla(50, 90%, 50%, 0.3), 0 0 20px hsla(50, 90%, 60%, 0.2)' : undefined
}}
>
<span className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-black/10 pointer-events-none" />
{instrument.emoji}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* Displays the detected note name and octave.
*/
import type { Note } from '../../domain/types';
import { formatNote } from '../../domain/note-converter';
interface NoteDisplayProps {
note: Note | null;
}
export function NoteDisplay({ note }: NoteDisplayProps) {
return (
<div className="text-center">
<div className="note-display">
{note ? formatNote(note) : '--'}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
/**
*Status indicator component.
*/
interface StatusIndicatorProps {
status: 'listening' | 'idle' | 'error';
}
const statusConfig = {
listening: {
label: 'Listening...',
dotClass: 'animate-pulse',
dotStyle: { background: 'var(--color-success)', boxShadow: '0 0 10px var(--color-success)' }
},
idle: {
label: 'Ready',
dotClass: '',
dotStyle: { background: 'hsl(210, 15%, 70%)' }
},
error: {
label: 'Error',
dotClass: '',
dotStyle: { background: 'var(--color-error)' }
}
};
export function StatusIndicator({ status }: StatusIndicatorProps) {
const config = statusConfig[status];
return (
<div className="inline-flex items-center gap-2 px-3 py-2 rounded-full text-sm font-semibold bg-white/30 backdrop-blur-md">
<div className={`w-2.5 h-2.5 rounded-full ${config.dotClass}`} style={config.dotStyle} />
<span>{config.label}</span>
</div>
);
}

View File

@@ -0,0 +1,97 @@
/**
* String-by-string tuning guide component.
* Shows all strings with visual status indicators.
*/
import type { Tuning } from '../../domain/tunings';
interface StringGuideProps {
tuning: Tuning;
detectedFrequency: number | null;
}
export function StringGuide({ tuning, detectedFrequency }: StringGuideProps) {
// Find the closest string to the detected frequency
const getStringStatus = (stringFreq: number) => {
if (!detectedFrequency) {
return { color: 'bg-gray-400/30', isClosest: false, diff: 0 };
}
const cents = 1200 * Math.log2(detectedFrequency / stringFreq);
const absCents = Math.abs(cents);
// Determine if this is the closest string
const isClosest = tuning.strings.every(s =>
Math.abs(detectedFrequency - stringFreq) <= Math.abs(detectedFrequency - s.frequency)
);
if (!isClosest) {
return { color: 'bg-gray-400/30', isClosest: false, diff: cents };
}
// Color based on tuning accuracy
let color = 'bg-gray-400/30';
if (absCents <= 5) {
color = 'bg-green-500/80';
} else if (absCents <= 15) {
color = 'bg-yellow-500/80';
} else if (absCents <= 30) {
color = 'bg-orange-500/80';
} else {
color = 'bg-red-500/80';
}
return { color, isClosest: true, diff: cents };
};
return (
<div className="mx-auto w-[calc(100%-2rem)] md:w-md">
<div className="bg-white/40 backdrop-blur-[15px] px-4 py-3 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)]">
<div className="flex flex-col gap-2">
{tuning.strings.map((string, index) => {
const status = getStringStatus(string.frequency);
return (
<div
key={`${string.name}-${index}`}
className={`flex items-center gap-3 p-2 rounded-lg transition-all duration-300 ${status.isClosest ? 'bg-white/50 scale-105' : 'bg-white/20'
}`}
>
{/* String name */}
<div className="w-12 text-center font-bold text-sm" style={{ color: 'var(--color-primary-dark)' }}>
{string.name}
</div>
{/* Visual string line */}
<div className="flex-1 relative h-3 bg-gradient-to-r from-gray-400/40 to-gray-500/40 rounded-full overflow-hidden shadow-inner">
{/* Status indicator */}
{status.isClosest && (
<div
className={`absolute top-0 left-0 h-full ${status.color} transition-all duration-300 rounded-full`}
style={{
width: `${Math.min(100, 100 - Math.abs(status.diff) * 2)}%`,
boxShadow: status.color.includes('green') ? '0 0 8px rgba(34, 197, 94, 0.6)' : undefined
}}
/>
)}
</div>
{/* Frequency */}
<div className="w-16 text-right text-xs opacity-70">
{string.frequency.toFixed(0)} Hz
</div>
{/* Cents indicator */}
{status.isClosest && detectedFrequency && (
<div className="w-12 text-right text-xs font-semibold">
{status.diff > 0 ? '+' : ''}{status.diff.toFixed(0)}¢
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
/**
* Main tuner interface with Tailwind styling.
*/
import { useState, useEffect } from 'react';
import { useTuner } from '../hooks/useTuner';
import { useInstrument } from '../hooks/useInstrument';
import { useTuning } from '../hooks/useTuning';
import { Button } from './Button';
import { FrequencyDisplay } from './FrequencyDisplay';
import { CircularGauge } from './CircularGauge';
import { InstrumentSelector } from './InstrumentSelector';
import { TuningSelector } from './TuningSelector';
import { ErrorMessage } from './ErrorMessage';
import { StringGuide } from './StringGuide';
import { WaveformDisplay } from './WaveformDisplay';
import Header from './Header';
export function TunerInterface() {
const { instrument, setInstrument } = useInstrument();
const { tuning, availableTunings, changeTuning } = useTuning(instrument);
const { isActive, error, start, stop, frequency, note, tuningState, audioService } = useTuner(tuning);
// Persisted state for Pro Mode
const [isProMode, setIsProMode] = useState(() => {
const saved = localStorage.getItem('aero-tuner-pro-mode');
return saved !== null ? JSON.parse(saved) : false;
});
useEffect(() => {
localStorage.setItem('aero-tuner-pro-mode', JSON.stringify(isProMode));
}, [isProMode]);
const handleToggle = () => {
if (isActive) {
stop();
} else {
start();
}
};
const instrumentConfig = {
name: tuning.name,
type: instrument
};
const status = error ? 'error' : isActive ? 'listening' : 'idle';
return (
<>
{/* Header */}
<Header status={status} />
<div className="max-w-full bg-white/15 backdrop-blur-[20px] h-full min-h-[calc(100vh-80px)] pb-4">
<div className="flex flex-col gap-4 md:gap-2 max-md:gap-1">
{/* Error */}
{error && <ErrorMessage error={error} onRetry={handleToggle} />}
{/* Gauge */}
<CircularGauge note={note} tuningState={tuningState} />
{/* Frequency */}
<FrequencyDisplay frequency={frequency} />
{/* String Guide (Pro Only) */}
{isProMode && (
<StringGuide tuning={tuning} detectedFrequency={frequency} />
)}
{/* Instruments */}
<InstrumentSelector value={instrument} onChange={setInstrument} />
{/* Button */}
<div className="flex items-center justify-center flex-col gap-4 md:gap-4">
<Button onClick={handleToggle} variant="primary">
{isActive ? 'Stop' : 'Tune'}
</Button>
</div>
{/* Waveform Display (Pro Only) */}
{isProMode && (
<WaveformDisplay audioService={audioService} isActive={isActive} />
)}
{/* Info */}
<div className="md:w-md bg-white/40 backdrop-blur-[15px] px-4 py-2 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)] text-center max-md:px-2 max-md:py-1 max-md:text-sm mt-3 mx-auto">
<p>
<strong>{instrumentConfig.name}</strong>
</p>
<p className="mt-1 text-sm">
{isActive
? 'Play a note on your instrument'
: 'Click Tune to begin'}
</p>
</div>
{/* Pro Controls */}
<div className="mx-auto md:w-md flex flex-col gap-4">
{/* Tuning Selector (Pro Only) */}
{isProMode && (
<TuningSelector
tunings={availableTunings}
selectedTuning={tuning}
onChange={changeTuning}
/>
)}
{/* Mode Toggle */}
<button
onClick={() => setIsProMode(!isProMode)}
className="text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors flex items-center justify-center gap-2 opacity-70 hover:opacity-100 py-2"
>
<span className={!isProMode ? 'font-bold text-blue-600' : ''}>Basic</span>
<div className={`w-8 h-4 rounded-full relative transition-colors duration-300 ${isProMode ? 'bg-blue-500' : 'bg-gray-300'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow-sm transition-transform duration-300 ${isProMode ? 'left-4.5' : 'left-0.5'}`} />
</div>
<span className={isProMode ? 'font-bold text-blue-600' : ''}>Pro</span>
</button>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,45 @@
/**
* Visual gauge showing tuning accuracy.
* Displays deviation from target pitch.
*/
import type { TuningState } from '../../domain/types';
interface TuningMeterProps {
tuningState: TuningState | null;
}
export function TuningMeter({ tuningState }: TuningMeterProps) {
if (!tuningState) {
return (
<div className="tuning-meter">
<div className="tuning-meter-center" />
<div className="tuning-meter-labels">
<span>Flat</span>
<span>In Tune</span>
<span>Sharp</span>
</div>
</div>
);
}
// Calculate meter position (-50 to +50 cents -> 0% to 100%)
const maxCents = 50;
const clampedCents = Math.max(-maxCents, Math.min(maxCents, tuningState.cents));
const position = 50 + (clampedCents / maxCents) * 50; // 0-100%
return (
<div className="tuning-meter">
<div
className={`tuning-meter-bar ${tuningState.status}`}
style={{ left: `${position}%` }}
/>
<div className="tuning-meter-center" />
<div className="tuning-meter-labels">
<span>Flat</span>
<span>{tuningState.cents > 0 ? '+' : ''}{tuningState.cents}¢</span>
<span>Sharp</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Tuning selector component with Tailwind styling.
*/
import type { Tuning } from '../../domain/tunings';
interface TuningSelectorProps {
tunings: Tuning[];
selectedTuning: Tuning;
onChange: (tuning: Tuning) => void;
}
export function TuningSelector({ tunings, selectedTuning, onChange }: TuningSelectorProps) {
// Don't show selector if there's only one tuning option
if (tunings.length <= 1) {
return null;
}
return (
<div className="flex flex-col gap-2 w-full">
<label className="text-sm font-semibold text-center" style={{ color: 'var(--color-primary-dark)' }}>
Tuning
</label>
<select
value={selectedTuning.id}
onChange={(e) => {
const tuning = tunings.find(t => t.id === e.target.value);
if (tuning) onChange(tuning);
}}
className="px-4 py-3 rounded-xl font-semibold text-center cursor-pointer transition-all duration-300 border-2 shadow-[inset_0_1px_0_hsla(0,0%,100%,0.3),0_4px_8px_hsla(0,0%,0%,0.1)] hover:shadow-[inset_0_1px_0_hsla(0,0%,100%,0.3),0_6px_12px_hsla(0,0%,0%,0.15)] focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{
background: 'linear-gradient(135deg, hsla(0, 0%, 100%, 0.4) 0%, hsla(0, 0%, 100%, 0.2) 100%)',
backdropFilter: 'blur(15px)',
WebkitBackdropFilter: 'blur(15px)',
borderColor: 'hsla(0, 0%, 100%, 0.5)',
color: 'var(--color-primary-dark)'
}}
>
{tunings.map((tuning) => (
<option key={tuning.id} value={tuning.id}>
{tuning.name} ({tuning.description})
</option>
))}
</select>
</div>
);
}

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>
);
}