initial commit
This commit is contained in:
391
flutter/lib/main.dart
Normal file
391
flutter/lib/main.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_sdk/helper.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const SupertonicApp());
|
||||
}
|
||||
|
||||
class SupertonicApp extends StatelessWidget {
|
||||
const SupertonicApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Supertonic 2',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const TTSPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TTSPage extends StatefulWidget {
|
||||
const TTSPage({super.key});
|
||||
|
||||
@override
|
||||
State<TTSPage> createState() => _TTSPageState();
|
||||
}
|
||||
|
||||
class _TTSPageState extends State<TTSPage> {
|
||||
final TextEditingController _textController = TextEditingController(
|
||||
text: 'Hello, this is a text to speech example.',
|
||||
);
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
TextToSpeech? _textToSpeech;
|
||||
Style? _style;
|
||||
bool _isLoading = false;
|
||||
bool _isGenerating = false;
|
||||
String _status = 'Not initialized';
|
||||
int _totalSteps = 5;
|
||||
double _speed = 1.05;
|
||||
String _selectedLang = 'en';
|
||||
bool _isPlaying = false;
|
||||
String? _lastGeneratedFilePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadModels();
|
||||
_setupAudioPlayerListeners();
|
||||
}
|
||||
|
||||
void _setupAudioPlayerListeners() {
|
||||
_audioPlayer.playerStateStream.listen((state) {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isPlaying = state.playing;
|
||||
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_isPlaying = false;
|
||||
_status = 'Ready';
|
||||
} else if (state.processingState == ProcessingState.loading) {
|
||||
_status = 'Loading audio...';
|
||||
} else if (state.processingState == ProcessingState.buffering) {
|
||||
_status = 'Buffering...';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadModels() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_status = 'Loading models...';
|
||||
});
|
||||
|
||||
try {
|
||||
_textToSpeech = await loadTextToSpeech('assets/onnx', useGpu: false);
|
||||
_style = await loadVoiceStyle(['assets/voice_styles/M1.json']);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_status = 'Ready';
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
logger.e('Error loading models', error: e, stackTrace: stackTrace);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_status = 'Error: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generateSpeech() async {
|
||||
if (_textToSpeech == null || _style == null) {
|
||||
setState(() => _status = 'Models not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_textController.text.trim().isEmpty) {
|
||||
setState(() => _status = 'Please enter some text');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
_status = 'Generating speech...';
|
||||
});
|
||||
|
||||
List<double>? wav;
|
||||
List<double>? duration;
|
||||
|
||||
// Step 1: Generate speech
|
||||
try {
|
||||
final result = await _textToSpeech!.call(
|
||||
_textController.text,
|
||||
_selectedLang,
|
||||
_style!,
|
||||
_totalSteps,
|
||||
speed: _speed,
|
||||
);
|
||||
|
||||
wav = result['wav'] is List<double>
|
||||
? result['wav']
|
||||
: (result['wav'] as List).cast<double>();
|
||||
duration = result['duration'] is List<double>
|
||||
? result['duration']
|
||||
: (result['duration'] as List).cast<double>();
|
||||
} catch (e) {
|
||||
logger.e('Error generating speech', error: e);
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_status = 'Error generating speech: $e';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Save to file and play
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final outputPath = '${tempDir.path}/speech_$timestamp.wav';
|
||||
|
||||
writeWavFile(outputPath, wav!, _textToSpeech!.sampleRate);
|
||||
|
||||
final file = File(outputPath);
|
||||
if (!file.existsSync()) {
|
||||
throw Exception('Failed to create WAV file');
|
||||
}
|
||||
|
||||
final absolutePath = file.absolute.path;
|
||||
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_status = 'Playing ${duration![0].toStringAsFixed(2)}s of audio...';
|
||||
_lastGeneratedFilePath = absolutePath;
|
||||
});
|
||||
|
||||
logger.i('Audio saved to $absolutePath');
|
||||
|
||||
final uri = Uri.file(absolutePath);
|
||||
await _audioPlayer.setAudioSource(AudioSource.uri(uri));
|
||||
await _audioPlayer.play();
|
||||
} catch (e) {
|
||||
logger.e('Error playing audio', error: e);
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_status = 'Error playing audio: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadFile() async {
|
||||
if (_lastGeneratedFilePath == null) return;
|
||||
|
||||
try {
|
||||
final sourceFile = File(_lastGeneratedFilePath!);
|
||||
if (!sourceFile.existsSync()) {
|
||||
setState(() => _status = 'Error: File no longer exists');
|
||||
return;
|
||||
}
|
||||
|
||||
final downloadsDir = await getDownloadsDirectory();
|
||||
if (downloadsDir == null) {
|
||||
setState(() => _status = 'Error: Could not access downloads folder');
|
||||
return;
|
||||
}
|
||||
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final downloadPath = '${downloadsDir.path}/speech_$timestamp.wav';
|
||||
|
||||
await sourceFile.copy(downloadPath);
|
||||
logger.i('File saved to $downloadPath');
|
||||
|
||||
setState(() => _status = 'File saved to: $downloadPath');
|
||||
} catch (e) {
|
||||
logger.e('Error downloading file', error: e);
|
||||
setState(() => _status = 'Error downloading file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: const Text('Supertonic 2'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Status indicator
|
||||
Card(
|
||||
color: _isLoading || _isGenerating
|
||||
? Colors.orange.shade100
|
||||
: _status.startsWith('Error')
|
||||
? Colors.red.shade100
|
||||
: Colors.green.shade100,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_isLoading || _isGenerating)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
if (_isLoading || _isGenerating) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child:
|
||||
Text(_status, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Text input
|
||||
TextField(
|
||||
controller: _textController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Text to synthesize',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter the text you want to convert to speech...',
|
||||
),
|
||||
enabled: !_isLoading && !_isGenerating,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Parameters
|
||||
Text('Parameters', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Denoising steps slider
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(flex: 2, child: Text('Denoising Steps:')),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: _totalSteps.toDouble(),
|
||||
min: 1,
|
||||
max: 20,
|
||||
divisions: 19,
|
||||
label: _totalSteps.toString(),
|
||||
onChanged: _isLoading || _isGenerating
|
||||
? null
|
||||
: (value) =>
|
||||
setState(() => _totalSteps = value.toInt()),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child:
|
||||
Text(_totalSteps.toString(), textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Speed slider
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(flex: 2, child: Text('Speed:')),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: _speed,
|
||||
min: 0.5,
|
||||
max: 2.0,
|
||||
divisions: 30,
|
||||
label: _speed.toStringAsFixed(2),
|
||||
onChanged: _isLoading || _isGenerating
|
||||
? null
|
||||
: (value) => setState(() => _speed = value),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(_speed.toStringAsFixed(2),
|
||||
textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Language selector
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(flex: 2, child: Text('Language:')),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedLang,
|
||||
isExpanded: true,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ko', child: Text('한국어')),
|
||||
DropdownMenuItem(value: 'es', child: Text('Español')),
|
||||
DropdownMenuItem(value: 'pt', child: Text('Português')),
|
||||
DropdownMenuItem(value: 'fr', child: Text('Français')),
|
||||
],
|
||||
onChanged: _isLoading || _isGenerating
|
||||
? null
|
||||
: (value) => setState(() => _selectedLang = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Generate button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isLoading || _isGenerating
|
||||
? null
|
||||
: _isPlaying
|
||||
? () async {
|
||||
await _audioPlayer.stop();
|
||||
setState(() => _status = 'Ready');
|
||||
}
|
||||
: _generateSpeech,
|
||||
icon: Icon(_isPlaying ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(
|
||||
_isGenerating
|
||||
? 'Generating...'
|
||||
: _isPlaying
|
||||
? 'Stop Playback'
|
||||
: 'Generate & Play Speech',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
|
||||
// Download button
|
||||
if (_lastGeneratedFilePath != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading || _isGenerating ? null : _downloadFile,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download WAV File',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_audioPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user