stat_ort_plugin 0.0.1 copy "stat_ort_plugin: ^0.0.1" to clipboard
stat_ort_plugin: ^0.0.1 copied to clipboard

On-device speech recognition (Vaani ASR) for Flutter via dart:ffi — file and streaming transcription with Silero VAD and speaker diarization, powered by ONNX Runtime.

example/lib/main.dart

import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stat_ort_plugin/stat_ort_plugin.dart';
import 'package:stat_ort_plugin_example/load_asset.dart';
import 'package:stat_ort_plugin_example/streaming_session.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final AudioRecorder _audioRecorder = AudioRecorder();

  Vaani? vaani;
  StreamingSession? _session;
  StreamSubscription<Uint8List>? _recordSub;
  String transcript = "";

  // Models load (hundreds of MB) before anything can run, so the UI stays
  // disabled with a clear status until they're ready. _startingStream covers
  // the brief window between tapping Start and the mic actually capturing —
  // the user shouldn't speak until that clears.
  bool _modelReady = false;
  bool _modelLoading = false;
  bool _transcribing = false;
  bool _streaming = false;
  bool _startingStream = false;

  @override
  void initState() {
    super.initState();
    _loadModels();
  }

  @override
  void dispose() {
    _recordSub?.cancel();
    _audioRecorder.dispose();
    _session?.abort();
    vaani?.dispose();
    super.dispose();
  }

  Future<void> _loadModels() async {
    setState(() => _modelLoading = true);
    try {
      final encPath = await loadAsset('assets/encoder-vaani.onnx');
      final decPath = await loadAsset('assets/decoder_joint-vaani.onnx');
      final tokenPath = await loadAsset('assets/tokens.txt');
      final vadPath = await loadAsset('assets/silero_vad.onnx');
      final speakerPath = await loadAsset('assets/voxblink2_samresnet34_ft.onnx');

      vaani = await Vaani.create(
        encPath,
        decPath,
        tokenPath,
        4,
        vadPath: vadPath,
        speakerPath: speakerPath,
      );
      if (mounted) setState(() => _modelReady = true);
    } catch (e) {
      if (mounted) setState(() => transcript = "Failed to load models: $e");
    } finally {
      if (mounted) setState(() => _modelLoading = false);
    }
  }

  Future<void> transcribeFile() async {
    setState(() {
      _transcribing = true;
      transcript = "";
    });

    try {
      final audioPath = await loadAsset('assets/audio.wav');
      final result = await vaani!.transcribe(audioPath);
      setState(() => transcript = result.isEmpty ? "No speech detected." : result);
    } catch (e) {
      setState(() => transcript = "Error: $e");
    } finally {
      setState(() => _transcribing = false);
    }
  }

  Future<void> startMicStream() async {
    if (!await _audioRecorder.hasPermission()) {
      if (mounted) setState(() => transcript = "Microphone permission denied.");
      return;
    }

    setState(() {
      _streaming = true;
      _startingStream = true;
      transcript = "";
    });

    final directory = await getApplicationDocumentsDirectory();
    final String outPath = '${directory.path}/debug_recording.wav';

    // Drive the VaaniStream on a background isolate. The recorder must run on
    // the main isolate (platform plugin), but inference must NOT — decoding a
    // segment on the UI thread blocks it for seconds and ANRs the app.
    final session = await StreamingSession.start(
      pipelineAddress: vaani!.nativeAddress,
      outWavPath: outPath,
    );
    _session = session;

    session.transcripts.listen((line) {
      if (mounted) setState(() => transcript += line);
    });

    final stream = await _audioRecorder.startStream(const RecordConfig(
      encoder: AudioEncoder.pcm16bits,
      sampleRate: 16000,
      numChannels: 1,
      androidConfig: AndroidRecordConfig(
        audioSource: AndroidAudioSource.voiceRecognition,
      ),
      iosConfig: IosRecordConfig(
        allowHapticsAndSystemSoundsDuringRecording: false,
        categoryOptions: []
       )
    ));

    // Forward raw recorder bytes straight to the worker — the plugin's
    // pushChunkBytes handles the int16 decoding. Cheap, so the UI stays
    // responsive even while the worker decodes a segment.
    _recordSub = stream.listen((data) => _session?.pushBytes(data));

    // The mic is now actually capturing — only now is it safe to speak.
    if (mounted) setState(() => _startingStream = false);
  }

  Future<void> stopStream() async {
    if (await _audioRecorder.isRecording()) {
      await _audioRecorder.stop();
    }
    await _recordSub?.cancel();
    _recordSub = null;

    final session = _session;
    _session = null;
    // Flush the trailing segment; the worker delivers it on the transcripts
    // stream before this completes.
    await session?.finish();

    debugPrint(transcript);
    if (mounted) {
      setState(() {
        _streaming = false;
        _startingStream = false;
      });
    }
  }

  // Single source of truth for the status banner.
  ({String text, Color color, bool busy}) _status() {
    if (!_modelReady) {
      return _modelLoading
          ? (text: 'Loading models…', color: Colors.orange, busy: true)
          : (text: 'Models failed to load', color: Colors.red, busy: false);
    }
    if (_transcribing) {
      return (text: 'Transcribing file…', color: Colors.orange, busy: true);
    }
    if (_streaming && _startingStream) {
      return (text: 'Starting microphone…', color: Colors.orange, busy: true);
    }
    if (_streaming) {
      return (text: '● Listening — speak now', color: Colors.red, busy: false);
    }
    return (text: 'Ready', color: Colors.green, busy: false);
  }

  @override
  Widget build(BuildContext context) {
    final status = _status();
    // While streaming, the only allowed action is Stop (disabled until the mic
    // is live). Otherwise actions require the models to be ready and idle.
    final idle = _modelReady && !_transcribing && !_streaming;

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Vaani Diarization Stream')),
        body: SingleChildScrollView(
          child: Container(
            padding: const EdgeInsets.all(16),
            width: double.infinity,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    if (status.busy) ...[
                      const CupertinoActivityIndicator(),
                      const SizedBox(width: 8),
                    ],
                    Text(
                      status.text,
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w600,
                        color: status.color,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 16),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    ElevatedButton(
                      onPressed: idle ? transcribeFile : null,
                      child: const Text('Transcribe File'),
                    ),
                    const SizedBox(width: 10),
                    ElevatedButton(
                      onPressed: _streaming
                          ? (_startingStream ? null : stopStream)
                          : (idle ? startMicStream : null),
                      style: ElevatedButton.styleFrom(
                        backgroundColor: _streaming ? Colors.red : null,
                        foregroundColor: _streaming ? Colors.white : null,
                      ),
                      child: Text(_streaming ? 'Stop Stream' : 'Start Mic Stream'),
                    ),
                  ],
                ),
                const SizedBox(height: 20),
                Container(
                  alignment: Alignment.topLeft,
                  padding: const EdgeInsets.all(12),
                  width: double.infinity,
                  decoration: BoxDecoration(
                    color: Colors.grey.shade100,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Text(
                    transcript,
                    style: const TextStyle(fontSize: 16, fontFamily: 'monospace'),
                  ),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}
1
likes
0
points
20
downloads

Publisher

unverified uploader

Weekly Downloads

On-device speech recognition (Vaani ASR) for Flutter via dart:ffi — file and streaming transcription with Silero VAD and speaker diarization, powered by ONNX Runtime.

Repository (GitHub)
View/report issues

Topics

#speech-recognition #asr #onnx #ffi #audio

License

unknown (license)

Dependencies

ffi, flutter, plugin_platform_interface

More

Packages that depend on stat_ort_plugin

Packages that implement stat_ort_plugin