Files
StackChan/app/lib/util/music_util.dart

563 lines
16 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
SPDX-License-Identifier: MIT
*/
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:flutter/cupertino.dart';
import 'package:just_audio/just_audio.dart';
import 'package:music_feature_analyzer/music_feature_analyzer.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
class MusicInfo {
int duration; //translated comment
String filePath;
String? title;
String? artist;
String? album;
String? artwork;
String? lyrics;
MusicInfo(
this.duration,
this.filePath, {
this.title,
this.artist,
this.album,
this.artwork,
this.lyrics,
});
///loadmusicfileBytedata
Future<Uint8List> loadData() async {
try {
final file = File(filePath);
if (!await file.exists()) {
throw FileSystemException("音乐文件不存在", file.path);
}
return await file.readAsBytes();
} catch (e) {
throw Exception("加载音乐文件失败: $e");
}
}
String get mimeType {
final ext = path.extension(filePath).toLowerCase();
switch (ext) {
case '.wav':
return 'audio/wav';
case '.m4a':
return 'audio/mp4';
case '.flac':
return 'audio/flac';
case '.mp3':
default:
return 'audio/mpeg';
}
}
Future<List<double>> getProgressData({int targetSampleCount = 100}) async {
if (targetSampleCount <= 0) {
throw ArgumentError("目标采样点数必须大于0: $targetSampleCount");
}
final audioFile = File(filePath);
if (!await audioFile.exists()) {
throw FileSystemException("音频文件不存在", filePath);
}
final tempDir = await getTemporaryDirectory();
final pcmFileName =
"audio_waveform_${DateTime.now().microsecondsSinceEpoch}.pcm";
final pcmFilePath = "${tempDir.path}/$pcmFileName";
try {
final command =
'-loglevel error -hide_banner -i "$filePath" -f s16le -ac 1 -ar 16000 -vn "$pcmFilePath"';
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
if (!ReturnCode.isSuccess(returnCode)) {
final error = await session.getAllLogsAsString();
throw Exception("FFmpeg转换失败: 码=$returnCode, 错误=$error");
}
//Process PCM file, calculate chunk volume (RMS)
final volumeData = await _processPcmFileForVolume(
pcmFilePath,
targetSampleCount,
);
return volumeData;
} catch (e) {
throw Exception("获取音频波动数据失败: $e");
} finally {
try {
final pcmFile = File(pcmFilePath);
if (await pcmFile.exists()) {
await pcmFile.delete();
}
} catch (e) {
//onlyPrintdeletefaillog,NotinterruptMainStreamProcess / Thread
}
}
}
///Process PCM file, calculate chunk volume (RMS/decibel)
Future<List<double>> _processPcmFileForVolume(
String pcmPath,
int targetSampleCount,
) async {
final file = File(pcmPath);
final bytes = await file.readAsBytes();
const sampleSize = 2; //16-bit PCM = 2 bytes/sample
final totalSamples = bytes.length ~/ sampleSize;
//Boundary: return all zeros when no samples
if (totalSamples == 0) {
return List.filled(targetSampleCount, 0.0);
}
final byteData = ByteData.view(bytes.buffer);
final volumeValues = <double>[];
//calculateeachBlockShouldContainssampleCount / Number
final samplesPerBlock = (totalSamples / targetSampleCount).ceil();
//Calculate volume in chunks (RMS)
for (int blockIndex = 0; blockIndex < targetSampleCount; blockIndex++) {
//calculatecurrentBlocksamplerange
final startSample = blockIndex * samplesPerBlock;
final endSample = ((blockIndex + 1) * samplesPerBlock).clamp(
0,
totalSamples,
);
final blockSampleCount = endSample - startSample;
//Boundary: chunk with no samples, volume is 0
if (blockSampleCount <= 0) {
volumeValues.add(0.0);
continue;
}
//Calculate RMS of current chunk: reflects average volume in this period
double sumOfSquares = 0.0;
for (int i = startSample; i < endSample; i++) {
//Read 16-bit little-endian PCM sample (range: -32768 ~ 32767)
final int16Value = byteData.getInt16(i * sampleSize, Endian.little);
//calculateSquareand
sumOfSquares += (int16Value * int16Value).toDouble();
}
//RMS = sqrt(sum of squares / sample count)
final rms = sqrt(sumOfSquares / blockSampleCount);
//Normalize to 0~1 range (32767 is max value for 16-bit signed integer)
final normalizedRms = (rms / 32767.0).clamp(0.0, 1.0);
//Optional: Convert to decibels (dB) (closer to human perception, range: 0~1)
//Decibel formula: 20 * log10(RMS / 32767), but handle 0 to avoid log(0)
// final db = normalizedRms > 0 ? 20 * log10(normalizedRms) : -100;
//final normalizedDb = (db + 100) / 100; // Map to 0~1
// volumeValues.add(normalizedDb.clamp(0.0, 1.0));
//Use normalized RMS directly (simpler, linear volume representation)
volumeValues.add(normalizedRms);
}
return volumeValues;
}
}
///Custom byte stream audio source (adapt just_audio)
class BytesAudioSource extends StreamAudioSource {
final Uint8List bytes;
final String contentType;
final String? id;
BytesAudioSource(this.bytes, {this.contentType = 'audio/mpeg', this.id});
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start ??= 0;
end ??= bytes.length;
return StreamAudioResponse(
sourceLength: bytes.length,
contentLength: end - start,
offset: start,
stream: Stream.value(bytes.sublist(start, end)),
contentType: contentType,
);
}
}
class MusicUtil {
//Singletonmode
MusicUtil._internal() {
_initAnalyzer();
_setupPlayerListener(); //beforeinitlisteneravoid
}
static final MusicUtil shared = MusicUtil._internal();
//Core player instance (just_audio)
final AudioPlayer _audioPlayer = AudioPlayer();
//playcompletecallback
void Function()? _playbackCompletion;
//musicduration(Second(s))
double _musicDuration = 0.0;
//currentplayprogress(Second(s))
double _currentPosition = 0.0;
//currentplaymusicinfo
MusicInfo? _currentMusicInfo;
///initmusicAnalyzer
Future<void> _initAnalyzer() async {
try {
await MusicFeatureAnalyzer.initialize();
} catch (e) {
}
}
///configplayerlistener(System1Managerstate)
void _setupPlayerListener() {
_audioPlayer.setVolume(1.0);
//playerstatelisten(Containsplaystateandhandlestate)
_audioPlayer.playerStateStream.listen((PlayerState state) {
//Playback completion check (handle completed status)
if (state.processingState == ProcessingState.completed) {
_currentPosition = 0.0; //resetprogress
//SingleloopThenreplay,elseexecutecompletecallback
if (_audioPlayer.loopMode == LoopMode.one &&
_currentMusicInfo != null) {
_audioPlayer.seek(Duration.zero);
_audioPlayer.play();
} else {
_playbackCompletion?.call();
_playbackCompletion = null;
}
}
//stopstateresetprogress
if (state.processingState == ProcessingState.idle) {
_currentPosition = 0.0;
}
});
//durationChangelisten
_audioPlayer.durationStream.listen((Duration? duration) {
if (duration != null) {
_musicDuration = duration.inMilliseconds / 1000.0;
}
});
//playprogresslisten
_audioPlayer.positionStream.listen((Duration position) {
_currentPosition = position.inMilliseconds / 1000.0;
//preventprogressExceeds totalduration
if (_currentPosition > _musicDuration && _musicDuration > 0) {
_currentPosition = _musicDuration;
}
});
//errorlisten
_audioPlayer.errorStream.listen((PlayerException? e) {
if (e != null) {
}
});
}
///playBytedataFormat / Formmusic
Future<void> playMusicData(
Uint8List data, {
String contentType = 'audio/mpeg',
}) async {
try {
await stopMusic();
_musicDuration = 0.0;
_currentPosition = 0.0;
//usecustomByteStreamaudioSourceload
final audioSource = BytesAudioSource(data, contentType: contentType);
await _audioPlayer.setAudioSource(audioSource);
await _audioPlayer.play();
} on PlayerException catch (e) {
throw Exception("播放失败: ${e.message}");
} catch (e) {
throw Exception("播放失败: $e");
}
}
///playSinglemusic(playcompleteafterexecutecallback)
Future<void> playMusicOnce(MusicInfo musicInfo, Function() completion) async {
_playbackCompletion = completion;
await playMusic(musicInfo, isLoop: false); //playcloseloop
}
///PlayOnlineMusic1Time(s),RepeatCallThenStopFrontFrom beginningPlay
Future<void> playUrlMusicOnce(String? url, {Function()? completion}) async {
if (url == null) {
return;
}
try {
// First / PreviouslyStopFrontPlay
await stopMusic();
// SetComplete / DoneCallback
_playbackCompletion = completion;
// SetIsNotLoop
_audioPlayer.setLoopMode(LoopMode.off);
// DirectlyUse setUrl Load URL
await _audioPlayer.setUrl(url);
await _audioPlayer.play();
} on PlayerException catch (e) {
throw Exception("播放失败: ${e.message}");
} catch (e) {
throw Exception("播放失败: $e");
}
}
///coreplaymethodsupportloop
Future<void> playMusic(MusicInfo? musicInfo, {bool isLoop = false}) async {
if (musicInfo == null) {
return;
}
//Recordcurrentplaymusicinfo
_currentMusicInfo = musicInfo;
//Set loop mode (just_audio LoopMode)
_audioPlayer.setLoopMode(isLoop ? LoopMode.one : LoopMode.off);
try {
final data = await musicInfo.loadData();
final contentType = musicInfo.mimeType;
await playMusicData(data, contentType: contentType);
} on PlayerException catch (e) {
throw Exception("播放失败: ${e.message}");
} catch (e) {
throw Exception("播放失败: $e");
}
}
///stopplay
Future<void> stopMusic() async {
await _audioPlayer.stop();
await _audioPlayer.seek(Duration.zero); //resetprogressto
_currentPosition = 0.0;
_playbackCompletion = null;
_currentMusicInfo = null;
}
///pauseplay
Future<void> pauseMusic() async {
if (_audioPlayer.playing) {
await _audioPlayer.pause();
}
}
///resumeplay
Future<void> resumeMusic() async {
if (!_audioPlayer.playing && _currentMusicInfo != null) {
await _audioPlayer.play();
}
}
///setloopplaystate
void setMusicLoop(bool isLoop) {
final loopMode = isLoop ? LoopMode.one : LoopMode.off;
_audioPlayer.setLoopMode(loopMode);
}
///jumpplayprogress
Future<void> seekTo(double seconds) async {
if (seconds < 0 || seconds > _musicDuration) {
return;
}
await _audioPlayer.seek(Duration(seconds: seconds.toInt()));
_currentPosition = seconds;
}
///Set volume (0.0 ~ 1.0)
Future<void> setVolume(double volume) async {
if (volume < 0.0 || volume > 1.0) {
return;
}
await _audioPlayer.setVolume(volume);
}
///setplayspeed
Future<void> setPlaybackSpeed(double speed) async {
if (speed <= 0) {
return;
}
await _audioPlayer.setSpeed(speed);
}
///Getcurrentplayprogress(Second(s))
double getCurrentPosition() => _currentPosition;
///GetmusicTotalduration(Second(s))
double getMusicDuration() => _musicDuration;
///Getcurrentloopstate
bool getIsLoop() => _audioPlayer.loopMode == LoopMode.one;
///GetcurrentplayerwhetherCurrentlyinplay
bool isPlaying() => _audioPlayer.playing;
///releaseplayerAsset / ResourceSource(pagedisposewhenCall)
Future<void> dispose() async {
await stopMusic();
await _audioPlayer.dispose();
_currentMusicInfo = null;
_playbackCompletion = null;
}
///improveaftermusicinfoparse(With / CarryVerboselog+cacheverify)
Future<MusicInfo?> getMusicInfoAsync(String urlString) async {
const tag = "MusicUtil/getMusicInfoAsync";
try {
//1. Parse URL
final uri = Uri.parse(urlString);
if (!uri.isAbsolute) {
return null;
}
//2. Generate cache file info
final extension = path.extension(uri.path);
if (extension.isEmpty ||
![
'.mp3',
'.wav',
'.m4a',
'.flac',
].contains(extension.toLowerCase())) {
return null;
}
final fileName = '${uri.hashCode.toRadixString(16)}$extension';
//useDocumentDirectoryAnd / WhileNotisWhenwhenDirectory,avoidSystemautocleancachefile
final cacheDir = await getApplicationDocumentsDirectory();
final musicCacheDir = Directory(path.join(cacheDir.path, 'music_cache'));
if (!await musicCacheDir.exists()) {
await musicCacheDir.create(recursive: true);
}
final filePath = path.join(musicCacheDir.path, fileName);
//3. Check cache file
final file = File(filePath);
if (await file.exists()) {
final stat = await file.stat();
final fileSizeKB = stat.size / 1024;
if (fileSizeKB < 10) {
await file.delete();
} else {
return await _extractMetadataFromFile(filePath, uri);
}
}
//4. Download file
await _downloadFile(uri, file);
final stat = await file.stat();
final fileSizeKB = stat.size / 1024;
if (fileSizeKB < 10) {
return null;
}
//5. Extract metadata
return await _extractMetadataFromFile(filePath, uri);
} catch (e, stackTrace) {
return null;
}
}
///DownLoadfiletoLocalcache
Future<void> _downloadFile(Uri uri, File file) async {
const tag = "MusicUtil/_downloadFile";
final httpClient = HttpClient();
try {
final request = await httpClient.getUrl(uri);
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw Exception("下载失败:状态码 ${response.statusCode}URL=$uri");
}
await response.pipe(file.openWrite());
} catch (e) {
rethrow;
} finally {
httpClient.close();
}
}
///fromfileExtractmusicMetadata / Metadata
Future<MusicInfo?> _extractMetadataFromFile(String filePath, Uri uri) async {
const tag = "MusicUtil/_extractMetadataFromFile";
try {
final song = await MusicFeatureAnalyzer.metadata(filePath);
if (song == null) {
return null;
}
final durationSec = song.duration ~/ 1000; //convertas
return MusicInfo(
durationSec,
filePath,
title: song.title,
artist: song.artist,
album: song.album,
artwork: song.albumArt,
);
} catch (e, stackTrace) {
return null;
}
}
///cleanExpiredmusiccache(optional:Periodicallyclean)
Future<void> clearExpiredCache({
Duration maxAge = const Duration(days: 7),
}) async {
const tag = "MusicUtil/clearExpiredCache";
try {
final cacheDir = await getTemporaryDirectory();
final files = await cacheDir.list().toList();
final now = DateTime.now();
int deletedCount = 0;
for (final file in files) {
if (file is File) {
final extension = path.extension(file.path).toLowerCase();
if (['.mp3', '.wav', '.m4a', '.flac'].contains(extension)) {
final stat = await file.stat();
final fileAge = now.difference(stat.modified);
if (fileAge > maxAge) {
await file.delete();
deletedCount++;
}
}
}
}
} catch (e) {
}
}
}