feat: Implement initial tuner application with core logic, audio processing, and presentation components.
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
204
ARCHITECTURE.md
Normal 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
90
README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# AeroTuner
|
||||||
|
|
||||||
|
An instrument tuner featuring a nostalgic **Frutiger Aero** aesthetic, built with modern web technologies and Clean Architecture.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal 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
18
index.html
Normal 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
34
package.json
Normal 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
BIN
public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
BIN
public/pwa-512x512.png
Normal file
BIN
public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
8
src/App.tsx
Normal file
8
src/App.tsx
Normal 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
BIN
src/assets/background.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
26
src/config.ts
Normal file
26
src/config.ts
Normal 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;
|
||||||
37
src/domain/__tests__/harmonic-analyzer.test.ts
Normal file
37
src/domain/__tests__/harmonic-analyzer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/domain/__tests__/note-converter.test.ts
Normal file
56
src/domain/__tests__/note-converter.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/domain/__tests__/tuning-calculator.test.ts
Normal file
44
src/domain/__tests__/tuning-calculator.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/domain/harmonic-analyzer.ts
Normal file
81
src/domain/harmonic-analyzer.ts
Normal 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
73
src/domain/instruments.ts
Normal 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];
|
||||||
|
}
|
||||||
79
src/domain/note-converter.ts
Normal file
79
src/domain/note-converter.ts
Normal 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}`;
|
||||||
|
}
|
||||||
53
src/domain/tuning-calculator.ts
Normal file
53
src/domain/tuning-calculator.ts
Normal 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
213
src/domain/tunings.ts
Normal 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
38
src/domain/types.ts
Normal 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
60
src/index.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/infrastructure/audio-capture.ts
Normal file
175
src/infrastructure/audio-capture.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/infrastructure/audio-errors.ts
Normal file
24
src/infrastructure/audio-errors.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/infrastructure/fft-pitch-detector.ts
Normal file
186
src/infrastructure/fft-pitch-detector.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/infrastructure/pitch-detector.ts
Normal file
151
src/infrastructure/pitch-detector.ts
Normal 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
10
src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/presentation/hooks/useAudioCapture.ts
Normal file
73
src/presentation/hooks/useAudioCapture.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/presentation/hooks/useFFTPitchDetection.ts
Normal file
60
src/presentation/hooks/useFFTPitchDetection.ts
Normal 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;
|
||||||
|
}
|
||||||
24
src/presentation/hooks/useInstrument.ts
Normal file
24
src/presentation/hooks/useInstrument.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
src/presentation/hooks/usePitchDetection.ts
Normal file
49
src/presentation/hooks/usePitchDetection.ts
Normal 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 };
|
||||||
|
}
|
||||||
66
src/presentation/hooks/useTuner.ts
Normal file
66
src/presentation/hooks/useTuner.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/presentation/hooks/useTuning.ts
Normal file
82
src/presentation/hooks/useTuning.ts
Normal 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
28
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
49
vite.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user