import 'dart:convert'; import 'dart:typed_data'; const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) const int _defaultLoRaPreambleSymbols = 8; const int _defaultLoRaCrcEnabled = 1; const int _defaultLoRaExplicitHeader = 1; const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle /// Identifies which Codec2 mode was used for a voice packet. /// Matches the modeId byte in the text/binary packet header. enum VoicePacketMode { mode700c(0, '700C'), mode1200(1, '1200'), mode2400(2, '2400'), mode1300(3, '1300'), mode1400(4, '1400'), mode1600(5, '1600'), mode3200(6, '3200'); const VoicePacketMode(this.id, this.label); final int id; final String label; static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere( (m) => m.id == id, orElse: () => VoicePacketMode.mode1300, ); } /// A single Codec2-encoded chunk belonging to a multi-packet voice session. /// /// Text format (channels): /// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2} /// /// Binary format (direct contacts, received via pushRawData): /// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...] class VoicePacket { final String sessionId; // 8 hex chars (4 bytes) final VoicePacketMode mode; final int index; // 0-based final int total; // total packet count final Uint8List codec2Data; const VoicePacket({ required this.sessionId, required this.mode, required this.index, required this.total, required this.codec2Data, }); // ── Text (channel) format ──────────────────────────────────────────────── static const String _textPrefix = 'V:'; static bool isVoiceText(String text) => text.startsWith(_textPrefix); /// Parse a text-format voice packet. Returns null on failure. static VoicePacket? tryParseText(String text) { if (!text.startsWith(_textPrefix)) return null; try { final body = text.substring(_textPrefix.length); final parts = body.split(':'); // parts: [sessionId, modeId, 'idx/total', base64data] if (parts.length != 4) return null; final sessionId = parts[0]; if (sessionId.length != 8) return null; final modeId = int.tryParse(parts[1]); if (modeId == null) return null; final indexTotal = parts[2].split('/'); if (indexTotal.length != 2) return null; final index = int.tryParse(indexTotal[0]); final total = int.tryParse(indexTotal[1]); if (index == null || total == null || total < 1) return null; final codec2Data = base64.decode(parts[3]); return VoicePacket( sessionId: sessionId, mode: VoicePacketMode.fromId(modeId), index: index, total: total, codec2Data: codec2Data, ); } catch (_) { return null; } } /// Encode as text (channel format). Max ~198 chars for 700C/1200/2400. String encodeText() { final b64 = base64.encode(codec2Data); return 'V:$sessionId:${mode.id}:$index/$total:$b64'; } // ── Binary format ──────────────────────────────────────────────────────── static const int _binaryMagic = 0x56; // 'V' static const int _binaryHeaderLen = 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1) static bool isVoiceBinary(Uint8List payload) => payload.isNotEmpty && payload[0] == _binaryMagic; /// Parse binary-format voice packet (from pushRawData payload). static VoicePacket? tryParseBinary(Uint8List payload) { if (payload.length < _binaryHeaderLen) return null; if (payload[0] != _binaryMagic) return null; try { final sessionBytes = payload.sublist(1, 5); final sessionId = sessionBytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final modeId = payload[5]; final index = payload[6]; final total = payload[7]; if (total < 1) return null; final codec2Data = payload.sublist(_binaryHeaderLen); return VoicePacket( sessionId: sessionId, mode: VoicePacketMode.fromId(modeId), index: index, total: total, codec2Data: codec2Data, ); } catch (_) { return null; } } /// Encode as binary payload (for cmdSendRawData). Uint8List encodeBinary() { final sessionBytes = Uint8List(4); for (var i = 0; i < 4; i++) { sessionBytes[i] = int.parse( sessionId.substring(i * 2, i * 2 + 2), radix: 16, ); } final out = Uint8List(_binaryHeaderLen + codec2Data.length); out[0] = _binaryMagic; out.setRange(1, 5, sessionBytes); out[5] = mode.id; out[6] = index; out[7] = total; out.setRange(_binaryHeaderLen, out.length, codec2Data); return out; } // ── Duration helpers ───────────────────────────────────────────────────── /// Estimated audio duration of this packet in milliseconds. int get durationMs { // bytesPerSecond for each mode final bps = switch (mode) { VoicePacketMode.mode700c => 100, VoicePacketMode.mode1200 => 150, VoicePacketMode.mode1300 => 175, VoicePacketMode.mode1400 => 175, VoicePacketMode.mode1600 => 200, VoicePacketMode.mode2400 => 300, VoicePacketMode.mode3200 => 400, }; if (bps == 0) return 0; return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500); } @override String toString() => 'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)'; } /// Lightweight public/direct message envelope advertising voice availability. /// /// Text format: /// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} /// Example: /// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1 class VoiceEnvelope { static const String _prefix = 'VE1:'; final String sessionId; final VoicePacketMode mode; final int total; final int durationMs; final String senderKey6; final int timestampSec; final int version; const VoiceEnvelope({ required this.sessionId, required this.mode, required this.total, required this.durationMs, required this.senderKey6, required this.timestampSec, this.version = 1, }); static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); static VoiceEnvelope? tryParseText(String text) { if (!isVoiceEnvelopeText(text)) return null; final body = text.substring(_prefix.length); return _tryParseCompact(body); } static VoiceEnvelope? _tryParseCompact(String body) { final parts = body.split(':'); if (parts.length != 7) return null; try { final sid = parts[0]; final mode = int.tryParse(parts[1]); final total = int.tryParse(parts[2]); final durMs = int.tryParse(parts[3]); final senderKey6 = parts[4]; final ts = int.tryParse(parts[5]); final ver = int.tryParse(parts[6]); if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { return null; } if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) { return null; } if (total == null || total < 1 || total > 255) return null; if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null; if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) { return null; } if (ts == null || ts <= 0) return null; if (ver == null || ver != 1) return null; return VoiceEnvelope( sessionId: sid.toLowerCase(), mode: VoicePacketMode.fromId(mode), total: total, durationMs: durMs, senderKey6: senderKey6.toLowerCase(), timestampSec: ts, version: ver, ); } catch (_) { return null; } } String encodeText() { return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version'; } } int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { VoicePacketMode.mode700c => 100, VoicePacketMode.mode1200 => 150, VoicePacketMode.mode1300 => 175, VoicePacketMode.mode1400 => 175, VoicePacketMode.mode1600 => 200, VoicePacketMode.mode2400 => 300, VoicePacketMode.mode3200 => 400, }; /// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. /// /// Uses envelope-level metadata (mode + duration + packet count) when only the /// envelope is available and packet bytes are not yet received locally. Duration estimateVoiceTransmitDuration({ required VoicePacketMode mode, required int packetCount, required int durationMs, int pathLen = 0, int? radioBw, int? radioSf, int? radioCr, }) { if (packetCount <= 0 || durationMs <= 0) return Duration.zero; final bytesPerSecond = voiceModeBytesPerSecond(mode); final totalCodecBytes = (durationMs * bytesPerSecond / 1000.0).round(); final safePathLen = pathLen.clamp(0, 64); final hops = safePathLen + 1; final baseBytesPerPacket = totalCodecBytes ~/ packetCount; final extraBytes = totalCodecBytes % packetCount; var totalMs = 0.0; for (var i = 0; i < packetCount; i++) { final codecBytes = baseBytesPerPacket + (i < extraBytes ? 1 : 0); final loraLen = _meshPacketHeaderBytes + safePathLen + _voicePacketHeaderBytes + codecBytes; final airtimeMs = _estimateLoRaAirtimeMs( loraLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; } return Duration(milliseconds: totalMs.round()); } /// Approximate transmit time using actually received voice packet sizes. Duration estimateVoiceTransmitDurationFromPackets({ required Iterable packets, int pathLen = 0, int? radioBw, int? radioSf, int? radioCr, }) { final safePathLen = pathLen.clamp(0, 64); final hops = safePathLen + 1; var totalMs = 0.0; for (final packet in packets) { if (packet == null) continue; final loraLen = _meshPacketHeaderBytes + safePathLen + _voicePacketHeaderBytes + packet.codec2Data.length; final airtimeMs = _estimateLoRaAirtimeMs( loraLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; } return Duration(milliseconds: totalMs.round()); } double _estimateLoRaAirtimeMs( int payloadLenBytes, { int? radioBw, int? radioSf, int? radioCr, }) { final sf = _normalizeSf(radioSf); final bw = _resolveBandwidthHz(radioBw).toDouble(); final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; final symbolMs = ((1 << sf) / bw) * 1000.0; final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; final num = (8 * payloadLenBytes) - (4 * sf) + 28 + (16 * _defaultLoRaCrcEnabled) - (20 * ih); final den = 4 * (sf - (2 * de)); final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); final payloadSymbols = 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); final payloadMs = payloadSymbols * symbolMs; return preambleMs + payloadMs; } int _normalizeSf(int? value) { if (value == null) return _defaultLoRaSf; if (value >= 5 && value <= 12) return value; return _defaultLoRaSf; } int _normalizeCr(int? value) { if (value == null) return _defaultLoRaCr; if (value >= 5 && value <= 8) return value; return _defaultLoRaCr; } int _resolveBandwidthHz(int? rawBw) { if (rawBw == null) return _defaultLoRaBwHz; if (rawBw > 1000) return rawBw; switch (rawBw) { case 0: return 7800; case 1: return 10400; case 2: return 15600; case 3: return 20800; case 4: return 31250; case 5: return 41700; case 6: return 62500; case 7: return 125000; case 8: return 250000; case 9: return 500000; default: return _defaultLoRaBwHz; } } /// Direct control-plane request to fetch voice packets for a session. /// /// Text format: /// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} /// Example: /// VR1:00112233:a:aabbccddeeff:1234567890:1 class VoiceFetchRequest { static const String _prefix = 'VR1:'; final String sessionId; final String want; final List missingIndices; final String requesterKey6; final int timestampSec; final int version; const VoiceFetchRequest({ required this.sessionId, this.want = 'all', this.missingIndices = const [], required this.requesterKey6, required this.timestampSec, this.version = 1, }); static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix); static VoiceFetchRequest? tryParseText(String text) { if (!isVoiceFetchRequestText(text)) return null; final body = text.substring(_prefix.length); return _tryParseCompact(body); } static VoiceFetchRequest? _tryParseCompact(String body) { final parts = body.split(':'); if (parts.length != 5) return null; try { final sid = parts[0]; final wantToken = parts[1]; final requesterKey6 = parts[2]; final ts = int.tryParse(parts[3]); final ver = int.tryParse(parts[4]); final normalizedWant = wantToken == 'a' ? 'all' : (wantToken.startsWith('m-') ? 'missing' : wantToken); if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { return null; } final missingIndices = []; if (normalizedWant == 'missing') { final encoded = wantToken.substring(2); if (encoded.isEmpty) return null; for (final raw in encoded.split(',')) { final idx = int.tryParse(raw); if (idx == null || idx < 0 || idx > 254) return null; missingIndices.add(idx); } if (missingIndices.isEmpty) return null; } else if (normalizedWant != 'all') { return null; } if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { return null; } if (ts == null || ts <= 0) return null; if (ver == null || ver != 1) return null; return VoiceFetchRequest( sessionId: sid.toLowerCase(), want: normalizedWant, missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), timestampSec: ts, version: ver, ); } catch (_) { return null; } } String encodeText() { final wantToken = want == 'missing' && missingIndices.isNotEmpty ? 'm-${missingIndices.join(',')}' : (want == 'all' ? 'a' : want); return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; } } /// Builds a compact visual waveform from real voice packet bytes. /// /// Note: This uses the encoded Codec2 packet bytes as the source so it works /// even before full PCM decode/playback is available. class VoiceWaveform { static List buildBarsFromPackets( Iterable packets, { int bars = 24, }) { if (bars <= 0) return const []; final merged = []; for (final pkt in packets) { if (pkt == null || pkt.codec2Data.isEmpty) continue; merged.addAll(pkt.codec2Data); } if (merged.isEmpty) return List.filled(bars, 0.0); final out = List.filled(bars, 0.0); for (var i = 0; i < bars; i++) { final start = (i * merged.length) ~/ bars; var end = ((i + 1) * merged.length) ~/ bars; if (end <= start) end = start + 1; if (end > merged.length) end = merged.length; var sum = 0.0; for (var j = start; j < end; j++) { final centered = (merged[j] - 128).abs(); sum += centered / 127.0; } out[i] = (sum / (end - start)).clamp(0.0, 1.0); } // Light smoothing to avoid jittery adjacent bars. if (bars > 2) { final smoothed = List.from(out); for (var i = 1; i < bars - 1; i++) { smoothed[i] = ((out[i - 1] + out[i] + out[i + 1]) / 3.0).clamp( 0.0, 1.0, ); } return smoothed; } return out; } }