Skip to main content

transcribe_cpp 0.1.1 copy "transcribe_cpp: ^0.1.1" to clipboard
transcribe_cpp: ^0.1.1 copied to clipboard

Offline, on-device speech-to-text for Flutter. Hands-free dictation with live text on iOS and Android: no network, no API keys, audio never leaves the phone.

example/lib/main.dart

// Live dictation harness for the transcribe_cpp plugin.
//
// Deliberately one thing: start the microphone, run VAD-gated decoding with
// live provisional text, and print a log that says exactly which configuration
// produced what. The benchmark screens this app used to carry answered
// questions that are now settled — batch beats streaming on this hardware, and
// parakeet beats moonshine on speed — so they were removed rather than left to
// rot alongside the path actually being developed.
//
// The console is the output that matters. See session_log.dart.
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:transcribe_cpp/transcribe_cpp.dart';

import 'background.dart';
import 'session_log.dart';

void main() {
  // Before anything loads a model, per the engine's contract. Without it every
  // launch opens with a page of ggml backend registration and Metal buffer
  // allocations, which is the noise that makes a pasted log unreadable.
  NativeLog.quiet();
  runApp(const DictationApp());
}

class DictationApp extends StatelessWidget {
  const DictationApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'transcribe.cpp dictation',
    theme: ThemeData(colorSchemeSeed: Colors.indigo),
    darkTheme: ThemeData(
      colorSchemeSeed: Colors.indigo,
      brightness: Brightness.dark,
    ),
    home: const DictationPage(),
  );
}

/// Tuning knobs surfaced in the UI.
///
/// Only the ones a session can actually settle. Everything else stays at its
/// documented default, because a control that is never the reason a run went
/// badly is a control that makes runs harder to compare.
enum _Preset {
  // Spelled out rather than leaning on EndpointerConfig's default, so that a
  // change to the default cannot silently make two presets identical.
  eager('eager', EndpointerConfig(hangover: Duration(milliseconds: 250))),
  balanced('balanced', EndpointerConfig(hangover: Duration(milliseconds: 400))),
  patient('patient', EndpointerConfig(hangover: Duration(milliseconds: 700)));

  const _Preset(this.label, this.endpointer);
  final String label;
  final EndpointerConfig endpointer;
}

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

  @override
  State<DictationPage> createState() => _DictationPageState();
}

class _DictationPageState extends State<DictationPage> {
  // Ids from the bundled catalogue. Downloaded on first use, then kept.
  static const _models = <({String label, ModelId id, String note})>[
    (
      label: 'parakeet-tdt_ctc-110m',
      id: ModelId('parakeet-tdt_ctc-110m-Q4_K_M'),
      note: '90 MB Q4_K_M · 2.53% WER',
    ),
    (
      label: 'moonshine-streaming-tiny',
      id: ModelId('moonshine-streaming-tiny-Q8_0'),
      note: '50 MB Q8_0 · 4.52% WER',
    ),
  ];

  static const _provisionalInterval = Duration(milliseconds: 350);
  static const _stableAgreement = 2;

  int _selected = 0;
  _Preset _preset = _Preset.balanced;
  bool _normalize = true;

  ModelManager? _manager;
  TranscribeSession? _session;
  final _sessionSubscriptions = <StreamSubscription<Object?>>[];

  /// The log of the run in progress, live or replayed. The session's streams
  /// last as long as it does, so every run writes to whichever log is current.
  SessionLog? _log;
  String? _recording;
  String _compareStatus = '';

  String _transcript = '';
  String _provisional = '';
  CaptureStatus _capture = const CaptureStatus(CaptureState.idle);
  LiveLevel _level = LiveLevel.silent;
  bool _speaking = false;
  bool _listening = false;
  Duration _lost = Duration.zero;
  String _status = 'Idle';
  String? _error;
  bool _busy = false;

  @override
  void initState() {
    super.initState();
    // A recording outlives the process, so a comparison can be run against a
    // session captured before the last restart — which is usually exactly the
    // session worth looking at again.
    _findRecording();
    ModelManager.open().then((m) {
      if (mounted) setState(() => _manager = m);
    });
  }

  Future<void> _findRecording() async {
    final dir = await getApplicationSupportDirectory();
    final file = File('${dir.path}/session.wav');
    if (file.existsSync() && file.lengthSync() > 1024 && mounted) {
      setState(() => _recording = file.path);
    }
  }

  @override
  void dispose() {
    _closeSession();
    super.dispose();
  }

  Future<void> _closeSession() async {
    final session = _session;
    _session = null;
    for (final s in _sessionSubscriptions) {
      await s.cancel();
    }
    _sessionSubscriptions.clear();
    await session?.dispose();
  }

