Skip to main content

transcribe_cpp 0.1.3 copy "transcribe_cpp: ^0.1.3" to clipboard
transcribe_cpp: ^0.1.3 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

// An example app for transcribe_cpp: choose a model, download it, and
// dictate. Everything runs on the phone.
import 'dart:async';

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

import 'background.dart';
import 'models.dart';
import 'models_page.dart';
import 'screen.dart';

void main() {
  // Before anything loads a model: the engine otherwise logs every backend
  // it registers.
  NativeLog.quiet();
  runApp(const ExampleApp());
}

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

  static ThemeData _theme(Brightness brightness) => ThemeData(
    colorSchemeSeed: const Color(0xFF4F46E5),
    brightness: brightness,
  );

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'transcribe_cpp',
    debugShowCheckedModeBanner: false,
    theme: _theme(Brightness.light),
    darkTheme: _theme(Brightness.dark),
    home: const DictationPage(),
  );
}

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

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

class _DictationPageState extends State<DictationPage> {
  ModelManager? _models;
  ModelId? _choice;
  StreamSubscription<Object?>? _downloads;

  TranscribeSession? _session;
  ModelId? _sessionModel;
  final _subscriptions = <StreamSubscription<Object?>>[];

  /// Finished sentences, one paragraph per pause.
  final _paragraphs = <String>[];

  /// Whether the last utterance was cut off mid-speech, so the next one
  /// carries on the same paragraph.
  var _continues = false;

  final _scroll = ScrollController();
  var _loading = false;
  String? _error;

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

  Future<void> _init() async {
    final models = await ModelManager.open();
    final saved = await ModelChoice.load();
    if (!mounted) return;
    _downloads = models.installed.stream.listen(
      (all) =>
          Screen.keepOn('download', all.containsValue(ModelStatus.downloading)),
    );
    setState(() {
      _models = models;
      _choice = saved != null && models.catalog[saved] != null
          ? saved
          : recommended.first.id;
    });
  }

  @override
  void dispose() {
    _downloads?.cancel();
    _closeSession();
    _scroll.dispose();
    super.dispose();
  }

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

  bool get _listening => switch (_session?.state.value) {
    SessionState.starting ||
    SessionState.listening ||
    SessionState.finishing => true,
    _ => false,
  };

  Future<TranscribeSession> _open(ModelId id) async {
    if (_session case final session? when _sessionModel == id) return session;
    await _closeSession();
    final installed = await _models!.ensure(id);
    final session = await TranscribeSession.open(installed);
    _session = session;
    _sessionModel = id;
    _subscriptions.addAll([
      // The app bar and model card change with it.
      session.state.stream.listen((_) {
        setState(() {});
        Screen.keepOn('listening', _listening);
      }),
      session.utterances.listen((r) {
        if (r.isFinal) _addSentence(r);
      }, onError: (Object e) => setState(() => _error = 'Decoding failed: $e')),
      session.capture.stream.listen((status) {
        if (status.state == CaptureState.failed) {
          setState(() => _error = 'Microphone stopped: ${status.message}');
        }
      }),
    ]);
    return session;
  }

