mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: add Codec2 voice messages over LoRa mesh (iOS/macOS only)
Push-to-talk voice messaging using the Codec2 ultra-low-bitrate speech codec, transmitted as V: prefixed packets over the existing MeshCore LoRa mesh pipeline. Voice recording UI (long-press send button or + menu) is gated behind Platform.isIOS || Platform.isMacOS since the `record` package only supports microphone capture on those platforms in this build. Key changes: - VoiceRecorderService: streams 8kHz mono PCM chunks via `record` package - VoicePlayerService: decodes Codec2 bytes to WAV and plays via audioplayers - VoiceCodecService: async Codec2 encode/decode in background isolates - VoiceProvider: reassembles multi-packet sessions, drives playback - VoiceMessageBubble: shows packet progress, play/stop controls - MessagesProvider: detects V: prefix, routes to VoiceProvider - MessagesTab: PTT long-press gesture + recording indicator (iOS/macOS) - Message model: isVoice + voiceId fields for session tracking - Auto-selects codec mode from radio bandwidth (700C/1200/1300 bps) - codec2_flutter + meshcore_client switched from path to git deps
This commit is contained in:
98
lib/services/voice_player_service.dart
Normal file
98
lib/services/voice_player_service.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
||||
/// to the system temp directory and using [AudioPlayer].
|
||||
class VoicePlayerService {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
bool _isPlaying = false;
|
||||
|
||||
bool get isPlaying => _isPlaying;
|
||||
|
||||
VoicePlayerService() {
|
||||
_player.onPlayerStateChanged.listen((state) {
|
||||
debugPrint('🔊 [VoicePlayer] state → $state');
|
||||
_isPlaying = state == PlayerState.playing;
|
||||
});
|
||||
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
|
||||
}
|
||||
|
||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
||||
Future<void> play(Int16List pcmSamples) async {
|
||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||
if (_isPlaying) await stop();
|
||||
|
||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||
await file.writeAsBytes(wavBytes);
|
||||
debugPrint('🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}');
|
||||
|
||||
try {
|
||||
_isPlaying = true;
|
||||
await _player.play(DeviceFileSource(file.path));
|
||||
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
debugPrint('🔊 [VoicePlayer] stop()');
|
||||
await _player.stop();
|
||||
_isPlaying = false;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
}
|
||||
|
||||
// ── WAV file builder ─────────────────────────────────────────────────────
|
||||
|
||||
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
|
||||
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
||||
const int numChannels = 1;
|
||||
const int bitsPerSample = 16;
|
||||
const int audioFormat = 1; // PCM
|
||||
|
||||
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
|
||||
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
||||
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
||||
final totalSize = 36 + dataSize;
|
||||
|
||||
final buf = ByteData(44 + dataSize);
|
||||
var offset = 0;
|
||||
|
||||
void writeStr(String s) {
|
||||
for (final c in s.codeUnits) { buf.setUint8(offset++, c); }
|
||||
}
|
||||
void writeU32(int v) { buf.setUint32(offset, v, Endian.little); offset += 4; }
|
||||
void writeU16(int v) { buf.setUint16(offset, v, Endian.little); offset += 2; }
|
||||
|
||||
writeStr('RIFF');
|
||||
writeU32(totalSize);
|
||||
writeStr('WAVE');
|
||||
writeStr('fmt ');
|
||||
writeU32(16); // subchunk1 size
|
||||
writeU16(audioFormat); // 1 = PCM
|
||||
writeU16(numChannels);
|
||||
writeU32(sampleRate);
|
||||
writeU32(byteRate);
|
||||
writeU16(blockAlign);
|
||||
writeU16(bitsPerSample);
|
||||
writeStr('data');
|
||||
writeU32(dataSize);
|
||||
|
||||
// PCM sample data (little-endian Int16)
|
||||
for (final s in samples) {
|
||||
buf.setInt16(offset, s, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buf.buffer.asUint8List();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user