feat: Implement initial tuner application with core logic, audio processing, and presentation components.
This commit is contained in:
36
src/presentation/components/Button.tsx
Normal file
36
src/presentation/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/presentation/components/CircularGauge.tsx
Normal file
153
src/presentation/components/CircularGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/presentation/components/ErrorBoundary.tsx
Normal file
52
src/presentation/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/presentation/components/ErrorMessage.tsx
Normal file
37
src/presentation/components/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/presentation/components/FrequencyDisplay.tsx
Normal file
15
src/presentation/components/FrequencyDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/presentation/components/Header.tsx
Normal file
18
src/presentation/components/Header.tsx
Normal 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>;
|
||||
}
|
||||
43
src/presentation/components/InstrumentSelector.tsx
Normal file
43
src/presentation/components/InstrumentSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/presentation/components/NoteDisplay.tsx
Normal file
20
src/presentation/components/NoteDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/presentation/components/StatusIndicator.tsx
Normal file
36
src/presentation/components/StatusIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/presentation/components/StringGuide.tsx
Normal file
97
src/presentation/components/StringGuide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/presentation/components/TunerInterface.tsx
Normal file
124
src/presentation/components/TunerInterface.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/presentation/components/TuningMeter.tsx
Normal file
45
src/presentation/components/TuningMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/presentation/components/TuningSelector.tsx
Normal file
47
src/presentation/components/TuningSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/presentation/components/WaveformDisplay.tsx
Normal file
120
src/presentation/components/WaveformDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user