mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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
122 lines
4.0 KiB
Dart
122 lines
4.0 KiB
Dart
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();
|
|
}
|
|
}
|