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

@@ -6,11 +6,13 @@ import 'contacts_provider.dart';
import 'messages_provider.dart';
import 'drawing_provider.dart';
import 'channels_provider.dart';
import 'voice_provider.dart';
import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -19,6 +21,7 @@ class AppProvider with ChangeNotifier {
final MessagesProvider messagesProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService =
LocationTrackingService();
@@ -38,6 +41,7 @@ class AppProvider with ChangeNotifier {
required this.messagesProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.voiceProvider,
required this.tileCacheService,
}) {
_setupCallbacks();
@@ -335,6 +339,19 @@ class AppProvider with ChangeNotifier {
return;
}
// If it's a text-format voice packet, feed it to VoiceProvider
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
if (pkt != null) {
voiceProvider.addPacket(pkt);
// Mark the message with voice metadata before adding to chat
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage(
enrichedMessage,
@@ -380,6 +397,18 @@ class AppProvider with ChangeNotifier {
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
};
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Used for direct binary voice packets (VoicePacket binary format, magic 0x56 'V')
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
if (!VoicePacket.isVoiceBinary(payload)) return;
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
};
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint(
@@ -693,6 +722,41 @@ class AppProvider with ChangeNotifier {
}
}
/// Insert or update a voice placeholder message for binary raw-data packets.
///
/// Binary voice packets arrive without a chat message, so we synthesise one
/// to give the user a playable bubble in the message list.
void _handleIncomingVoicePacket(VoicePacket pkt, {required bool justComplete}) {
final sessionId = pkt.sessionId;
// Check if a placeholder for this session already exists
final existing = messagesProvider.messages.where(
(m) => m.isVoice && m.voiceId == sessionId,
).firstOrNull;
if (existing != null) {
// Already have a placeholder — no need to add another
return;
}
// First packet of a new session: insert placeholder message
final msgId = 'voice_$sessionId';
final placeholder = Message(
id: msgId,
messageType: MessageType.contact, // direct contact (binary only)
senderPublicKeyPrefix: null,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: '', // no text — displayed as VoiceMessageBubble
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.received,
isVoice: true,
voiceId: sessionId,
);
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -167,6 +167,7 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
@@ -498,6 +499,10 @@ class ConnectionProvider with ChangeNotifier {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_bleService.onRawDataReceived = (payload, snrRaw, rssiDbm) {
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
};
_bleService.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] Received DeviceInfo:');
debugPrint(' Firmware Version: ${deviceInfo['firmwareVersion']}');
@@ -1318,6 +1323,21 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25).
/// Only works for contacts with a known direct route (outPathLen >= 0).
Future<void> sendRawVoicePacket({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
}) async {
if (!_bleService.isConnected) return;
await _bleService.sendRawVoicePacket(
contactPathLen: contactPathLen,
contactPath: contactPath,
payload: payload,
);
}
/// Request telemetry from contact
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).

View File

@@ -8,6 +8,7 @@ import '../services/message_storage_service.dart';
import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
@@ -277,6 +278,17 @@ class MessagesProvider with ChangeNotifier {
);
}
// Check if it's a voice message (V:...) and not already marked
if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage;
if (enhancedMessage.isChannelMessage &&

View File

@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart';
/// Reassembly state for one voice session.
class VoiceSession {
final String sessionId;
final VoicePacketMode mode;
final int total;
final List<VoicePacket?> packets; // indexed by packet.index
VoiceSession({
required this.sessionId,
required this.mode,
required this.total,
}) : packets = List.filled(total, null);
int get receivedCount => packets.where((p) => p != null).length;
bool get isComplete => receivedCount == total;
/// Total estimated audio duration in seconds (sum of all received packets).
double get estimatedDurationSeconds {
var ms = 0;
for (final p in packets) {
if (p != null) ms += p.durationMs;
}
return ms / 1000.0;
}
}
/// Manages incoming voice packet sessions and coordinates playback.
class VoiceProvider with ChangeNotifier {
final VoiceCodecService _codec;
final VoicePlayerService _player;
/// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {};
/// Currently playing session ID, or null.
String? _playingSessionId;
VoiceProvider({
required VoiceCodecService codec,
required VoicePlayerService player,
}) : _codec = codec,
_player = player;
// ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId];
bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false;
bool isPlaying(String sessionId) =>
_playingSessionId == sessionId && _player.isPlaying;
// ── Packet reception ─────────────────────────────────────────────────────
/// Add an incoming [packet] to its session. Creates the session on first packet.
/// Returns true if the session just became complete.
bool addPacket(VoicePacket packet) {
_sessions.putIfAbsent(
packet.sessionId,
() => VoiceSession(
sessionId: packet.sessionId,
mode: packet.mode,
total: packet.total,
),
);
final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) {
session.packets[packet.index] = packet;
}
final justComplete = session.isComplete;
notifyListeners();
return justComplete;
}
// ── Playback ─────────────────────────────────────────────────────────────
/// Decode and play the voice session with [sessionId].
/// Plays whatever packets are available (handles partial reception gracefully).
Future<void> play(String sessionId) async {
final session = _sessions[sessionId];
if (session == null) {
debugPrint('❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}');
return;
}
debugPrint('🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}');
try {
final pcm = await _codec.decodePackets(session.packets, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId;
notifyListeners();
await _player.play(pcm);
} catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
} finally {
if (_playingSessionId == sessionId) {
_playingSessionId = null;
notifyListeners();
}
}
}
Future<void> stop() async {
await _player.stop();
_playingSessionId = null;
notifyListeners();
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
}