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:
Janez T
2026-02-28 19:19:18 +01:00
parent 3e5b2ac0de
commit f96d8022ac
27 changed files with 1509 additions and 81 deletions

View 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;
}
}

View 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();
}
}

View 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;
}
}