twin_stream 0.2.0
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,
);
}
}