fix: retain voice codec settings now

ref:
This commit is contained in:
Janez T
2026-03-01 11:00:30 +01:00
parent daeb8aeb9c
commit 474fd02635
34 changed files with 3796 additions and 639 deletions

View File

@@ -133,20 +133,30 @@ class MessageStorageService {
// Echo detection for channel messages
'echoCount': message.echoCount,
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAtMillis': message.lastEchoAt?.millisecondsSinceEpoch,
// Drawing message tracking
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
// Voice message tracking
'isVoice': message.isVoice,
'voiceId': message.voiceId,
// Message grouping (for bulk sends)
'groupId': message.groupId,
'recipients': message.recipients?.map((r) => {
'publicKey': base64Encode(r.publicKey),
'displayName': r.displayName,
'deliveryStatus': r.deliveryStatus.name,
'expectedAckTag': r.expectedAckTag,
'roundTripTimeMs': r.roundTripTimeMs,
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
}).toList(),
'recipients': message.recipients
?.map(
(r) => {
'publicKey': base64Encode(r.publicKey),
'displayName': r.displayName,
'deliveryStatus': r.deliveryStatus.name,
'expectedAckTag': r.expectedAckTag,
'roundTripTimeMs': r.roundTripTimeMs,
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
},
)
.toList(),
};
}
@@ -216,34 +226,46 @@ class MessageStorageService {
json['firstEchoAtMillis'] as int,
)
: null,
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
lastEchoAt: json['lastEchoAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['lastEchoAtMillis'] as int,
)
: null,
// Drawing message tracking
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
// Voice message tracking
isVoice: json['isVoice'] as bool? ?? false,
voiceId: json['voiceId'] as String?,
// Message grouping
groupId: json['groupId'] as String?,
recipients: json['recipients'] != null
? (json['recipients'] as List<dynamic>)
.map((r) => MessageRecipient(
publicKey: Uint8List.fromList(
base64Decode(r['publicKey'] as String),
),
displayName: r['displayName'] as String,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == r['deliveryStatus'],
orElse: () => MessageDeliveryStatus.sending,
),
expectedAckTag: r['expectedAckTag'] as int?,
roundTripTimeMs: r['roundTripTimeMs'] as int?,
deliveredAt: r['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
r['deliveredAtMillis'] as int,
)
: null,
sentAt: DateTime.fromMillisecondsSinceEpoch(
r['sentAtMillis'] as int,
),
))
.toList()
.map(
(r) => MessageRecipient(
publicKey: Uint8List.fromList(
base64Decode(r['publicKey'] as String),
),
displayName: r['displayName'] as String,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == r['deliveryStatus'],
orElse: () => MessageDeliveryStatus.sending,
),
expectedAckTag: r['expectedAckTag'] as int?,
roundTripTimeMs: r['roundTripTimeMs'] as int?,
deliveredAt: r['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
r['deliveredAtMillis'] as int,
)
: null,
sentAt: DateTime.fromMillisecondsSinceEpoch(
r['sentAtMillis'] as int,
),
),
)
.toList()
: null,
);
} catch (e) {

View File

@@ -69,7 +69,7 @@ class NotificationService {
// Initialize plugin
await _notificationsPlugin.initialize(
initSettings,
settings: initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
@@ -286,10 +286,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'sar:${type.name}:$coordinates',
);
@@ -457,10 +457,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
);
@@ -487,7 +487,7 @@ class NotificationService {
/// Cancel specific notification
Future<void> cancel(int id) async {
try {
await _notificationsPlugin.cancel(id);
await _notificationsPlugin.cancel(id: id);
debugPrint('✅ [NotificationService] Cancelled notification: $id');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notification: $e');
@@ -601,10 +601,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
_updateNotificationId,
title,
body,
notificationDetails,
id: _updateNotificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'update:$downloadUrl',
);

View File

@@ -21,7 +21,8 @@ class SseClientService {
StreamSubscription? _contactSubscription;
bool _isConnected = false;
bool _isConnecting = false;
bool _hasConnectedBefore = false; // Track if we've ever successfully connected
bool _hasConnectedBefore =
false; // Track if we've ever successfully connected
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
int _reconnectAttempts = 0;
@@ -56,10 +57,7 @@ class SseClientService {
String? get serverUrl => _serverUrl;
/// Connect to SSE server
Future<void> connect({
required String serverUrl,
String? authToken,
}) async {
Future<void> connect({required String serverUrl, String? authToken}) async {
if (_isConnected) {
debugPrint('⚠️ [SseClient] Already connected');
return;
@@ -69,14 +67,18 @@ class SseClientService {
_authToken = authToken;
_isConnecting = true;
debugPrint('🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)');
debugPrint(
'🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)',
);
try {
// Create a new HTTP client with custom configuration for SSE streaming
// Using IOClient with custom HttpClient for better control over connection settings
final ioHttpClient = io.HttpClient();
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
ioHttpClient.idleTimeout = const Duration(hours: 1); // Keep SSE connections alive
ioHttpClient.idleTimeout = const Duration(
hours: 1,
); // Keep SSE connections alive
_httpClient = io_client.IOClient(ioHttpClient);
// Test server availability
@@ -90,7 +92,9 @@ class SseClientService {
// Subscribe to SSE streams
debugPrint('🔗 [SseClient] Subscribing to message stream...');
debugPrint('🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}');
debugPrint(
'🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}',
);
await _subscribeToMessages();
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
await _subscribeToContacts();
@@ -149,9 +153,9 @@ class SseClientService {
final url = Uri.parse('$_serverUrl/api/status');
try {
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 5),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
@@ -179,7 +183,8 @@ class SseClientService {
if (errorStr.contains('Connection refused')) {
return 'Server not available at $host:$port. The server may be offline or not running.';
} else if (errorStr.contains('TimeoutException') || errorStr.contains('timed out')) {
} else if (errorStr.contains('TimeoutException') ||
errorStr.contains('timed out')) {
return 'Connection to $host:$port timed out. Check your network connection.';
} else if (errorStr.contains('SocketException')) {
return 'Network error connecting to $host:$port. Check your network connection.';
@@ -195,18 +200,22 @@ class SseClientService {
Future<void> _fetchMessageHistory() async {
try {
final url = Uri.parse('$_serverUrl/api/messages/history');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to fetch message history: ${response.statusCode}');
throw Exception(
'Failed to fetch message history: ${response.statusCode}',
);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final messages = data['messages'] as List;
debugPrint('📥 [SseClient] Received ${messages.length} messages from history');
debugPrint(
'📥 [SseClient] Received ${messages.length} messages from history',
);
for (final msgJson in messages) {
try {
@@ -226,9 +235,9 @@ class SseClientService {
Future<void> _fetchContacts() async {
try {
final url = Uri.parse('$_serverUrl/api/contacts');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to fetch contacts: ${response.statusCode}');
@@ -270,45 +279,63 @@ class SseClientService {
debugPrint('📡 [SseClient] Sending message stream request to $url');
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException('Message stream connection timed out after 10 seconds');
},
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException(
'Message stream connection timed out after 10 seconds',
);
},
);
debugPrint(
'📡 [SseClient] Received response with status: ${streamedResponse.statusCode}',
);
debugPrint(
'📡 [SseClient] Response headers: ${streamedResponse.headers}',
);
debugPrint(
'📡 [SseClient] Response content length: ${streamedResponse.contentLength}',
);
debugPrint(
'📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}',
);
debugPrint('📡 [SseClient] Received response with status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Response headers: ${streamedResponse.headers}');
debugPrint('📡 [SseClient] Response content length: ${streamedResponse.contentLength}');
debugPrint('📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}');
if (streamedResponse.statusCode != 200) {
throw Exception('SSE messages subscription failed: ${streamedResponse.statusCode}');
throw Exception(
'SSE messages subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint('📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}');
debugPrint(
'📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up stream listener...');
_messageSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Message stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Message stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Message stream listener set up successfully');
} catch (e) {
@@ -332,39 +359,49 @@ class SseClientService {
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending contact stream request to $url');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Contact stream connection timed out after 10 seconds');
},
);
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException(
'Contact stream connection timed out after 10 seconds',
);
},
);
if (streamedResponse.statusCode != 200) {
throw Exception('SSE contacts subscription failed: ${streamedResponse.statusCode}');
throw Exception(
'SSE contacts subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint('📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}');
debugPrint(
'📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up contact stream listener...');
_contactSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Contact stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Contact stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
} catch (e) {
@@ -423,7 +460,9 @@ class SseClientService {
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
debugPrint(
'🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
@@ -436,7 +475,9 @@ class SseClientService {
/// Start heartbeat to detect connection loss
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) async {
try {
await _checkServerStatus();
} catch (e) {
@@ -457,17 +498,16 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/messages');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
).timeout(const Duration(seconds: 10));
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send message failed: ${response.statusCode}');
@@ -492,17 +532,13 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/messages/channel');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'channelIdx': channelIdx,
'text': text,
}),
).timeout(const Duration(seconds: 10));
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({'channelIdx': channelIdx, 'text': text}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send channel message failed: ${response.statusCode}');
@@ -521,10 +557,9 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/contacts/sync');
final response = await http.post(
url,
headers: _getHeaders(),
).timeout(const Duration(seconds: 10));
final response = await http
.post(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Contact sync failed: ${response.statusCode}');
@@ -555,7 +590,9 @@ class SseClientService {
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList((json['senderPublicKeyPrefix'] as List).cast<int>())
? Uint8List.fromList(
(json['senderPublicKeyPrefix'] as List).cast<int>(),
)
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
@@ -597,6 +634,11 @@ class SseClientService {
firstEchoAt: json['firstEchoAt'] != null
? DateTime.parse(json['firstEchoAt'] as String)
: null,
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
lastEchoAt: json['lastEchoAt'] != null
? DateTime.parse(json['lastEchoAt'] as String)
: null,
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
);

View File

@@ -76,11 +76,14 @@ class SseServerService {
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization',
});
return response.change(
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'Origin, Content-Type, Authorization',
},
);
},
);
}
@@ -95,7 +98,9 @@ class SseServerService {
_config = config;
try {
debugPrint('🚀 [SseServer] Starting server on ${config.host}:${config.port}');
debugPrint(
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
);
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
@@ -104,11 +109,7 @@ class SseServerService {
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(
handler,
config.host,
config.port,
);
_server = await io.serve(handler, config.host, config.port);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
@@ -127,7 +128,9 @@ class SseServerService {
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint('📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...');
debugPrint(
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
);
_bonjourRegistration = await register(
const Service(
@@ -148,7 +151,9 @@ class SseServerService {
port: config.port,
),
);
debugPrint('✅ [SseServer] Bonjour service registered on port ${config.port}');
debugPrint(
'✅ [SseServer] Bonjour service registered on port ${config.port}',
);
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
@@ -168,20 +173,28 @@ class SseServerService {
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList();
final deadMessageStreams = _messageStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList();
final deadContactStreams = _contactStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams');
debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients');
debugPrint(
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
);
debugPrint(
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
);
}
}
@@ -269,7 +282,9 @@ class SseServerService {
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (messages) via hijack');
debugPrint(
'📥 [SseServer] New SSE client connected (messages) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
@@ -297,7 +312,9 @@ class SseServerService {
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
@@ -337,7 +354,9 @@ class SseServerService {
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (contacts) via hijack');
debugPrint(
'📥 [SseServer] New SSE client connected (contacts) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
@@ -365,7 +384,9 @@ class SseServerService {
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
@@ -432,7 +453,9 @@ class SseServerService {
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(shelf.Request request) async {
Future<shelf.Response> _handlePostChannelMessage(
shelf.Request request,
) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
@@ -442,7 +465,9 @@ class SseServerService {
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send channel message callback not configured'}),
body: jsonEncode({
'error': 'Send channel message callback not configured',
}),
);
}
@@ -574,10 +599,7 @@ class SseServerService {
</body>
</html>
''';
return shelf.Response.ok(
html,
headers: {'content-type': 'text/html'},
);
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
}
/// Broadcast a new message to all SSE clients
@@ -599,7 +621,9 @@ class SseServerService {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
@@ -608,14 +632,20 @@ class SseServerService {
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast');
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
);
}
debugPrint('📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients');
debugPrint(
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
);
}
/// Broadcast a new or updated contact to all SSE clients
@@ -634,7 +664,9 @@ class SseServerService {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
@@ -643,14 +675,20 @@ class SseServerService {
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast');
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
);
}
debugPrint('📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients');
debugPrint(
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
);
}
/// Format SSE event
@@ -694,6 +732,9 @@ class SseServerService {
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};

View File

@@ -0,0 +1,41 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/voice_message_parser.dart';
/// Stores user-selected voice bitrate and maps it to supported codec modes.
class VoiceBitratePreferences {
static const String _bitrateKey = 'voice_bitrate';
static const int defaultBitrate = 1300;
static const List<int> supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200];
static Future<int> getBitrate() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_bitrateKey) ?? defaultBitrate;
return supportedBitrates.contains(value) ? value : defaultBitrate;
}
static Future<void> setBitrate(int bitrate) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_bitrateKey, bitrate);
}
static VoicePacketMode toVoiceMode(int bitrate) {
switch (bitrate) {
case 1200:
return VoicePacketMode.mode1200;
case 1300:
return VoicePacketMode.mode1300;
case 1400:
return VoicePacketMode.mode1400;
case 1600:
return VoicePacketMode.mode1600;
case 2400:
return VoicePacketMode.mode2400;
case 3200:
return VoicePacketMode.mode3200;
case 700:
return VoicePacketMode.mode700c;
default:
return VoicePacketMode.mode1300;
}
}
}

View File

@@ -8,6 +8,9 @@ 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.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
@@ -8,36 +9,74 @@ import 'package:path_provider/path_provider.dart';
/// to the system temp directory and using [AudioPlayer].
class VoicePlayerService {
final AudioPlayer _player = AudioPlayer();
final StreamController<void> _events = StreamController<void>.broadcast();
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Timer? _fallbackTicker;
DateTime? _playbackStartedAt;
bool get isPlaying => _isPlaying;
Duration get position => _position;
Duration get duration => _duration;
Stream<void> get events => _events.stream;
VoicePlayerService() {
_player.onPlayerStateChanged.listen((state) {
debugPrint('🔊 [VoicePlayer] state → $state');
_isPlaying = state == PlayerState.playing;
if (_isPlaying) {
_startFallbackTicker();
} else {
_stopFallbackTicker();
}
_events.add(null);
});
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
_player.onPositionChanged.listen((position) {
_position = position;
_events.add(null);
});
_player.onDurationChanged.listen((duration) {
_duration = duration;
_events.add(null);
});
_player.onPlayerComplete.listen((_) {
_isPlaying = false;
_position = _duration;
_stopFallbackTicker();
_events.add(null);
});
}
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop();
_position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
_playbackStartedAt = DateTime.now();
_events.add(null);
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}');
debugPrint(
'🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}',
);
try {
_isPlaying = true;
_startFallbackTicker();
_events.add(null);
await _player.play(DeviceFileSource(file.path));
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
} catch (e, st) {
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
_isPlaying = false;
_stopFallbackTicker();
_events.add(null);
}
}
@@ -45,40 +84,76 @@ class VoicePlayerService {
debugPrint('🔊 [VoicePlayer] stop()');
await _player.stop();
_isPlaying = false;
_position = Duration.zero;
_playbackStartedAt = null;
_stopFallbackTicker();
_events.add(null);
}
void dispose() {
_stopFallbackTicker();
_events.close();
_player.dispose();
}
void _startFallbackTicker() {
if (_fallbackTicker != null) return;
_fallbackTicker = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!_isPlaying || _duration.inMilliseconds <= 0) return;
final startedAt = _playbackStartedAt;
if (startedAt == null) return;
final elapsed = DateTime.now().difference(startedAt);
final clamped = elapsed > _duration ? _duration : elapsed;
if (clamped > _position) {
_position = clamped;
_events.add(null);
}
});
}
void _stopFallbackTicker() {
_fallbackTicker?.cancel();
_fallbackTicker = null;
}
// ── 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 numChannels = 1;
const int bitsPerSample = 16;
const int audioFormat = 1; // PCM
const int audioFormat = 1; // PCM
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
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 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); }
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;
}
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
writeU32(16); // subchunk1 size
writeU16(audioFormat); // 1 = PCM
writeU16(numChannels);
writeU32(sampleRate);
writeU32(byteRate);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:record/record.dart';
@@ -23,9 +24,11 @@ class VoiceRecorderService {
/// Start capturing PCM audio.
///
/// [chunkDuration] controls how often samples are emitted (default 1 s).
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1),
bool enableBandPassFilter = true,
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
@@ -36,11 +39,17 @@ class VoiceRecorderService {
);
_isRecording = true;
_startRecording(chunkDuration);
_startRecording(
chunkDuration,
enableBandPassFilter: enableBandPassFilter,
);
return _controller!.stream;
}
Future<void> _startRecording(Duration chunkDuration) async {
Future<void> _startRecording(
Duration chunkDuration, {
required bool enableBandPassFilter,
}) async {
final config = const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 8000,
@@ -50,6 +59,11 @@ class VoiceRecorderService {
try {
final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000,
lowCutHz: 250.0,
highCutHz: 3400.0,
);
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[];
@@ -59,13 +73,19 @@ class VoiceRecorderService {
while (buffer.length >= chunkBytes) {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
_controller?.add(_bytesToInt16(Uint8List.fromList(chunk)));
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
}
},
onDone: () {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
_controller?.add(_bytesToInt16(Uint8List.fromList(padded)));
final pcm = _bytesToInt16(Uint8List.fromList(padded));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
}
_controller?.close();
},
@@ -117,3 +137,121 @@ class VoiceRecorderService {
return buf;
}
}
/// Band-pass filter tuned for human voice at 8 kHz input.
///
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency
/// rumble and high-frequency noise outside the speech band.
class _VoiceBandPassFilter {
final _BiquadFilter _highPass;
final _BiquadFilter _lowPass;
_VoiceBandPassFilter({
required int sampleRate,
required double lowCutHz,
required double highCutHz,
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
Int16List process(Int16List input) {
final output = Int16List(input.length);
for (var i = 0; i < input.length; i++) {
var sample = input[i].toDouble();
sample = _highPass.process(sample);
sample = _lowPass.process(sample);
output[i] = sample.clamp(-32768.0, 32767.0).round();
}
return output;
}
}
/// Standard biquad IIR filter (Direct Form I).
class _BiquadFilter {
final double _b0;
final double _b1;
final double _b2;
final double _a1;
final double _a2;
double _x1 = 0.0;
double _x2 = 0.0;
double _y1 = 0.0;
double _y2 = 0.0;
_BiquadFilter._({
required double b0,
required double b1,
required double b2,
required double a1,
required double a2,
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
factory _BiquadFilter.lowPass({
required double sampleRate,
required double cutoffHz,
}) {
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
final cosOmega = math.cos(omega);
final alpha = math.sin(omega) / (2.0 * q);
final b0 = (1.0 - cosOmega) / 2.0;
final b1 = 1.0 - cosOmega;
final b2 = (1.0 - cosOmega) / 2.0;
final a0 = 1.0 + alpha;
final a1 = -2.0 * cosOmega;
final a2 = 1.0 - alpha;
return _BiquadFilter._(
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
);
}
factory _BiquadFilter.highPass({
required double sampleRate,
required double cutoffHz,
}) {
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
final cosOmega = math.cos(omega);
final alpha = math.sin(omega) / (2.0 * q);
final b0 = (1.0 + cosOmega) / 2.0;
final b1 = -(1.0 + cosOmega);
final b2 = (1.0 + cosOmega) / 2.0;
final a0 = 1.0 + alpha;
final a1 = -2.0 * cosOmega;
final a2 = 1.0 - alpha;
return _BiquadFilter._(
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
);
}
double process(double x) {
final y = _b0 * x + _b1 * _x1 + _b2 * _x2 - _a1 * _y1 - _a2 * _y2;
_x2 = _x1;
_x1 = x;
_y2 = _y1;
_y1 = y;
return y;
}
}