diff --git a/src/App.tsx b/src/App.tsx index 7dee7b7..baacddc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,7 +64,7 @@ function App() { disableAnimation: false }); - const { pitchData, error } = usePitchDetector(listening, settings.micSensitivity); + const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity); const [matchStartTime, setMatchStartTime] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(""); @@ -705,6 +705,9 @@ function App() { onClose={() => setIsSettingsOpen(false)} settings={settings} onUpdateSettings={setSettings} + audioLevel={audioLevel} + debugInfo={debugInfo} + isListening={isListening} /> number | null; private audioContext: AudioContext | null = null; @@ -8,6 +19,12 @@ export class PitchAnalyzer { private source: MediaStreamAudioSourceNode | null = null; private buffer: Float32Array; + // Debug info + private inputDeviceLabel: string = 'Not detected'; + private inputDeviceId: string = ''; + private permissionState: PermissionState | 'unknown' = 'unknown'; + private currentRmsLevel: number = 0; + constructor() { this.detector = YIN({ sampleRate: 44100 }); // Default, will update on start this.buffer = new Float32Array(2048); // Standard size @@ -36,17 +53,66 @@ export class PitchAnalyzer { async start(): Promise { if (this.audioContext) return; + // Check permission state if available + try { + if (navigator.permissions && navigator.permissions.query) { + const result = await navigator.permissions.query({ name: 'microphone' as PermissionName }); + this.permissionState = result.state; + } + } catch { + // permissions API not available on all browsers + this.permissionState = 'unknown'; + } + this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); this.detector = YIN({ sampleRate: this.audioContext.sampleRate }); try { - this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Use more explicit audio constraints for better Android compatibility + const constraints: MediaStreamConstraints = { + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + } + }; + + this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + + // Get device info + const audioTracks = this.mediaStream.getAudioTracks(); + if (audioTracks.length > 0) { + const track = audioTracks[0]; + const settings = track.getSettings(); + this.inputDeviceId = settings.deviceId || ''; + this.inputDeviceLabel = track.label || 'Unknown Device'; + + // If label is empty, try to get it from enumerateDevices + if (!track.label && settings.deviceId) { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const matchedDevice = devices.find(d => d.deviceId === settings.deviceId); + if (matchedDevice && matchedDevice.label) { + this.inputDeviceLabel = matchedDevice.label; + } + } catch { + // enumerateDevices not available + } + } + } + this.source = this.audioContext.createMediaStreamSource(this.mediaStream); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 4096; // Higher FFT size for better resolution at low frequencies this.buffer = new Float32Array(this.analyser.fftSize); this.source.connect(this.analyser); + + // CRITICAL for Android: Resume AudioContext after user interaction + // Android Chrome suspends AudioContext by default + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } } catch (e) { console.error("Error accessing microphone:", e); throw e; @@ -62,6 +128,36 @@ export class PitchAnalyzer { this.audioContext.close(); this.audioContext = null; } + this.currentRmsLevel = 0; + this.inputDeviceLabel = 'Not detected'; + this.inputDeviceId = ''; + } + + /** + * Get current debug/diagnostic information + */ + getDebugInfo(): MicrophoneDebugInfo { + const rmsDb = this.currentRmsLevel > 0 + ? 20 * Math.log10(this.currentRmsLevel) + : -Infinity; + + return { + audioContextState: this.audioContext?.state || 'inactive', + sampleRate: this.audioContext?.sampleRate || null, + inputDeviceLabel: this.inputDeviceLabel, + inputDeviceId: this.inputDeviceId, + permissionState: this.permissionState, + currentRmsLevel: this.currentRmsLevel, + currentRmsDb: isFinite(rmsDb) ? rmsDb : -100, + isCapturing: this.analyser !== null && this.audioContext?.state === 'running', + }; + } + + /** + * Get current RMS level (0-1 range, useful for level meters) + */ + getCurrentLevel(): number { + return this.currentRmsLevel; } /** @@ -78,6 +174,9 @@ export class PitchAnalyzer { } rms = Math.sqrt(rms / this.buffer.length); + // Store RMS for level meter + this.currentRmsLevel = rms; + if (rms < this.sensitivityThreshold) return null; const pitch = this.detector(this.buffer); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e4a9d05..e62dfd0 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,15 +1,67 @@ -import React from 'react'; -import { X, Volume2, VolumeX } from 'lucide-react'; +import React, { useState } from 'react'; +import { X, Volume2, VolumeX, ChevronDown, ChevronUp } from 'lucide-react'; import type { AppSettings } from './Controls'; +import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; settings: AppSettings; onUpdateSettings: (s: AppSettings) => void; + audioLevel?: number; + debugInfo?: MicrophoneDebugInfo | null; + isListening?: boolean; } -export const SettingsModal: React.FC = ({ isOpen, onClose, settings, onUpdateSettings }) => { +// Level meter component +const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isActive }) => { + // Convert RMS to dB for display, then normalize to 0-100% + // RMS of 0.001 = -60dB, RMS of 1.0 = 0dB + const db = level > 0 ? 20 * Math.log10(level) : -100; + // Map -60dB to 0dB => 0% to 100% + const percentage = Math.max(0, Math.min(100, ((db + 60) / 60) * 100)); + + return ( +
+
+
80 ? '#ef4444' : percentage > 50 ? '#f59e0b' : '#22c55e', + borderRadius: '6px', + transition: 'width 0.05s ease-out' + }} /> +
+ + {isActive ? `${Math.round(db)} dB` : '— dB'} + +
+ ); +}; + +export const SettingsModal: React.FC = ({ + isOpen, + onClose, + settings, + onUpdateSettings, + audioLevel = 0, + debugInfo, + isListening = false +}) => { + const [showDebugInfo, setShowDebugInfo] = useState(false); + if (!isOpen) return null; return ( @@ -141,6 +193,125 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, s Adjust if notes are not detected (increase/right) or if background noise triggers notes (decrease/left).

+ + {/* Microphone Level Meter */} +
+ + + {!isListening && ( +

+ Enable microphone to see audio levels +

+ )} +
+ + {/* Debug Info Toggle */} + + + {/* Debug Info Panel */} + {showDebugInfo && ( +
+
+ Status: + + {debugInfo?.isCapturing ? '✓ Capturing' : + debugInfo?.audioContextState === 'suspended' ? '⚠ Suspended' : + isListening ? '✗ Not Capturing' : '○ Inactive'} + + + AudioContext: + {debugInfo?.audioContextState || 'N/A'} + + Sample Rate: + {debugInfo?.sampleRate ? `${debugInfo.sampleRate} Hz` : 'N/A'} + + Input Device: + + {debugInfo?.inputDeviceLabel || 'N/A'} + + + Permission: + + {debugInfo?.permissionState || 'unknown'} + + + RMS Level: + + {debugInfo?.currentRmsLevel !== undefined + ? `${debugInfo.currentRmsLevel.toFixed(4)} (${Math.round(debugInfo.currentRmsDb)} dB)` + : 'N/A'} + + + Browser: + + {typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 60) + '...' : 'N/A'} + +
+ + {debugInfo?.audioContextState === 'suspended' && ( +
+ ⚠ AudioContext is suspended. Try tapping the mic button again or interacting with the page. +
+ )} + + {isListening && !debugInfo?.isCapturing && debugInfo?.audioContextState === 'running' && ( +
+ ✗ AudioContext is running but no audio is being captured. Check if another app is using the microphone. +
+ )} +
+ )} diff --git a/src/hooks/usePitchDetector.ts b/src/hooks/usePitchDetector.ts index ba1e65f..969542e 100644 --- a/src/hooks/usePitchDetector.ts +++ b/src/hooks/usePitchDetector.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { PitchAnalyzer } from '../audio/PitchAnalyzer'; +import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer'; import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils'; interface PitchData { @@ -10,11 +10,15 @@ interface PitchData { clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it } +export type { MicrophoneDebugInfo }; + export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { const analyzerRef = useRef(null); const [pitchData, setPitchData] = useState(null); const [isListening, setIsListening] = useState(false); const [error, setError] = useState(null); + const [audioLevel, setAudioLevel] = useState(0); + const [debugInfo, setDebugInfo] = useState(null); const animationRef = useRef(null); // Update sensitivity when it changes @@ -27,18 +31,11 @@ export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { const updatePitch = useCallback(() => { if (!analyzerRef.current) return; - // Ensure sensitivity is set on start - // (Though the effect above handles updates, safely re-asserting here doesn't hurt, - // but let's trust the effect and the init sequence). - const freq = analyzerRef.current.getPitch(); if (freq) { const midi = frequencyToMidi(freq); const { scientific } = getNoteDetails(midi); - // Calculate cents off purely for display if needed, - // but for training we usually care if midi matches. - const cents = getCentDifference(freq, midi); setPitchData({ @@ -50,6 +47,10 @@ export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { }); } + // Always update level and debug info + setAudioLevel(analyzerRef.current.getCurrentLevel()); + setDebugInfo(analyzerRef.current.getDebugInfo()); + animationRef.current = requestAnimationFrame(updatePitch); }, []); @@ -63,11 +64,21 @@ export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { analyzerRef.current.start() .then(() => { setIsListening(true); + setError(null); updatePitch(); }) .catch(err => { console.error(err); - setError("Could not access microphone."); + // Provide more specific error messages + if (err.name === 'NotAllowedError') { + setError("Microphone access denied. Please allow microphone access."); + } else if (err.name === 'NotFoundError') { + setError("No microphone found. Please connect a microphone."); + } else if (err.name === 'NotReadableError') { + setError("Microphone is in use by another app."); + } else { + setError("Could not access microphone."); + } setIsListening(false); }); } else { @@ -77,6 +88,8 @@ export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { } setIsListening(false); setPitchData(null); + setAudioLevel(0); + setDebugInfo(null); if (animationRef.current) cancelAnimationFrame(animationRef.current); } @@ -88,5 +101,5 @@ export function usePitchDetector(active: boolean, sensitivity: number = 0.5) { }; }, [active, updatePitch]); - return { pitchData, isListening, error }; + return { pitchData, isListening, error, audioLevel, debugInfo }; }