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:
72
lib/services/voice_codec_service.dart
Normal file
72
lib/services/voice_codec_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:codec2_flutter/codec2_flutter.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
||||
|
||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||
switch (pktMode) {
|
||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
|
||||
///
|
||||
/// Call with [radioBandwidthHz] from the device's radio params
|
||||
/// (e.g. 125000 for 125 kHz).
|
||||
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
||||
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
||||
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
||||
return VoicePacketMode.mode1300;
|
||||
}
|
||||
|
||||
/// High-level codec service that provides async Codec2 encode/decode
|
||||
/// executed in a background isolate so the UI thread is never blocked.
|
||||
class VoiceCodecService {
|
||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
||||
/// Returns the raw Codec2-encoded bytes.
|
||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) =>
|
||||
Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||
|
||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) =>
|
||||
Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||
|
||||
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
|
||||
/// Packets with null/missing entries are substituted with silence.
|
||||
Future<Int16List> decodePackets(
|
||||
List<VoicePacket?> packets,
|
||||
VoicePacketMode mode,
|
||||
) async {
|
||||
final c2Mode = codec2ModeFor(mode);
|
||||
final c2 = Codec2.create(c2Mode);
|
||||
final spf = c2.samplesPerFrame;
|
||||
c2.destroy();
|
||||
|
||||
// Estimate total samples (use actual data or silence per missing packet)
|
||||
final all = <Int16List>[];
|
||||
for (final pkt in packets) {
|
||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||
// Silence for missing packet — duration approximated by mode
|
||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
||||
all.add(Int16List(silenceSamples));
|
||||
} else {
|
||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
||||
all.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||
final result = Int16List(total);
|
||||
var offset = 0;
|
||||
for (final chunk in all) {
|
||||
result.setRange(offset, offset + chunk.length, chunk);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
119
lib/services/voice_recorder_service.dart
Normal file
119
lib/services/voice_recorder_service.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
||||
///
|
||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||
class VoiceRecorderService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
StreamSubscription<Uint8List>? _sub;
|
||||
StreamController<Int16List>? _controller;
|
||||
|
||||
bool _isRecording = false;
|
||||
bool get isRecording => _isRecording;
|
||||
|
||||
/// Request microphone permission. Returns true if granted.
|
||||
Future<bool> requestPermission() async {
|
||||
return _recorder.hasPermission();
|
||||
}
|
||||
|
||||
/// Start capturing PCM audio.
|
||||
///
|
||||
/// [chunkDuration] controls how often samples are emitted (default 1 s).
|
||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
||||
Stream<Int16List> startCapture({
|
||||
Duration chunkDuration = const Duration(seconds: 1),
|
||||
}) {
|
||||
if (_isRecording) {
|
||||
throw StateError('VoiceRecorderService: already recording');
|
||||
}
|
||||
|
||||
_controller = StreamController<Int16List>(
|
||||
onCancel: () => _stopInternal(),
|
||||
);
|
||||
_isRecording = true;
|
||||
|
||||
_startRecording(chunkDuration);
|
||||
return _controller!.stream;
|
||||
}
|
||||
|
||||
Future<void> _startRecording(Duration chunkDuration) async {
|
||||
final config = const RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
sampleRate: 8000,
|
||||
numChannels: 1,
|
||||
bitRate: 128000, // ignored for PCM, but required by API
|
||||
);
|
||||
|
||||
try {
|
||||
final stream = await _recorder.startStream(config);
|
||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||
final buffer = <int>[];
|
||||
|
||||
_sub = stream.listen(
|
||||
(data) {
|
||||
buffer.addAll(data);
|
||||
while (buffer.length >= chunkBytes) {
|
||||
final chunk = buffer.sublist(0, chunkBytes);
|
||||
buffer.removeRange(0, chunkBytes);
|
||||
_controller?.add(_bytesToInt16(Uint8List.fromList(chunk)));
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (buffer.isNotEmpty) {
|
||||
final padded = _padToEven(buffer);
|
||||
_controller?.add(_bytesToInt16(Uint8List.fromList(padded)));
|
||||
}
|
||||
_controller?.close();
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('❌ [VoiceRecorder] Stream error: $e');
|
||||
_controller?.addError(e);
|
||||
_controller?.close();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceRecorder] Failed to start: $e');
|
||||
_isRecording = false;
|
||||
_controller?.addError(e);
|
||||
_controller?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording and flush remaining samples.
|
||||
Future<void> stopCapture() async {
|
||||
await _stopInternal();
|
||||
}
|
||||
|
||||
Future<void> _stopInternal() async {
|
||||
if (!_isRecording) return;
|
||||
_isRecording = false;
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await _recorder.stop();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_stopInternal();
|
||||
_recorder.dispose();
|
||||
}
|
||||
|
||||
/// Convert raw little-endian PCM bytes to Int16List.
|
||||
static Int16List _bytesToInt16(Uint8List bytes) {
|
||||
final bd = ByteData.sublistView(bytes);
|
||||
final samples = Int16List(bytes.length ~/ 2);
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
samples[i] = bd.getInt16(i * 2, Endian.little);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
static List<int> _padToEven(List<int> buf) {
|
||||
if (buf.length % 2 != 0) buf.add(0);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user