mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -11,7 +11,10 @@ import 'providers/messages_provider.dart';
|
||||
import 'providers/map_provider.dart';
|
||||
import 'providers/drawing_provider.dart';
|
||||
import 'providers/channels_provider.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'providers/app_provider.dart';
|
||||
import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
@@ -231,10 +234,19 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => VoiceProvider(
|
||||
codec: VoiceCodecService(),
|
||||
player: VoicePlayerService(),
|
||||
),
|
||||
),
|
||||
|
||||
// Tile cache service
|
||||
Provider(create: (_) => TileCacheService()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||
ChangeNotifierProxyProvider6<
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
@@ -250,6 +262,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
messagesProvider: context.read<MessagesProvider>(),
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
tileCacheService: context.read<TileCacheService>(),
|
||||
),
|
||||
update:
|
||||
@@ -270,6 +283,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
messagesProvider: messages,
|
||||
drawingProvider: drawings,
|
||||
channelsProvider: channels,
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
tileCacheService: tileCache,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,20 @@ export 'package:meshcore_client/meshcore_client.dart'
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
121
lib/providers/voice_provider.dart
Normal file
121
lib/providers/voice_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -8,6 +11,7 @@ import '../providers/contacts_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/voice_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -15,8 +19,11 @@ import '../widgets/messages/sar_update_sheet.dart';
|
||||
import '../widgets/messages/recipient_selector_sheet.dart';
|
||||
import '../widgets/messages/message_bubble.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../services/voice_recorder_service.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class MessagesTab extends StatefulWidget {
|
||||
@@ -42,6 +49,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
Contact? _selectedRecipient;
|
||||
|
||||
// Voice recording state
|
||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||
bool _isRecording = false;
|
||||
bool _isSendingVoice = false;
|
||||
static const int _maxVoicePackets = 10;
|
||||
bool get _voiceSupported => Platform.isIOS || Platform.isMacOS;
|
||||
StreamSubscription<Int16List>? _voiceStreamSub;
|
||||
String? _currentVoiceSessionId;
|
||||
final List<Int16List> _recordedChunks = [];
|
||||
VoicePacketMode? _activeVoiceMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -68,6 +86,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
@override
|
||||
void dispose() {
|
||||
_highlightTimer?.cancel();
|
||||
_voiceStreamSub?.cancel();
|
||||
_voiceRecorder.dispose();
|
||||
_textController.dispose();
|
||||
_focusNode.dispose();
|
||||
_scrollController.dispose();
|
||||
@@ -231,7 +251,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
/// Get tooltip for destination button
|
||||
String _getDestinationTooltip() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel && _selectedRecipient != null) {
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
_selectedRecipient != null) {
|
||||
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
|
||||
return '$channelName (tap to change)';
|
||||
} else if (_selectedRecipient != null) {
|
||||
@@ -260,8 +281,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
// Send to selected channel (or public channel if none selected)
|
||||
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; // Extract channel index from pseudo public key
|
||||
await _sendToChannel(text, connectionProvider, messagesProvider, channelIdx);
|
||||
final channelIdx =
|
||||
_selectedRecipient?.publicKey[1] ??
|
||||
0; // Extract channel index from pseudo public key
|
||||
await _sendToChannel(
|
||||
text,
|
||||
connectionProvider,
|
||||
messagesProvider,
|
||||
channelIdx,
|
||||
);
|
||||
} else if (_selectedRecipient != null) {
|
||||
// Send to contact or room
|
||||
await _sendToRecipient(
|
||||
@@ -376,6 +404,218 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice recording ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _startVoiceRecording() async {
|
||||
if (_isSendingVoice || _isRecording) return;
|
||||
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
||||
final hasPermission = await _voiceRecorder.requestPermission();
|
||||
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Microphone permission required for voice');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
debugPrint(
|
||||
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
|
||||
);
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ToastLogger.error(context, 'Not connected to device');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new session ID (4 random bytes → 8 hex chars)
|
||||
final rng = math.Random.secure();
|
||||
_currentVoiceSessionId = List.generate(
|
||||
4,
|
||||
(_) => rng.nextInt(256),
|
||||
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
|
||||
final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125;
|
||||
_activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000);
|
||||
final packetDuration = Duration(
|
||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
||||
);
|
||||
|
||||
_recordedChunks.clear();
|
||||
setState(() => _isRecording = true);
|
||||
|
||||
try {
|
||||
final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration);
|
||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||
_voiceStreamSub = stream.listen(
|
||||
(pcmChunk) {
|
||||
if (!_isRecording) return;
|
||||
_recordedChunks.add(pcmChunk);
|
||||
debugPrint(
|
||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||
);
|
||||
setState(() {});
|
||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('❌ [Voice] stream error: $e');
|
||||
_stopAndSendVoice();
|
||||
},
|
||||
onDone: () => debugPrint('🎙️ [Voice] stream done'),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [Voice] startCapture threw: $e');
|
||||
if (mounted) setState(() => _isRecording = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopAndSendVoice() async {
|
||||
if (!_isRecording) return;
|
||||
debugPrint(
|
||||
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
|
||||
);
|
||||
|
||||
await _voiceStreamSub?.cancel();
|
||||
_voiceStreamSub = null;
|
||||
await _voiceRecorder.stopCapture();
|
||||
|
||||
final chunks = List<Int16List>.from(_recordedChunks);
|
||||
final sessionId = _currentVoiceSessionId;
|
||||
final mode = _activeVoiceMode;
|
||||
_recordedChunks.clear();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
_isSendingVoice = chunks.isNotEmpty && sessionId != null;
|
||||
});
|
||||
}
|
||||
|
||||
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
|
||||
if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _encodeAndSendAllPackets(
|
||||
chunks: chunks,
|
||||
sessionId: sessionId,
|
||||
mode: mode,
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
|
||||
} finally {
|
||||
debugPrint('🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSendingVoice = false;
|
||||
_currentVoiceSessionId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _encodeAndSendAllPackets({
|
||||
required List<Int16List> chunks,
|
||||
required String sessionId,
|
||||
required VoicePacketMode mode,
|
||||
}) async {
|
||||
final total = chunks.length;
|
||||
final codec = VoiceCodecService();
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
|
||||
// Insert the chat placeholder before sending (so it appears immediately)
|
||||
final msgId = 'voice_${sessionId}_sent';
|
||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
||||
final isChannel =
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
final sentMsg = Message(
|
||||
id: msgId,
|
||||
messageType: (!isChannel && _selectedRecipient != null)
|
||||
? MessageType.contact
|
||||
: MessageType.channel,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: '',
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
isVoice: true,
|
||||
voiceId: sessionId,
|
||||
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null,
|
||||
recipientPublicKey: _selectedRecipient?.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMsg);
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId',
|
||||
);
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final codec2Data = await codec.encode(chunks[i], mode);
|
||||
debugPrint(
|
||||
'🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes',
|
||||
);
|
||||
final packet = VoicePacket(
|
||||
sessionId: sessionId,
|
||||
mode: mode,
|
||||
index: i,
|
||||
total: total,
|
||||
codec2Data: codec2Data,
|
||||
);
|
||||
|
||||
voiceProvider.addPacket(packet);
|
||||
|
||||
if (!isChannel &&
|
||||
_selectedRecipient != null &&
|
||||
_selectedRecipient!.outPathLen >= 0) {
|
||||
debugPrint(
|
||||
'🎙️ [Voice] packet $i → binary (raw data), pathLen=${_selectedRecipient!.outPathLen}',
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: _selectedRecipient!.outPath,
|
||||
contactPathLen: _selectedRecipient!.outPathLen,
|
||||
payload: packet.encodeBinary(),
|
||||
);
|
||||
} else {
|
||||
final channelIdx = isChannel
|
||||
? (_selectedRecipient?.publicKey[1] ?? 0)
|
||||
: 0;
|
||||
final text = packet.encodeText();
|
||||
debugPrint(
|
||||
'🎙️ [Voice] packet $i → text ch=$channelIdx len=${text.length}: $text',
|
||||
);
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
);
|
||||
}
|
||||
debugPrint('🎙️ [Voice] packet $i sent ok');
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [Voice] packet $i send error: $e\n$st');
|
||||
}
|
||||
}
|
||||
debugPrint('🎙️ [Voice] all packets sent for session $sessionId');
|
||||
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
||||
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
||||
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||
}
|
||||
|
||||
// ── SAR dialog ─────────────────────────────────────────────────────────────
|
||||
|
||||
void _showSarDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -406,6 +646,45 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showComposerActions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_location_alt),
|
||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showSarDialog();
|
||||
},
|
||||
),
|
||||
if (_voiceSupported)
|
||||
ListTile(
|
||||
enabled: !_isSendingVoice,
|
||||
leading: Icon(_isRecording ? Icons.stop : Icons.mic),
|
||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||
onTap: _isSendingVoice
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
} else {
|
||||
_startVoiceRecording();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendSarMessage(
|
||||
String emoji,
|
||||
String name,
|
||||
@@ -426,7 +705,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Please select a destination to send SAR marker');
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Please select a destination to send SAR marker',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -443,7 +725,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
if (chatContacts.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, AppLocalizations.of(context)!.noContactsAvailable);
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.noContactsAvailable,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -788,9 +1073,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
: widget.onNavigateToMap != null &&
|
||||
message.isDrawing && message.drawingId != null
|
||||
message.isDrawing &&
|
||||
message.drawingId != null
|
||||
? () {
|
||||
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
|
||||
debugPrint(
|
||||
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
|
||||
);
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
final drawingProvider = context
|
||||
@@ -823,18 +1111,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// SAR quick action button
|
||||
// Quick actions (+) button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: AppLocalizations.of(context)!.sendSarMarker,
|
||||
onPressed: _showSarDialog,
|
||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
||||
tooltip: _isRecording ? 'Stop recording' : 'More actions',
|
||||
onPressed: _isRecording
|
||||
? _stopAndSendVoice
|
||||
: _showComposerActions,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
foregroundColor: _isRecording
|
||||
? Colors.red
|
||||
: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -890,18 +1180,52 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
suffixIcon: GestureDetector(
|
||||
onLongPressStart: (_voiceSupported && !_isSendingVoice)
|
||||
? (_) => _startVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd: (_voiceSupported && _isRecording)
|
||||
? (_) => _stopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel: (_voiceSupported && _isRecording)
|
||||
? () => _stopAndSendVoice()
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: _isSendingVoice
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isRecording
|
||||
? Icons.mic
|
||||
: Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _isRecording
|
||||
? Colors.red
|
||||
: (_textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary),
|
||||
),
|
||||
onPressed:
|
||||
_isRecording ||
|
||||
_isSendingVoice ||
|
||||
_textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: _isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (_isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: _voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send'),
|
||||
),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
@@ -917,4 +1241,3 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
lib/services/voice_codec_service.dart
Normal file
72
lib/services/voice_codec_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/services/voice_player_service.dart
Normal file
98
lib/services/voice_player_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
119
lib/services/voice_recorder_service.dart
Normal file
119
lib/services/voice_recorder_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
155
lib/utils/voice_message_parser.dart
Normal file
155
lib/utils/voice_message_parser.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 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');
|
||||
|
||||
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.mode700c);
|
||||
}
|
||||
|
||||
/// 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.mode2400 => 300,
|
||||
};
|
||||
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)';
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import '../../utils/sar_message_parser.dart';
|
||||
import '../../utils/key_comparison.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
|
||||
/// Reusable message bubble widget that displays messages with various types:
|
||||
/// - Regular text messages (channel or direct)
|
||||
@@ -634,6 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final displayName = isOwnMessage
|
||||
? AppLocalizations.of(context)!.you
|
||||
: message.getRichDisplayName(senderContact);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// For sent direct/channel messages, look up destination display label
|
||||
dynamic recipientContact;
|
||||
@@ -667,17 +669,22 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}
|
||||
} else if (isOwnMessage && message.isChannelMessage) {
|
||||
if (message.channelIdx == 0) {
|
||||
recipientDisplayName = AppLocalizations.of(context)!.publicChannel;
|
||||
recipientDisplayName = l10n.publicChannel;
|
||||
} else {
|
||||
final channelContact = contactsProvider.channels.where((c) {
|
||||
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
|
||||
}).firstOrNull;
|
||||
recipientDisplayName =
|
||||
channelContact?.getLocalizedDisplayName(context) ??
|
||||
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}';
|
||||
'${l10n.channel} ${message.channelIdx}';
|
||||
}
|
||||
}
|
||||
|
||||
final recipientSubtitle =
|
||||
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
|
||||
? '${l10n.channel}: $recipientDisplayName'
|
||||
: recipientDisplayName;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
|
||||
@@ -860,47 +867,63 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
const Icon(Icons.person, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnMessage
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnMessage
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Show destination for sent direct/channel messages on a separate line
|
||||
if (isOwnMessage &&
|
||||
recipientSubtitle != null &&
|
||||
!widget.isCompact) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 12,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
recipientSubtitle,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.75),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Show destination for sent direct/channel messages
|
||||
if (isOwnMessage &&
|
||||
recipientDisplayName != null &&
|
||||
!widget.isCompact) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
recipientDisplayName,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Time for regular messages (not shown for SAR/drawing as it's already above)
|
||||
if (!isSarMarker && !message.isDrawing) ...[
|
||||
const SizedBox(width: 8),
|
||||
// Hop count indicator for received messages
|
||||
if (!isOwnMessage && message.pathLen < 255) ...[
|
||||
const SizedBox(width: 4),
|
||||
@@ -1055,6 +1078,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
},
|
||||
)
|
||||
// Voice message content
|
||||
else if (message.isVoice &&
|
||||
message.voiceId != null &&
|
||||
!widget.isCompact)
|
||||
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Regular message content
|
||||
else if (!message.isDrawing || widget.isCompact)
|
||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
||||
|
||||
149
lib/widgets/messages/voice_message_bubble.dart
Normal file
149
lib/widgets/messages/voice_message_bubble.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../models/message.dart';
|
||||
|
||||
/// A message bubble that shows a voice recording with play/stop controls.
|
||||
class VoiceMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool isSentByMe;
|
||||
|
||||
const VoiceMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isSentByMe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final voiceId = message.voiceId;
|
||||
if (voiceId == null) return const SizedBox.shrink();
|
||||
|
||||
return Consumer<VoiceProvider>(
|
||||
builder: (context, voiceProvider, _) {
|
||||
final session = voiceProvider.session(voiceId);
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
final isComplete = voiceProvider.isComplete(voiceId);
|
||||
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? 0;
|
||||
final durationSec = session?.estimatedDurationSeconds ?? 0.0;
|
||||
final durationLabel = _formatDuration(durationSec);
|
||||
final modeLabel = session?.mode.label ?? '?';
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Play / Stop button
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
} else {
|
||||
await voiceProvider.play(voiceId);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isSentByMe
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isPlaying ? Icons.stop : Icons.play_arrow,
|
||||
size: 28,
|
||||
color: isSentByMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Waveform placeholder / progress indicator
|
||||
if (isPlaying)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
),
|
||||
)
|
||||
else
|
||||
_WaveformBar(isComplete: isComplete),
|
||||
const SizedBox(height: 4),
|
||||
// Duration + mode + packet progress
|
||||
Text(
|
||||
_buildStatusText(
|
||||
durationLabel: durationLabel,
|
||||
modeLabel: modeLabel,
|
||||
received: received,
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDuration(double seconds) {
|
||||
final s = seconds.round();
|
||||
if (s < 60) return '${s}s';
|
||||
return '${s ~/ 60}m ${s % 60}s';
|
||||
}
|
||||
|
||||
static String _buildStatusText({
|
||||
required String durationLabel,
|
||||
required String modeLabel,
|
||||
required int received,
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
}) {
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (!isComplete && total > 0) {
|
||||
return '🎙️ $durationLabel · $modeLabel$progress';
|
||||
}
|
||||
return '🎙️ $durationLabel · $modeLabel';
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple static waveform bar using a row of rectangles.
|
||||
class _WaveformBar extends StatelessWidget {
|
||||
final bool isComplete;
|
||||
const _WaveformBar({required this.isComplete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const heights = [8.0, 14.0, 10.0, 18.0, 12.0, 16.0, 10.0, 14.0, 8.0, 12.0, 16.0, 10.0];
|
||||
final color = isComplete
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
||||
: Colors.grey.withValues(alpha: 0.5);
|
||||
return Row(
|
||||
children: heights
|
||||
.map((h) => Container(
|
||||
width: 3,
|
||||
height: h,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user