  Future<void> _start() async {
    setState(() {
      _busy = true;
      _error = null;
      _lost = Duration.zero;
    });
    try {
      final session = await _ensureLoaded();
      final normalizer = _normalize ? const NormalizerConfig() : null;
      final log = SessionLog(
        model: _models[_selected].label,
        info: session.modelInfo,
        endpointer: _preset.endpointer,
        normalizer: normalizer,
        provisionalInterval: _provisionalInterval,
        stableAgreement: _stableAgreement,
      );

      // Every session is recorded, so any run can be turned into a controlled
      // comparison afterwards. Cheap — float32 at 16 kHz is 3.7 MB a minute —
      // and the alternative is discovering after the fact that the interesting
      // session cannot be repeated.
      final dir = await getApplicationSupportDirectory();
      final recording = '${dir.path}/session.wav';

      setState(() => _status = 'Starting');
      _log = log;
      await session.start(
        endpointer: _preset.endpointer,
        normalizer: normalizer,
        provisionalInterval: _provisionalInterval,
        stableAgreement: _stableAgreement,
        recordTo: recording,
      );
      await BackgroundListening.begin();
      log.start();
      setState(() {
        _listening = true;
        _recording = recording;
        _status = 'Listening';
      });
    } catch (e) {
      _log = null;
      setState(() {
        _status = 'Idle';
        _error = '$e';
      });
    } finally {
      setState(() => _busy = false);
    }
  }

  Future<void> _stop() async {
    final session = _session;
    if (session == null || !_listening) return;
    setState(() {
      _busy = true;
      _status = 'Finishing';
    });
    await session.stop();
    await BackgroundListening.end();
    // After stop, so the flushed final utterance is in the tally.
    _log?.stop();
    _log = null;
    setState(() {
      _listening = false;
      _speaking = false;
      _status = 'Stopped';
      _busy = false;
    });
  }

  /// Replays the last recording under each setting worth comparing.
  ///
  /// The reason this exists: a microphone hands you different audio every time,
  /// so two settings tried across two live sessions were measured on two
  /// different passages and the difference between them says nothing. Same
  /// bytes, every run, is the only way the comparison means anything.
  Future<void> _compare() async {
    final path = _recording;
    if (path == null) return;

    setState(() {
      _busy = true;
      _error = null;
      _compareStatus = 'Loading recording';
    });
    try {
      final audio = Wav.read(path);
      final session = await _ensureLoaded();

      final matrix =
          <({String label, EndpointerConfig ep, NormalizerConfig? n})>[
            for (final p in _Preset.values)
              (label: p.label, ep: p.endpointer, n: const NormalizerConfig()),
            // Gain off, measured against the default preset.
            (
              label: 'balanced, no gain',
              ep: _Preset.balanced.endpointer,
              n: null,
            ),
          ];

      for (var i = 0; i < matrix.length; i++) {
        final run = matrix[i];
        setState(
          () => _compareStatus = '${i + 1}/${matrix.length}  ${run.label}',
        );
        final log = SessionLog(
          model: _models[_selected].label,
          info: session.modelInfo,
          endpointer: run.ep,
          normalizer: run.n,
          provisionalInterval: Duration.zero,
          stableAgreement: _stableAgreement,
          label: run.label,
          replay: true,
          audioDuration: Duration(
            microseconds:
                audio.length * 1000000 ~/ TranscribeSession.sampleRate,
          ),
        );
        _log = log;
        log.start();
        await session.replay(
          audio,
          endpointer: run.ep,
          normalizer: run.n,
          stableAgreement: _stableAgreement,
        );
        log.stop();
        _log = null;
      }
      setState(() => _compareStatus = 'Done — see the console');
    } catch (e) {
      _log = null;
      setState(() {
        _error = '$e';
        _compareStatus = '';
      });
    } finally {
      setState(() => _busy = false);
    }
  }

