Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ React Native wrapper for [FluidAudio](https://github.com/FluidInference/FluidAud
## Features

- **ASR (Automatic Speech Recognition)** - High-quality speech-to-text using Parakeet TDT models
- **Streaming ASR** - Real-time transcription from microphone or system audio
- **Streaming ASR** - Real-time transcription from the microphone or from audio you feed in
- **VAD (Voice Activity Detection)** - Detect speech segments in audio
- **Speaker Diarization** - Identify and track different speakers
- **TTS (Text-to-Speech)** - Natural voice synthesis using Kokoro TTS

## Requirements

- iOS 17.0+
- React Native 0.71+ or Expo SDK 50+
- React Native 0.75+ or Expo SDK 52+

FluidAudio (0.18.0 and later 0.18.x releases) is pulled from GitHub through Swift Package Manager with React Native's `spm_dependency` podspec helper, which needs React Native 0.75. CocoaPods trunk only has FluidAudio 0.7.8 and goes read-only on December 2, 2026.

## Installation

Expand All @@ -42,6 +44,8 @@ npm install @fluidinference/react-native-fluidaudio
cd ios && pod install
```

Remove any `pod 'FluidAudio'` line from your Podfile: the package brings FluidAudio in itself. If the app fails to link with static pods, install with `USE_FRAMEWORKS=dynamic pod install` (see [react-native#44627](https://github.com/facebook/react-native/pull/44627#issuecomment-2123119711)).

## Usage

### Basic Transcription
Expand Down Expand Up @@ -75,20 +79,24 @@ import { StreamingASRManager, onStreamingUpdate } from '@fluidinference/react-na

const streaming = new StreamingASRManager();

// Start streaming with update callback
// Transcribe the microphone (the module records it)
await streaming.start({ source: 'microphone' }, (update) => {
console.log('Confirmed:', update.confirmed);
console.log('Volatile:', update.volatile);
console.log('Transcript so far:', update.confirmed);
});

// Feed audio data (16-bit PCM, 16kHz, base64 encoded)
await streaming.feedAudio(base64AudioChunk);
// ...or transcribe audio you supply: little-endian 16-bit mono PCM at 16 kHz
await streaming.start({ source: 'manual' }, (update) => {
console.log('Transcript so far:', update.confirmed);
});
await streaming.feedAudio(base64AudioChunk); // or feedBuffer(arrayBuffer)

// Stop and get final result
// Stop: queued audio is transcribed first, then the final text is returned
const result = await streaming.stop();
console.log('Final transcription:', result.text);
```

`feedAudio` / `feedBuffer` only work in a `'manual'` session; in a microphone session they reject. The default model is Parakeet EOU 320 ms (English); pick another with `model`, e.g. `{ source: 'manual', model: 'parakeet-eou-160ms' }`. The first start downloads and compiles the model.

### Voice Activity Detection

```typescript
Expand Down
10 changes: 9 additions & 1 deletion __tests__/FluidAudio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,20 @@ describe('FluidAudio', () => {
});

it('should feed audio while streaming', async () => {
await streaming.start();
await streaming.start({ source: 'manual' });
await streaming.feedAudio('base64chunk');

expect(mockModule.feedStreamingAudio).toHaveBeenCalledWith('base64chunk');
});

it('should feed an ArrayBuffer as base64', async () => {
await streaming.start({ source: 'manual' });
// Two 16-bit samples: 1 and -1, little-endian.
await streaming.feedBuffer(new Uint8Array([0x01, 0x00, 0xff, 0xff]).buffer);

expect(mockModule.feedStreamingAudio).toHaveBeenCalledWith('AQD//w==');
});

it('should throw when feeding audio without starting', async () => {
await expect(streaming.feedAudio('base64chunk')).rejects.toThrow(
'Streaming not started'
Expand Down
11 changes: 7 additions & 4 deletions __tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,21 @@ describe('Type Definitions', () => {
expect(config.source).toBe('microphone');
});

it('should support system audio source', () => {
it('should support a manual source and a model choice', () => {
const config: StreamingASRConfig = {
source: 'system',
source: 'manual',
model: 'parakeet-eou-160ms',
};

expect(config.source).toBe('system');
expect(config.source).toBe('manual');
expect(config.model).toBe('parakeet-eou-160ms');
});

it('should define StreamingUpdate correctly', () => {
const update: StreamingUpdate = {
volatile: 'partial',
volatile: '',
confirmed: 'full',
text: 'full',
isFinal: false,
};

Expand Down
97 changes: 89 additions & 8 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
View,
ActivityIndicator,
TextInput,
Image,
} from 'react-native';

import {
Expand All @@ -30,6 +31,45 @@ import {
type StreamingUpdate,
} from 'react-native-fluidaudio';

/** The PCM data of a 16 kHz mono 16-bit WAV file. */
function pcm16FromWav(wav: ArrayBuffer): ArrayBuffer {
const view = new DataView(wav);
const tag = (offset: number) =>
String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3)
);
if (view.byteLength < 12 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE') {
throw new Error('Not a WAV file');
}
let formatOk = false;
let offset = 12;
while (offset + 8 <= view.byteLength) {
const id = tag(offset);
const size = view.getUint32(offset + 4, true);
const body = offset + 8;
if (id === 'fmt ') {
formatOk =
view.getUint16(body, true) === 1 && // PCM
view.getUint16(body + 2, true) === 1 && // mono
view.getUint32(body + 4, true) === 16000 &&
view.getUint16(body + 14, true) === 16;
} else if (id === 'data') {
if (!formatOk) {
throw new Error('Expected 16 kHz mono 16-bit PCM');
}
return wav.slice(body, body + size);
}
offset = body + size + (size % 2);
}
throw new Error('WAV file has no data chunk');
}

/** 320 ms of 16 kHz 16-bit audio per feedBuffer call. */
const FEED_CHUNK_BYTES = 16000 * 2 * 0.32;

const App = () => {
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -129,6 +169,36 @@ const App = () => {
}
}, [streaming]);

// Stream the bundled sample (FluidAudio test fixture, 16.9 s) through feedBuffer,
// to check streaming transcription without a microphone.
const streamSampleFile = useCallback(async () => {
setLoading(true);
setResult('');
setStreamingText('');
setConfirmedText('');
try {
const asset = Image.resolveAssetSource(require('./assets/speech-16k-mono.wav'));
const response = await fetch(asset.uri);
const pcm = pcm16FromWav(await response.arrayBuffer());

await streaming.start({ source: 'manual' }, handleStreamingUpdate);
setIsStreaming(true);
for (let offset = 0; offset < pcm.byteLength; offset += FEED_CHUNK_BYTES) {
await streaming.feedBuffer(pcm.slice(offset, offset + FEED_CHUNK_BYTES));
}
const stopResult = await streaming.stop();
setResult(`File transcription:\n${stopResult.text || '(no speech detected)'}`);
} catch (error) {
if (streaming.isStreaming()) {
await streaming.stop().catch(() => undefined);
}
setResult(`Error: ${(error as Error).message}`);
} finally {
setIsStreaming(false);
setLoading(false);
}
}, [streaming, handleStreamingUpdate]);

// ============================================================================
// VAD Functions
// ============================================================================
Expand Down Expand Up @@ -247,15 +317,26 @@ const App = () => {
{/* Streaming ASR Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Streaming Speech-to-Text</Text>
<Text style={styles.sectionDesc}>Real-time transcription from microphone</Text>
<Text style={styles.sectionDesc}>
Real-time transcription from the microphone, or from a bundled file fed in chunks
</Text>
{!isStreaming ? (
<TouchableOpacity
style={[styles.button, styles.buttonPrimary, loading && styles.buttonDisabled]}
onPress={startStreaming}
disabled={loading}
>
<Text style={styles.buttonText}>🎤 Start Streaming</Text>
</TouchableOpacity>
<>
<TouchableOpacity
style={[styles.button, styles.buttonPrimary, loading && styles.buttonDisabled]}
onPress={startStreaming}
disabled={loading}
>
<Text style={styles.buttonText}>🎤 Start Streaming</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.buttonPrimary, loading && styles.buttonDisabled]}
onPress={streamSampleFile}
disabled={loading}
>
<Text style={styles.buttonText}>📄 Stream Sample File</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
style={[styles.button, styles.buttonDanger]}
Expand Down
2 changes: 2 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ npm run ios

- **System Info**: Shows device capabilities
- **ASR**: Speech-to-text initialization
- **Streaming ASR**: Live transcription from the microphone, or of a bundled clip fed in 320 ms chunks through `feedBuffer` (**Stream Sample File**, no microphone needed)
- **VAD**: Voice activity detection
- **Diarization**: Speaker identification
- **TTS**: Text-to-speech synthesis
Expand All @@ -40,3 +41,4 @@ npm run ios

- First run downloads ~500MB of ML models
- Model compilation takes 20-30 seconds on first launch
- `assets/speech-16k-mono.wav` (16.9 s, 16 kHz mono 16-bit) is `03-diff-explanation-16.9s.wav` from FluidAudio's test fixtures (Apache-2.0)
Binary file added example/assets/speech-16k-mono.wav
Binary file not shown.
2 changes: 2 additions & 0 deletions example/ios/FluidAudioExample/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Streaming transcription listens to the microphone.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>UILaunchStoryboardName</key>
Expand Down
3 changes: 0 additions & 3 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ end
target 'FluidAudioExample' do
config = use_native_modules!

# FluidAudio native library
pod 'FluidAudio', '~> 0.7'

use_react_native!(
:path => config[:reactNativePath],
:app_path => "#{Pod::Config.instance.installation_root}/.."
Expand Down
2 changes: 1 addition & 1 deletion example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading