silk_decoder 0.0.2
silk_decoder: ^0.0.2 copied to clipboard
A Dart library for decoding SILK audio files to PCM format using native bindings, with asynchronous support to avoid blocking the main thread.
import 'dart:io';
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:audioplayers/audioplayers.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:silk_decoder/silk_decoder.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Silk V3 Player & Converter',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
brightness: Brightness.dark,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final AudioPlayer _audioPlayer = AudioPlayer();
String? _ffmpegPath;
String _status = 'Please drag and drop or select an audio file';
String? _currentFilePath;
String? _currentWavPath;
PlayerState _playerState = PlayerState.stopped;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
@override
void initState() {
super.initState();
_loadFFmpegPath();
_audioPlayer.onPlayerStateChanged.listen((state) {
if (mounted) setState(() => _playerState = state);
});
_audioPlayer.onDurationChanged.listen((d) {
if (mounted) setState(() => _duration = d);
});
_audioPlayer.onPositionChanged.listen((p) {
if (mounted) setState(() => _position = p);
});
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
Future<void> _loadFFmpegPath() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_ffmpegPath = prefs.getString('ffmpeg_path');
});
if (_ffmpegPath == null) {
_autoDetectFFmpeg();
}
}
Future<void> _saveFFmpegPath(String path) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('ffmpeg_path', path);
setState(() {
_ffmpegPath = path;
});
}
Future<void> _autoDetectFFmpeg() async {
String command = Platform.isWindows ? 'where' : 'which';
ProcessResult result = await Process.run(command, ['ffmpeg']);
if (result.exitCode == 0) {
String path = result.stdout.toString().trim().split('\n').first;
await _saveFFmpegPath(path);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Auto-detected FFmpeg: $path')));
}
}
}
Future<void> _pickFFmpegPath() async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
await _saveFFmpegPath(result.files.single.path!);
}
}
Future<void> _processFile(String filePath) async {
if (_ffmpegPath == null || _ffmpegPath!.isEmpty) {
setState(() {
_status = 'Error: Please set the FFmpeg path first!';
});
return;
}
setState(() {
_status = 'Decoding...';
_currentFilePath = filePath;
_playerState = PlayerState.stopped;
_duration = Duration.zero;
_position = Duration.zero;
});
try {
final tempDir = await getTemporaryDirectory();
final pcmPath = p.join(tempDir.path, '${p.basename(filePath)}.pcm');
final wavPath = p.join(tempDir.path, '${p.basename(filePath)}.wav');
await decodeSilkFileAsync(filePath, pcmPath, 24000);
setState(() => _status = 'Converting to WAV...');
await _convertPcmToWav(pcmPath, wavPath);
await _audioPlayer.setSourceDeviceFile(wavPath);
await _audioPlayer.resume();
setState(() {
_status = 'Ready: ${p.basename(filePath)}';
_currentWavPath = wavPath;
});
} catch (e) {
setState(() {
_status = 'Processing failed: $e';
});
}
}
Future<void> _convertPcmToWav(String pcmPath, String wavPath) async {
final result = await Process.run(_ffmpegPath!, [
'-y',
// Overwrite output file if it exists
'-f',
's16le',
// Format of the input file (signed 16-bit little-endian PCM)
'-ar',
'24000',
// Audio sample rate of the input file
'-ac',
'1',
// Audio channels of the input file (1 for mono)
'-i',
pcmPath,
// Input file path
'-c:a',
'pcm_s16le',
// Explicitly set the audio codec for the output to pcm_s16le
wavPath,
// Output file path
]);
if (result.exitCode != 0) {
throw Exception('FFmpeg conversion failed: ${result.stderr}');
}
}
Future<void> _exportFile(String format) async {
if (_currentFilePath == null) return;
String? outputFile = await FilePicker.platform.saveFile(
dialogTitle: 'Please select a save location:',
fileName: '${p.basenameWithoutExtension(_currentFilePath!)}.$format',
);
if (outputFile == null) return;
setState(() => _status = 'Exporting to $format...');
try {
final tempDir = await getTemporaryDirectory();
final pcmPath = p.join(
tempDir.path,
'${p.basename(_currentFilePath!)}.pcm',
);
await File(pcmPath).exists();
final result = await Process.run(_ffmpegPath!, [
'-y',
'-f',
's16le',
'-ar',
'24000',
'-ac',
'1',
'-i',
pcmPath,
outputFile,
]);
if (result.exitCode == 0) {
setState(() => _status = 'Export successful: $outputFile');
} else {
throw Exception('FFmpeg export failed: ${result.stderr}');
}
} catch (e) {
setState(() => _status = 'Export failed: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Silk Player & Converter'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => _showSettingsDialog(),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildDropZone(),
const SizedBox(height: 20),
if (_currentFilePath != null) _buildPlayerControls(),
const SizedBox(height: 20),
_buildInfoZone(),
],
),
),
);
}
Widget _buildDropZone() {
return DropTarget(
onDragDone: (details) {
final path = details.files.first.path;
if (['.slk', '.aud', '.amr'].any((ext) => path.endsWith(ext))) {
_processFile(path);
}
},
child: InkWell(
onTap: () async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['slk', 'aud', 'amr'],
);
if (result != null) {
_processFile(result.files.single.path!);
}
},
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade600,
style: BorderStyle.solid,
width: 2,
),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.upload_file, size: 50),
SizedBox(height: 10),
Text('Drag & drop a file here or click to select'),
Text('.slk, .aud, .amr', style: TextStyle(color: Colors.grey)),
],
),
),
),
),
);
}
Widget _buildPlayerControls() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Slider(
value: _position.inSeconds.toDouble().clamp(
0.0,
_duration.inSeconds.toDouble() + 0.1,
),
max: _duration.inSeconds.toDouble() + 0.1,
onChanged: (value) async {
final position = Duration(seconds: value.toInt());
await _audioPlayer.seek(position);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatDuration(_position)),
Text(_formatDuration(_duration)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(
_playerState == PlayerState.playing
? Icons.pause
: Icons.play_arrow,
),
iconSize: 48,
onPressed: _currentWavPath == null
? null
: () {
if (_playerState == PlayerState.playing) {
_audioPlayer.pause();
} else {
_audioPlayer.resume();
}
},
),
const SizedBox(width: 20),
PopupMenuButton<String>(
onSelected: _exportFile,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'wav',
child: Text('Export as WAV'),
),
const PopupMenuItem<String>(
value: 'mp3',
child: Text('Export as MP3'),
),
const PopupMenuItem<String>(
value: 'm4a',
child: Text('Export as M4A (AAC)'),
),
],
child: const Row(
children: [
Icon(Icons.save_alt),
SizedBox(width: 8),
Text('Export'),
],
),
),
],
),
],
),
),
);
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Settings'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'FFmpeg Path:',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(_ffmpegPath ?? 'Not set'),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _autoDetectFFmpeg,
child: const Text('Auto-detect'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _pickFFmpegPath,
child: const Text('Select manually'),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
},
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
Widget _buildInfoZone() {
return Container(
height: 120,
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withAlpha((255 * 0.3).round()),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withAlpha((255 * 0.5).round())),
),
child: SingleChildScrollView(
child: SelectableText(
_status,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
),
);
}
}