  /// Downloads the selected model if needed, and opens a session on it once.
  Future<TranscribeSession> _ensureLoaded() async {
    if (_session case final session?) return session;
    final manager = _manager ??= await ModelManager.open();
    final id = _models[_selected].id;

    setState(() => _status = 'Preparing model');
    final installing = manager.ensure(id);
    final progress = manager.progressOf(id)?.stream.listen((p) {
      setState(
        () => _status = 'Downloading ${(p.fraction * 100).toStringAsFixed(0)}%',
      );
    });
    final InstalledModel installed;
    try {
      installed = await installing;
    } finally {
      await progress?.cancel();
    }

    setState(() => _status = 'Loading model');
    final session = await TranscribeSession.open(installed);
    _session = session;
    _sessionSubscriptions.addAll([
      session.utterances.listen(
        (r) => r.isFinal ? _log?.utterance(r) : _log?.provisional(r),
        onError: (Object e) {
          _log?.error(e);
          setState(() => _error = '$e');
        },
      ),
      session.text.stream.listen(
        (t) => setState(() {
          _transcript = t.settled;
          _provisional = t.interim;
        }),
      ),
      session.events.listen((e) {
        if (e is SpeechStarted) setState(() => _speaking = true);
        if (e is SpeechEnded) setState(() => _speaking = false);
        if (e is SpeechDiscarded) _log?.discarded(e);
      }),
      session.capture.stream.listen((s) {
        if (_listening) _log?.captureState(s);
        setState(() => _capture = s);
      }),
      session.level.stream.listen((l) {
        _log?.level(l);
        setState(() => _level = l);
      }),
      session.dropouts.listen((d) {
        _log?.dropout(d);
        setState(() => _lost += d.duration);
      }),
    ]);
    setState(() => _status = 'Idle');
    return session;
  }

  Future<void> _select(int index) async {
    if (index == _selected) return;
    setState(() {
      _selected = index;
      _status = 'Idle';
    });
    await _closeSession();
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(title: const Text('transcribe.cpp')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            SegmentedButton<int>(
              segments: [
                for (var i = 0; i < _models.length; i++)
                  ButtonSegment(
                    value: i,
                    label: Text(
                      _models[i].label.split('-').first,
                      style: theme.textTheme.labelMedium,
                    ),
                  ),
              ],
              selected: {_selected},
              onSelectionChanged: (_busy || _listening)
                  ? null
                  : (s) => _select(s.first),
            ),
            const SizedBox(height: 6),
            Text(
              _manager?.installed.value[_models[_selected].id] ==
                      ModelStatus.installed
                  ? _models[_selected].note
                  : '${_models[_selected].note} · downloads on first use',
              textAlign: TextAlign.center,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.outline,
              ),
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: SegmentedButton<_Preset>(
                    segments: [
                      for (final p in _Preset.values)
                        ButtonSegment(
                          value: p,
                          label: Text(
                            p.label,
                            style: theme.textTheme.labelSmall,
                          ),
                        ),
                    ],
                    selected: {_preset},
                    showSelectedIcon: false,
                    onSelectionChanged: (_busy || _listening)
                        ? null
                        : (s) => setState(() => _preset = s.first),
                  ),
                ),
                const SizedBox(width: 10),
                FilterChip(
                  label: const Text('gain'),
                  selected: _normalize,
                  onSelected: (_busy || _listening)
                      ? null
                      : (v) => setState(() => _normalize = v),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Text(
              'hangover ${_preset.endpointer.hangover.inMilliseconds}ms · '
              'provisional ${_provisionalInterval.inMilliseconds}ms · '
              'agreement $_stableAgreement',
              textAlign: TextAlign.center,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.outline,
              ),
            ),
            const SizedBox(height: 14),
            FilledButton.icon(
              onPressed: _busy ? null : (_listening ? _stop : _start),
              style: FilledButton.styleFrom(
                backgroundColor: _listening
                    ? theme.colorScheme.error
                    : theme.colorScheme.primary,
                foregroundColor: _listening
                    ? theme.colorScheme.onError
                    : theme.colorScheme.onPrimary,
                minimumSize: const Size.fromHeight(52),
              ),
              icon: _busy
                  ? const SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : Icon(_listening ? Icons.stop : Icons.mic),
              label: Text(
                _busy ? _status : (_listening ? 'Stop' : 'Start listening'),
              ),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: (_busy || _listening || _recording == null)
                  ? null
                  : _compare,
              icon: const Icon(Icons.compare_arrows),
              label: Text(
                _recording == null
                    ? 'Compare settings (record a session first)'
                    : _compareStatus.isEmpty
                    ? 'Compare settings on the last recording'
                    : _compareStatus,
              ),
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: _LevelMeter(
                    level: _level.probability,
                    speaking: _speaking,
                  ),
                ),
                const SizedBox(width: 8),
                _Chip(
                  label: '${_level.gain.toStringAsFixed(1)}x',
                  tone: _level.gain > 1.2
                      ? theme.colorScheme.secondaryContainer
                      : theme.colorScheme.surfaceContainerHighest,
                ),
                if (_listening) ...[
                  const SizedBox(width: 8),
                  _Chip(
                    label: _capture.state.name,
                    tone: switch (_capture.state) {
                      CaptureState.running =>
                        theme.colorScheme.primaryContainer,
                      CaptureState.failed => theme.colorScheme.errorContainer,
                      _ => theme.colorScheme.tertiaryContainer,
                    },
                  ),
                ],
                if (_lost > Duration.zero) ...[
                  const SizedBox(width: 8),
                  _Chip(
                    label: 'lost ${_lost.inMilliseconds}ms',
                    tone: theme.colorScheme.errorContainer,
                  ),
                ],
              ],
            ),
            if (_session?.modelInfo case final info?) ...[
              const SizedBox(height: 8),
              // CPU is only a fallback on iOS, where Metal is expected. The
              // Android engine has no GPU backend, so CPU is the plan there.
              Text(
                '${info.isGpu ? 'GPU' : (Platform.isIOS ? 'CPU FALLBACK' : 'CPU')}'
                ' · ${info.backend} · ${info.variant} · ${info.architecture}',
                style: theme.textTheme.bodySmall?.copyWith(
                  color: info.isGpu || !Platform.isIOS
                      ? theme.colorScheme.outline
                      : theme.colorScheme.error,
                ),
              ),
            ],
            if (_error case final message?) ...[
              const SizedBox(height: 8),
              Text(
                message,
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.error,
                ),
              ),
            ],
            const SizedBox(height: 16),
            if (_listening) ...[
              _LiveBox(text: _provisional),
              const SizedBox(height: 12),
            ],
            Expanded(
              child: SingleChildScrollView(
                reverse: _listening,
                child: SelectableText(
                  _transcript,
                  style: theme.textTheme.bodyLarge,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Where speech in progress shows up, above the settled transcript.
///
/// Separate from the transcript on purpose. The text in here is a decode of an
/// utterance that has not finished, so it grows as the sentence does and is
/// then replaced wholesale by the final decode. Interleaving that with settled
/// text would make the bottom of the transcript twitch; keeping it in its own
/// box means the transcript below only ever gains a finished sentence.
///
/// It shows the agreed prefix rather than the newest decode, so what appears
/// here extends and does not rewrite itself.
class _LiveBox extends StatelessWidget {
  const _LiveBox({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final empty = text.isEmpty;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 150),
      width: double.infinity,
      constraints: const BoxConstraints(minHeight: 76),
      padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
      decoration: BoxDecoration(
        color: empty
            ? theme.colorScheme.surfaceContainerLow
            : theme.colorScheme.secondaryContainer,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: empty
              ? theme.colorScheme.outlineVariant
              : theme.colorScheme.secondary,
        ),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            empty ? 'listening' : 'live',
            style: theme.textTheme.labelSmall?.copyWith(
              color: theme.colorScheme.outline,
              letterSpacing: 1.2,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            empty ? '' : text,
            style: theme.textTheme.bodyLarge?.copyWith(
              color: theme.colorScheme.onSecondaryContainer,
            ),
          ),
        ],
      ),
    );
  }
}

