UNPKG

@loqalabs/loqa-audio-dsp

Version:

Production-grade Expo native module for audio DSP analysis (FFT, pitch detection, formant extraction, spectral analysis)

1,533 lines (1,279 loc) 42.5 kB
# Integration Guide **@loqalabs/loqa-audio-dsp** This guide provides real-world integration patterns and complete examples for using @loqalabs/loqa-audio-dsp in your React Native/Expo applications. --- ## Table of Contents - [Quick Start](#quick-start) - [Integration Patterns](#integration-patterns) - [Pattern 1: Voice Recording with expo-av](#pattern-1-voice-recording-with-expo-av) - [Pattern 2: Real-Time Analysis with Streaming Audio](#pattern-2-real-time-analysis-with-streaming-audio) - [Pattern 3: Batch Processing Pre-Recorded Files](#pattern-3-batch-processing-pre-recorded-files) - [Complete Examples](#complete-examples) - [Pitch Tracking App](#pitch-tracking-app) - [Formant Visualization](#formant-visualization) - [Spectral Analyzer](#spectral-analyzer) - [Performance & Memory Management](#performance--memory-management) - [Error Handling Best Practices](#error-handling-best-practices) - [Platform-Specific Considerations](#platform-specific-considerations) --- ## Quick Start Install the package: ```bash npx expo install @loqalabs/loqa-audio-dsp ``` Basic usage: ```typescript import { detectPitch } from '@loqalabs/loqa-audio-dsp'; const audioBuffer = new Float32Array(2048); // ... fill buffer with audio samples ... const pitch = await detectPitch(audioBuffer, 44100); console.log(`Pitch: ${pitch.frequency} Hz`); ``` --- ## Integration Patterns ### Pattern 1: Voice Recording with expo-av This pattern shows how to record audio using `expo-av` and analyze it with @loqalabs/loqa-audio-dsp. #### Dependencies ```bash npx expo install expo-av ``` #### Complete Implementation ```typescript import { Audio } from 'expo-av'; import { detectPitch, extractFormants } from '@loqalabs/loqa-audio-dsp'; import { useState } from 'react'; import { View, Button, Text } from 'react-native'; export function VoiceRecorderWithAnalysis() { const [recording, setRecording] = useState<Audio.Recording | null>(null); const [analysis, setAnalysis] = useState<{ pitch: number | null; formants: { f1: number; f2: number; f3: number } | null; } | null>(null); const [error, setError] = useState<string | null>(null); const [isProcessing, setIsProcessing] = useState(false); // Request microphone permissions async function requestPermissions() { try { const { status } = await Audio.requestPermissionsAsync(); if (status !== 'granted') { throw new Error('Microphone permission denied'); } } catch (error) { setError('Failed to request microphone permissions'); throw error; } } // Start recording async function startRecording() { try { setError(null); await requestPermissions(); await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, }); const { recording } = await Audio.Recording.createAsync( Audio.RecordingOptionsPresets.HIGH_QUALITY ); setRecording(recording); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to start recording'; setError(message); console.error('Recording start error:', error); } } // Stop recording and analyze async function stopRecordingAndAnalyze() { if (!recording) return; try { setIsProcessing(true); setError(null); await recording.stopAndUnloadAsync(); const uri = recording.getURI(); if (!uri) { throw new Error('Recording URI not available'); } // Load recorded audio const { sound, status } = await Audio.Sound.createAsync({ uri }); if (!status.isLoaded) { throw new Error('Failed to load recording'); } // Get audio buffer from recording // Note: expo-av doesn't directly expose PCM data, so you need to: // 1. Save the file and decode it with expo-file-system + audio decoder // 2. Use @loqalabs/loqa-audio-bridge for direct PCM access // 3. Use a custom native module to extract PCM // For this example, assume we have audio samples in Float32Array const audioSamples = await extractPCMFromRecording(uri); const sampleRate = status.durationMillis ? 44100 : 16000; // Infer or get from metadata // Analyze pitch const pitchResult = await detectPitch(audioSamples, sampleRate, { minFrequency: 80, maxFrequency: 400, }); // Analyze formants (if voiced) let formantsResult = null; if (pitchResult.isVoiced) { formantsResult = await extractFormants(audioSamples, sampleRate); } setAnalysis({ pitch: pitchResult.frequency, formants: formantsResult ? { f1: formantsResult.f1, f2: formantsResult.f2, f3: formantsResult.f3, } : null, }); setRecording(null); } catch (error) { const message = error instanceof Error ? error.message : 'Analysis failed'; setError(message); console.error('Analysis error:', error); } finally { setIsProcessing(false); } } return ( <View> <Button title={recording ? 'Stop & Analyze' : 'Start Recording'} onPress={recording ? stopRecordingAndAnalyze : startRecording} disabled={isProcessing} /> {isProcessing && <Text>Processing...</Text>} {error && ( <View style={{ backgroundColor: '#ffebee', padding: 10 }}> <Text style={{ color: '#c62828' }}>Error: {error}</Text> </View> )} {analysis && ( <View> <Text>Pitch: {analysis.pitch?.toFixed(1) ?? 'N/A'} Hz</Text> {analysis.formants && ( <Text> Formants: F1={analysis.formants.f1.toFixed(0)} F2={analysis.formants.f2.toFixed(0)} F3={analysis.formants.f3.toFixed(0)} Hz </Text> )} </View> )} </View> ); } // ⚠️ IMPORTANT: This is an intentional placeholder function // expo-av does not directly expose PCM data, so you'll need to choose one of these approaches: async function extractPCMFromRecording(uri: string): Promise<Float32Array> { // RECOMMENDED APPROACH: Use @loqalabs/loqa-audio-bridge for direct PCM access // This eliminates the need for expo-av and provides real-time PCM streaming // See Pattern 2 below for complete implementation // ALTERNATIVE APPROACH 1: Server-side decoding // Upload the recording to your backend, decode to PCM, and download // ALTERNATIVE APPROACH 2: For WAV files only // Parse WAV file format using expo-file-system to extract PCM samples // Example: const base64 = await FileSystem.readAsStringAsync(uri, { encoding: 'base64' }); // Then decode WAV header and extract PCM data // ALTERNATIVE APPROACH 3: Use a third-party audio decoder library // Search npm for React Native audio decoder packages throw new Error( 'PCM extraction not implemented. ' + 'Use @loqalabs/loqa-audio-bridge for real-time analysis (see Pattern 2) ' + 'or implement one of the alternative approaches listed in the function comments.' ); } ``` #### Key Points - **expo-av limitations**: `expo-av` doesn't directly expose PCM audio data, making real-time analysis difficult - **Recommended approach**: Use [@loqalabs/loqa-audio-bridge](https://github.com/loqalabs/loqa-audio-bridge) for direct PCM access - **Alternative**: Save recordings and decode them to PCM using `expo-file-system` + audio decoders --- ### Pattern 2: Real-Time Analysis with Streaming Audio This pattern demonstrates real-time audio analysis using [@loqalabs/loqa-audio-bridge](https://github.com/loqalabs/loqa-audio-bridge) for continuous audio streaming. #### Dependencies ```bash npx expo install @loqalabs/loqa-audio-bridge @loqalabs/loqa-audio-dsp ``` #### Complete Implementation ```typescript import { startAudioStream, stopAudioStream, addAudioSampleListener, removeAudioSampleListener, } from '@loqalabs/loqa-audio-bridge'; import { detectPitch, analyzeSpectrum } from '@loqalabs/loqa-audio-dsp'; import { useEffect, useState } from 'react'; import { View, Button, Text } from 'react-native'; // Define the audio sample event type from loqa-audio-bridge interface AudioSampleEvent { samples: Float32Array; sampleRate: number; } export function RealtimePitchTracker() { const [currentPitch, setCurrentPitch] = useState<number | null>(null); const [spectralCentroid, setSpectralCentroid] = useState<number | null>(null); const [isListening, setIsListening] = useState(false); // Audio analysis callback const handleAudioSample = async (event: AudioSampleEvent) => { const { samples, sampleRate } = event; try { // Detect pitch const pitch = await detectPitch(samples, sampleRate, { minFrequency: 80, maxFrequency: 1000, }); if (pitch.isVoiced && pitch.confidence > 0.5) { setCurrentPitch(pitch.frequency); } else { setCurrentPitch(null); } // Analyze spectrum for brightness const spectrum = await analyzeSpectrum(samples, sampleRate); setSpectralCentroid(spectrum.centroid); } catch (error) { console.error('Analysis failed:', error); } }; // Start streaming async function startListening() { try { await startAudioStream({ sampleRate: 16000, bufferSize: 2048, channels: 1, }); const listener = addAudioSampleListener(handleAudioSample); setIsListening(true); return listener; } catch (error) { console.error('Failed to start audio stream:', error); } } // Stop streaming async function stopListening() { await stopAudioStream(); setIsListening(false); setCurrentPitch(null); setSpectralCentroid(null); } // Cleanup on unmount useEffect(() => { let listener: any; let isMounted = true; async function setupListener() { if (isListening && isMounted) { listener = await startListening(); } } setupListener(); return () => { isMounted = false; if (listener) { removeAudioSampleListener(listener); } stopListening(); }; }, [isListening]); return ( <View> <Button title={isListening ? 'Stop Listening' : 'Start Listening'} onPress={() => setIsListening(!isListening)} /> {isListening && ( <View> <Text>Pitch: {currentPitch ? `${currentPitch.toFixed(1)} Hz` : 'No pitch detected'}</Text> <Text>Brightness: {spectralCentroid?.toFixed(0) ?? 'N/A'} Hz</Text> </View> )} </View> ); } ``` #### Key Points - **Buffer size**: Use 2048 samples for good balance between latency and accuracy - **Sample rate**: 16 kHz is sufficient for voice analysis (lower CPU usage than 44.1 kHz) - **Performance**: Each analysis should complete in <5ms to avoid blocking the audio thread - **Error handling**: Always wrap analysis in try-catch to handle edge cases gracefully - **Cleanup**: Remove listeners and stop streams when component unmounts --- ### Pattern 3: Batch Processing Pre-Recorded Files This pattern shows how to process pre-recorded audio files for offline analysis. #### Dependencies ```bash npx expo install expo-file-system expo-asset ``` #### Complete Implementation ```typescript import * as FileSystem from 'expo-file-system'; import { computeFFT, detectPitch, extractFormants } from '@loqalabs/loqa-audio-dsp'; interface AudioAnalysisResult { pitch: { frequency: number | null; confidence: number }; formants: { f1: number; f2: number; f3: number } | null; spectrum: { magnitude: Float32Array; frequencies: Float32Array }; } export async function analyzeBatchAudioFile( fileUri: string, sampleRate: number = 16000 ): Promise<AudioAnalysisResult[]> { // 1. Load and decode audio file to PCM const pcmData = await loadAndDecodeToPCM(fileUri, sampleRate); // 2. Split into analysis windows const windowSize = 2048; const hopSize = 512; // 75% overlap for smoother tracking const results: AudioAnalysisResult[] = []; for (let i = 0; i + windowSize <= pcmData.length; i += hopSize) { const window = pcmData.slice(i, i + windowSize); // Run all analyses in parallel for efficiency const [pitch, fft] = await Promise.all([ detectPitch(window, sampleRate), computeFFT(window, { fftSize: 2048, windowType: 'hanning' }), ]); // Extract formants only for voiced segments let formants = null; if (pitch.isVoiced && pitch.confidence > 0.6) { formants = await extractFormants(window, sampleRate); } results.push({ pitch: { frequency: pitch.frequency, confidence: pitch.confidence, }, formants: formants ? { f1: formants.f1, f2: formants.f2, f3: formants.f3, } : null, spectrum: { magnitude: fft.magnitude, frequencies: fft.frequencies, }, }); } return results; } // ⚠️ IMPORTANT: This is an intentional placeholder function // Audio file decoding depends on your file format. Choose an approach: async function loadAndDecodeToPCM( fileUri: string, targetSampleRate: number ): Promise<Float32Array> { // EXAMPLE IMPLEMENTATION FOR WAV FILES: // This shows how to decode uncompressed WAV files using expo-file-system /* import * as FileSystem from 'expo-file-system'; const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); const arrayBuffer = Uint8Array.from(atob(base64), c => c.charCodeAt(0)).buffer; const dataView = new DataView(arrayBuffer); // Parse WAV header (simplified - production code should validate format) const dataOffset = 44; // Standard WAV header size const numChannels = dataView.getUint16(22, true); const sampleRate = dataView.getUint32(24, true); const bitsPerSample = dataView.getUint16(34, true); // Extract PCM samples (assuming 16-bit mono) const numSamples = (arrayBuffer.byteLength - dataOffset) / 2; const pcmData = new Float32Array(numSamples); for (let i = 0; i < numSamples; i++) { const sample = dataView.getInt16(dataOffset + i * 2, true); pcmData[i] = sample / 32768.0; // Normalize to [-1, 1] } return pcmData; */ // ALTERNATIVE APPROACH 1: Server-side decoding (recommended for production) // Upload file to your backend, decode using ffmpeg or similar, return PCM // ALTERNATIVE APPROACH 2: Use a React Native audio decoder library // Search npm for packages like react-native-audio-decoder-wav // ALTERNATIVE APPROACH 3: Use expo-av + custom native module // Load with expo-av, capture PCM during playback with native bridge throw new Error( 'Audio decoding not implemented. ' + 'See function comments for WAV file example and alternative approaches. ' + 'For real-time analysis, use @loqalabs/loqa-audio-bridge (Pattern 2).' ); } // Example: Analyze and extract statistics export async function generateAudioReport(fileUri: string) { const results = await analyzeBatchAudioFile(fileUri, 16000); // Calculate statistics const pitches = results.map((r) => r.pitch.frequency).filter((f): f is number => f !== null); const avgPitch = pitches.reduce((a, b) => a + b, 0) / pitches.length; const minPitch = Math.min(...pitches); const maxPitch = Math.max(...pitches); const voicedFrames = results.filter((r) => r.formants !== null).length; const voicedPercentage = (voicedFrames / results.length) * 100; return { duration: (results.length * 512) / 16000, // in seconds avgPitch, minPitch, maxPitch, pitchRange: maxPitch - minPitch, voicedPercentage, totalFrames: results.length, }; } ``` #### Key Points - **Window overlap**: Use 50-75% overlap for smoother pitch tracking - **Parallel processing**: Run independent analyses concurrently with `Promise.all()` - **Memory management**: Process large files in chunks to avoid memory issues - **Audio decoding**: You'll need a decoder to convert audio files to PCM (see options in code comments) --- ## Complete Examples ### Pitch Tracking App A complete musical tuner application that detects pitch in real-time and displays note information. ```typescript import { useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { startAudioStream, stopAudioStream, addAudioSampleListener, } from '@loqalabs/loqa-audio-bridge'; import { detectPitch } from '@loqalabs/loqa-audio-dsp'; // Musical note data const NOTE_NAMES = ['C', 'C♯', 'D', 'D♯', 'E', 'F', 'F♯', 'G', 'G♯', 'A', 'A♯', 'B']; const A4_FREQUENCY = 440; interface NoteInfo { note: string; octave: number; cents: number; // Cents deviation from perfect pitch frequency: number; } function frequencyToNote(frequency: number): NoteInfo { const semitones = 12 * Math.log2(frequency / A4_FREQUENCY); const semitonesFromA4 = Math.round(semitones); const cents = Math.round((semitones - semitonesFromA4) * 100); const noteIndex = (semitonesFromA4 + 9) % 12; const octave = Math.floor((semitonesFromA4 + 9) / 12) + 4; return { note: NOTE_NAMES[noteIndex < 0 ? noteIndex + 12 : noteIndex], octave, cents, frequency, }; } export default function PitchTrackerApp() { const [isActive, setIsActive] = useState(false); const [noteInfo, setNoteInfo] = useState<NoteInfo | null>(null); const [confidence, setConfidence] = useState(0); useEffect(() => { let listener: any; async function start() { await startAudioStream({ sampleRate: 44100, bufferSize: 4096, // Larger buffer for better pitch accuracy channels: 1, }); listener = addAudioSampleListener(async (event) => { try { const pitch = await detectPitch(event.samples, event.sampleRate, { minFrequency: 82, // E2 (low guitar string) maxFrequency: 1047, // C6 (high voice) }); if (pitch.isVoiced && pitch.frequency && pitch.confidence > 0.7) { setNoteInfo(frequencyToNote(pitch.frequency)); setConfidence(pitch.confidence); } else { setNoteInfo(null); setConfidence(0); } } catch (error) { console.error('Pitch detection error:', error); } }); } if (isActive) { start(); } else { stopAudioStream(); setNoteInfo(null); } return () => { if (listener) { stopAudioStream(); } }; }, [isActive]); return ( <View style={styles.container}> <Text style={styles.title}>Musical Tuner</Text> <TouchableOpacity style={[styles.button, isActive && styles.buttonActive]} onPress={() => setIsActive(!isActive)} > <Text style={styles.buttonText}>{isActive ? 'Stop' : 'Start'}</Text> </TouchableOpacity> {isActive && ( <View style={styles.display}> {noteInfo ? ( <> <Text style={styles.noteText}> {noteInfo.note} <Text style={styles.octaveText}>{noteInfo.octave}</Text> </Text> <Text style={styles.frequencyText}>{noteInfo.frequency.toFixed(1)} Hz</Text> <View style={styles.tuningIndicator}> <View style={[styles.tuningNeedle, { left: `${50 + noteInfo.cents / 2}%` }]} /> <Text style={styles.centsText}> {noteInfo.cents > 0 ? '+' : ''} {noteInfo.cents}¢ </Text> </View> <Text style={styles.confidenceText}> Confidence: {(confidence * 100).toFixed(0)}% </Text> </> ) : ( <Text style={styles.waitingText}>Listening...</Text> )} </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20, backgroundColor: '#1a1a1a', }, title: { fontSize: 32, fontWeight: 'bold', color: '#fff', marginBottom: 40, }, button: { backgroundColor: '#4CAF50', paddingHorizontal: 40, paddingVertical: 15, borderRadius: 25, marginBottom: 40, }, buttonActive: { backgroundColor: '#f44336', }, buttonText: { color: '#fff', fontSize: 18, fontWeight: 'bold', }, display: { alignItems: 'center', justifyContent: 'center', width: '100%', }, noteText: { fontSize: 120, fontWeight: 'bold', color: '#4CAF50', }, octaveText: { fontSize: 60, }, frequencyText: { fontSize: 24, color: '#ccc', marginTop: 10, }, tuningIndicator: { width: '80%', height: 60, backgroundColor: '#333', borderRadius: 10, marginTop: 30, position: 'relative', justifyContent: 'center', }, tuningNeedle: { position: 'absolute', width: 4, height: '100%', backgroundColor: '#4CAF50', shadowColor: '#4CAF50', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 10, }, centsText: { textAlign: 'center', color: '#fff', fontSize: 18, fontWeight: 'bold', }, confidenceText: { marginTop: 20, color: '#999', fontSize: 14, }, waitingText: { fontSize: 24, color: '#666', }, }); ``` --- ### Formant Visualization A vowel analysis app that visualizes formants on an F1-F2 chart. ```typescript import { useState, useEffect } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; import Svg, { Circle, Text as SvgText, Line } from 'react-native-svg'; import { startAudioStream, stopAudioStream, addAudioSampleListener, } from '@loqalabs/loqa-audio-bridge'; import { extractFormants, detectPitch } from '@loqalabs/loqa-audio-dsp'; const { width } = Dimensions.get('window'); const CHART_SIZE = width - 40; // Vowel reference points (F1, F2) for English vowels const VOWEL_REFERENCES = { i: { f1: 280, f2: 2250, label: 'i (beat)' }, // /i/ as in "beat" ɪ: { f1: 400, f2: 1920, label: 'ɪ (bit)' }, // /ɪ/ as in "bit" ɛ: { f1: 550, f2: 1770, label: 'ɛ (bet)' }, // /ɛ/ as in "bet" æ: { f1: 690, f2: 1660, label: 'æ (bat)' }, // /æ/ as in "bat" ɑ: { f1: 710, f2: 1100, label: 'ɑ (father)' }, // /ɑ/ as in "father" ɔ: { f1: 590, f2: 880, label: 'ɔ (bought)' }, // /ɔ/ as in "bought" ʊ: { f1: 450, f2: 1030, label: 'ʊ (book)' }, // /ʊ/ as in "book" u: { f1: 310, f2: 870, label: 'u (boot)' }, // /u/ as in "boot" }; interface FormantPoint { f1: number; f2: number; timestamp: number; } export default function FormantVisualizerApp() { const [isActive, setIsActive] = useState(false); const [currentFormants, setCurrentFormants] = useState<FormantPoint | null>(null); const [formantHistory, setFormantHistory] = useState<FormantPoint[]>([]); useEffect(() => { let listener: any; async function start() { await startAudioStream({ sampleRate: 16000, bufferSize: 2048, channels: 1, }); listener = addAudioSampleListener(async (event) => { try { // First check if segment is voiced const pitch = await detectPitch(event.samples, event.sampleRate); if (pitch.isVoiced && pitch.confidence > 0.6) { // Extract formants from voiced segment const formants = await extractFormants(event.samples, event.sampleRate); const point: FormantPoint = { f1: formants.f1, f2: formants.f2, timestamp: Date.now(), }; setCurrentFormants(point); setFormantHistory((prev) => [...prev.slice(-50), point]); // Keep last 50 points } } catch (error) { console.error('Formant extraction error:', error); } }); } if (isActive) { start(); } else { stopAudioStream(); setCurrentFormants(null); setFormantHistory([]); } return () => { if (listener) { stopAudioStream(); } }; }, [isActive]); // Convert formant frequencies to chart coordinates function formantToCoords(f1: number, f2: number): { x: number; y: number } { // Invert F1 axis (high F1 = low vowels at bottom) const x = ((f2 - 600) / (2400 - 600)) * CHART_SIZE; const y = CHART_SIZE - ((f1 - 200) / (900 - 200)) * CHART_SIZE; return { x, y }; } return ( <View style={styles.container}> <Text style={styles.title}>Vowel Formant Analyzer</Text> <TouchableOpacity style={[styles.button, isActive && styles.buttonActive]} onPress={() => setIsActive(!isActive)} > <Text style={styles.buttonText}>{isActive ? 'Stop' : 'Start'}</Text> </TouchableOpacity> <View style={styles.chartContainer}> <Svg width={CHART_SIZE} height={CHART_SIZE}> {/* Background grid */} <Line x1="0" y1={CHART_SIZE / 2} x2={CHART_SIZE} y2={CHART_SIZE / 2} stroke="#333" strokeWidth="1" /> <Line x1={CHART_SIZE / 2} y1="0" x2={CHART_SIZE / 2} y2={CHART_SIZE} stroke="#333" strokeWidth="1" /> {/* Vowel reference points */} {Object.entries(VOWEL_REFERENCES).map(([key, vowel]) => { const { x, y } = formantToCoords(vowel.f1, vowel.f2); return <Circle key={key} cx={x} cy={y} r="6" fill="#666" opacity="0.5" />; })} {/* Formant history trail (fading) */} {formantHistory.map((point, idx) => { const { x, y } = formantToCoords(point.f1, point.f2); const opacity = (idx / formantHistory.length) * 0.5; return ( <Circle key={point.timestamp} cx={x} cy={y} r="3" fill="#4CAF50" opacity={opacity} /> ); })} {/* Current formant point */} {currentFormants && (() => { const { x, y } = formantToCoords(currentFormants.f1, currentFormants.f2); return <Circle cx={x} cy={y} r="8" fill="#4CAF50" stroke="#fff" strokeWidth="2" />; })()} </Svg> {/* Axis labels */} <Text style={styles.axisLabel}>F2 (Hz) →</Text> <Text style={[styles.axisLabel, styles.verticalLabel]}>F1 (Hz) →</Text> </View> {currentFormants && ( <View style={styles.infoPanel}> <Text style={styles.infoText}>F1: {currentFormants.f1.toFixed(0)} Hz</Text> <Text style={styles.infoText}>F2: {currentFormants.f2.toFixed(0)} Hz</Text> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', padding: 20, backgroundColor: '#1a1a1a', }, title: { fontSize: 24, fontWeight: 'bold', color: '#fff', marginBottom: 20, }, button: { backgroundColor: '#4CAF50', paddingHorizontal: 30, paddingVertical: 12, borderRadius: 20, marginBottom: 20, }, buttonActive: { backgroundColor: '#f44336', }, buttonText: { color: '#fff', fontSize: 16, fontWeight: 'bold', }, chartContainer: { backgroundColor: '#222', borderRadius: 10, padding: 10, position: 'relative', }, axisLabel: { color: '#999', fontSize: 12, marginTop: 5, }, verticalLabel: { position: 'absolute', left: 0, top: '50%', transform: [{ rotate: '-90deg' }], }, infoPanel: { marginTop: 20, backgroundColor: '#333', padding: 15, borderRadius: 10, width: '100%', }, infoText: { color: '#4CAF50', fontSize: 18, fontWeight: 'bold', marginVertical: 5, }, }); ``` --- ### Spectral Analyzer A frequency spectrum visualizer with real-time FFT display. ```typescript import { useState, useEffect } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; import Svg, { Rect } from 'react-native-svg'; import { startAudioStream, stopAudioStream, addAudioSampleListener, } from '@loqalabs/loqa-audio-bridge'; import { computeFFT, analyzeSpectrum } from '@loqalabs/loqa-audio-dsp'; const { width } = Dimensions.get('window'); const SPECTRUM_WIDTH = width - 40; const SPECTRUM_HEIGHT = 300; const NUM_BARS = 64; // Number of frequency bars to display export default function SpectralAnalyzerApp() { const [isActive, setIsActive] = useState(false); const [magnitudes, setMagnitudes] = useState<number[]>(new Array(NUM_BARS).fill(0)); const [spectralFeatures, setSpectralFeatures] = useState({ centroid: 0, rolloff: 0, tilt: 0, }); useEffect(() => { let listener: any; async function start() { await startAudioStream({ sampleRate: 44100, bufferSize: 4096, channels: 1, }); listener = addAudioSampleListener(async (event) => { try { // Compute FFT const fft = await computeFFT(event.samples, { fftSize: 4096, windowType: 'hanning', includePhase: false, }); // Downsample magnitude spectrum to NUM_BARS const binSize = Math.floor(fft.magnitude.length / NUM_BARS); const bars = new Array(NUM_BARS).fill(0); for (let i = 0; i < NUM_BARS; i++) { let sum = 0; for (let j = 0; j < binSize; j++) { sum += fft.magnitude[i * binSize + j]; } bars[i] = sum / binSize; } // Normalize to 0-1 range const maxMag = Math.max(...bars, 1); const normalized = bars.map((m) => m / maxMag); setMagnitudes(normalized); // Analyze spectral features const spectrum = await analyzeSpectrum(event.samples, event.sampleRate); setSpectralFeatures({ centroid: spectrum.centroid, rolloff: spectrum.rolloff, tilt: spectrum.tilt, }); } catch (error) { console.error('FFT error:', error); } }); } if (isActive) { start(); } else { stopAudioStream(); setMagnitudes(new Array(NUM_BARS).fill(0)); } return () => { if (listener) { stopAudioStream(); } }; }, [isActive]); const barWidth = SPECTRUM_WIDTH / NUM_BARS; return ( <View style={styles.container}> <Text style={styles.title}>Spectral Analyzer</Text> <TouchableOpacity style={[styles.button, isActive && styles.buttonActive]} onPress={() => setIsActive(!isActive)} > <Text style={styles.buttonText}>{isActive ? 'Stop' : 'Start'}</Text> </TouchableOpacity> <View style={styles.spectrumContainer}> <Svg width={SPECTRUM_WIDTH} height={SPECTRUM_HEIGHT}> {magnitudes.map((magnitude, idx) => { const barHeight = magnitude * SPECTRUM_HEIGHT; const hue = (idx / NUM_BARS) * 280; // Color gradient from red to blue return ( <Rect key={idx} x={idx * barWidth} y={SPECTRUM_HEIGHT - barHeight} width={barWidth - 1} height={barHeight} fill={`hsl(${hue}, 80%, 50%)`} /> ); })} </Svg> <Text style={styles.frequencyLabel}>0 Hz</Text> <Text style={[styles.frequencyLabel, styles.frequencyLabelRight]}>22 kHz</Text> </View> {isActive && ( <View style={styles.featuresPanel}> <View style={styles.feature}> <Text style={styles.featureLabel}>Centroid (Brightness)</Text> <Text style={styles.featureValue}>{spectralFeatures.centroid.toFixed(0)} Hz</Text> </View> <View style={styles.feature}> <Text style={styles.featureLabel}>Rolloff (95% Energy)</Text> <Text style={styles.featureValue}>{spectralFeatures.rolloff.toFixed(0)} Hz</Text> </View> <View style={styles.feature}> <Text style={styles.featureLabel}>Tilt (Timbre)</Text> <Text style={styles.featureValue}> {spectralFeatures.tilt.toFixed(3)} <Text style={styles.featureHint}> {spectralFeatures.tilt > 0 ? ' (bright)' : ' (dark)'} </Text> </Text> </View> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', padding: 20, backgroundColor: '#1a1a1a', }, title: { fontSize: 28, fontWeight: 'bold', color: '#fff', marginBottom: 20, }, button: { backgroundColor: '#4CAF50', paddingHorizontal: 30, paddingVertical: 12, borderRadius: 20, marginBottom: 20, }, buttonActive: { backgroundColor: '#f44336', }, buttonText: { color: '#fff', fontSize: 16, fontWeight: 'bold', }, spectrumContainer: { backgroundColor: '#111', borderRadius: 10, padding: 10, position: 'relative', }, frequencyLabel: { color: '#666', fontSize: 10, position: 'absolute', bottom: 5, left: 10, }, frequencyLabelRight: { left: undefined, right: 10, }, featuresPanel: { marginTop: 20, width: '100%', backgroundColor: '#222', borderRadius: 10, padding: 15, }, feature: { marginVertical: 8, }, featureLabel: { color: '#999', fontSize: 12, marginBottom: 4, }, featureValue: { color: '#4CAF50', fontSize: 20, fontWeight: 'bold', }, featureHint: { fontSize: 14, color: '#666', }, }); ``` --- ## Performance & Memory Management ### Buffer Sizing Strategies **Real-Time Analysis (Low Latency):** ```typescript // Optimize for responsiveness const config = { bufferSize: 1024, // ~23ms at 44.1kHz sampleRate: 16000, // Lower sample rate for voice fftSize: 1024, // Match buffer size }; ``` **Pitch Detection (Accuracy):** ```typescript // Optimize for pitch accuracy const config = { bufferSize: 2048, // ~46ms at 44.1kHz sampleRate: 44100, fftSize: 2048, }; ``` **Spectral Analysis (Frequency Resolution):** ```typescript // Optimize for frequency resolution const config = { bufferSize: 4096, // ~93ms at 44.1kHz sampleRate: 44100, fftSize: 4096, // More frequency bins }; ``` ### Memory Optimization **Reuse Buffers:** ```typescript // Bad: Creates new array every frame listener = addAudioSampleListener(async (event) => { const buffer = new Float32Array(event.samples); // New allocation await detectPitch(buffer, 16000); }); // Good: Reuse existing buffer listener = addAudioSampleListener(async (event) => { await detectPitch(event.samples, 16000); // Use directly }); ``` **Batch Processing:** ```typescript // Process multiple analyses in parallel const [pitch, spectrum, fft] = await Promise.all([ detectPitch(samples, sampleRate), analyzeSpectrum(samples, sampleRate), computeFFT(samples, { fftSize: 2048 }), ]); ``` **Throttle Updates:** ```typescript // Avoid overwhelming UI with updates let lastUpdateTime = 0; const UPDATE_INTERVAL = 50; // Max 20 updates/second listener = addAudioSampleListener(async (event) => { const now = Date.now(); if (now - lastUpdateTime < UPDATE_INTERVAL) { return; // Skip this frame } lastUpdateTime = now; const pitch = await detectPitch(event.samples, event.sampleRate); setState({ pitch }); }); ``` ### CPU Optimization **Sample Rate Selection:** - **Voice analysis**: 16 kHz is sufficient (saves 64% CPU vs 44.1 kHz) - **Music/instruments**: 44.1 kHz for full frequency range - **High-fidelity**: 48 kHz only if absolutely necessary **Conditional Analysis:** ```typescript // Run expensive analyses only when needed listener = addAudioSampleListener(async (event) => { // Always run cheap pitch detection const pitch = await detectPitch(event.samples, event.sampleRate); // Only extract formants for voiced segments if (pitch.isVoiced && pitch.confidence > 0.7) { const formants = await extractFormants(event.samples, event.sampleRate); setState({ pitch, formants }); } }); ``` **Window Type Trade-offs:** ```typescript // Fast but less accurate await computeFFT(samples, { windowType: 'none' }); // Slower but better frequency resolution await computeFFT(samples, { windowType: 'blackman' }); // Good balance (recommended) await computeFFT(samples, { windowType: 'hanning' }); ``` --- ## Error Handling Best Practices ### Graceful Degradation ```typescript async function analyzeWithFallback(samples: Float32Array, sampleRate: number) { try { // Try full analysis const [pitch, formants, spectrum] = await Promise.all([ detectPitch(samples, sampleRate), extractFormants(samples, sampleRate), analyzeSpectrum(samples, sampleRate), ]); return { pitch, formants, spectrum, error: null }; } catch (error) { // Fallback: Try simpler analysis console.warn('Full analysis failed, trying pitch only:', error); try { const pitch = await detectPitch(samples, sampleRate); return { pitch, formants: null, spectrum: null, error: 'partial' }; } catch (fallbackError) { console.error('All analysis failed:', fallbackError); return { pitch: null, formants: null, spectrum: null, error: 'total' }; } } } ``` ### Input Validation ```typescript import { ValidationError } from '@loqalabs/loqa-audio-dsp'; function validateAudioInput(samples: Float32Array, sampleRate: number): boolean { // Check buffer if (!samples || samples.length === 0) { throw new ValidationError('Audio buffer is empty'); } if (samples.length > 16384) { throw new ValidationError('Buffer too large (max 16384 samples)'); } // Check for invalid values const hasInvalidValues = Array.from(samples).some((v) => !isFinite(v)); if (hasInvalidValues) { throw new ValidationError('Buffer contains NaN or Infinity'); } // Check sample rate if (!Number.isInteger(sampleRate)) { throw new ValidationError('Sample rate must be an integer'); } if (sampleRate < 8000 || sampleRate > 48000) { throw new ValidationError('Sample rate must be between 8000 and 48000 Hz'); } return true; } // Usage try { validateAudioInput(audioSamples, sampleRate); const pitch = await detectPitch(audioSamples, sampleRate); } catch (error) { if (error instanceof ValidationError) { console.error('Invalid input:', error.message); } } ``` ### Timeout Protection ```typescript function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { return Promise.race([ promise, new Promise<T>((_, reject) => setTimeout(() => reject(new Error('Analysis timeout')), timeoutMs) ), ]); } // Usage: Ensure analysis completes within 10ms try { const pitch = await withTimeout(detectPitch(samples, sampleRate), 10); } catch (error) { console.error('Analysis took too long:', error); } ``` --- ## Platform-Specific Considerations ### iOS Considerations **Audio Session Configuration:** ```typescript // Configure audio session before starting analysis import { Audio } from 'expo-av'; await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, staysActiveInBackground: false, shouldDuckAndroid: false, }); ``` **Background Processing:** - iOS suspends apps in background - analysis will pause - For continuous analysis, implement background audio session - Consider using background tasks for batch processing ### Android Considerations **Audio Permissions:** ```xml <!-- android/app/src/main/AndroidManifest.xml --> <uses-permission android:name="android.permission.RECORD_AUDIO" /> ``` **Runtime Permission Request:** ```typescript import { PermissionsAndroid, Platform } from 'react-native'; async function requestMicrophonePermission(): Promise<boolean> { if (Platform.OS !== 'android') { return true; // iOS permissions handled by expo-av } try { const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, { title: 'Microphone Permission', message: 'This app needs access to your microphone for audio analysis.', buttonNeutral: 'Ask Me Later', buttonNegative: 'Cancel', buttonPositive: 'OK', }); return granted === PermissionsAndroid.RESULTS.GRANTED; } catch (error) { console.error('Permission request failed:', error); return false; } } // Usage before starting audio stream async function startAnalysis() { const hasPermission = await requestMicrophonePermission(); if (!hasPermission) { Alert.alert('Permission Denied', 'Cannot analyze audio without microphone permission'); return; } // Proceed with audio stream await startAudioStream({ sampleRate: 16000, bufferSize: 2048 }); } ``` **Buffer Size Limitations:** - Some Android devices have minimum buffer size requirements - Test on multiple devices (especially older ones) - Use dynamic buffer sizing based on device capabilities **Memory Constraints:** - Lower-end Android devices may have limited memory - Use smaller buffer sizes on older devices - Monitor memory usage and throttle processing if needed ### Cross-Platform Testing **Key Test Scenarios:** 1. **Different sample rates**: 8000, 16000, 22050, 44100, 48000 Hz 2. **Different buffer sizes**: 512, 1024, 2048, 4096, 8192 samples 3. **Edge cases**: Silence, noise, very low/high pitches 4. **Sustained load**: Run analysis for 5+ minutes continuously 5. **Memory leaks**: Monitor memory over long sessions --- ## Next Steps - **API Reference**: See [API.md](../API.md) for complete function signatures - **Example App**: Explore `example/` directory for working demos - **Companion Package**: Check [@loqalabs/loqa-audio-bridge](https://github.com/loqalabs/loqa-audio-bridge) for audio streaming - **Support**: Report issues at [GitHub Issues](https://github.com/loqalabs/loqa-audio-dsp/issues)