mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: retain voice codec settings now
ref:
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
41
lib/services/voice_bitrate_preferences.dart
Normal file
41
lib/services/voice_bitrate_preferences.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user