groq_whisper_stt 0.1.2
groq_whisper_stt: ^0.1.2 copied to clipboard
Real-time speech-to-text for Flutter using Groq's Whisper API. Supports Android and iOS with voice activity detection and streaming transcription results.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:groq_whisper_stt/groq_whisper_stt.dart';
void main() => runApp(const SttDemoApp());
// -- Design Tokens --
class _Colors {
static const primary = Color(0xFF2563EB);
static const recording = Color(0xFFEF4444);
static const processing = Color(0xFFF97316);
static const surface = Color(0xFFF8FAFC);
static const surfaceElevated = Color(0xFFFFFFFF);
static const textPrimary = Color(0xFF1E293B);
static const textSecondary = Color(0xFF64748B);
static const textPlaceholder = Color(0xFF94A3B8);
static const border = Color(0xFFE2E8F0);
static const errorBg = Color(0xFFFEF2F2);
static const errorBorder = Color(0xFFFECACA);
static const errorText = Color(0xFF991B1B);
}
class SttDemoApp extends StatelessWidget {
const SttDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Groq Whisper STT',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: _Colors.primary,
useMaterial3: true,
scaffoldBackgroundColor: _Colors.surface,
),
home: const SttDemoScreen(),
);
}
}
class SttDemoScreen extends StatefulWidget {
const SttDemoScreen({super.key});
@override
State<SttDemoScreen> createState() => _SttDemoScreenState();
}
class _SttDemoScreenState extends State<SttDemoScreen>
with TickerProviderStateMixin {
late final GroqWhisperStt _stt;
String _transcript = '';
SttState _state = SttState.idle;
bool _isInitialized = false;
String? _error;
late final AnimationController _pulseController;
late final AnimationController _waveController;
late final Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_waveController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_stt = GroqWhisperStt(
apiKey: const String.fromEnvironment('GROQ_API_KEY'),
config: const SttConfig(
model: WhisperModel.largev3Turbo,
language: 'en',
chunkDuration: Duration(seconds: 3),
),
);
_init();
}
Future<void> _init() async {
await _stt.initialize();
setState(() => _isInitialized = true);
_stt.transcriptionStream.listen((result) {
setState(() {
_transcript = result.sessionText;
_error = null;
});
});
_stt.stateStream.listen((state) {
setState(() => _state = state);
_updateAnimations(state);
});
_stt.errorStream.listen((error) {
setState(() => _error = error.message);
});
}
void _updateAnimations(SttState state) {
if (state == SttState.recording) {
_pulseController.repeat(reverse: true);
_waveController.repeat();
} else if (state == SttState.processing) {
_pulseController.stop();
_waveController.repeat();
} else {
_pulseController.stop();
_pulseController.reset();
_waveController.stop();
_waveController.reset();
}
}
@override
void dispose() {
_pulseController.dispose();
_waveController.dispose();
_stt.dispose();
super.dispose();
}
bool get _isActive =>
_state == SttState.listening ||
_state == SttState.recording ||
_state == SttState.processing;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Scaffold(
body: SafeArea(
child: Column(
children: [
_buildHeader(),
if (_error != null) _buildErrorBanner(),
Expanded(child: _buildTranscriptArea()),
_buildBottomControls(),
],
),
),
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
child: Row(
children: [
const Icon(Icons.graphic_eq_rounded,
color: _Colors.primary, size: 28),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Groq Whisper',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _Colors.textPrimary,
letterSpacing: -0.5,
),
),
),
_buildStateBadge(),
],
),
);
}
Widget _buildStateBadge() {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _stateColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _stateColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_state == SttState.recording || _state == SttState.processing)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: _stateColor,
shape: BoxShape.circle,
),
),
Text(
_stateLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _stateColor,
letterSpacing: 0.3,
),
),
],
),
);
}
Widget _buildErrorBanner() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _Colors.errorBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _Colors.errorBorder),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: _Colors.errorText, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: const TextStyle(
color: _Colors.errorText, fontSize: 14, height: 1.4),
),
),
GestureDetector(
onTap: () => setState(() => _error = null),
child: const Icon(Icons.close_rounded,
color: _Colors.errorText, size: 18),
),
],
),
),
);
}
Widget _buildTranscriptArea() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: _Colors.surfaceElevated,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isActive
? _Colors.primary.withValues(alpha: 0.3)
: _Colors.border,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Transcript header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
Text(
_isActive ? 'Listening...' : 'Transcript',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _isActive
? _Colors.primary
: _Colors.textSecondary,
letterSpacing: 0.3,
),
),
const Spacer(),
if (_transcript.isNotEmpty)
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: _transcript));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Copied to clipboard'),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
duration: const Duration(seconds: 2),
),
);
},
child: const Icon(Icons.copy_rounded,
size: 18, color: _Colors.textSecondary),
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Divider(height: 24, color: _Colors.border),
),
// Transcript body
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: _transcript.isEmpty
? _buildEmptyState()
: Text(
_transcript,
style: const TextStyle(
fontSize: 17,
height: 1.65,
color: _Colors.textPrimary,
letterSpacing: -0.2,
),
),
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Padding(
padding: const EdgeInsets.only(top: 48),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.mic_none_rounded,
size: 48,
color: _Colors.textPlaceholder.withValues(alpha: 0.5),
),
const SizedBox(height: 12),
const Text(
'Tap the microphone to start',
style: TextStyle(
fontSize: 16,
color: _Colors.textPlaceholder,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
const Text(
'Your transcription will appear here',
style: TextStyle(
fontSize: 14,
color: _Colors.textPlaceholder,
),
),
],
),
),
);
}
Widget _buildBottomControls() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: Column(
children: [
// Waveform indicator
AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return SizedBox(
height: 32,
child: _isActive
? _buildWaveform()
: const SizedBox.shrink(),
);
},
),
const SizedBox(height: 16),
// Mic button
_buildMicButton(),
],
),
);
}
Widget _buildWaveform() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(24, (index) {
final phase = _waveController.value * 2 * pi + index * 0.4;
final height = _state == SttState.recording
? 8.0 + 16.0 * ((sin(phase) + 1) / 2)
: 4.0 + 6.0 * ((sin(phase) + 1) / 2);
return AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: 3,
height: height,
margin: const EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration(
color: _state == SttState.recording
? _Colors.recording.withValues(alpha: 0.7)
: _Colors.processing.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(2),
),
);
}),
);
}
Widget _buildMicButton() {
final isIdle = _state == SttState.idle;
final buttonColor = isIdle ? _Colors.primary : _Colors.recording;
return GestureDetector(
onTap: _isInitialized ? _toggleRecording : null,
child: AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final scale =
_state == SttState.recording ? _pulseAnimation.value : 1.0;
return Transform.scale(
scale: scale,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: buttonColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: buttonColor.withValues(alpha: 0.3),
blurRadius: _state == SttState.recording ? 24 : 12,
spreadRadius: _state == SttState.recording ? 4 : 0,
),
],
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Icon(
isIdle ? Icons.mic_rounded : Icons.stop_rounded,
key: ValueKey(isIdle),
size: 32,
color: Colors.white,
),
),
),
);
},
),
);
}
Future<void> _toggleRecording() async {
HapticFeedback.mediumImpact();
if (_state == SttState.idle) {
await _stt.start();
} else {
await _stt.stop();
}
}
String get _stateLabel => switch (_state) {
SttState.idle => 'Ready',
SttState.initializing => 'Starting',
SttState.listening => 'Listening',
SttState.recording => 'Recording',
SttState.processing => 'Processing',
SttState.paused => 'Paused',
SttState.error => 'Error',
};
Color get _stateColor => switch (_state) {
SttState.idle => _Colors.textSecondary,
SttState.initializing => _Colors.primary,
SttState.listening => _Colors.primary,
SttState.recording => _Colors.recording,
SttState.processing => _Colors.processing,
SttState.paused => const Color(0xFFD97706),
SttState.error => _Colors.recording,
};
}