|
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:developer' as log1;
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:math';
|
|
|
|
|
import 'package:signalr_netcore/ihub_protocol.dart';
|
|
|
|
|
import 'package:signalr_netcore/signalr_client.dart';
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
import 'package:flutter_sound/flutter_sound.dart' as sound;
|
|
|
|
|
import 'package:http/http.dart';
|
|
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
|
import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
|
|
|
|
import 'package:test_sa/controllers/api_routes/urls.dart';
|
|
|
|
|
|
|
|
|
|
import '../model/copilot_message_model.dart';
|
|
|
|
|
import '../utils/resilient_signalr_http_client.dart';
|
|
|
|
|
|
|
|
|
|
enum PttStatus { idle, connecting, recording, thinking, playingAudio, error }
|
|
|
|
|
|
|
|
|
|
class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
List<CopilotHistoryItem> history = [];
|
|
|
|
|
bool historyLoading = false;
|
|
|
|
|
List<CopilotMessage> messages = [];
|
|
|
|
|
bool conversationLoading = false;
|
|
|
|
|
bool isStreaming = false;
|
|
|
|
|
bool isTranscribing = false;
|
|
|
|
|
String? sessionId;
|
|
|
|
|
String? errorMessage;
|
|
|
|
|
StreamSubscription? _streamSub;
|
|
|
|
|
|
|
|
|
|
HubConnection? _pttHub;
|
|
|
|
|
// Serializes connect/disconnect so they never overlap when the user navigates
|
|
|
|
|
// in and out of the Push-to-Talk screen quickly.
|
|
|
|
|
Future<void> _pttOpQueue = Future.value();
|
|
|
|
|
PttStatus pttStatus = PttStatus.idle;
|
|
|
|
|
String? pttErrorMessage;
|
|
|
|
|
List<CopilotMessage> pttMessages = [];
|
|
|
|
|
String? pttSessionId;
|
|
|
|
|
bool _pttButtonHeld = false;
|
|
|
|
|
bool _pttIsLive = false;
|
|
|
|
|
String? _pttLanguage;
|
|
|
|
|
bool liveSessionActive = false;
|
|
|
|
|
|
|
|
|
|
final sound.FlutterSoundRecorder _pttRecorder = sound.FlutterSoundRecorder();
|
|
|
|
|
bool _pttRecorderOpen = false;
|
|
|
|
|
StreamController<Uint8List>? _pttMicStreamController;
|
|
|
|
|
StreamSubscription? _pttMicSub;
|
|
|
|
|
Future<void> _pttSendQueue = Future.value();
|
|
|
|
|
int _pttChunkCount = 0;
|
|
|
|
|
|
|
|
|
|
// Live Talk voice-activity (silence) detection.
|
|
|
|
|
StreamSubscription<sound.RecordingDisposition>? _pttProgressSub;
|
|
|
|
|
DateTime? _pttLastLoudAt;
|
|
|
|
|
DateTime? _pttRecordingStartedAt;
|
|
|
|
|
bool _pttAutoStopTriggered = false;
|
|
|
|
|
bool _pttHasDetectedSpeech = false;
|
|
|
|
|
static const double _silenceDbThreshold = 38.0;
|
|
|
|
|
static const Duration _silenceHoldDuration = Duration(milliseconds: 1400);
|
|
|
|
|
|
|
|
|
|
CopilotMessage? _pttCurrentAssistantMsg;
|
|
|
|
|
|
|
|
|
|
void _pttLog(String message) => log1.log('[PTT] $message');
|
|
|
|
|
|
|
|
|
|
Future<void> loadHistory() async {
|
|
|
|
|
historyLoading = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
try {
|
|
|
|
|
final res = await ApiManager.instance.get(
|
|
|
|
|
URLs.copilotGetQueryHistory,
|
|
|
|
|
enableToastMessage: false,
|
|
|
|
|
);
|
|
|
|
|
final decoded = jsonDecode(res.body);
|
|
|
|
|
if (decoded is Map && decoded['isSuccess'] == true) {
|
|
|
|
|
final List threads = decoded['data']?['threads'] ?? [];
|
|
|
|
|
history = threads.map((e) => CopilotHistoryItem.fromJson(e)).toList();
|
|
|
|
|
} else {
|
|
|
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load history";
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errorMessage = "Failed to load history";
|
|
|
|
|
} finally {
|
|
|
|
|
historyLoading = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> deleteHistory(String threadId) async {
|
|
|
|
|
final prevList = List<CopilotHistoryItem>.from(history);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final res = await ApiManager.instance.delete(URLs.copilotDeleteQueryHistory(threadId), body: const {});
|
|
|
|
|
final decoded = jsonDecode(res.body);
|
|
|
|
|
final success = decoded is Map && decoded['isSuccess'] == true && decoded['data'] == true;
|
|
|
|
|
if (success) {
|
|
|
|
|
history.removeWhere((h) => h.threadId == threadId);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} else {
|
|
|
|
|
history = prevList;
|
|
|
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to delete";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
history = prevList;
|
|
|
|
|
errorMessage = "Failed to delete";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> loadConversation(String threadId) async {
|
|
|
|
|
conversationLoading = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
messages = [];
|
|
|
|
|
sessionId = threadId;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
try {
|
|
|
|
|
final res = await ApiManager.instance.get(
|
|
|
|
|
URLs.copilotGetConversationByThreadId(threadId),
|
|
|
|
|
enableToastMessage: false,
|
|
|
|
|
);
|
|
|
|
|
final decoded = jsonDecode(res.body);
|
|
|
|
|
if (decoded is Map && decoded['isSuccess'] == true) {
|
|
|
|
|
final List rawMessages = decoded['data']?['messages'] ?? [];
|
|
|
|
|
messages = rawMessages.map<CopilotMessage>((e) => CopilotMessage.fromHistoryJson(e)).toList();
|
|
|
|
|
} else {
|
|
|
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load conversation";
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errorMessage = "Failed to load conversation";
|
|
|
|
|
} finally {
|
|
|
|
|
conversationLoading = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void startNewConversation() {
|
|
|
|
|
_streamSub?.cancel();
|
|
|
|
|
sessionId = null;
|
|
|
|
|
messages = [];
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> sendText(String query, String language) async {
|
|
|
|
|
if (isStreaming || query.trim().isEmpty) return;
|
|
|
|
|
sessionId ??= _generateSessionId();
|
|
|
|
|
|
|
|
|
|
messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, content: query, timestamp: DateTime.now()));
|
|
|
|
|
final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now());
|
|
|
|
|
messages.add(assistantMsg);
|
|
|
|
|
isStreaming = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final streamedResponse = await ApiManager.instance.postFormDataStreaming(
|
|
|
|
|
URLs.copilotQueryStream,
|
|
|
|
|
body: {
|
|
|
|
|
"sessionId": sessionId!,
|
|
|
|
|
"isAudio": "false",
|
|
|
|
|
"langId": "$language",
|
|
|
|
|
'isFromMobile': 'true',
|
|
|
|
|
"query": query,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await _consumeStream(streamedResponse, assistantMsg, null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_onStreamError(assistantMsg, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> sendAudio(File audioFile, String language) async {
|
|
|
|
|
if (isStreaming) return;
|
|
|
|
|
sessionId ??= _generateSessionId();
|
|
|
|
|
final humanAudioMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now());
|
|
|
|
|
messages.add(humanAudioMsg);
|
|
|
|
|
final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now());
|
|
|
|
|
messages.add(assistantMsg);
|
|
|
|
|
isStreaming = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final streamedResponse = await ApiManager.instance.postFormDataStreamingWithFile(
|
|
|
|
|
URLs.copilotQueryStream,
|
|
|
|
|
fields: {
|
|
|
|
|
"sessionId": sessionId!,
|
|
|
|
|
"isAudio": "true",
|
|
|
|
|
"langId": "$language",
|
|
|
|
|
'isFromMobile': 'true',
|
|
|
|
|
},
|
|
|
|
|
fileFieldName: "AudioFile",
|
|
|
|
|
file: audioFile,
|
|
|
|
|
filename: audioFile.path.split('/').last,
|
|
|
|
|
);
|
|
|
|
|
await _consumeStream(streamedResponse, assistantMsg, humanAudioMsg);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_onStreamError(assistantMsg, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> transcribeAudio(File audioFile, String language) async {
|
|
|
|
|
isTranscribing = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
final exists = await audioFile.exists();
|
|
|
|
|
final sizeBytes = exists ? await audioFile.length() : 0;
|
|
|
|
|
if (!exists || sizeBytes == 0) {
|
|
|
|
|
errorMessage = "Recording file is empty or missing";
|
|
|
|
|
isTranscribing = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final response = await ApiManager.instance.multiPart(
|
|
|
|
|
URLs.copilotAudioTranscription,
|
|
|
|
|
body: {
|
|
|
|
|
"langId": language,
|
|
|
|
|
},
|
|
|
|
|
showToast: false,
|
|
|
|
|
files: [
|
|
|
|
|
MultipartFile.fromPath("AudioFile", audioFile.path, filename: audioFile.path.split('/').last),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
|
|
|
errorMessage = "Failed to transcribe audio";
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _extractTranscriptionText(response.body);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errorMessage = "Failed to transcribe audio";
|
|
|
|
|
return null;
|
|
|
|
|
} finally {
|
|
|
|
|
isTranscribing = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String? _extractTranscriptionText(String rawBody) {
|
|
|
|
|
final trimmedRaw = rawBody.trim();
|
|
|
|
|
if (trimmedRaw.isEmpty) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final decoded = jsonDecode(trimmedRaw);
|
|
|
|
|
if (decoded is String) {
|
|
|
|
|
return decoded.trim();
|
|
|
|
|
} else if (decoded is Map) {
|
|
|
|
|
final map = Map<String, dynamic>.from(decoded);
|
|
|
|
|
final lowerKeyMap = {for (final entry in map.entries) entry.key.toLowerCase(): entry.value};
|
|
|
|
|
final text = lowerKeyMap['transcription'] ?? lowerKeyMap['data'] ?? lowerKeyMap['text'] ?? lowerKeyMap['result'];
|
|
|
|
|
if (text is Map) {
|
|
|
|
|
return _extractTranscriptionText(jsonEncode(text));
|
|
|
|
|
}
|
|
|
|
|
if (text != null) return text.toString().trim();
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
return trimmedRaw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _generateSessionId() {
|
|
|
|
|
final rand = Random();
|
|
|
|
|
final bytes = List<int>.generate(16, (_) => rand.nextInt(256));
|
|
|
|
|
bytes[6] = (bytes[6] & 0x0F) | 0x40;
|
|
|
|
|
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
|
|
|
|
String byteHex(int i) => bytes[i].toRadixString(16).padLeft(2, '0');
|
|
|
|
|
final hex = List.generate(16, byteHex).join();
|
|
|
|
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) async {
|
|
|
|
|
if (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300) {
|
|
|
|
|
_onStreamError(assistantMsg, "Server error ${streamedResponse.statusCode}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String buffer = '';
|
|
|
|
|
_streamSub = streamedResponse.stream.transform(utf8.decoder).listen(
|
|
|
|
|
(rawChunk) {
|
|
|
|
|
buffer += rawChunk;
|
|
|
|
|
while (buffer.contains('\n\n')) {
|
|
|
|
|
final idx = buffer.indexOf('\n\n');
|
|
|
|
|
final rawEvent = buffer.substring(0, idx);
|
|
|
|
|
buffer = buffer.substring(idx + 2);
|
|
|
|
|
_handleSseEvent(rawEvent, assistantMsg, humanAudioMsg);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onDone: () {
|
|
|
|
|
if (buffer.trim().isNotEmpty) {
|
|
|
|
|
_handleSseEvent(buffer, assistantMsg, humanAudioMsg);
|
|
|
|
|
}
|
|
|
|
|
assistantMsg.isStreaming = false;
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
},
|
|
|
|
|
onError: (e) => _onStreamError(assistantMsg, e),
|
|
|
|
|
cancelOnError: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) {
|
|
|
|
|
final dataLines = rawEvent.split('\n').where((line) => line.startsWith('data:')).map((line) => line.substring(5).trim()).toList();
|
|
|
|
|
if (dataLines.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
final payload = dataLines.join('\n');
|
|
|
|
|
|
|
|
|
|
if (payload == '[DONE]') {
|
|
|
|
|
assistantMsg.isStreaming = false;
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final json = jsonDecode(payload);
|
|
|
|
|
final event = CopilotStreamEvent.fromJson(json);
|
|
|
|
|
if (event.type == 'response' && event.content != null) {
|
|
|
|
|
assistantMsg.content = event.content!.answer ?? '';
|
|
|
|
|
assistantMsg.documentAnswer = event.content!.documentAnswer;
|
|
|
|
|
assistantMsg.websiteAnswer = event.content!.websiteAnswer;
|
|
|
|
|
assistantMsg.images = event.content!.images;
|
|
|
|
|
} else if (event.type == 'error') {
|
|
|
|
|
_onStreamError(assistantMsg, event.content?.answer ?? 'Something went wrong');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.type == 'transcription' && event.transcriptionContent != null) {
|
|
|
|
|
humanAudioMsg?.content = event.transcriptionContent ?? '';
|
|
|
|
|
humanAudioMsg?.localAudioPath = null;
|
|
|
|
|
}
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onStreamError(CopilotMessage assistantMsg, Object e) {
|
|
|
|
|
assistantMsg.isStreaming = false;
|
|
|
|
|
assistantMsg.isError = true;
|
|
|
|
|
if (assistantMsg.content.isEmpty) {
|
|
|
|
|
assistantMsg.content = "Something went wrong. Please try again.";
|
|
|
|
|
}
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
errorMessage = "Failed to get a response";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void cancelStreaming() {
|
|
|
|
|
_streamSub?.cancel();
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
if (messages.isNotEmpty && messages.last.role == CopilotRole.assistant) {
|
|
|
|
|
messages.last.isStreaming = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds a brand-new push-to-talk connection. Any previous connection is
|
|
|
|
|
/// torn down first, so entering the screen always gives a fresh connection.
|
|
|
|
|
/// Serialized via [_pttOpQueue] so it never races with a disconnect.
|
|
|
|
|
Future<void> connectPushToTalkHub() {
|
|
|
|
|
_pttOpQueue = _pttOpQueue.then((_) => _doConnectPushToTalkHub()).catchError((e) {
|
|
|
|
|
_pttLog('connectPushToTalkHub: queued error=$e');
|
|
|
|
|
});
|
|
|
|
|
return _pttOpQueue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _doConnectPushToTalkHub() async {
|
|
|
|
|
// Always start from a clean slate so every entry is a fresh connection.
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
|
|
|
|
|
final token = ApiManager.instance.user?.token ?? '';
|
|
|
|
|
final url = "${URLs.copilotHubUrl}?access_token=$token";
|
|
|
|
|
_pttLog('connectPushToTalkHub: building connection url=${URLs.copilotHubUrl}');
|
|
|
|
|
final httpOp = HttpConnectionOptions(
|
|
|
|
|
// Server only accepts LongPolling.
|
|
|
|
|
transport: HttpTransportType.LongPolling,
|
|
|
|
|
skipNegotiation: false,
|
|
|
|
|
// Never log message content: it contains base64 audio and is very heavy.
|
|
|
|
|
logMessageContent: false,
|
|
|
|
|
headers: MessageHeaders()..setHeaderValue('Authorization', 'Bearer $token'),
|
|
|
|
|
// Keeps the long-poll alive across poll timeouts / network blips instead
|
|
|
|
|
// of closing the whole connection (signalr_netcore bug workaround).
|
|
|
|
|
httpClient: ResilientSignalRHttpClient(),
|
|
|
|
|
);
|
|
|
|
|
final hub = HubConnectionBuilder().withUrl(url, options: httpOp).withAutomaticReconnect(retryDelays: <int>[0, 2000, 5000, 10000, 20000]).build();
|
|
|
|
|
// More tolerant timeouts for mobile networks (defaults are 30s / 15s).
|
|
|
|
|
hub.serverTimeoutInMilliseconds = 60000;
|
|
|
|
|
hub.keepAliveIntervalInMilliseconds = 15000;
|
|
|
|
|
_pttHub = hub;
|
|
|
|
|
|
|
|
|
|
hub.onreconnecting(({Exception? error}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return;
|
|
|
|
|
_pttLog('hub reconnecting error=$error');
|
|
|
|
|
_handleConnectionInterrupted("Connection lost. Reconnecting…");
|
|
|
|
|
});
|
|
|
|
|
hub.onreconnected(({String? connectionId}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return;
|
|
|
|
|
_pttLog('hub reconnected connectionId=$connectionId');
|
|
|
|
|
// Server-side recording state is bound to the old connection id, so the
|
|
|
|
|
// user must start a new turn.
|
|
|
|
|
if (pttStatus == PttStatus.error || pttStatus == PttStatus.connecting) {
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
}
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
hub.onclose(({Exception? error}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return; // intentional stop of an old hub
|
|
|
|
|
_pttLog('hub closed error=$error');
|
|
|
|
|
_handleConnectionInterrupted("Connection lost. Hold the mic to reconnect.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_pttHub!.on("RecordingStarted", _onRecordingStarted);
|
|
|
|
|
_pttHub!.on("RecordingStopped", _onRecordingStopped);
|
|
|
|
|
_pttHub!.on("RecordingCancelled", _onRecordingCancelled);
|
|
|
|
|
_pttHub!.on("TranscriptionReceived", _onTranscriptionReceived);
|
|
|
|
|
_pttHub!.on("ResponseReceived", _onResponseReceived);
|
|
|
|
|
_pttHub!.on("AudioReceived", _onAudioReceived);
|
|
|
|
|
_pttHub!.on("RecordingError", _onRecordingError);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
_pttLog('connectPushToTalkHub: calling start()...');
|
|
|
|
|
await _pttHub!.start()?.timeout(const Duration(seconds: 20), onTimeout: () {
|
|
|
|
|
throw TimeoutException('Connection to push-to-talk service timed out');
|
|
|
|
|
});
|
|
|
|
|
_pttLog('connectPushToTalkHub: connected, state=${_pttHub!.state}');
|
|
|
|
|
} catch (e, st) {
|
|
|
|
|
_pttLog('connectPushToTalkHub: failed error=$e\n$st');
|
|
|
|
|
pttErrorMessage = "Failed to connect to push-to-talk service";
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
notifyListeners();
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Disconnects the push-to-talk connection cleanly. Called when leaving the
|
|
|
|
|
/// screen. Serialized via [_pttOpQueue] so it never races with a connect.
|
|
|
|
|
Future<void> disconnectPushToTalkHub() {
|
|
|
|
|
_pttOpQueue = _pttOpQueue.then((_) => _doDisconnectPushToTalkHub()).catchError((e) {
|
|
|
|
|
_pttLog('disconnectPushToTalkHub: queued error=$e');
|
|
|
|
|
});
|
|
|
|
|
return _pttOpQueue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _doDisconnectPushToTalkHub() async {
|
|
|
|
|
_pttLog('disconnectPushToTalkHub: stopping');
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stops mic streaming and tears down the hub. Does NOT touch [_pttOpQueue]
|
|
|
|
|
/// so it is safe to call from inside queued operations.
|
|
|
|
|
Future<void> _stopHubAndCleanup() async {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
// Detach first so callbacks (onclose) from this hub are ignored.
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
_pttHub = null;
|
|
|
|
|
try {
|
|
|
|
|
await _abortMicStreaming();
|
|
|
|
|
if (hub != null) {
|
|
|
|
|
await hub.stop().timeout(const Duration(seconds: 10), onTimeout: () {
|
|
|
|
|
_pttLog('_stopHubAndCleanup: stop() timed out, forcing cleanup');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_stopHubAndCleanup: error=$e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Called when the hub drops (reconnecting/closed). Stops the mic and frees
|
|
|
|
|
/// the UI so it never stays stuck in recording/thinking forever.
|
|
|
|
|
void _handleConnectionInterrupted(String message) {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_abortMicStreaming().catchError((_) {});
|
|
|
|
|
if (pttStatus != PttStatus.idle) {
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = message;
|
|
|
|
|
}
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-establishes the connection if it was lost (e.g. after the app was
|
|
|
|
|
/// in background). Safe to call any time.
|
|
|
|
|
Future<void> ensurePushToTalkConnected() async {
|
|
|
|
|
final state = _pttHub?.state;
|
|
|
|
|
if (state == HubConnectionState.Connected || state == HubConnectionState.Connecting || state == HubConnectionState.Reconnecting) return;
|
|
|
|
|
await connectPushToTalkHub();
|
|
|
|
|
if (_pttHub?.state == HubConnectionState.Connected && pttStatus == PttStatus.error) {
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Timer? _pttResponseWatchdog;
|
|
|
|
|
static const Duration _responseTimeout = Duration(seconds: 60);
|
|
|
|
|
|
|
|
|
|
void _startResponseWatchdog() {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_pttResponseWatchdog = Timer(_responseTimeout, () {
|
|
|
|
|
if (pttStatus != PttStatus.thinking) return;
|
|
|
|
|
_pttLog('response watchdog fired: no response from server');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "No response from server. Please try again.";
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _cancelResponseWatchdog() {
|
|
|
|
|
_pttResponseWatchdog?.cancel();
|
|
|
|
|
_pttResponseWatchdog = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void startNewPttConversation() {
|
|
|
|
|
pttSessionId = null;
|
|
|
|
|
pttMessages = [];
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_pttIsLive = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> beginPushToTalk(String language, {bool isLive = false}) async {
|
|
|
|
|
if (pttStatus != PttStatus.idle && pttStatus != PttStatus.error) return;
|
|
|
|
|
pttSessionId ??= _generateSessionId();
|
|
|
|
|
_pttButtonHeld = true;
|
|
|
|
|
_pttIsLive = isLive;
|
|
|
|
|
_pttLanguage = language;
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
pttStatus = PttStatus.connecting;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (_pttHub == null || _pttHub!.state != HubConnectionState.Connected) {
|
|
|
|
|
await connectPushToTalkHub();
|
|
|
|
|
}
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
if (hub == null || hub.state != HubConnectionState.Connected) {
|
|
|
|
|
throw StateError('Push-to-talk hub is not connected');
|
|
|
|
|
}
|
|
|
|
|
final args = [
|
|
|
|
|
{
|
|
|
|
|
"sessionId": pttSessionId ?? '',
|
|
|
|
|
"userId": ApiManager.instance.user?.id ?? '',
|
|
|
|
|
"assetGroupId": (ApiManager.instance.assetGroup?.id ?? '').toString(),
|
|
|
|
|
"languageId": "1",
|
|
|
|
|
"isLiveSession": isLive,
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
_pttLog('beginPushToTalk: invoking StartRecording args=$args');
|
|
|
|
|
await _pttHub!.invoke("StartRecording", args: args);
|
|
|
|
|
_pttLog('beginPushToTalk: StartRecording invoked, waiting for RecordingStarted event');
|
|
|
|
|
} catch (e, st) {
|
|
|
|
|
_pttLog('beginPushToTalk: failed error=$e\n$st');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "Failed to start recording";
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Starts a continuous "Live Talk" session: records, waits for a response,
|
|
|
|
|
/// then automatically starts listening again until [stopLiveTalk] is called.
|
|
|
|
|
Future<void> startLiveTalk(String language) async {
|
|
|
|
|
if (pttStatus != PttStatus.idle && pttStatus != PttStatus.error) return;
|
|
|
|
|
liveSessionActive = true;
|
|
|
|
|
await beginPushToTalk(language, isLive: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stops the current Live Talk session (does not auto-restart listening again).
|
|
|
|
|
Future<void> stopLiveTalk() async {
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
if (pttStatus == PttStatus.recording) {
|
|
|
|
|
await endPushToTalk();
|
|
|
|
|
} else {
|
|
|
|
|
await cancelPushToTalk();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> endPushToTalk() async {
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
if (pttStatus != PttStatus.recording) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pttStatus = PttStatus.thinking;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
_pttLog('endPushToTalk: stopping mic and flushing $_pttChunkCount chunks sent so far');
|
|
|
|
|
try {
|
|
|
|
|
await _stopMicStreamingAndFlush();
|
|
|
|
|
if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) {
|
|
|
|
|
_pttLog('endPushToTalk: invoking StopRecording');
|
|
|
|
|
await _pttHub!.invoke("StopRecording");
|
|
|
|
|
_startResponseWatchdog();
|
|
|
|
|
} else {
|
|
|
|
|
throw StateError('Connection lost before StopRecording');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('endPushToTalk: failed error=$e');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "Failed to stop recording";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> cancelPushToTalk() async {
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
try {
|
|
|
|
|
await _abortMicStreaming();
|
|
|
|
|
if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) {
|
|
|
|
|
_pttLog('cancelPushToTalk: invoking CancelRecording');
|
|
|
|
|
await _pttHub!.invoke("CancelRecording");
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('cancelPushToTalk: error=$e');
|
|
|
|
|
}
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _startMicStreaming() async {
|
|
|
|
|
final micStatus = await Permission.microphone.request();
|
|
|
|
|
if (!micStatus.isGranted) {
|
|
|
|
|
_pttLog('_startMicStreaming: microphone permission denied');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "Microphone permission denied";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
await cancelPushToTalk();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_pttRecorderOpen) {
|
|
|
|
|
await _pttRecorder.openRecorder();
|
|
|
|
|
_pttRecorderOpen = true;
|
|
|
|
|
}
|
|
|
|
|
await _pttRecorder.setSubscriptionDuration(const Duration(milliseconds: 100));
|
|
|
|
|
|
|
|
|
|
_pttMicStreamController = StreamController<Uint8List>();
|
|
|
|
|
_pttSendQueue = Future.value();
|
|
|
|
|
_pttChunkCount = 0;
|
|
|
|
|
|
|
|
|
|
_pttMicSub = _pttMicStreamController!.stream.listen((chunk) {
|
|
|
|
|
_pttChunkCount++;
|
|
|
|
|
_pttLog('mic chunk #$_pttChunkCount captured bytes=${chunk.length}');
|
|
|
|
|
_enqueueChunkSend(chunk);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await _pttRecorder.startRecorder(
|
|
|
|
|
toStream: _pttMicStreamController!.sink,
|
|
|
|
|
codec: sound.Codec.pcm16,
|
|
|
|
|
numChannels: 1,
|
|
|
|
|
sampleRate: 48000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
_pttLog('_startMicStreaming: recorder started');
|
|
|
|
|
pttStatus = PttStatus.recording;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
if (_pttIsLive) {
|
|
|
|
|
_startSilenceDetection();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Monitors mic decibel levels while in Live Talk mode and automatically
|
|
|
|
|
/// triggers [endPushToTalk] once the user has been silent for
|
|
|
|
|
/// [_silenceHoldDuration] AFTER having spoken, so the user doesn't need to
|
|
|
|
|
/// tap anything. Won't trigger on the initial silence before the user
|
|
|
|
|
/// starts speaking.
|
|
|
|
|
void _startSilenceDetection() {
|
|
|
|
|
_pttAutoStopTriggered = false;
|
|
|
|
|
_pttHasDetectedSpeech = false;
|
|
|
|
|
_pttRecordingStartedAt = DateTime.now();
|
|
|
|
|
_pttLastLoudAt = null;
|
|
|
|
|
_pttProgressSub?.cancel();
|
|
|
|
|
_pttProgressSub = _pttRecorder.onProgress?.listen((disposition) {
|
|
|
|
|
final db = disposition.decibels ?? 0;
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
if (db > _silenceDbThreshold) {
|
|
|
|
|
_pttLastLoudAt = now;
|
|
|
|
|
_pttHasDetectedSpeech = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (_pttAutoStopTriggered || !_pttHasDetectedSpeech) return;
|
|
|
|
|
final lastLoud = _pttLastLoudAt;
|
|
|
|
|
if (lastLoud != null && now.difference(lastLoud) >= _silenceHoldDuration) {
|
|
|
|
|
_pttAutoStopTriggered = true;
|
|
|
|
|
_pttLog('_startSilenceDetection: silence detected after speech, auto-stopping recording (live talk)');
|
|
|
|
|
endPushToTalk().catchError((_) {});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _stopSilenceDetection() {
|
|
|
|
|
_pttProgressSub?.cancel();
|
|
|
|
|
_pttProgressSub = null;
|
|
|
|
|
_pttLastLoudAt = null;
|
|
|
|
|
_pttRecordingStartedAt = null;
|
|
|
|
|
_pttHasDetectedSpeech = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _stopMicStreamingAndFlush() async {
|
|
|
|
|
_stopSilenceDetection();
|
|
|
|
|
try {
|
|
|
|
|
if (_pttRecorderOpen) {
|
|
|
|
|
await _pttRecorder.stopRecorder();
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_stopMicStreamingAndFlush: stopRecorder error=$e');
|
|
|
|
|
}
|
|
|
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
|
|
|
await _pttMicSub?.cancel();
|
|
|
|
|
_pttMicSub = null;
|
|
|
|
|
await _pttMicStreamController?.close();
|
|
|
|
|
_pttMicStreamController = null;
|
|
|
|
|
await _pttSendQueue;
|
|
|
|
|
_pttLog('_stopMicStreamingAndFlush: done, total chunks sent=$_pttChunkCount');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _abortMicStreaming() async {
|
|
|
|
|
_stopSilenceDetection();
|
|
|
|
|
try {
|
|
|
|
|
if (_pttRecorderOpen) {
|
|
|
|
|
await _pttRecorder.stopRecorder();
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_abortMicStreaming: stopRecorder error=$e');
|
|
|
|
|
}
|
|
|
|
|
await _pttMicSub?.cancel();
|
|
|
|
|
_pttMicSub = null;
|
|
|
|
|
await _pttMicStreamController?.close();
|
|
|
|
|
_pttMicStreamController = null;
|
|
|
|
|
_pttSendQueue = Future.value();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _enqueueChunkSend(Uint8List chunk) {
|
|
|
|
|
_pttSendQueue = _pttSendQueue.then((_) => _sendAudioChunk(chunk));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _sendAudioChunk(Uint8List chunk) async {
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
if (hub == null || hub.state != HubConnectionState.Connected) return;
|
|
|
|
|
try {
|
|
|
|
|
await hub.send("SendAudioChunk", args: [base64Encode(chunk)]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_sendAudioChunk: failed error=$e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onRecordingStarted(List<Object?>? params) async {
|
|
|
|
|
_pttLog('event RecordingStarted params=$params');
|
|
|
|
|
try {
|
|
|
|
|
if (!_pttButtonHeld) {
|
|
|
|
|
_pttLog('RecordingStarted received but button already released, cancelling');
|
|
|
|
|
try {
|
|
|
|
|
await _pttHub?.invoke("CancelRecording");
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await _startMicStreaming();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_onRecordingStarted: failed error=$e');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "Failed to start recording";
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onRecordingStopped(List<Object?>? params) {
|
|
|
|
|
_pttLog('event RecordingStopped params=$params');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onRecordingCancelled(List<Object?>? params) {
|
|
|
|
|
_pttLog('event RecordingCancelled params=$params');
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onTranscriptionReceived(List<Object?>? params) {
|
|
|
|
|
_pttLog('event TranscriptionReceived params=$params');
|
|
|
|
|
final data = _firstArgAsMap(params);
|
|
|
|
|
final text = data?['text']?.toString() ?? '';
|
|
|
|
|
if (text.isEmpty) return;
|
|
|
|
|
pttMessages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, content: text, timestamp: DateTime.now()));
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onResponseReceived(List<Object?>? params) {
|
|
|
|
|
_pttLog('event ResponseReceived params=$params');
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
final data = _firstArgAsMap(params);
|
|
|
|
|
final text = data?['voiceAnswer']?.toString() ?? '';
|
|
|
|
|
_pttCurrentAssistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, content: text, timestamp: DateTime.now());
|
|
|
|
|
pttMessages.add(_pttCurrentAssistantMsg!);
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
if (liveSessionActive && _pttIsLive) {
|
|
|
|
|
final lang = _pttLanguage ?? '1';
|
|
|
|
|
_pttLog('ResponseReceived: live session active, auto-restarting listening');
|
|
|
|
|
Future.microtask(() => beginPushToTalk(lang, isLive: true).catchError((_) {}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onAudioReceived(List<Object?>? params) {
|
|
|
|
|
if (params == null || params.isEmpty || params.first == null) return;
|
|
|
|
|
try {
|
|
|
|
|
final bytes = base64Decode(params.first.toString());
|
|
|
|
|
_pttLog('event AudioReceived bytes=${bytes.length}');
|
|
|
|
|
_pttCurrentAssistantMsg?.ttsAudioClips.add(bytes);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_onAudioReceived: failed to decode error=$e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onRecordingError(List<Object?>? params) {
|
|
|
|
|
final msg = (params != null && params.isNotEmpty) ? params.first?.toString() : null;
|
|
|
|
|
_pttLog('event RecordingError message=$msg');
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
pttErrorMessage = msg ?? "Recording error occurred";
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_abortMicStreaming();
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic>? _firstArgAsMap(List<Object?>? params) {
|
|
|
|
|
if (params == null || params.isEmpty || params.first == null) return null;
|
|
|
|
|
final first = params.first;
|
|
|
|
|
if (first is Map) return Map<String, dynamic>.from(first);
|
|
|
|
|
try {
|
|
|
|
|
final decoded = jsonDecode(jsonEncode(first));
|
|
|
|
|
if (decoded is Map) return Map<String, dynamic>.from(decoded);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_streamSub?.cancel();
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_abortMicStreaming();
|
|
|
|
|
if (_pttRecorderOpen) {
|
|
|
|
|
_pttRecorder.closeRecorder();
|
|
|
|
|
}
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
_pttHub = null;
|
|
|
|
|
hub?.stop();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|