/// A small status pill. The capture state is the thing to watch during a live
/// session — it is where interruptions and route changes show up.
class _Chip extends StatelessWidget {
  const _Chip({required this.label, required this.tone});

  final String label;
  final Color tone;

  @override
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
    decoration: BoxDecoration(
      color: tone,
      borderRadius: BorderRadius.circular(20),
    ),
    child: Text(label, style: Theme.of(context).textTheme.labelSmall),
  );
}

/// The VAD's raw probability, updated every ~60 ms.
///
/// Deliberately not the endpointer's `isSpeaking`: that reports *confirmed*
/// speech, so it turns on a quarter second late and off at the endpoint, which
/// makes it read "silence" while someone is plainly talking. The bar follows
/// the model directly; the dot shows what the endpointer has committed to.
class _LevelMeter extends StatelessWidget {
  const _LevelMeter({required this.level, required this.speaking});

  final double level;
  final bool speaking;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Row(
      children: [
        Container(
          width: 8,
          height: 8,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: speaking
                ? theme.colorScheme.primary
                : theme.colorScheme.outlineVariant,
          ),
        ),
        const SizedBox(width: 8),
        Expanded(
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: LinearProgressIndicator(
              value: level.clamp(0.0, 1.0),
              minHeight: 8,
              backgroundColor: theme.colorScheme.surfaceContainerHighest,
              valueColor: AlwaysStoppedAnimation(
                level >= 0.5
                    ? theme.colorScheme.primary
                    : theme.colorScheme.outline,
              ),
            ),
          ),
        ),
      ],
    );
  }
}
0
likes
160
points
133
downloads
screenshot

Documentation

Documentation

Publisher

verified publisherishangavidusha.com

Weekly Downloads

Offline, on-device speech-to-text for Flutter. Hands-free dictation with live text on iOS and Android: no network, no API keys, audio never leaves the phone.

Repository (GitHub)
View/report issues
Contributing

Topics

#speech-to-text #speech-recognition #transcription #offline #ffi

License

MIT (license)

Dependencies

crypto, ffi, flutter

More

Packages that depend on transcribe_cpp

Packages that implement transcribe_cpp