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

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
README.md
dev-dist/

204
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,204 @@
# Tuner App - Architecture Documentation
## Clean Architecture Diagram
```mermaid
graph TB
subgraph "Presentation Layer"
UI[TunerInterface Component]
Hooks[Custom Hooks<br/>useTuner, useAudioCapture, etc.]
Components[Dumb Components<br/>Button, NoteDisplay, etc.]
end
subgraph "Domain Layer - Pure Business Logic"
Types[types.ts<br/>Core Type Definitions]
NoteConverter[note-converter.ts<br/>Frequency ↔ Note]
TuningCalc[tuning-calculator.ts<br/>Sharp/Flat/InTune Logic]
Instruments[instruments.ts<br/>Instrument Configs]
end
subgraph "Infrastructure Layer - External Services"
AudioCapture[AudioCaptureService<br/>Web Audio API Wrapper]
PitchDetector[PitchDetector<br/>Autocorrelation Algorithm]
Errors[Custom Error Types]
end
subgraph "External APIs"
WebAudio[Web Audio API]
Microphone[Device Microphone]
end
UI --> Hooks
Hooks --> Components
Hooks --> TuningCalc
Hooks --> NoteConverter
Hooks --> Instruments
Hooks --> AudioCapture
Hooks --> PitchDetector
AudioCapture --> WebAudio
WebAudio --> Microphone
TuningCalc --> NoteConverter
TuningCalc --> Types
NoteConverter --> Types
Instruments --> Types
PitchDetector --> Errors
AudioCapture --> Errors
style UI fill:#00bfff,stroke:#0080ff,stroke-width:2px
style Hooks fill:#87ceeb,stroke:#4682b4,stroke-width:2px
style Components fill:#b0e0e6,stroke:#4682b4,stroke-width:2px
style Types fill:#98fb98,stroke:#228b22,stroke-width:2px
style NoteConverter fill:#98fb98,stroke:#228b22,stroke-width:2px
style TuningCalc fill:#98fb98,stroke:#228b22,stroke-width:2px
style Instruments fill:#98fb98,stroke:#228b22,stroke-width:2px
style AudioCapture fill:#ffd700,stroke:#ff8c00,stroke-width:2px
style PitchDetector fill:#ffd700,stroke:#ff8c00,stroke-width:2px
style Errors fill:#ffd700,stroke:#ff8c00,stroke-width:2px
```
## Dependency Flow
The architecture strictly enforces the dependency rule:
```
Presentation → Domain ← Infrastructure
↓ ↓
→ Infrastructure → External APIs
```
### Key Rules
1. **Domain has zero dependencies** - Pure TypeScript, no React, no external libraries
2. **Infrastructure depends on external APIs** - Web Audio API, browser APIs
3. **Presentation depends on both** - Uses hooks to orchestrate domain + infrastructure
4. **Components are pure** - Only receive props, no business logic
## Data Flow Example
User clicks "Start Tuning":
```mermaid
sequenceDiagram
participant User
participant TunerInterface
participant useTuner
participant AudioCapture
participant PitchDetector
participant NoteConverter
participant TuningCalc
User->>TunerInterface: Click "Start"
TunerInterface->>useTuner: start()
useTuner->>AudioCapture: start()
AudioCapture->>Browser: Request mic permission
Browser-->>User: Permission dialog
User-->>Browser: Grant permission
Browser-->>AudioCapture: MediaStream
loop Real-time processing
AudioCapture->>useTuner: Audio samples
useTuner->>PitchDetector: detectPitch(samples)
PitchDetector-->>useTuner: frequency (Hz)
useTuner->>NoteConverter: frequencyToNote(Hz)
NoteConverter-->>useTuner: note {name, octave}
useTuner->>TuningCalc: calculateTuningState()
TuningCalc-->>useTuner: {status, cents, accuracy}
useTuner-->>TunerInterface: Update state
TunerInterface-->>User: Display note & meter
end
```
## Component Composition
The UI is built through composition of small, focused components:
```mermaid
graph TD
TunerInterface[TunerInterface]
TunerInterface --> InstrumentSelector
TunerInterface --> ErrorMessage
TunerInterface --> DisplayPanel[Display Panel]
TunerInterface --> Button
TunerInterface --> StatusIndicator
DisplayPanel --> NoteDisplay
DisplayPanel --> FrequencyDisplay
DisplayPanel --> TuningMeter
style TunerInterface fill:#00bfff,stroke:#0080ff,stroke-width:3px
style InstrumentSelector fill:#87ceeb,stroke:#4682b4
style ErrorMessage fill:#87ceeb,stroke:#4682b4
style DisplayPanel fill:#87ceeb,stroke:#4682b4
style Button fill:#87ceeb,stroke:#4682b4
style StatusIndicator fill:#87ceeb,stroke:#4682b4
style NoteDisplay fill:#b0e0e6,stroke:#4682b4
style FrequencyDisplay fill:#b0e0e6,stroke:#4682b4
style TuningMeter fill:#b0e0e6,stroke:#4682b4
```
## File Organization
```
src/
├── domain/ # 🟢 Pure Logic (Green)
│ ├── types.ts # Type definitions
│ ├── note-converter.ts # Mathematical conversions
│ ├── tuning-calculator.ts # Tuning logic
│ └── instruments.ts # Configuration data
├── infrastructure/ # 🟡 External Services (Gold)
│ ├── audio-capture.ts # Microphone access
│ ├── pitch-detector.ts # Signal processing
│ └── audio-errors.ts # Error handling
├── presentation/ # 🔵 UI Layer (Blue)
│ ├── hooks/ # Smart - contain logic
│ │ ├── useAudioCapture.ts
│ │ ├── usePitchDetection.ts
│ │ ├── useInstrument.ts
│ │ └── useTuner.ts
│ │
│ └── components/ # Dumb - only presentation
│ ├── Button.tsx
│ ├── FrequencyDisplay.tsx
│ ├── NoteDisplay.tsx
│ ├── TuningMeter.tsx
│ ├── InstrumentSelector.tsx
│ ├── StatusIndicator.tsx
│ ├── ErrorMessage.tsx
│ └── TunerInterface.tsx
├── styles/ # Design system
│ └── components.css
├── index.css # Frutiger Aero design tokens
├── App.tsx # Root component
└── main.tsx # Entry point
```
## Benefits of This Architecture
### Testability
- Domain logic can be unit tested without React
- Infrastructure can be mocked for testing
- Components can be tested in isolation
### Maintainability
- Clear separation makes changes easier
- Each layer has a single responsibility
- Dependencies flow in one direction
### Scalability
- Easy to add new instruments (just update domain)
- Can swap pitch detection algorithms (infrastructure)
- Can redesign UI without touching logic (presentation)
### Reusability
- Domain logic could be used in a mobile app
- Components can be used in other projects
- Hooks encapsulate reusable behavior

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# AeroTuner
An instrument tuner featuring a nostalgic **Frutiger Aero** aesthetic, built with modern web technologies and Clean Architecture.
![Status](https://img.shields.io/badge/Status-Production_Ready-success)
![PWA](https://img.shields.io/badge/PWA-Installable-purple)
![Tests](https://img.shields.io/badge/Tests-Passing-green)
![Design](https://img.shields.io/badge/Design-Frutiger%20Aero-aqua)
## Features
### Precision Tuning
- **FFT-based Pitch Detection**: High-precision frequency analysis using the Web Audio API.
- **Harmonic Product Spectrum (HPS)**: Robust fundamental frequency detection, even for bass instruments.
- **Accurate to ±1 cent**: Professional-grade tuning accuracy (don't quote me on that).
- **Multi-Instrument Support**: Guitar, Ukulele, Bass, and Piano.
- **Alternate Tunings**: Drop D, Open G, DADGAD, and more (Pro Mode).
### Frutiger Aero UI
- **Glossy Aesthetics**: Glassmorphism, vibrant gradients, and detailed reflections.
- **Interactive Gauge**: Smooth, physics-based needle animation.
- **Basic vs. Pro Modes**:
- **Basic**: Clean interface for quick tuning.
- **Pro**: Advanced tools including Waveform Display and String Guide.
- **Real-time Waveform**: Visualizes the audio signal in real-time.
- **String Guide**: Visual indicator of the target string.
### Progressive Web App (PWA)
- **Installable**: Add to home screen on iOS, Android, and Desktop.
- **Offline Capable**: Works without an internet connection.
- **App-like Experience**: Fullscreen mode with custom icons.
## Technology Stack
- **Core**: [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/)
- **Build**: [Vite](https://vitejs.dev/), [Bun](https://bun.sh/)
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) (with custom Frutiger Aero configuration)
- **Audio**: Web Audio API (`AnalyserNode`, `AudioContext`)
- **State**: React Hooks + LocalStorage persistence
- **Testing**: [Bun Test](https://bun.sh/docs/cli/test)
## Architecture
This project strictly follows **Clean Architecture** principles to ensure maintainability and testability:
- **Domain Layer** (`src/domain`): Pure business logic (Note conversion, Tuning calculations, Harmonic analysis). Zero dependencies.
- **Infrastructure Layer** (`src/infrastructure`): Implementation details (Web Audio API, FFT algorithms).
- **Presentation Layer** (`src/presentation`): UI components and React hooks.
## Quick Start
```bash
# Install dependencies
bun install
# Start development server
bun dev
# Run tests
bun test
# Build for production
bun run build
```
## Testing
The project includes a comprehensive suite of unit tests for the Domain layer.
```bash
bun test
```
Tests cover:
- Frequency ↔ Note conversion
- Cents calculation & Tuning status
- Harmonic analysis & Fundamental frequency detection
## Usage
1. **Grant Permission**: Allow microphone access when prompted.
2. **Select Instrument**: Choose Guitar, Bass, Ukulele, or Piano.
3. **Choose Mode**: Toggle between **Basic** (simple) and **Pro** (advanced) views.
4. **Tune**: Play a string. The gauge shows if you are sharp (right) or flat (left).
- **Green**: In tune!
- **Blue/Red**: Adjust your tuning pegs.
## License
MIT

1165
bun.lock Normal file

File diff suppressed because it is too large Load Diff

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/pwa-192x192.png" />
<link rel="apple-touch-icon" href="/pwa-192x192.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff">
<meta name="description" content="A Frutiger Aero styled instrument tuner">
<title>AeroTuner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "tuner",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
}
}

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

8
src/App.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { TunerInterface } from './presentation/components/TunerInterface';
function App() {
return <TunerInterface />;
}
export default App;

BIN
src/assets/background.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

26
src/config.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Centralized configuration for the AeroTuner application.
*/
export const config = {
audio: {
sampleRate: 48000,
fftSize: 2048,
smoothingTimeConstant: 0.8,
},
pitchDetection: {
minFrequency: 60, // Hz (Low B on 5-string bass is ~30Hz, but 60Hz is safer for noise)
maxFrequency: 4000, // Hz (High C on piano is ~4186Hz)
clarityThreshold: 0.9, // Correlation threshold for autocorrelation
silenceThreshold: 0.05, // Amplitude threshold to consider "silence"
fftPeakThreshold: 0.05, // 5% of max magnitude
},
tuning: {
referencePitch: 440, // A4 frequency
inTuneThresholdCents: 5, // ±5 cents is considered "in tune"
},
ui: {
gaugeRangeCents: 50, // Range of the tuning gauge in cents (±50)
animationSmoothing: 0.2, // Smoothing factor for UI updates
}
} as const;

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { isHarmonic, findFundamentalFromHarmonics } from "../harmonic-analyzer";
describe("Harmonic Analyzer", () => {
describe("isHarmonic", () => {
test("identifies 2nd harmonic (octave)", () => {
expect(isHarmonic(110, 220)).toBe(true); // A2 -> A3
});
test("identifies 3rd harmonic", () => {
expect(isHarmonic(110, 330)).toBe(true); // A2 -> E4
});
test("rejects non-harmonics", () => {
expect(isHarmonic(110, 115)).toBe(false);
});
});
describe("findFundamentalFromHarmonics", () => {
test("finds fundamental from a harmonic series", () => {
// Series for A2 (110Hz): 110, 220, 330, 440
const candidates = [220, 330, 110, 440];
const fundamental = findFundamentalFromHarmonics(candidates);
expect(fundamental).toBe(110);
});
test("returns lowest frequency if no clear series", () => {
const candidates = [440, 445]; // Close but not harmonic
const fundamental = findFundamentalFromHarmonics(candidates);
expect(fundamental).toBe(440);
});
test("handles empty input", () => {
expect(findFundamentalFromHarmonics([])).toBe(0);
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { frequencyToNote, midiNumberToFrequency } from "../note-converter";
describe("Note Converter", () => {
describe("frequencyToNote", () => {
test("correctly identifies A4 (440Hz)", () => {
const result = frequencyToNote(440);
expect(result).not.toBeNull();
if (result) {
expect(result.name).toBe("A");
expect(result.octave).toBe(4);
expect(result.frequency).toBe(440);
}
});
test("correctly identifies C4 (Middle C)", () => {
const result = frequencyToNote(261.63);
expect(result).not.toBeNull();
if (result) {
expect(result.name).toBe("C");
expect(result.octave).toBe(4);
}
});
test("correctly identifies E2 (Low E on Guitar)", () => {
const result = frequencyToNote(82.41);
expect(result).not.toBeNull();
if (result) {
expect(result.name).toBe("E");
expect(result.octave).toBe(2);
}
});
test("handles slight deviations", () => {
// 442Hz is still A4, just sharp
const result = frequencyToNote(442);
expect(result).not.toBeNull();
if (result) {
expect(result.name).toBe("A");
expect(result.octave).toBe(4);
}
});
});
describe("midiNumberToFrequency", () => {
test("returns correct frequency for A4 (MIDI 69)", () => {
const freq = midiNumberToFrequency(69);
expect(freq).toBeCloseTo(440, 1);
});
test("returns correct frequency for C4 (MIDI 60)", () => {
const freq = midiNumberToFrequency(60);
expect(freq).toBeCloseTo(261.63, 1);
});
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { calculateTuningState } from "../tuning-calculator";
import { frequencyToCents } from "../note-converter";
describe("Tuning Calculator", () => {
describe("frequencyToCents", () => {
test("returns 0 for exact match", () => {
expect(frequencyToCents(440, 440)).toBe(0);
});
test("returns positive for sharp", () => {
// A bit sharp
const cents = frequencyToCents(442, 440);
expect(cents).toBeGreaterThan(0);
expect(cents).toBeCloseTo(7.85, 1);
});
test("returns negative for flat", () => {
// A bit flat
const cents = frequencyToCents(438, 440);
expect(cents).toBeLessThan(0);
expect(cents).toBeCloseTo(-7.89, 1);
});
});
describe("calculateTuningState", () => {
test("returns 'in-tune' for < 5 cents", () => {
const state = calculateTuningState(440.5, 440);
expect(state.status).toBe("in-tune");
});
test("returns 'sharp' for > 5 cents", () => {
// 445Hz vs 440Hz is ~19 cents sharp
const state = calculateTuningState(445, 440);
expect(state.status).toBe("sharp");
});
test("returns 'flat' for < -5 cents", () => {
// 435Hz vs 440Hz is ~-19 cents flat
const state = calculateTuningState(435, 440);
expect(state.status).toBe("flat");
});
});
});

View File

@@ -0,0 +1,81 @@
/**
* Pure domain logic for harmonic analysis.
* Helps identify fundamental frequency from harmonics.
*/
/**
* Check if two frequencies have a harmonic relationship.
* @param f1 - First frequency
* @param f2 - Second frequency
* @param tolerance - Tolerance in cents (default 50)
*/
export function isHarmonic(f1: number, f2: number, tolerance: number = 50): boolean {
const ratio = f2 / f1;
const roundedRatio = Math.round(ratio);
// Check if ratio is close to an integer (2x, 3x, 4x, etc.)
if (roundedRatio < 2 || roundedRatio > 6) {
return false;
}
// Calculate cents difference
const expectedFreq = f1 * roundedRatio;
const cents = 1200 * Math.log2(f2 / expectedFreq);
return Math.abs(cents) < tolerance;
}
/**
* Detect harmonic series from a list of frequency candidates.
* Returns the fundamental frequency if a harmonic series is detected.
*/
export function detectHarmonicSeries(frequencies: number[]): number | null {
if (frequencies.length < 2) {
return null;
}
// Sort frequencies
const sorted = [...frequencies].sort((a, b) => a - b);
// Check if we have a harmonic series starting from the lowest frequency
const fundamental = sorted[0];
let harmonicCount = 1; // fundamental counts as first harmonic
for (let i = 1; i < sorted.length; i++) {
if (isHarmonic(fundamental, sorted[i])) {
harmonicCount++;
}
}
// If we found at least 2 harmonics (fundamental + 1 more), it's likely a harmonic series
if (harmonicCount >= 2) {
return fundamental;
}
return null;
}
/**
* Find the fundamental frequency from a list of candidates.
* Uses harmonic analysis to filter out overtones.
*/
export function findFundamentalFromHarmonics(candidates: number[]): number {
if (candidates.length === 0) {
return 0;
}
if (candidates.length === 1) {
return candidates[0];
}
// Try to detect harmonic series
const fundamental = detectHarmonicSeries(candidates);
if (fundamental) {
return fundamental;
}
// If no harmonic series detected, return the lowest frequency
// (most conservative approach)
return Math.min(...candidates);
}

73
src/domain/instruments.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Standard tuning configurations for different instruments.
*/
import type { InstrumentConfig, InstrumentType } from './types';
// Standard guitar tuning (6 strings, standard tuning E-A-D-G-B-E)
export const GUITAR_STANDARD: InstrumentConfig = {
type: 'guitar',
name: 'Guitar (Standard)',
strings: [
{ name: 'E2', frequency: 82.41 }, // Low E
{ name: 'A2', frequency: 110.00 }, // A
{ name: 'D3', frequency: 146.83 }, // D
{ name: 'G3', frequency: 196.00 }, // G
{ name: 'B3', frequency: 246.94 }, // B
{ name: 'E4', frequency: 329.63 }, // High E
],
};
// Piano standard tuning (commonly tuned notes, middle octave)
export const PIANO_STANDARD: InstrumentConfig = {
type: 'piano',
name: 'Piano (Standard)',
strings: [
{ name: 'C4', frequency: 261.63 }, // Middle C
{ name: 'D4', frequency: 293.66 },
{ name: 'E4', frequency: 329.63 },
{ name: 'F4', frequency: 349.23 },
{ name: 'G4', frequency: 392.00 },
{ name: 'A4', frequency: 440.00 }, // Concert A
{ name: 'B4', frequency: 493.88 },
{ name: 'C5', frequency: 523.25 },
],
};
// Standard ukulele tuning (4 strings, G-C-E-A)
export const UKULELE_STANDARD: InstrumentConfig = {
type: 'ukulele',
name: 'Ukulele (Standard)',
strings: [
{ name: 'G4', frequency: 392.00 }, // G
{ name: 'C4', frequency: 261.63 }, // C
{ name: 'E4', frequency: 329.63 }, // E
{ name: 'A4', frequency: 440.00 }, // A
],
};
/**
* Get the configuration for a specific instrument type.
* @param type - The instrument type
* @returns InstrumentConfig for the specified instrument
*/
export function getInstrumentConfig(type: InstrumentType): InstrumentConfig {
switch (type) {
case 'guitar':
return GUITAR_STANDARD;
case 'piano':
return PIANO_STANDARD;
case 'ukulele':
return UKULELE_STANDARD;
default:
return GUITAR_STANDARD;
}
}
/**
* Get all available instrument configs.
* @returns Array of all instrument configurations
*/
export function getAllInstruments(): InstrumentConfig[] {
return [GUITAR_STANDARD, PIANO_STANDARD, UKULELE_STANDARD];
}

View File

@@ -0,0 +1,79 @@
/**
* Pure functions for converting between frequencies and musical notes.
* Uses equal temperament tuning (A4 = 440 Hz).
*/
import type { Note, NoteName } from './types';
const NOTE_NAMES: NoteName[] = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// A4 is the reference pitch
const A4_FREQUENCY = 440;
const A4_MIDI_NUMBER = 69;
/**
* Convert a frequency in Hz to the nearest musical note.
* @param frequency - Frequency in Hz
* @returns Note object with name, octave, and exact frequency
*/
export function frequencyToNote(frequency: number): Note | null {
if (frequency <= 0 || !isFinite(frequency)) {
return null;
}
// Calculate MIDI note number from frequency
// Formula: n = 12 * log2(f / 440) + 69
const midiNumber = Math.round(12 * Math.log2(frequency / A4_FREQUENCY) + A4_MIDI_NUMBER);
// MIDI numbers range from 0 to 127
if (midiNumber < 0 || midiNumber > 127) {
return null;
}
const noteName = NOTE_NAMES[midiNumber % 12];
const octave = Math.floor(midiNumber / 12) - 1;
// Calculate the exact frequency for this note
const exactFrequency = midiNumberToFrequency(midiNumber);
return {
name: noteName,
octave,
frequency: exactFrequency,
};
}
/**
* Convert MIDI note number to frequency in Hz.
* @param midiNumber - MIDI note number (0-127)
* @returns Frequency in Hz
*/
export function midiNumberToFrequency(midiNumber: number): number {
// Formula: f = 440 * 2^((n - 69) / 12)
return A4_FREQUENCY * Math.pow(2, (midiNumber - A4_MIDI_NUMBER) / 12);
}
/**
* Calculate the deviation in cents between a detected frequency and target frequency.
* A cent is 1/100th of a semitone.
* @param detectedFrequency - The detected frequency in Hz
* @param targetFrequency - The target frequency in Hz
* @returns Deviation in cents (positive = sharp, negative = flat)
*/
export function frequencyToCents(detectedFrequency: number, targetFrequency: number): number {
if (detectedFrequency <= 0 || targetFrequency <= 0) {
return 0;
}
// Formula: cents = 1200 * log2(f1 / f2)
return 1200 * Math.log2(detectedFrequency / targetFrequency);
}
/**
* Get the note name and octave as a string (e.g., "A4", "C#3").
* @param note - Note object
* @returns Formatted note string
*/
export function formatNote(note: Note): string {
return `${note.name}${note.octave}`;
}

View File

@@ -0,0 +1,53 @@
/**
* Pure functions for calculating tuning state and accuracy.
*/
import type { TuningState } from './types';
import { frequencyToCents } from './note-converter';
// Threshold for considering a note "in tune" (±5 cents)
const IN_TUNE_THRESHOLD_CENTS = 5;
/**
* Calculate the tuning state based on detected and target frequencies.
* @param detectedFrequency - The frequency detected from audio input
* @param targetFrequency - The target frequency for the note
* @returns TuningState with status, cents deviation, and accuracy
*/
export function calculateTuningState(
detectedFrequency: number,
targetFrequency: number
): TuningState {
const cents = frequencyToCents(detectedFrequency, targetFrequency);
// Determine status
let status: TuningState['status'];
if (Math.abs(cents) <= IN_TUNE_THRESHOLD_CENTS) {
status = 'in-tune';
} else if (cents > 0) {
status = 'sharp';
} else {
status = 'flat';
}
// Calculate accuracy (100% when perfectly in tune, decreases with deviation)
// We'll use 50 cents as the reference point for 0% accuracy
const accuracy = Math.max(0, Math.min(100, 100 - (Math.abs(cents) / 50) * 100));
return {
status,
cents: Math.round(cents * 10) / 10, // Round to 1 decimal place
accuracy: Math.round(accuracy),
};
}
/**
* Check if a frequency is within the "in tune" threshold.
* @param detectedFrequency - The detected frequency
* @param targetFrequency - The target frequency
* @returns True if in tune
*/
export function isInTune(detectedFrequency: number, targetFrequency: number): boolean {
const cents = Math.abs(frequencyToCents(detectedFrequency, targetFrequency));
return cents <= IN_TUNE_THRESHOLD_CENTS;
}

213
src/domain/tunings.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* Alternate tuning configurations for different instruments.
*/
import type { InstrumentType, StringConfig } from './types';
export interface Tuning {
id: string;
name: string;
description: string;
strings: StringConfig[];
}
// ===== GUITAR TUNINGS =====
export const GUITAR_STANDARD: Tuning = {
id: 'guitar-standard',
name: 'Standard',
description: 'E A D G B E',
strings: [
{ name: 'E2', frequency: 82.41 },
{ name: 'A2', frequency: 110.00 },
{ name: 'D3', frequency: 146.83 },
{ name: 'G3', frequency: 196.00 },
{ name: 'B3', frequency: 246.94 },
{ name: 'E4', frequency: 329.63 },
],
};
export const GUITAR_DROP_D: Tuning = {
id: 'guitar-drop-d',
name: 'Drop D',
description: 'D A D G B E',
strings: [
{ name: 'D2', frequency: 73.42 }, // Dropped a whole step
{ name: 'A2', frequency: 110.00 },
{ name: 'D3', frequency: 146.83 },
{ name: 'G3', frequency: 196.00 },
{ name: 'B3', frequency: 246.94 },
{ name: 'E4', frequency: 329.63 },
],
};
export const GUITAR_DROP_C: Tuning = {
id: 'guitar-drop-c',
name: 'Drop C',
description: 'C G C F A D',
strings: [
{ name: 'C2', frequency: 65.41 },
{ name: 'G2', frequency: 98.00 },
{ name: 'C3', frequency: 130.81 },
{ name: 'F3', frequency: 174.61 },
{ name: 'A3', frequency: 220.00 },
{ name: 'D4', frequency: 293.66 },
],
};
export const GUITAR_DADGAD: Tuning = {
id: 'guitar-dadgad',
name: 'DADGAD',
description: 'D A D G A D',
strings: [
{ name: 'D2', frequency: 73.42 },
{ name: 'A2', frequency: 110.00 },
{ name: 'D3', frequency: 146.83 },
{ name: 'G3', frequency: 196.00 },
{ name: 'A3', frequency: 220.00 },
{ name: 'D4', frequency: 293.66 },
],
};
export const GUITAR_OPEN_G: Tuning = {
id: 'guitar-open-g',
name: 'Open G',
description: 'D G D G B D',
strings: [
{ name: 'D2', frequency: 73.42 },
{ name: 'G2', frequency: 98.00 },
{ name: 'D3', frequency: 146.83 },
{ name: 'G3', frequency: 196.00 },
{ name: 'B3', frequency: 246.94 },
{ name: 'D4', frequency: 293.66 },
],
};
export const GUITAR_OPEN_D: Tuning = {
id: 'guitar-open-d',
name: 'Open D',
description: 'D A D F# A D',
strings: [
{ name: 'D2', frequency: 73.42 },
{ name: 'A2', frequency: 110.00 },
{ name: 'D3', frequency: 146.83 },
{ name: 'F#3', frequency: 185.00 },
{ name: 'A3', frequency: 220.00 },
{ name: 'D4', frequency: 293.66 },
],
};
// ===== UKULELE TUNINGS =====
export const UKULELE_STANDARD: Tuning = {
id: 'ukulele-standard',
name: 'Standard (C)',
description: 'G C E A',
strings: [
{ name: 'G4', frequency: 392.00 },
{ name: 'C4', frequency: 261.63 },
{ name: 'E4', frequency: 329.63 },
{ name: 'A4', frequency: 440.00 },
],
};
export const UKULELE_D_TUNING: Tuning = {
id: 'ukulele-d',
name: 'D Tuning',
description: 'A D F# B',
strings: [
{ name: 'A4', frequency: 440.00 },
{ name: 'D4', frequency: 293.66 },
{ name: 'F#4', frequency: 369.99 },
{ name: 'B4', frequency: 493.88 },
],
};
export const UKULELE_BARITONE: Tuning = {
id: 'ukulele-baritone',
name: 'Baritone',
description: 'D G B E',
strings: [
{ name: 'D3', frequency: 146.83 },
{ name: 'G3', frequency: 196.00 },
{ name: 'B3', frequency: 246.94 },
{ name: 'E4', frequency: 329.63 },
],
};
// ===== PIANO TUNINGS =====
// Piano doesn't have alternate tunings, but we keep it for completeness
export const PIANO_STANDARD: Tuning = {
id: 'piano-standard',
name: 'Standard',
description: 'A440 Concert Pitch',
strings: [
{ name: 'C4', frequency: 261.63 },
{ name: 'D4', frequency: 293.66 },
{ name: 'E4', frequency: 329.63 },
{ name: 'F4', frequency: 349.23 },
{ name: 'G4', frequency: 392.00 },
{ name: 'A4', frequency: 440.00 },
{ name: 'B4', frequency: 493.88 },
{ name: 'C5', frequency: 523.25 },
],
};
// ===== TUNING MAPS =====
export const GUITAR_TUNINGS: Tuning[] = [
GUITAR_STANDARD,
GUITAR_DROP_D,
GUITAR_DROP_C,
GUITAR_DADGAD,
GUITAR_OPEN_G,
GUITAR_OPEN_D,
];
export const UKULELE_TUNINGS: Tuning[] = [
UKULELE_STANDARD,
UKULELE_D_TUNING,
UKULELE_BARITONE,
];
export const PIANO_TUNINGS: Tuning[] = [PIANO_STANDARD];
/**
* Get available tunings for a specific instrument type.
*/
export function getTuningsForInstrument(instrumentType: InstrumentType): Tuning[] {
switch (instrumentType) {
case 'guitar':
return GUITAR_TUNINGS;
case 'ukulele':
return UKULELE_TUNINGS;
case 'piano':
return PIANO_TUNINGS;
default:
return GUITAR_TUNINGS;
}
}
/**
* Get the default tuning for an instrument.
*/
export function getDefaultTuning(instrumentType: InstrumentType): Tuning {
switch (instrumentType) {
case 'guitar':
return GUITAR_STANDARD;
case 'ukulele':
return UKULELE_STANDARD;
case 'piano':
return PIANO_STANDARD;
default:
return GUITAR_STANDARD;
}
}
/**
* Find a tuning by ID.
*/
export function getTuningById(id: string): Tuning | null {
const allTunings = [...GUITAR_TUNINGS, ...UKULELE_TUNINGS, ...PIANO_TUNINGS];
return allTunings.find(t => t.id === id) || null;
}

38
src/domain/types.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Core domain types for the tuner application.
* Pure TypeScript with no external dependencies.
*/
export type InstrumentType = 'guitar' | 'piano' | 'ukulele';
export type NoteName = 'C' | 'C#' | 'D' | 'D#' | 'E' | 'F' | 'F#' | 'G' | 'G#' | 'A' | 'A#' | 'B';
export interface Note {
name: NoteName;
octave: number;
frequency: number;
}
export interface TuningState {
status: 'in-tune' | 'sharp' | 'flat';
cents: number; // Deviation in cents (-50 to +50)
accuracy: number; // Percentage (0-100)
}
export interface StringConfig {
name: string; // e.g., "E2", "A4"
frequency: number; // Standard frequency in Hz
}
export interface InstrumentConfig {
type: InstrumentType;
name: string;
strings: StringConfig[];
}
export interface TuningReading {
detectedNote: Note | null;
targetNote: Note | null;
tuningState: TuningState | null;
frequency: number | null;
}

60
src/index.css Normal file
View File

@@ -0,0 +1,60 @@
@import "tailwindcss";
/**
* Frutiger Aero Design System
* Design tokens and base styles for Tailwind CSS
*/
/* ===== BASE ===== */
body {
margin: 0;
min-height: 100vh;
background: url('/src/assets/background.avif') center center / cover no-repeat fixed;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ===== DESIGN TOKENS ===== */
:root {
/* Colors */
--color-primary: hsl(195, 100%, 50%);
--color-primary-light: hsl(195, 90%, 65%);
--color-primary-dark: hsl(200, 70%, 35%);
--color-success: hsl(140, 75%, 50%);
--color-error: hsl(355, 85%, 60%);
/* Gradients */
--gradient-header: linear-gradient(135deg, hsl(200, 85%, 55%) 0%, hsl(190, 80%, 60%) 50%, hsl(140, 70%, 55%) 100%);
--gradient-button-blue: linear-gradient(180deg, hsl(200, 90%, 60%) 0%, hsl(200, 85%, 50%) 100%);
--gradient-button-green: linear-gradient(180deg, hsl(140, 70%, 55%) 0%, hsl(140, 65%, 45%) 100%);
--gradient-gauge-bezel: linear-gradient(135deg, hsl(200, 20%, 75%) 0%, hsl(200, 15%, 85%) 50%, hsl(200, 20%, 75%) 100%);
--gradient-gauge-face: radial-gradient(circle at 50% 40%, hsl(190, 70%, 85%) 0%, hsl(195, 65%, 75%) 30%, hsl(200, 60%, 65%) 100%);
--gradient-instrument-active: linear-gradient(135deg, hsl(50, 90%, 60%) 0%, hsl(45, 85%, 55%) 100%);
/* Animations */
--transition-base: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ===== ANIMATIONS ===== */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes glow {
0%,
100% {
box-shadow: 0 0 20px hsla(140, 80%, 50%, 0.6), 0 0 40px hsla(140, 80%, 50%, 0.3);
}
50% {
box-shadow: 0 0 30px hsla(140, 80%, 50%, 0.8), 0 0 60px hsla(140, 80%, 50%, 0.4);
}
}

View File

@@ -0,0 +1,175 @@
/**
* Audio capture service using Web Audio API.
* Handles microphone access and audio stream management.
*/
import {
MicrophonePermissionError,
AudioContextError,
AudioNotSupportedError,
} from './audio-errors';
export type AudioDataCallback = (audioData: Float32Array) => void;
export class AudioCaptureService {
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private microphone: MediaStreamAudioSourceNode | null = null;
private mediaStream: MediaStream | null = null;
private rafId: number | null = null;
private callback: AudioDataCallback | null = null;
/**
* Start capturing audio from the microphone.
* @throws MicrophonePermissionError if permission is denied
* @throws AudioContextError if audio context creation fails
* @throws AudioNotSupportedError if Web Audio API is not supported
*/
async start(): Promise<void> {
console.log('[AudioCapture] Starting...');
// Check browser support
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new AudioNotSupportedError();
}
try {
// Request microphone access
console.log('[AudioCapture] Requesting microphone access...');
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
console.log('[AudioCapture] Microphone access granted');
} catch (error) {
console.error('[AudioCapture] Microphone access failed:', error);
if (error instanceof Error && error.name === 'NotAllowedError') {
throw new MicrophonePermissionError('Microphone access was denied');
}
throw new MicrophonePermissionError('Failed to access microphone');
}
try {
// Create audio context
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
this.audioContext = new AudioContextClass();
console.log('[AudioCapture] Audio context created, sample rate:', this.audioContext.sampleRate);
// Create analyser node
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 4096; // Larger FFT for better low-frequency resolution
this.analyser.smoothingTimeConstant = 0.8;
// Connect microphone to analyser
this.microphone = this.audioContext.createMediaStreamSource(this.mediaStream);
this.microphone.connect(this.analyser);
console.log('[AudioCapture] Audio nodes connected');
// Start processing audio data
console.log('[AudioCapture] Starting audio processing loop...');
this.processAudio();
} catch (error) {
console.error('[AudioCapture] Audio context setup failed:', error);
this.cleanup();
throw new AudioContextError('Failed to initialize audio processing');
}
}
/**
* Stop capturing audio and release resources.
*/
stop(): void {
this.cleanup();
}
/**
* Set callback to receive audio data.
* @param callback - Function to call with audio data
*/
onAudioData(callback: AudioDataCallback): void {
console.log('[AudioCapture] Callback registered');
this.callback = callback;
}
/**
* Get the sample rate of the audio context.
*/
getSampleRate(): number {
return this.audioContext?.sampleRate ?? 44100;
}
/**
* Get the AnalyserNode for FFT-based pitch detection.
*/
getAnalyser(): AnalyserNode | null {
return this.analyser;
}
/**
* Check if currently capturing audio.
*/
isActive(): boolean {
return this.audioContext !== null && this.audioContext.state === 'running';
}
/**
* Process audio data in a loop.
*/
private processAudio = (): void => {
// Always continue the loop if we have an analyser
if (!this.analyser) {
console.log('[AudioCapture] No analyser, stopping loop');
return;
}
// Only process and send data if we have a callback (used by autocorrelation)
// Note: FFT detection reads directly from the AnalyserNode, so callback may be null
if (this.callback) {
// Get time domain data (waveform)
const bufferLength = this.analyser.fftSize;
const dataArray = new Float32Array(bufferLength);
this.analyser.getFloatTimeDomainData(dataArray);
// Send data to callback
this.callback(dataArray);
}
// Continue loop
this.rafId = requestAnimationFrame(this.processAudio);
};
/**
* Clean up resources.
*/
private cleanup(): void {
// Cancel animation frame
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Disconnect audio nodes
if (this.microphone) {
this.microphone.disconnect();
this.microphone = null;
}
// Close audio context
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
// Stop media stream
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
this.analyser = null;
this.callback = null;
}
}

View File

@@ -0,0 +1,24 @@
/**
* Custom error types for audio-related errors.
*/
export class MicrophonePermissionError extends Error {
constructor(message: string = 'Microphone permission denied') {
super(message);
this.name = 'MicrophonePermissionError';
}
}
export class AudioContextError extends Error {
constructor(message: string = 'Failed to create audio context') {
super(message);
this.name = 'AudioContextError';
}
}
export class AudioNotSupportedError extends Error {
constructor(message: string = 'Web Audio API not supported in this browser') {
super(message);
this.name = 'AudioNotSupportedError';
}
}

View File

@@ -0,0 +1,186 @@
/**
* FFT-based pitch detection using Web Audio API.
* Uses Harmonic Product Spectrum (HPS) for fundamental frequency detection.
*/
import { findFundamentalFromHarmonics } from '../domain/harmonic-analyzer';
export class FFTPitchDetector {
private analyser: AnalyserNode;
private sampleRate: number;
private frequencyData: Float32Array;
private bufferLength: number;
constructor(analyser: AnalyserNode, sampleRate: number) {
this.analyser = analyser;
this.sampleRate = sampleRate;
this.bufferLength = analyser.frequencyBinCount;
this.frequencyData = new Float32Array(this.bufferLength);
}
/**
* Detect pitch using FFT frequency analysis.
* @returns Detected frequency in Hz, or null if no pitch detected
*/
detectPitch(): number | null {
// Get frequency data from analyser
this.analyser.getFloatFrequencyData(this.frequencyData as any);
// Convert dB to linear magnitude
const magnitude = this.convertToMagnitude(this.frequencyData) as Float32Array;
// Find peak frequencies
const peaks = this.findPeaks(magnitude, 5);
if (peaks.length === 0) {
return null;
}
// Convert bin indices to frequencies
const frequencies = peaks.map(binIndex => this.binToFrequency(binIndex));
// Use harmonic analysis to find fundamental
const frequency = findFundamentalFromHarmonics(frequencies);
// Apply Harmonic Product Spectrum for more accuracy
const hpsFrequency = this.harmonicProductSpectrum(magnitude);
// If HPS found a fundamental and it's lower, prefer it
if (hpsFrequency && hpsFrequency < frequency) {
return hpsFrequency;
}
// Filter unrealistic frequencies (60 Hz to 4000 Hz for musical instruments)
if (frequency < 60 || frequency > 4000) {
return null;
}
return frequency;
}
/**
* Convert frequency data from dB to linear magnitude.
*/
private convertToMagnitude(dbData: Float32Array): Float32Array {
const magnitude = new Float32Array(dbData.length);
for (let i = 0; i < dbData.length; i++) {
// Convert dB to linear: magnitude = 10^(dB/20)
magnitude[i] = Math.pow(10, dbData[i] / 20);
}
return magnitude;
}
/**
* Find peaks in the magnitude spectrum.
*/
private findPeaks(magnitude: Float32Array, maxPeaks: number = 5): number[] {
const peaks: Array<{ bin: number; magnitude: number }> = [];
// Find max magnitude for dynamic thresholding
let maxMag = 0;
for (let i = 0; i < magnitude.length; i++) {
if (magnitude[i] > maxMag) {
maxMag = magnitude[i];
}
}
// Use dynamic threshold (5% of max magnitude)
const minMagnitude = maxMag * 0.05;
// Define frequency range for musical instruments (60 Hz to 4000 Hz)
const minFreq = 60; // Lowest note we care about
const maxFreq = 4000;
const minBin = Math.floor(minFreq * this.bufferLength / (this.sampleRate / 2));
const maxBin = Math.floor(maxFreq * this.bufferLength / (this.sampleRate / 2));
// Find local maxima in the valid frequency range
for (let i = Math.max(1, minBin); i < Math.min(magnitude.length - 1, maxBin); i++) {
if (magnitude[i] > magnitude[i - 1] &&
magnitude[i] > magnitude[i + 1] &&
magnitude[i] > minMagnitude) {
// Use parabolic interpolation for sub-bin accuracy
const refinedBin = this.parabolicInterpolation(magnitude, i);
peaks.push({ bin: refinedBin, magnitude: magnitude[i] });
}
}
// Sort by magnitude (descending)
peaks.sort((a, b) => b.magnitude - a.magnitude);
// Return top N peaks
return peaks.slice(0, maxPeaks).map(p => p.bin);
}
/**
* Parabolic interpolation for sub-bin frequency accuracy.
*/
private parabolicInterpolation(magnitude: Float32Array, index: number): number {
if (index <= 0 || index >= magnitude.length - 1) {
return index;
}
const alpha = magnitude[index - 1];
const beta = magnitude[index];
const gamma = magnitude[index + 1];
const p = 0.5 * (alpha - gamma) / (alpha - 2 * beta + gamma);
return index + p;
}
/**
* Harmonic Product Spectrum algorithm for fundamental detection.
* Downsamples and multiplies the spectrum to enhance the fundamental.
*/
private harmonicProductSpectrum(magnitude: Float32Array): number | null {
const hpsLength = Math.floor(magnitude.length / 5);
const hps = new Float32Array(hpsLength);
// Initialize HPS with first spectrum
for (let i = 0; i < hpsLength; i++) {
hps[i] = magnitude[i];
}
// Multiply with downsampled versions (harmonics 2x, 3x, 4x, 5x)
for (let harmonic = 2; harmonic <= 5; harmonic++) {
for (let i = 0; i < hpsLength; i++) {
const bin = i * harmonic;
if (bin < magnitude.length) {
hps[i] *= magnitude[bin];
}
}
}
// Find the peak in HPS (this is likely the fundamental)
let maxBin = 0;
let maxValue = hps[0];
const minBin = Math.floor(20 * hpsLength / (this.sampleRate / 2)); // Skip very low frequencies
for (let i = minBin; i < hpsLength; i++) {
if (hps[i] > maxValue) {
maxValue = hps[i];
maxBin = i;
}
}
// Check if peak is strong enough
if (maxValue < 0.01) {
return null;
}
// Use parabolic interpolation for accuracy
const refinedBin = this.parabolicInterpolation(hps, maxBin);
return this.binToFrequency(refinedBin);
}
/**
* Convert FFT bin index to frequency in Hz.
*/
private binToFrequency(bin: number): number {
return bin * this.sampleRate / (2 * this.bufferLength);
}
}

View File

@@ -0,0 +1,151 @@
/**
* Pitch detection using autocorrelation algorithm with harmonic analysis.
* Based on the YIN algorithm with added harmonic detection for improved accuracy.
*/
import { findFundamentalFromHarmonics } from '../domain/harmonic-analyzer';
export class PitchDetector {
private sampleRate: number;
constructor(sampleRate: number = 44100) {
this.sampleRate = sampleRate;
}
/**
* Detect the pitch (fundamental frequency) from an audio buffer.
* Uses harmonic analysis to improve accuracy.
* @param buffer - Float32Array of audio samples
* @returns Detected frequency in Hz, or null if no pitch detected
*/
detectPitch(buffer: Float32Array): number | null {
// Normalize the buffer
const normalized = this.normalize(buffer);
// Apply autocorrelation
const correlations = this.autoCorrelate(normalized);
if (!correlations) {
return null;
}
// Find multiple correlation peaks (potential harmonics)
const candidates = this.findCorrelationPeaks(correlations, 5);
if (candidates.length === 0) {
return null;
}
// Convert lags to frequencies
const frequencies = candidates.map(lag => this.sampleRate / lag);
// Use harmonic analysis to find the true fundamental
const frequency = findFundamentalFromHarmonics(frequencies);
// Filter out unrealistic frequencies (human hearing range ~20Hz to 4000Hz for tuning)
if (frequency < 20 || frequency > 4000) {
return null;
}
return frequency;
}
/**
* Normalize audio buffer to range [-1, 1].
*/
private normalize(buffer: Float32Array): Float32Array {
const normalized = new Float32Array(buffer.length);
let max = 0;
// Find max absolute value
for (let i = 0; i < buffer.length; i++) {
const abs = Math.abs(buffer[i]);
if (abs > max) {
max = abs;
}
}
// Normalize
if (max > 0) {
for (let i = 0; i < buffer.length; i++) {
normalized[i] = buffer[i] / max;
}
} else {
return buffer;
}
return normalized;
}
/**
* Autocorrelation function to find periodic patterns in the signal.
*/
private autoCorrelate(buffer: Float32Array): Float32Array | null {
const size = buffer.length;
const correlations = new Float32Array(size);
// Calculate RMS (root mean square) to check if signal is strong enough
let rms = 0;
for (let i = 0; i < size; i++) {
rms += buffer[i] * buffer[i];
}
rms = Math.sqrt(rms / size);
// If signal is too weak, return null
if (rms < 0.01) {
return null;
}
// Autocorrelation
for (let lag = 0; lag < size; lag++) {
let sum = 0;
for (let i = 0; i < size - lag; i++) {
sum += buffer[i] * buffer[i + lag];
}
correlations[lag] = sum;
}
return correlations;
}
/**
* Find multiple correlation peaks that could represent the fundamental or harmonics.
* Returns an array of lag values sorted by correlation strength.
*/
private findCorrelationPeaks(correlations: Float32Array, maxPeaks: number = 5): number[] {
const size = correlations.length;
const peaks: Array<{ lag: number; correlation: number }> = [];
// Define search range
const minLag = Math.floor(this.sampleRate / 1000); // ~44 for 44.1kHz
const maxLag = Math.floor(this.sampleRate / 60); // ~735 for 44.1kHz
// Find first negative crossing after lag 0
let negativeThreshold = -1;
for (let i = 1; i < maxLag && i < size; i++) {
if (correlations[i] < 0) {
negativeThreshold = i;
break;
}
}
const startLag = negativeThreshold === -1 ? minLag : negativeThreshold;
// Find all local maxima in the correlation function
for (let lag = startLag + 1; lag < maxLag - 1 && lag < size - 1; lag++) {
// Check if this is a local maximum
if (correlations[lag] > correlations[lag - 1] &&
correlations[lag] > correlations[lag + 1] &&
correlations[lag] > correlations[0] * 0.3) { // At least 30% of zero-lag
peaks.push({ lag, correlation: correlations[lag] });
}
}
// Sort peaks by correlation strength (descending)
peaks.sort((a, b) => b.correlation - a.correlation);
// Return top N lag values
return peaks.slice(0, maxPeaks).map(p => p.lag);
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

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

View File

@@ -0,0 +1,73 @@
/**
* Hook for managing audio capture lifecycle.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { AudioCaptureService } from '../../infrastructure/audio-capture';
import type { AudioDataCallback } from '../../infrastructure/audio-capture';
interface UseAudioCaptureResult {
isActive: boolean;
error: Error | null;
start: () => Promise<void>;
stop: () => void;
onAudioData: (callback: AudioDataCallback) => void;
sampleRate: number;
audioService: AudioCaptureService | null;
}
export function useAudioCapture(): UseAudioCaptureResult {
const serviceRef = useRef<AudioCaptureService | null>(null);
const [isActive, setIsActive] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [sampleRate, setSampleRate] = useState(44100);
// Initialize service
useEffect(() => {
serviceRef.current = new AudioCaptureService();
return () => {
if (serviceRef.current) {
serviceRef.current.stop();
serviceRef.current = null;
}
};
}, []);
const start = async () => {
if (!serviceRef.current) return;
try {
setError(null);
await serviceRef.current.start();
setIsActive(true);
setSampleRate(serviceRef.current.getSampleRate());
} catch (err) {
setError(err as Error);
setIsActive(false);
}
};
const stop = () => {
if (serviceRef.current) {
serviceRef.current.stop();
setIsActive(false);
}
};
const onAudioData = useCallback((callback: AudioDataCallback) => {
if (serviceRef.current) {
serviceRef.current.onAudioData(callback);
}
}, []);
return {
isActive,
error,
start,
stop,
onAudioData,
sampleRate,
audioService: serviceRef.current,
};
}

View File

@@ -0,0 +1,60 @@
/**
* Hook for FFT-based pitch detection.
*/
import { useEffect, useRef, useState } from 'react';
import { FFTPitchDetector } from '../../infrastructure/fft-pitch-detector';
import type { AudioCaptureService } from '../../infrastructure/audio-capture';
interface UseFFTPitchDetectionProps {
audioService: AudioCaptureService | null;
isActive: boolean;
}
export function useFFTPitchDetection({ audioService, isActive }: UseFFTPitchDetectionProps): number | null {
const detectorRef = useRef<FFTPitchDetector | null>(null);
const [frequency, setFrequency] = useState<number | null>(null);
const rafIdRef = useRef<number | null>(null);
useEffect(() => {
// Clear frequency when inactive
if (!isActive || !audioService) {
setFrequency(null);
detectorRef.current = null; // Clear detector when inactive
return;
}
const analyser = audioService.getAnalyser();
const sampleRate = audioService.getSampleRate();
if (!analyser) {
setFrequency(null);
return;
}
// Always create a fresh FFT pitch detector with the current analyser
// This is important because the analyser node can change when audio restarts
detectorRef.current = new FFTPitchDetector(analyser, sampleRate);
// Start detection loop
const detect = () => {
if (detectorRef.current && audioService.isActive()) {
const detectedFrequency = detectorRef.current.detectPitch();
setFrequency(detectedFrequency);
}
rafIdRef.current = requestAnimationFrame(detect);
};
detect();
// Cleanup
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [audioService, isActive]);
return frequency;
}

View File

@@ -0,0 +1,24 @@
/**
* Hook for managing instrument selection.
*/
import { useState } from 'react';
import type { InstrumentType, InstrumentConfig } from '../../domain/types';
import { getInstrumentConfig } from '../../domain/instruments';
interface UseInstrumentResult {
instrument: InstrumentType;
setInstrument: (type: InstrumentType) => void;
config: InstrumentConfig;
}
export function useInstrument(initialInstrument: InstrumentType = 'guitar'): UseInstrumentResult {
const [instrument, setInstrument] = useState<InstrumentType>(initialInstrument);
const config = getInstrumentConfig(instrument);
return {
instrument,
setInstrument,
config,
};
}

View File

@@ -0,0 +1,49 @@
/**
* Hook for pitch detection from audio data.
*/
import { useEffect, useRef, useState } from 'react';
import { PitchDetector } from '../../infrastructure/pitch-detector';
interface UsePitchDetectionProps {
isActive: boolean;
onAudioData: (callback: (data: Float32Array) => void) => void;
sampleRate: number;
}
interface UsePitchDetectionResult {
frequency: number | null;
}
export function usePitchDetection({
isActive,
onAudioData,
sampleRate,
}: UsePitchDetectionProps): UsePitchDetectionResult {
const detectorRef = useRef<PitchDetector | null>(null);
const [frequency, setFrequency] = useState<number | null>(null);
// Initialize pitch detector with correct sample rate
useEffect(() => {
detectorRef.current = new PitchDetector(sampleRate);
}, [sampleRate]);
// Set up audio data processing
useEffect(() => {
if (!isActive) {
setFrequency(null);
return;
}
const handleAudioData = (data: Float32Array) => {
if (detectorRef.current) {
const detectedFrequency = detectorRef.current.detectPitch(data);
setFrequency(detectedFrequency);
}
};
onAudioData(handleAudioData);
}, [isActive, onAudioData]);
return { frequency };
}

View File

@@ -0,0 +1,66 @@
/**
* Main tuner hook - orchestrates all tuner functionality.
* Combines audio capture, FFT pitch detection, and tuning calculation.
*/
import { useMemo } from 'react';
import { useAudioCapture } from './useAudioCapture';
import { useFFTPitchDetection } from './useFFTPitchDetection';
import { frequencyToNote } from '../../domain/note-converter';
import { calculateTuningState } from '../../domain/tuning-calculator';
import type { Tuning } from '../../domain/tunings';
import type { Note, TuningState } from '../../domain/types';
import type { AudioCaptureService } from '../../infrastructure/audio-capture';
interface UseTunerResult {
isActive: boolean;
error: Error | null;
start: () => Promise<void>;
stop: () => void;
frequency: number | null;
note: Note | null;
tuningState: TuningState | null;
audioService: AudioCaptureService | null;
}
export function useTuner(tuning: Tuning): UseTunerResult {
const { isActive, error, start, stop, audioService } = useAudioCapture();
const frequency = useFFTPitchDetection({ audioService, isActive });
// Convert detected frequency to note
const note = useMemo(() => {
if (!frequency) return null;
return frequencyToNote(frequency);
}, [frequency]);
// Calculate tuning state based on nearest string in the tuning
const tuningState = useMemo(() => {
if (!frequency || !note) return null;
// Find the closest string frequency in the tuning
let closestString = tuning.strings[0];
let minDiff = Math.abs(frequency - closestString.frequency);
for (const string of tuning.strings) {
const diff = Math.abs(frequency - string.frequency);
if (diff < minDiff) {
minDiff = diff;
closestString = string;
}
}
return calculateTuningState(frequency, closestString.frequency);
}, [frequency, note, tuning]);
return {
isActive,
error,
start,
stop,
frequency,
note,
tuningState,
audioService,
};
}

View File

@@ -0,0 +1,82 @@
/**
* Hook for managing selected tuning state.
* Persists selection to localStorage.
*/
import { useState, useEffect } from 'react';
import type { InstrumentType } from '../../domain/types';
import type { Tuning } from '../../domain/tunings';
import { getDefaultTuning, getTuningsForInstrument, getTuningById } from '../../domain/tunings';
const STORAGE_KEY = 'tuner-selected-tunings';
export function useTuning(instrumentType: InstrumentType) {
const [selectedTuning, setSelectedTuning] = useState<Tuning>(() => {
// Try to load from localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const tunings = JSON.parse(stored);
const tuningId = tunings[instrumentType];
if (tuningId) {
const tuning = getTuningById(tuningId);
if (tuning) return tuning;
}
} catch {
// Fall through to default
}
}
return getDefaultTuning(instrumentType);
});
// Update tuning when instrument changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
let tuningId: string | null = null;
if (stored) {
try {
const tunings = JSON.parse(stored);
tuningId = tunings[instrumentType];
} catch {
// Ignore parsing errors
}
}
if (tuningId) {
const tuning = getTuningById(tuningId);
if (tuning) {
setSelectedTuning(tuning);
return;
}
}
// Fall back to default
setSelectedTuning(getDefaultTuning(instrumentType));
}, [instrumentType]);
const changeTuning = (tuning: Tuning) => {
setSelectedTuning(tuning);
// Persist to localStorage
const stored = localStorage.getItem(STORAGE_KEY);
let tunings: Record<string, string> = {};
if (stored) {
try {
tunings = JSON.parse(stored);
} catch {
// Start fresh
}
}
tunings[instrumentType] = tuning.id;
localStorage.setItem(STORAGE_KEY, JSON.stringify(tunings));
};
return {
tuning: selectedTuning,
availableTunings: getTuningsForInstrument(instrumentType),
changeTuning,
};
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

49
vite.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler']],
},
}),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'AeroTuner',
short_name: 'AeroTuner',
description: 'A Frutiger Aero styled instrument tuner',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
})
],
})