import React, { useState, useRef, useEffect } from 'react'; import { Mic, X, CheckCircle2 } from 'lucide-react'; import { InteractionEntropy } from './lib/interactionEntropy'; interface AudioStats { sampleRate: number; duration: number; peakAmplitude: number; rmsAmplitude: number; zeroCrossings: number; frequencyBands: number[]; spectralEntropy: number; interactionSamples: number; totalBits: number; } interface AudioEntropyProps { wordCount: 12 | 24; onEntropyGenerated: (mnemonic: string, stats: AudioStats) => void; onCancel: () => void; interactionEntropy: InteractionEntropy; } const AudioEntropy: React.FC = ({ wordCount, onEntropyGenerated, onCancel, interactionEntropy }) => { const [step, setStep] = useState<'permission' | 'capture' | 'processing' | 'stats'>('permission'); const [stream, setStream] = useState(null); const [audioLevel, setAudioLevel] = useState(0); const [captureEnabled, setCaptureEnabled] = useState(false); const [stats, setStats] = useState(null); const [generatedMnemonic, setGeneratedMnemonic] = useState(''); const [error, setError] = useState(''); const [captureProgress, setCaptureProgress] = useState(0); const audioContextRef = useRef(null); const analyserRef = useRef(null); const animationRef = useRef(); const audioDataRef = useRef([]); const audioLevelLoggedRef = useRef(false); const scriptProcessorRef = useRef(null); const rawAudioDataRef = useRef([]); const frameCounterRef = useRef(0); const teardownAudio = async () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = undefined; } if (stream) { stream.getTracks().forEach(t => t.stop()); setStream(null); } if (scriptProcessorRef.current) { (scriptProcessorRef.current as any).onaudioprocess = null; try { scriptProcessorRef.current.disconnect(); } catch {} scriptProcessorRef.current = null; } analyserRef.current = null; const ctx = audioContextRef.current; audioContextRef.current = null; if (ctx && ctx.state !== 'closed') { try { await ctx.close(); } catch {} } }; const requestMicrophoneAccess = async () => { try { console.log('🎤 Requesting microphone access...'); // Clean up any existing audio context first await teardownAudio(); const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: { ideal: 44100 }, // Safari prefers this channelCount: 1, }, }); console.log('✅ Microphone access granted'); // Set up Web Audio API const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); const analyser = audioContext.createAnalyser(); // Back to normal analyser settings analyser.fftSize = 2048; // back to normal analyser.smoothingTimeConstant = 0.3; analyser.minDecibels = -100; analyser.maxDecibels = 0; analyser.channelCount = 1; const source = audioContext.createMediaStreamSource(mediaStream); // Silent sink that still "pulls" the graph (no speaker output) const silentGain = audioContext.createGain(); silentGain.gain.value = 0; const silentSink = audioContext.createMediaStreamDestination(); // IMPORTANT: analyser must be in the pulled path source.connect(analyser); analyser.connect(silentGain); silentGain.connect(silentSink); // Safari fallback: ScriptProcessor gets RAW mic PCM try { const scriptProcessor = (audioContext as any).createScriptProcessor(1024, 1, 1); scriptProcessor.onaudioprocess = (event: AudioProcessingEvent) => { const inputBuffer = event.inputBuffer.getChannelData(0); // RAW MIC DATA! // Append for entropy rawAudioDataRef.current.push(new Float32Array(inputBuffer)); // Calc RMS from raw data let sum = 0; for (let i = 0; i < inputBuffer.length; i++) { sum += inputBuffer[i] * inputBuffer[i]; } const rawRms = Math.sqrt(sum / inputBuffer.length); // Update state via postMessage (React-safe) if (Math.random() < 0.1) { // Throttle setAudioLevel(Math.min(rawRms * 2000, 100)); } // Deterministic logging every 30 frames if (frameCounterRef.current++ % 30 === 0) { console.log('🎙️ RAW mic RMS:', rawRms.toFixed(4), 'Sample:', inputBuffer.slice(0,5)); } }; // ScriptProcessor branch also pulled source.connect(scriptProcessor); scriptProcessor.connect(silentGain); // pull it via the same sink path scriptProcessorRef.current = scriptProcessor; console.log('✅ ScriptProcessor active (Safari fallback)'); } catch (e) { console.log('⚠️ ScriptProcessor not supported'); } console.log('🎧 Pipeline primed:', { sampleRate: audioContext.sampleRate, state: audioContext.state, fftSize: analyser.fftSize, channels: analyser.channelCount, }); audioContextRef.current = audioContext; analyserRef.current = analyser; setStream(mediaStream); // Resume context if (audioContext.state === 'suspended') { await audioContext.resume(); console.log('▶️ Audio context resumed:', audioContext.state); } // Give pipeline 300ms to fill buffer setTimeout(() => { if (analyserRef.current) { console.log('▶️ Starting analysis after buffer fill'); startAudioAnalysis(); setStep('capture'); } }, 300); } catch (err: any) { console.error('❌ Microphone error:', err); setError(`Microphone access denied: ${err.message}`); setTimeout(() => onCancel(), 2000); } }; const startAudioAnalysis = () => { if (!analyserRef.current) { console.error('❌ No analyser'); return; } console.log('✅ Analysis loop started'); const analyze = () => { if (!analyserRef.current) return; // Use FLOAT data (more precise than Byte) const bufferLength = analyserRef.current.frequencyBinCount; const timeData = new Float32Array(bufferLength); const freqData = new Float32Array(bufferLength); analyserRef.current.getFloatTimeDomainData(timeData); analyserRef.current.getFloatFrequencyData(freqData); // RMS from time domain (-1 to 1 range) let sum = 0; for (let i = 0; i < bufferLength; i++) { sum += timeData[i] * timeData[i]; } const rms = Math.sqrt(sum / bufferLength); const level = Math.min(rms * 2000, 100); // Scale for visibility // Proper dBFS to linear energy let freqEnergy = 0; let activeBins = 0; for (let i = 0; i < bufferLength; i++) { const dB = freqData[i]; if (dB > -100) { // Ignore silence floor const linear = Math.pow(10, dB / 20); // dB → linear amplitude freqEnergy += linear * linear; // Power activeBins++; } } const freqRms = activeBins > 0 ? Math.sqrt(freqEnergy / activeBins) : 0; const freqLevel = Math.min(freqRms * 1000, 50); const finalLevel = Math.max(level, freqLevel); // CLAMP const clampedLevel = Math.min(Math.max(finalLevel, 0), 100); // Log first few + random if (!audioLevelLoggedRef.current) { audioLevelLoggedRef.current = true; console.log('📊 First frame:', { rms: rms.toFixed(4), level: level.toFixed(1), timeSample: timeData.slice(0, 5), freqSample: freqData.slice(0, 5) }); } else if (Math.random() < 0.03) { console.log('🎵 Level:', clampedLevel.toFixed(1), 'RMS:', rms.toFixed(4)); } setAudioLevel(clampedLevel); setCaptureEnabled(clampedLevel > 1); // Lower threshold animationRef.current = requestAnimationFrame(analyze); }; analyze(); }; // Auto-start analysis when analyser is ready useEffect(() => { if (analyserRef.current && step === 'capture' && !animationRef.current) { console.log('🎬 useEffect: Starting audio analysis'); startAudioAnalysis(); } }, [analyserRef.current, step]); const captureAudioEntropy = async () => { // Ensure audio context is running if (audioContextRef.current && audioContextRef.current.state === 'suspended') { await audioContextRef.current.resume(); console.log('▶️ Audio context resumed on capture'); } setStep('processing'); setCaptureProgress(0); console.log('🎙️ Capturing audio entropy...'); // Capture 3 seconds of audio data const captureDuration = 3000; // 3 seconds const sampleInterval = 50; // Sample every 50ms const totalSamples = captureDuration / sampleInterval; audioDataRef.current = []; rawAudioDataRef.current = []; for (let i = 0; i < totalSamples; i++) { await new Promise(resolve => setTimeout(resolve, sampleInterval)); // Try to get data from analyser first, fall back to raw audio data if (analyserRef.current) { const bufferLength = analyserRef.current!.frequencyBinCount; const timeData = new Float32Array(bufferLength); analyserRef.current!.getFloatTimeDomainData(timeData); // Store Float32Array directly (no conversion needed) audioDataRef.current.push(new Float32Array(timeData)); } setCaptureProgress(((i + 1) / totalSamples) * 100); } // Use raw audio data if available (from ScriptProcessor) if (rawAudioDataRef.current.length > 0) { console.log('✅ Using raw audio data from ScriptProcessor:', rawAudioDataRef.current.length, 'samples'); audioDataRef.current = rawAudioDataRef.current.slice(-totalSamples); // Use most recent samples } console.log('✅ Audio captured:', audioDataRef.current.length, 'samples'); // Analyze captured audio const audioStats = await analyzeAudioEntropy(); const mnemonic = await generateMnemonicFromAudio(audioStats); setStats(audioStats); setGeneratedMnemonic(mnemonic); setStep('stats'); // Use teardownAudio for proper cleanup await teardownAudio(); }; const analyzeAudioEntropy = async (): Promise => { // Convert Float32Array[] to number[] by flattening and converting each Float32Array to array const allSamples: number[] = audioDataRef.current.flatMap(arr => Array.from(arr)); const sampleRate = audioContextRef.current?.sampleRate || 48000; // Peak amplitude const peakAmplitude = Math.max(...allSamples.map(Math.abs)); // RMS amplitude const sumSquares = allSamples.reduce((sum, val) => sum + val * val, 0); const rmsAmplitude = Math.sqrt(sumSquares / allSamples.length); // Zero crossings (measure of frequency content) let zeroCrossings = 0; for (let i = 1; i < allSamples.length; i++) { if ((allSamples[i] >= 0 && allSamples[i - 1] < 0) || (allSamples[i] < 0 && allSamples[i - 1] >= 0)) { zeroCrossings++; } } // Frequency analysis (simplified) const frequencyBands = Array(8).fill(0); // 8 bands for (const frame of audioDataRef.current) { const bufferLength = frame.length; const bandSize = Math.floor(bufferLength / 8); for (let band = 0; band < 8; band++) { const start = band * bandSize; const end = start + bandSize; let bandEnergy = 0; for (let i = start; i < end && i < bufferLength; i++) { bandEnergy += Math.abs(frame[i]); } frequencyBands[band] += bandEnergy / bandSize; } } // Normalize frequency bands const maxBand = Math.max(...frequencyBands); if (maxBand > 0) { for (let i = 0; i < frequencyBands.length; i++) { frequencyBands[i] = (frequencyBands[i] / maxBand) * 100; } } // Spectral entropy (simplified) let spectralEntropy = 0; const total = frequencyBands.reduce((a, b) => a + b, 0); if (total > 0) { for (const band of frequencyBands) { if (band > 0) { const p = band / total; spectralEntropy -= p * Math.log2(p); } } } return { sampleRate, duration: audioDataRef.current.length * 50, // milliseconds peakAmplitude, rmsAmplitude, zeroCrossings, frequencyBands, spectralEntropy, interactionSamples: interactionEntropy.getSampleCount().total, totalBits: 256, }; }; const generateMnemonicFromAudio = async (audioStats: AudioStats): Promise => { // Mix audio data with other entropy sources // Convert Float32Array[] to a single Float32Array by concatenating all arrays const allAudioData = audioDataRef.current.flatMap(arr => Array.from(arr)); const audioHash = await crypto.subtle.digest( 'SHA-256', new Float32Array(allAudioData).buffer ); const interactionBytes = await interactionEntropy.getEntropyBytes(); const cryptoBytes = crypto.getRandomValues(new Uint8Array(32)); const combined = [ Array.from(new Uint8Array(audioHash)).join(','), audioStats.zeroCrossings.toString(), audioStats.peakAmplitude.toString(), performance.now().toString(), Array.from(interactionBytes).join(','), Array.from(cryptoBytes).join(','), ].join('|'); const encoder = new TextEncoder(); const data = encoder.encode(combined); const hash = await crypto.subtle.digest('SHA-256', data); const { entropyToMnemonic } = await import('bip39'); const entropyLength = wordCount === 12 ? 16 : 32; const finalEntropy = new Uint8Array(hash).slice(0, entropyLength); const entropyHex = Buffer.from(finalEntropy).toString('hex'); return entropyToMnemonic(entropyHex); }; useEffect(() => { return () => { teardownAudio(); }; }, []); const getStatusMessage = () => { if (audioLevel > 10) { return { text: '✅ Excellent audio - ready!', color: '#39ff14' }; } else if (audioLevel > 5) { return { text: '🟡 Good - speak or make noise', color: '#ffd700' }; } else if (audioLevel > 2) { return { text: '🟠 Low - louder noise needed', color: '#ff9500' }; } else { return { text: '🔴 Too quiet - tap desk/speak', color: '#ff006e' }; } }; return (
{/* Permission Screen */} {step === 'permission' && (

Microphone Permission Needed

Beta Feature

To generate entropy, we need:

  • Microphone access to capture ambient noise
  • Audio data processed locally (never transmitted)
  • 3 seconds of audio capture
  • Microphone auto-closes after use
)} {/* Capture Screen */} {step === 'capture' && (
{/* Waveform Visualization */}
{/* Animated audio level bars */}
{[...Array(20)].map((_, i) => (
))}
{/* Status */}

Instructions:

Make noise: tap desk, rustle paper, speak, or play music

Audio Level: {audioLevel.toFixed(1)}%
{getStatusMessage().text}
)} {/* Processing Screen */} {step === 'processing' && (

Capturing audio entropy...

{captureProgress.toFixed(0)}%

)} {/* Stats Display */} {step === 'stats' && stats && (

Audio Entropy Analysis

Primary Source:

Microphone Ambient Noise

AUDIO METRICS:

Sample Rate:
{stats.sampleRate} Hz
Duration:
{stats.duration}ms
Peak Amplitude:
{stats.peakAmplitude.toFixed(3)}
RMS Amplitude:
{stats.rmsAmplitude.toFixed(3)}
Zero Crossings:
{stats.zeroCrossings.toLocaleString()}
Spectral Entropy:
{stats.spectralEntropy.toFixed(2)}/3.00

FREQUENCY DISTRIBUTION:

{stats.frequencyBands.map((val, i) => (
))}
Low High

GENERATED SEED:

{generatedMnemonic}

👆 Hover to reveal - Write this down securely

HOW SEED IS GENERATED:

1. Captured {stats.duration}ms of audio ({(audioDataRef.current.flat().length / 1024).toFixed(1)}KB)
2. Analyzed {stats.zeroCrossings.toLocaleString()} zero crossings
3. Extracted frequency spectrum (8 bands)
4. Mixed with {stats.interactionSamples} interaction samples
5. Enhanced with crypto.getRandomValues() (32 bytes)
6. Final hash → {wordCount === 12 ? '128' : '256'} bits → {wordCount} BIP39 words

MIXED WITH:

- crypto.getRandomValues() ✓
- performance.now() ✓
- Interaction timing ({stats.interactionSamples} samples) ✓
Total Entropy: {stats.totalBits} bits
)} {error && (

{error}

)}
); }; export default AudioEntropy;