Files
meshcore-sar_android/lib/models/message.dart
Janez T f96d8022ac 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
2026-02-28 19:19:46 +01:00

71 lines
2.1 KiB
Dart

export 'package:meshcore_client/meshcore_client.dart'
show
Message,
MessageType,
MessageTextType,
MessageDeliveryStatus,
MessageRecipient;
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart';
import '../utils/voice_message_parser.dart';
extension MessageVoiceExtension on Message {
/// True when this message is a voice recording (`V:` text or binary voice).
bool get isVoiceMessage => isVoice;
/// Parses the voice packet mode from the stored [voiceId] session info.
/// Returns null for non-voice messages.
VoicePacketMode? get voicePacketMode {
if (!isVoice || text.isEmpty) return null;
final pkt = VoicePacket.tryParseText(text);
return pkt?.mode;
}
}
extension MessageSarExtension on Message {
/// Infer the [SarMarkerType] from stored SAR fields.
/// Returns null if this is not a SAR marker message.
SarMarkerType? get sarMarkerType {
if (!isSarMarker) return null;
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
return SarMarkerType.fromEmoji(sarCustomEmoji!);
}
final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null;
final parts = trimmed.split(':');
if (parts.length < 3) return null;
return SarMarkerType.fromEmoji(parts[1]);
}
/// Convert to a [SarMarker] if this message contains SAR data.
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
return null;
}
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
debugPrint(' message.text: "$text"');
debugPrint(' message.sarNotes: "$sarNotes"');
debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: sarNotes,
customEmoji: sarCustomEmoji,
colorIndex: sarColorIndex,
);
}
}