twin_stream 0.2.0 copy "twin_stream: ^0.2.0" to clipboard
twin_stream: ^0.2.0 copied to clipboard

Simultaneous speech-to-text and audio file recording in a single API. Solves the Android microphone lock issue that prevents using recording and speech recognition at the same time.

example/lib/main.dart

// ignore_for_file: avoid_print

import 'package:flutter/material.dart';
import 'package:twin_stream/twin_stream.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TwinStream Example',
      theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
      home: const RecorderPage(),
    );
  }
}

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

  @override
  State<RecorderPage> createState() => _RecorderPageState();
}

class _RecorderPageState extends State<RecorderPage> {
  final _controller = TwinStreamController();

  String _transcript = '';
  String _status = 'Not initialized';
  String? _lastFilePath;
  double? _soundLevel;
  Duration? _duration;
  bool _isReady = false;
  bool _isRecording = false;

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

  Future<void> _initController() async {
    try {
      final ready = await _controller.initialize(
        config: const TwinStreamConfig(
          sampleRate: 16000,
          partialResults: true,
          androidStrategy: AndroidRecordingStrategy.simultaneousWithFallback,
        ),
      );

      _controller.stateStream.listen((state) {
        if (!mounted) return;
        setState(() {
          _transcript = state.currentTranscript ?? state.finalTranscript ?? '';
          _soundLevel = state.soundLevel;
          _duration = state.recordingDuration;
          _isRecording = state.status == TwinStreamStatus.recording;

          if (state.error != null) {
            _status = 'Error: ${state.error!.message}';
          }
        });
      });

      setState(() {
        _isReady = ready;
        _status = ready ? 'Ready — tap the mic to start' : 'STT unavailable';
      });
    } on TwinStreamPermissionException {
      setState(() => _status = 'Permission denied');
    } on TwinStreamInitializationException catch (e) {
      setState(() => _status = 'Init failed: ${e.message}');
    }
  }

  Future<void> _toggleRecording() async {
    if (!_isReady) return;

    if (_isRecording) {
      setState(() => _status = 'Stopping...');
      final result = await _controller.stop();
      setState(() {
        _lastFilePath = result.audioFilePath;
        _transcript = result.transcript ?? 'No speech detected';
        _status =
            'Done — ${result.wasSimultaneous ? "simultaneous" : "sequential"} '
            'mode (confidence: ${result.confidence?.toStringAsFixed(2) ?? "N/A"})';
      });
    } else {
      setState(() {
        _transcript = '';
        _lastFilePath = null;
        _status = 'Recording...';
      });
      await _controller.start();
    }
  }

  String _formatDuration(Duration? d) {
    if (d == null) return '00:00';
    final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
    final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$minutes:$seconds';
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TwinStream Example')),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Status
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Text(
                  _status,
                  style: Theme.of(context).textTheme.bodyMedium,
                  textAlign: TextAlign.center,
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Duration & Level
            if (_isRecording) ...[
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.timer, size: 18),
                  const SizedBox(width: 8),
                  Text(
                    _formatDuration(_duration),
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                  const SizedBox(width: 24),
                  const Icon(Icons.graphic_eq, size: 18),
                  const SizedBox(width: 8),
                  Text(
                    '${_soundLevel?.toStringAsFixed(1) ?? "--"} dB',
                    style: Theme.of(context).textTheme.bodyLarge,
                  ),
                ],
              ),
              const SizedBox(height: 16),
            ],

            // Transcript
            Expanded(
              child: Card(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: SingleChildScrollView(
                    child: Text(
                      _transcript.isEmpty
                          ? 'Transcript will appear here...'
                          : _transcript,
                      style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                        color: _transcript.isEmpty
                            ? Theme.of(context).colorScheme.onSurfaceVariant
                            : null,
                      ),
                    ),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Last file path
            if (_lastFilePath != null)
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Audio saved to:',
                        style: Theme.of(context).textTheme.labelSmall,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        _lastFilePath!,
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                          fontFamily: 'monospace',
                        ),
                      ),
                    ],
                  ),
                ),
              ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.large(
        onPressed: _isReady ? _toggleRecording : null,
        backgroundColor: _isRecording
            ? Theme.of(context).colorScheme.error
            : null,
        child: Icon(_isRecording ? Icons.stop : Icons.mic, size: 36),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}
1
likes
130
points
11
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Simultaneous speech-to-text and audio file recording in a single API. Solves the Android microphone lock issue that prevents using recording and speech recognition at the same time.

Repository (GitHub)
View/report issues

Topics

#audio #speech-to-text #recording #microphone #speech-recognition

License

MIT (license)

Dependencies

flutter, path, path_provider, record, speech_to_text, vosk_flutter

More

Packages that depend on twin_stream