@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
Markdown
# 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)