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

@@ -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),

View 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(),
);
}
}