  void _addSentence(UtteranceResult result) {
    final text = result.text.trim();
    if (text.isEmpty) return;
    setState(() {
      if (_continues && _paragraphs.isNotEmpty) {
        _paragraphs.last = '${_paragraphs.last} $text';
      } else {
        _paragraphs.add(text);
      }
      _continues = result.utterance.truncated;
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scroll.hasClients) {
        _scroll.animateTo(
          _scroll.position.maxScrollExtent,
          duration: const Duration(milliseconds: 250),
          curve: Curves.easeOut,
        );
      }
    });
  }

  Future<void> _start() async {
    final id = _choice;
    if (id == null) return;
    setState(() {
      _loading = true;
      _error = null;
    });
    try {
      final session = await _open(id);
      setState(() => _loading = false);
      await session.start();
      await BackgroundListening.begin();
    } on StateError catch (e) {
      setState(
        () => _error = '${e.message}\nAllow the microphone in Settings.',
      );
    } catch (e) {
      setState(() => _error = '$e');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _stop() async {
    await _session?.stop();
    await BackgroundListening.end();
    _continues = false;
  }

  Future<void> _download() async {
    final id = _choice;
    if (id == null) return;
    setState(() => _error = null);
    try {
      await _models!.ensure(id);
    } on DownloadCancelledException {
      // Cancelled from the models page.
    } catch (e) {
      setState(() => _error = 'Download failed: $e');
    }
  }

  Future<void> _chooseModel() async {
    final models = _models;
    if (models == null) return;
    final chosen = await Navigator.of(context).push<ModelId>(
      MaterialPageRoute(
        builder: (_) => ModelsPage(models: models, selected: _choice),
      ),
    );
    if (chosen == null || chosen == _choice) return;
    await _closeSession();
    await ModelChoice.save(chosen);
    setState(() {
      _choice = chosen;
      _error = null;
    });
  }

  String get _text => _paragraphs.join('\n\n');

  Future<void> _copy() async {
    await Clipboard.setData(ClipboardData(text: _text));
    if (!mounted) return;
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Transcript copied')));
  }

  @override
  Widget build(BuildContext context) {
    final models = _models;
    final choice = _choice;
    if (models == null || choice == null) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }
    final descriptor = models.catalog[choice]!;
    final session = _session;

    return Scaffold(
      appBar: AppBar(
        title: const Text('transcribe_cpp'),
        actions: [
          IconButton(
            tooltip: 'Copy',
            onPressed: _paragraphs.isEmpty ? null : _copy,
            icon: const Icon(Icons.copy_all_outlined),
          ),
          IconButton(
            tooltip: 'Clear',
            onPressed: _paragraphs.isEmpty || _listening
                ? null
                : () => setState(_paragraphs.clear),
            icon: const Icon(Icons.delete_sweep_outlined),
          ),
        ],
      ),
      body: ValueListenableBuilder(
        valueListenable: models.installed,
        builder: (context, installed, _) {
          final status = installed[choice] ?? ModelStatus.notInstalled;
          return SafeArea(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Padding(
                  padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
                  child: _ModelCard(
                    descriptor: descriptor,
                    status: status,
                    progress: models.progressOf(choice),
                    info: _sessionModel == choice ? session?.modelInfo : null,
                    onTap: _listening ? null : _chooseModel,
                  ),
                ),
                if (session != null)
                  Padding(
                    padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
                    child: _LiveBox(session: session),
                  ),
                if (_error case final error?)
                  Padding(
                    padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
                    child: _ErrorBanner(
                      message: error,
                      onClose: () => setState(() => _error = null),
                    ),
                  ),
                Expanded(
                  child: _paragraphs.isEmpty
                      ? _EmptyHint(installed: status == ModelStatus.installed)
                      : SelectionArea(
                          child: ListView.separated(
                            controller: _scroll,
                            padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
                            itemCount: _paragraphs.length,
                            separatorBuilder: (_, _) =>
                                const SizedBox(height: 14),
                            itemBuilder: (context, i) => Text(
                              _paragraphs[i],
                              style: Theme.of(
                                context,
                              ).textTheme.bodyLarge?.copyWith(height: 1.45),
                            ),
                          ),
                        ),
                ),
                Padding(
                  padding: const EdgeInsets.only(bottom: 16, top: 8),
                  child: Center(
                    child: status == ModelStatus.installed
                        ? _MicButton(
                            session: session,
                            loading: _loading,
                            onStart: _start,
                            onStop: _stop,
                          )
                        : FilledButton.icon(
                            onPressed: status == ModelStatus.downloading
                                ? null
                                : _download,
                            icon: const Icon(Icons.download),
                            label: Text(switch (status) {
                              ModelStatus.downloading => 'Downloading…',
                              ModelStatus.partial => 'Resume download',
                              _ =>
                                'Download ${formatBytes(descriptor.sizeBytes)}',
                            }),
                          ),
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

/// Which model is in use, and the way to the models page.
class _ModelCard extends StatelessWidget {
  const _ModelCard({
    required this.descriptor,
    required this.status,
    required this.progress,
    required this.info,
    required this.onTap,
  });

  final ModelDescriptor descriptor;
  final ModelStatus status;
  final Live<DownloadProgress>? progress;
  final ModelInfo? info;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final detail = switch (status) {
      ModelStatus.installed => [
        formatBytes(descriptor.sizeBytes),
        if (info != null) info!.isGpu ? 'GPU' : 'CPU',
      ].join(' · '),
      ModelStatus.downloading => 'Downloading',
      ModelStatus.partial => 'Download paused',
      ModelStatus.notInstalled => 'Not downloaded yet',
    };
    return Card.filled(
      margin: EdgeInsets.zero,
      child: InkWell(
        borderRadius: BorderRadius.circular(12),
        onTap: onTap,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Icon(Icons.graphic_eq, color: theme.colorScheme.primary),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          '${descriptor.displayName} · ${descriptor.quant}',
                          style: theme.textTheme.titleSmall,
                        ),
                        Text(detail, style: theme.textTheme.bodySmall),
                      ],
                    ),
                  ),
                  Text(
                    'Change',
                    style: theme.textTheme.labelLarge?.copyWith(
                      color: onTap == null
                          ? theme.disabledColor
                          : theme.colorScheme.primary,
                    ),
                  ),
                ],
              ),
              if (status == ModelStatus.downloading && progress != null)
                Padding(
                  padding: const EdgeInsets.only(top: 10),
                  child: ValueListenableBuilder(
                    valueListenable: progress!,
                    builder: (context, p, _) => LinearProgressIndicator(
                      value: p.fraction,
                      borderRadius: BorderRadius.circular(4),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

/// The sentence being spoken, above the finished ones. Its words settle as
/// they are heard, and it empties when the sentence is done and moves below.
class _LiveBox extends StatelessWidget {
  const _LiveBox({required this.session});

  final TranscribeSession session;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return ValueListenableBuilder(
      valueListenable: session.state,
      builder: (context, state, _) {
        if (state != SessionState.listening &&
            state != SessionState.finishing) {
          return const SizedBox.shrink();
        }
        return ValueListenableBuilder(
          valueListenable: session.capture,
          builder: (context, capture, _) => ValueListenableBuilder(
            valueListenable: session.text,
            builder: (context, text, _) {
              final paused = capture.state != CaptureState.running;
              final label = switch (capture.state) {
                CaptureState.interrupted =>
                  'Paused${capture.message == null ? '' : ': ${capture.message}'}',
                CaptureState.recovering => 'Reconnecting the microphone',
                CaptureState.starting => 'Starting',
                _ => text.interim.isEmpty ? 'Listening' : 'Live',
              };
              return AnimatedContainer(
                duration: const Duration(milliseconds: 200),
                constraints: const BoxConstraints(minHeight: 72),
                padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
                decoration: BoxDecoration(
                  color: paused
                      ? theme.colorScheme.tertiaryContainer
                      : theme.colorScheme.secondaryContainer,
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        _Dot(active: !paused && text.interim.isNotEmpty),
                        const SizedBox(width: 8),
                        Expanded(
                          child: Text(
                            label,
                            style: theme.textTheme.labelMedium,
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 6),
                    Text(
                      text.interim,
                      maxLines: 3,
                      overflow: TextOverflow.fade,
                      style: theme.textTheme.bodyLarge?.copyWith(
                        color: theme.colorScheme.onSecondaryContainer,
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        );
      },
    );
  }
}

class _Dot extends StatelessWidget {
  const _Dot({required this.active});

  final bool active;

  @override
  Widget build(BuildContext context) => AnimatedContainer(
    duration: const Duration(milliseconds: 200),
    width: 8,
    height: 8,
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      color: active
          ? Theme.of(context).colorScheme.primary
          : Theme.of(context).colorScheme.outline,
    ),
  );
}

/// Start and stop, with a halo that follows the voice.
class _MicButton extends StatelessWidget {
  const _MicButton({
    required this.session,
    required this.loading,
    required this.onStart,
    required this.onStop,
  });

  final TranscribeSession? session;
  final bool loading;
  final VoidCallback onStart;
  final VoidCallback onStop;

  @override
  Widget build(BuildContext context) {
    final session = this.session;
    if (session == null || loading) {
      return _button(context, listening: false, busy: loading, level: 0);
    }
    return ValueListenableBuilder(
      valueListenable: session.state,
      builder: (context, state, _) => ValueListenableBuilder(
        valueListenable: session.level,
        builder: (context, level, _) => _button(
          context,
          listening: state == SessionState.listening,
          busy:
              state == SessionState.starting || state == SessionState.finishing,
          level: state == SessionState.listening ? level.probability : 0,
        ),
      ),
    );
  }

  Widget _button(
    BuildContext context, {
    required bool listening,
    required bool busy,
    required double level,
  }) {
    final scheme = Theme.of(context).colorScheme;
    final color = listening ? scheme.error : scheme.primary;
    return SizedBox(
      width: 112,
      height: 112,
      child: Stack(
        alignment: Alignment.center,
        children: [
          AnimatedContainer(
            duration: const Duration(milliseconds: 120),
            width: 76 + 36 * level.clamp(0.0, 1.0),
            height: 76 + 36 * level.clamp(0.0, 1.0),
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: color.withValues(alpha: 0.18),
            ),
          ),
          SizedBox(
            width: 72,
            height: 72,
            child: FilledButton(
              onPressed: busy ? null : (listening ? onStop : onStart),
              style: FilledButton.styleFrom(
                shape: const CircleBorder(),
                padding: EdgeInsets.zero,
                backgroundColor: color,
                foregroundColor: listening ? scheme.onError : scheme.onPrimary,
              ),
              child: busy
                  ? const SizedBox(
                      width: 24,
                      height: 24,
                      child: CircularProgressIndicator(strokeWidth: 2.5),
                    )
                  : Icon(listening ? Icons.stop_rounded : Icons.mic, size: 32),
            ),
          ),
        ],
      ),
    );
  }
}

class _EmptyHint extends StatelessWidget {
  const _EmptyHint({required this.installed});

  final bool installed;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Text(
          installed
              ? 'Tap the microphone and start talking.\n'
                    'Everything stays on this phone.'
              : 'Download the model to get started.\n'
                    'After that, no network is needed.',
          textAlign: TextAlign.center,
          style: theme.textTheme.bodyLarge?.copyWith(
            color: theme.colorScheme.outline,
          ),
        ),
      ),
    );
  }
}

class _ErrorBanner extends StatelessWidget {
  const _ErrorBanner({required this.message, required this.onClose});

  final String message;
  final VoidCallback onClose;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 8, 4, 8),
      decoration: BoxDecoration(
        color: scheme.errorContainer,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        children: [
          Expanded(
            child: Text(
              message,
              style: TextStyle(color: scheme.onErrorContainer),
            ),
          ),
          IconButton(
            onPressed: onClose,
            icon: Icon(Icons.close, color: scheme.onErrorContainer),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
133
downloads
screenshot

Documentation

Documentation
API reference

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