Files
xianyan/lib/features/file_transfer/services/voice_message_service.dart
Developer 228095f80a chore: 完成v6.7.0版本迭代更新
本次更新涵盖多个功能模块的优化与新增:
1. 新增Wi-Fi直连配对方式与协作画布模块
2. 完成设备管理重命名API与前端适配
3. 优化日签卡片空数据保护与UI细节
4. 新增剪贴板工具与每日运势会话
5. 修复应用锁恢复、语音消息录制等已知问题
6. 完善文件传输统计与配对逻辑
7. 更新安卓权限配置与iOS隐私描述
8. 新增自动化测试脚本与文档
9. 清理旧版审计报告与测试文件
2026-05-14 05:35:18 +08:00

334 lines
9.6 KiB
Dart

// ============================================================
// 闲言APP — 语音消息服务
// 创建时间: 2026-05-12
// 更新时间: 2026-05-12
// 作用: 语音消息录音/播放/波形可视化 — record+audioplayers
// 上次更新: v11.5.0 初始版本
// ============================================================
import 'dart:async';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
import 'package:xianyan/core/utils/logger.dart';
import '../models/voice_message_data.dart';
enum VoiceRecorderState {
idle,
recording,
paused,
stopped,
}
enum VoicePlayerState {
idle,
playing,
paused,
stopped,
}
class VoiceMessageService {
VoiceMessageService._();
static final VoiceMessageService _instance = VoiceMessageService._();
static VoiceMessageService get instance => _instance;
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
final _uuid = const Uuid();
VoiceRecorderState _recorderState = VoiceRecorderState.idle;
VoicePlayerState _playerState = VoicePlayerState.idle;
String? _currentRecordingPath;
DateTime? _recordingStartTime;
List<double> _currentWaveform = [];
StreamSubscription<RecordState>? _recordStateSub;
StreamSubscription<Amplitude>? _amplitudeSub;
final _recorderStateController =
StreamController<VoiceRecorderState>.broadcast();
final _playerStateController =
StreamController<VoicePlayerState>.broadcast();
final _waveformController =
StreamController<List<double>>.broadcast();
final _playerPositionController =
StreamController<Duration>.broadcast();
final _recordingDurationController =
StreamController<Duration>.broadcast();
Stream<VoiceRecorderState> get recorderState =>
_recorderStateController.stream;
Stream<VoicePlayerState> get playerState =>
_playerStateController.stream;
Stream<List<double>> get waveform => _waveformController.stream;
Stream<Duration> get playerPosition =>
_playerPositionController.stream;
Stream<Duration> get recordingDuration =>
_recordingDurationController.stream;
VoiceRecorderState get currentRecorderState => _recorderState;
VoicePlayerState get currentPlayerState => _playerState;
// ============================================================
// 录音
// ============================================================
Future<bool> hasPermission() async {
try {
return await _recorder.hasPermission();
} catch (e) {
Log.e('VoiceMessageService: Permission check failed: $e');
return false;
}
}
Future<bool> startRecording() async {
if (_recorderState == VoiceRecorderState.recording) return false;
try {
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) {
Log.w('VoiceMessageService: Microphone permission denied');
return false;
}
final directory = await getTemporaryDirectory();
final voiceDir = Directory('${directory.path}/voice_messages');
if (!voiceDir.existsSync()) {
voiceDir.createSync(recursive: true);
}
_currentRecordingPath =
'${voiceDir.path}/voice_${_uuid.v4()}.m4a';
_currentWaveform = [];
_recordingStartTime = DateTime.now();
await _recorder.start(
const RecordConfig(
numChannels: 1,
),
path: _currentRecordingPath!,
);
_setRecorderState(VoiceRecorderState.recording);
_amplitudeSub = _recorder
.onAmplitudeChanged(const Duration(milliseconds: 100))
.listen((amplitude) {
final normalized =
(1.0 - (amplitude.current / -160).clamp(0.0, 1.0))
.clamp(0.0, 1.0);
_currentWaveform.add(normalized);
_waveformController.add(List.unmodifiable(_currentWaveform));
if (_recordingStartTime != null) {
final elapsed = DateTime.now().difference(_recordingStartTime!);
_recordingDurationController.add(elapsed);
if (elapsed.inMilliseconds >= VoiceMessageData.maxDurationMs) {
stopRecording();
}
}
});
Log.i('VoiceMessageService: Recording started');
return true;
} catch (e) {
Log.e('VoiceMessageService: Start recording failed: $e');
_setRecorderState(VoiceRecorderState.idle);
return false;
}
}
Future<VoiceMessageData?> stopRecording() async {
if (_recorderState != VoiceRecorderState.recording) return null;
try {
final path = await _recorder.stop();
_amplitudeSub?.cancel();
_amplitudeSub = null;
_setRecorderState(VoiceRecorderState.idle);
if (path == null || _recordingStartTime == null) {
Log.w('VoiceMessageService: No recording data');
return null;
}
final duration =
DateTime.now().difference(_recordingStartTime!).inMilliseconds;
if (duration < 500) {
Log.w('VoiceMessageService: Recording too short (${duration}ms)');
_deleteFile(path);
return null;
}
final file = File(path);
if (!await file.exists()) {
Log.w('VoiceMessageService: Recording file not found');
return null;
}
final fileSize = await file.length();
if (fileSize > VoiceMessageData.maxFileSize) {
Log.w('VoiceMessageService: Recording too large ($fileSize bytes)');
_deleteFile(path);
return null;
}
final sampledWaveform = _sampleWaveform(_currentWaveform, 50);
Log.i(
'VoiceMessageService: Recording complete '
'(${duration}ms, ${(fileSize / 1024).toStringAsFixed(1)}KB)',
);
return VoiceMessageData(
id: _uuid.v4(),
messageId: '',
sessionId: '',
filePath: path,
duration: duration,
waveform: sampledWaveform,
);
} catch (e) {
Log.e('VoiceMessageService: Stop recording failed: $e');
_setRecorderState(VoiceRecorderState.idle);
return null;
}
}
Future<void> cancelRecording() async {
if (_recorderState != VoiceRecorderState.recording) return;
try {
final path = await _recorder.stop();
_amplitudeSub?.cancel();
_amplitudeSub = null;
_setRecorderState(VoiceRecorderState.idle);
if (path != null) {
_deleteFile(path);
}
Log.i('VoiceMessageService: Recording cancelled');
} catch (e) {
Log.e('VoiceMessageService: Cancel recording failed: $e');
_setRecorderState(VoiceRecorderState.idle);
}
}
// ============================================================
// 播放
// ============================================================
Future<void> playVoice(VoiceMessageData voice) async {
if (_playerState == VoicePlayerState.playing) {
await stopPlayback();
}
try {
final file = File(voice.filePath);
if (!await file.exists()) {
Log.w('VoiceMessageService: Voice file not found: ${voice.filePath}');
return;
}
_setPlayerState(VoicePlayerState.playing);
_player.onPositionChanged.listen((position) {
_playerPositionController.add(position);
});
_player.onPlayerComplete.listen((_) {
_setPlayerState(VoicePlayerState.idle);
});
_player.onLog.listen((msg) {
Log.d('VoiceMessageService: Player log: $msg');
});
await _player.play(DeviceFileSource(voice.filePath));
Log.i('VoiceMessageService: Playing voice ${voice.durationText}');
} catch (e) {
Log.e('VoiceMessageService: Play voice failed: $e');
_setPlayerState(VoicePlayerState.idle);
}
}
Future<void> pausePlayback() async {
if (_playerState != VoicePlayerState.playing) return;
await _player.pause();
_setPlayerState(VoicePlayerState.paused);
}
Future<void> resumePlayback() async {
if (_playerState != VoicePlayerState.paused) return;
await _player.resume();
_setPlayerState(VoicePlayerState.playing);
}
Future<void> stopPlayback() async {
await _player.stop();
_setPlayerState(VoicePlayerState.idle);
}
Future<void> seekTo(Duration position) async {
await _player.seek(position);
}
// ============================================================
// 工具方法
// ============================================================
List<double> _sampleWaveform(List<double> waveform, int targetCount) {
if (waveform.length <= targetCount) return List.from(waveform);
final step = waveform.length / targetCount;
final result = <double>[];
for (int i = 0; i < targetCount; i++) {
final start = (i * step).floor();
final end = ((i + 1) * step).floor().clamp(0, waveform.length);
double sum = 0;
for (int j = start; j < end; j++) {
sum += waveform[j];
}
result.add(end > start ? sum / (end - start) : 0.0);
}
return result;
}
void _setRecorderState(VoiceRecorderState state) {
_recorderState = state;
_recorderStateController.add(state);
}
void _setPlayerState(VoicePlayerState state) {
_playerState = state;
_playerStateController.add(state);
}
void _deleteFile(String path) {
try {
final file = File(path);
if (file.existsSync()) file.deleteSync();
} catch (_) {}
}
Future<void> dispose() async {
_amplitudeSub?.cancel();
_recordStateSub?.cancel();
await _recorder.dispose();
await _player.dispose();
await _recorderStateController.close();
await _playerStateController.close();
await _waveformController.close();
await _playerPositionController.close();
await _recordingDurationController.close();
}
}