|
|
|
|
@ -13,13 +13,11 @@ 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/ptt_signalr_http_client.dart';
|
|
|
|
|
|
|
|
|
|
enum PttStatus { idle, connecting, recording, thinking, playingAudio, error }
|
|
|
|
|
|
|
|
|
|
/// Connection state of the push-to-talk SignalR hub (observable by the UI).
|
|
|
|
|
enum PttHubState { disconnected, connecting, connected }
|
|
|
|
|
|
|
|
|
|
class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
@ -35,9 +33,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
|
|
|
|
|
HubConnection? _pttHub;
|
|
|
|
|
PttSignalRHttpClient? _pttHttpClient;
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
PttHubState pttHubState = PttHubState.disconnected;
|
|
|
|
|
@ -55,28 +50,23 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
StreamSubscription? _pttMicSub;
|
|
|
|
|
Future<void> _pttSendQueue = Future.value();
|
|
|
|
|
int _pttChunkCount = 0;
|
|
|
|
|
|
|
|
|
|
// Live Talk voice-activity (silence) detection, computed from the PCM mic
|
|
|
|
|
// chunks (onProgress metering is not emitted when recording to a stream).
|
|
|
|
|
DateTime? _pttLastLoudAt;
|
|
|
|
|
DateTime? _pttLastSpeechAt;
|
|
|
|
|
DateTime? _pttSpeechCandidateAt;
|
|
|
|
|
DateTime? _pttLastAudioChunkAt;
|
|
|
|
|
DateTime? _pttCalibrationStartedAt;
|
|
|
|
|
Timer? _pttSilenceTimer;
|
|
|
|
|
bool _pttAutoStopTriggered = false;
|
|
|
|
|
bool _pttHasDetectedSpeech = false;
|
|
|
|
|
// Self-calibrating VAD: we continuously track the quiet "floor" AND the loud
|
|
|
|
|
// speech "peak", then put the speaking threshold in the gap between them, so
|
|
|
|
|
// detection adapts to any device mic gain / ambient noise level.
|
|
|
|
|
double _pttNoiseFloor = 0.01;
|
|
|
|
|
double _pttSpeechPeak = 0.05;
|
|
|
|
|
// Smoothed voice-activity (0..1 moving average of "is this chunk speech?").
|
|
|
|
|
// Brief room-noise/echo spikes can't reset the silence timer — only sustained
|
|
|
|
|
// speech keeps this high, which is what we key the auto-stop off of.
|
|
|
|
|
double _pttVoiceActivity = 0.0;
|
|
|
|
|
static const Duration _silenceHoldDuration = Duration(milliseconds: 3000);
|
|
|
|
|
|
|
|
|
|
bool _pttDetectorActive = false;
|
|
|
|
|
bool _pttCalibrated = false;
|
|
|
|
|
double _pttNoiseFloor = 0.0;
|
|
|
|
|
double _pttCalibrationSum = 0.0;
|
|
|
|
|
int _pttCalibrationSamples = 0;
|
|
|
|
|
static const Duration _silenceHoldDuration = Duration(seconds: 3);
|
|
|
|
|
static const Duration _minimumSpeechDuration = Duration(milliseconds: 300);
|
|
|
|
|
static const Duration _calibrationDuration = Duration(milliseconds: 700);
|
|
|
|
|
static const Duration _audioChunkTimeout = Duration(seconds: 2);
|
|
|
|
|
CopilotMessage? _pttCurrentAssistantMsg;
|
|
|
|
|
|
|
|
|
|
// Live Talk: the provider plays the bot's TTS audio itself so it can resume
|
|
|
|
|
// listening only AFTER the bot has finished speaking (avoids recording the
|
|
|
|
|
// bot's own voice). Push-to-Talk does not use any of this.
|
|
|
|
|
final audioplayers.AudioPlayer _livePlayer = audioplayers.AudioPlayer();
|
|
|
|
|
final List<Uint8List> _liveAudioQueue = [];
|
|
|
|
|
bool _liveIsPlaying = false;
|
|
|
|
|
@ -229,6 +219,38 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> sendImageFile(File imageFile, String query, String language) async {
|
|
|
|
|
if (isStreaming) return;
|
|
|
|
|
sessionId ??= _generateSessionId();
|
|
|
|
|
final humanImageMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, content: query, localImagePath: imageFile.path, timestamp: DateTime.now());
|
|
|
|
|
messages.add(humanImageMsg);
|
|
|
|
|
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!,
|
|
|
|
|
"isImage": "true",
|
|
|
|
|
"isAudio": "false",
|
|
|
|
|
"langId": "$language",
|
|
|
|
|
'isFromMobile': 'true',
|
|
|
|
|
"query": query,
|
|
|
|
|
},
|
|
|
|
|
fileFieldName: "ImageFile",
|
|
|
|
|
file: imageFile,
|
|
|
|
|
filename: imageFile.path.split('/').last,
|
|
|
|
|
);
|
|
|
|
|
await _consumeStream(streamedResponse, assistantMsg, null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_onStreamError(assistantMsg, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> transcribeAudio(File audioFile, String language) async {
|
|
|
|
|
isTranscribing = true;
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
@ -383,9 +405,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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');
|
|
|
|
|
@ -394,63 +413,23 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _doConnectPushToTalkHub() async {
|
|
|
|
|
// Always start from a clean slate so every entry is a fresh connection.
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
_setHubState(PttHubState.connecting);
|
|
|
|
|
|
|
|
|
|
// signalr_netcore can throw a "Null check operator used on a null value"
|
|
|
|
|
// from HubConnection._startInternal when the server closes the long-poll
|
|
|
|
|
// connection mid-handshake (a package race on _handshakeCompleter). It is
|
|
|
|
|
// transient, so retry a few times before surfacing an error.
|
|
|
|
|
const maxAttempts = 3;
|
|
|
|
|
Object? lastError;
|
|
|
|
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
await _buildAndStartHub();
|
|
|
|
|
return; // connected
|
|
|
|
|
} catch (e, st) {
|
|
|
|
|
lastError = e;
|
|
|
|
|
_pttLog('connectPushToTalkHub: attempt $attempt/$maxAttempts failed error=$e');
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
if (attempt < maxAttempts) {
|
|
|
|
|
await Future.delayed(Duration(milliseconds: 400 * attempt));
|
|
|
|
|
} else {
|
|
|
|
|
_pttLog('connectPushToTalkHub: giving up\n$st');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pttErrorMessage = "Failed to connect to push-to-talk service";
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttHubState = PttHubState.disconnected;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
throw lastError ?? StateError('Failed to connect');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds a single fresh hub and starts it. Throws on failure so the caller
|
|
|
|
|
/// ([_doConnectPushToTalkHub]) can retry.
|
|
|
|
|
Future<void> _buildAndStartHub() async {
|
|
|
|
|
final token = ApiManager.instance.user?.token ?? '';
|
|
|
|
|
final url = "${URLs.copilotHubUrl}?access_token=$token";
|
|
|
|
|
_pttLog('connectPushToTalkHub: building connection url=${URLs.copilotHubUrl}');
|
|
|
|
|
final httpClient = PttSignalRHttpClient();
|
|
|
|
|
_pttHttpClient = httpClient;
|
|
|
|
|
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'),
|
|
|
|
|
// Ensures the Authorization header is also sent on poll + the DELETE
|
|
|
|
|
// that tells the server to drop the connection when we stop.
|
|
|
|
|
accessTokenFactory: () async => token,
|
|
|
|
|
// Closes sockets properly and truly aborts the pending long-poll on stop
|
|
|
|
|
// (signalr_netcore's default client leaks them).
|
|
|
|
|
httpClient: httpClient,
|
|
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
|
@ -465,8 +444,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
if (!identical(hub, _pttHub)) return;
|
|
|
|
|
_pttLog('hub reconnected connectionId=$connectionId');
|
|
|
|
|
pttHubState = PttHubState.connected;
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
@ -480,23 +457,33 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
_handleConnectionInterrupted("Connection lost. Tap retry to reconnect.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hub.on("RecordingStarted", _onRecordingStarted);
|
|
|
|
|
hub.on("RecordingStopped", _onRecordingStopped);
|
|
|
|
|
hub.on("RecordingCancelled", _onRecordingCancelled);
|
|
|
|
|
hub.on("TranscriptionReceived", _onTranscriptionReceived);
|
|
|
|
|
hub.on("ResponseReceived", _onResponseReceived);
|
|
|
|
|
hub.on("AudioReceived", _onAudioReceived);
|
|
|
|
|
hub.on("RecordingError", _onRecordingError);
|
|
|
|
|
|
|
|
|
|
_pttLog('connectPushToTalkHub: calling start()...');
|
|
|
|
|
await hub.start()?.timeout(const Duration(seconds: 20), onTimeout: () {
|
|
|
|
|
throw TimeoutException('Connection to push-to-talk service timed out');
|
|
|
|
|
});
|
|
|
|
|
_pttLog('connectPushToTalkHub: connected, state=${hub.state}');
|
|
|
|
|
pttHubState = PttHubState.connected;
|
|
|
|
|
if (pttStatus == PttStatus.error) pttStatus = PttStatus.idle;
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
_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}');
|
|
|
|
|
pttHubState = PttHubState.connected;
|
|
|
|
|
if (pttStatus == PttStatus.error) pttStatus = PttStatus.idle;
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} catch (e, st) {
|
|
|
|
|
_pttLog('connectPushToTalkHub: failed error=$e\n$st');
|
|
|
|
|
pttErrorMessage = "Failed to connect to push-to-talk service";
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
await _stopHubAndCleanup();
|
|
|
|
|
pttHubState = PttHubState.disconnected;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _setHubState(PttHubState state) {
|
|
|
|
|
@ -505,8 +492,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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');
|
|
|
|
|
@ -520,11 +505,8 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
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;
|
|
|
|
|
final httpClient = _pttHttpClient;
|
|
|
|
|
@ -540,14 +522,10 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_stopHubAndCleanup: error=$e');
|
|
|
|
|
} finally {
|
|
|
|
|
// Kill any request still open (e.g. a pending long-poll) so no orphan
|
|
|
|
|
// connections pile up between visits to the screen.
|
|
|
|
|
httpClient?.closeAll();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
@ -560,8 +538,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
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;
|
|
|
|
|
@ -643,25 +619,20 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 completely. The button is only used to
|
|
|
|
|
/// start/stop the whole session, so ending it must hard-stop everything:
|
|
|
|
|
/// no more listening, thinking, or speaking until the user taps to start again.
|
|
|
|
|
Future<void> stopLiveTalk() async {
|
|
|
|
|
_pttLog('stopLiveTalk: ending session, stopping everything');
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_pttIsLive = false;
|
|
|
|
|
_pttAutoStopTriggered = true; // block any in-flight auto-stop
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_stopLivePlayback();
|
|
|
|
|
await cancelPushToTalk();
|
|
|
|
|
if (pttStatus == PttStatus.recording) {
|
|
|
|
|
await endPushToTalk();
|
|
|
|
|
} else {
|
|
|
|
|
await cancelPushToTalk();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> endPushToTalk() async {
|
|
|
|
|
@ -738,93 +709,119 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
if (_pttIsLive) _processSilenceDetection(chunk);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await _pttRecorder.startRecorder(
|
|
|
|
|
toStream: _pttMicStreamController!.sink,
|
|
|
|
|
codec: sound.Codec.pcm16,
|
|
|
|
|
numChannels: 1,
|
|
|
|
|
sampleRate: 48000,
|
|
|
|
|
);
|
|
|
|
|
if (_pttIsLive) {
|
|
|
|
|
_startSilenceDetection();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await _pttRecorder.startRecorder(
|
|
|
|
|
toStream: _pttMicStreamController!.sink,
|
|
|
|
|
codec: sound.Codec.pcm16,
|
|
|
|
|
numChannels: 1,
|
|
|
|
|
sampleRate: 48000,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
_stopSilenceDetection();
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pttLog('_startMicStreaming: recorder started');
|
|
|
|
|
pttStatus = PttStatus.recording;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
if (_pttIsLive) {
|
|
|
|
|
_startSilenceDetection();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resets voice-activity state for a new Live Talk recording. Actual silence
|
|
|
|
|
/// detection happens per mic chunk in [_processSilenceDetection].
|
|
|
|
|
void _startSilenceDetection() {
|
|
|
|
|
_pttSilenceTimer?.cancel();
|
|
|
|
|
_pttAutoStopTriggered = false;
|
|
|
|
|
_pttHasDetectedSpeech = false;
|
|
|
|
|
_pttLastLoudAt = null;
|
|
|
|
|
_pttNoiseFloor = 0.01;
|
|
|
|
|
_pttSpeechPeak = 0.05;
|
|
|
|
|
_pttVoiceActivity = 0.0;
|
|
|
|
|
_pttDetectorActive = true;
|
|
|
|
|
_pttCalibrated = false;
|
|
|
|
|
_pttLastSpeechAt = null;
|
|
|
|
|
_pttSpeechCandidateAt = null;
|
|
|
|
|
_pttLastAudioChunkAt = null;
|
|
|
|
|
_pttNoiseFloor = 0;
|
|
|
|
|
_pttCalibrationSum = 0;
|
|
|
|
|
_pttCalibrationSamples = 0;
|
|
|
|
|
_pttCalibrationStartedAt = null;
|
|
|
|
|
|
|
|
|
|
_pttSilenceTimer = Timer.periodic(
|
|
|
|
|
const Duration(milliseconds: 100),
|
|
|
|
|
(_) => _checkSpeechSilence(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _stopSilenceDetection() {
|
|
|
|
|
_pttLastLoudAt = null;
|
|
|
|
|
_pttHasDetectedSpeech = false;
|
|
|
|
|
_pttSilenceTimer?.cancel();
|
|
|
|
|
_pttSilenceTimer = null;
|
|
|
|
|
_pttDetectorActive = false;
|
|
|
|
|
_pttAutoStopTriggered = false;
|
|
|
|
|
_pttVoiceActivity = 0.0;
|
|
|
|
|
_pttHasDetectedSpeech = false;
|
|
|
|
|
_pttLastSpeechAt = null;
|
|
|
|
|
_pttSpeechCandidateAt = null;
|
|
|
|
|
_pttLastAudioChunkAt = null;
|
|
|
|
|
_pttCalibrationStartedAt = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Live Talk auto-stop: once the user has spoken and then stayed silent for
|
|
|
|
|
/// [_silenceHoldDuration] (3s), automatically send StopRecording — the button
|
|
|
|
|
/// is only used to start/stop the whole session, never per-utterance.
|
|
|
|
|
///
|
|
|
|
|
/// Self-calibrating + noise/echo tolerant:
|
|
|
|
|
/// - Tracks the quiet floor (fast down / slow up) and the speech peak
|
|
|
|
|
/// (fast up / slow down), and sets a per-chunk threshold 45% of the way
|
|
|
|
|
/// from floor toward peak.
|
|
|
|
|
/// - Smooths that per-chunk decision into [_pttVoiceActivity] so a few
|
|
|
|
|
/// stray room-noise / echo chunks can't masquerade as speech and keep
|
|
|
|
|
/// resetting the silence countdown. Only *sustained* speech counts.
|
|
|
|
|
void _processSilenceDetection(Uint8List chunk) {
|
|
|
|
|
if (_pttAutoStopTriggered) return;
|
|
|
|
|
final rms = _chunkRms(chunk);
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
|
|
|
|
if (rms < _pttNoiseFloor) {
|
|
|
|
|
_pttNoiseFloor = rms;
|
|
|
|
|
} else {
|
|
|
|
|
_pttNoiseFloor = _pttNoiseFloor * 0.995 + rms * 0.005;
|
|
|
|
|
if (!_pttDetectorActive || _pttAutoStopTriggered || pttStatus != PttStatus.recording || !liveSessionActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (rms > _pttSpeechPeak) {
|
|
|
|
|
_pttSpeechPeak = rms;
|
|
|
|
|
} else {
|
|
|
|
|
_pttSpeechPeak = _pttSpeechPeak * 0.995 + rms * 0.005;
|
|
|
|
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final rms = _chunkRms(chunk);
|
|
|
|
|
if (!rms.isFinite) return;
|
|
|
|
|
_pttLastAudioChunkAt = now;
|
|
|
|
|
|
|
|
|
|
if (!_pttCalibrated) {
|
|
|
|
|
_pttCalibrationStartedAt ??= now;
|
|
|
|
|
_pttCalibrationSum += rms;
|
|
|
|
|
_pttCalibrationSamples++;
|
|
|
|
|
if (now.difference(_pttCalibrationStartedAt!) < _calibrationDuration) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_pttNoiseFloor = (_pttCalibrationSum / _pttCalibrationSamples).clamp(0.001, 0.15).toDouble();
|
|
|
|
|
_pttCalibrated = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final span = _pttSpeechPeak - _pttNoiseFloor;
|
|
|
|
|
final threshold = max(_pttNoiseFloor + span * 0.45, 0.012);
|
|
|
|
|
final chunkIsSpeech = rms > threshold && span > 0.01;
|
|
|
|
|
final speechThreshold = max(0.008, _pttNoiseFloor * 2.5);
|
|
|
|
|
final isSpeechCandidate = rms > speechThreshold;
|
|
|
|
|
|
|
|
|
|
// Smooth: a single loud chunk only nudges activity, sustained speech climbs.
|
|
|
|
|
_pttVoiceActivity = _pttVoiceActivity * 0.8 + (chunkIsSpeech ? 1.0 : 0.0) * 0.2;
|
|
|
|
|
// Hysteresis: need clear sustained energy to count as actively speaking.
|
|
|
|
|
final activelySpeaking = _pttVoiceActivity > 0.4;
|
|
|
|
|
if (isSpeechCandidate) {
|
|
|
|
|
_pttSpeechCandidateAt ??= now;
|
|
|
|
|
if (now.difference(_pttSpeechCandidateAt!) >= _minimumSpeechDuration) {
|
|
|
|
|
_pttHasDetectedSpeech = true;
|
|
|
|
|
_pttLastSpeechAt = now;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pttLog('silence: rms=${rms.toStringAsFixed(4)} floor=${_pttNoiseFloor.toStringAsFixed(4)} peak=${_pttSpeechPeak.toStringAsFixed(4)} thr=${threshold.toStringAsFixed(4)} act=${_pttVoiceActivity.toStringAsFixed(2)} speaking=$activelySpeaking spoke=$_pttHasDetectedSpeech');
|
|
|
|
|
_pttSpeechCandidateAt = null;
|
|
|
|
|
if (!_pttHasDetectedSpeech) {
|
|
|
|
|
_pttNoiseFloor = (_pttNoiseFloor * 0.95 + rms * 0.05).clamp(0.001, 0.15).toDouble();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activelySpeaking) {
|
|
|
|
|
_pttLastLoudAt = now;
|
|
|
|
|
_pttHasDetectedSpeech = true;
|
|
|
|
|
void _checkSpeechSilence() {
|
|
|
|
|
if (!_pttDetectorActive || _pttAutoStopTriggered || !_pttHasDetectedSpeech || pttStatus != PttStatus.recording || !liveSessionActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!_pttHasDetectedSpeech) return;
|
|
|
|
|
final lastLoud = _pttLastLoudAt;
|
|
|
|
|
if (lastLoud != null && now.difference(lastLoud) >= _silenceHoldDuration) {
|
|
|
|
|
_pttAutoStopTriggered = true;
|
|
|
|
|
_pttLog('live: silence after speech, auto-stopping recording');
|
|
|
|
|
endPushToTalk().catchError((_) {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final lastChunk = _pttLastAudioChunkAt;
|
|
|
|
|
if (lastChunk == null || now.difference(lastChunk) > _audioChunkTimeout) return;
|
|
|
|
|
|
|
|
|
|
final lastSpeech = _pttLastSpeechAt;
|
|
|
|
|
if (lastSpeech == null || now.difference(lastSpeech) < _silenceHoldDuration) return;
|
|
|
|
|
|
|
|
|
|
_pttAutoStopTriggered = true;
|
|
|
|
|
_pttDetectorActive = false;
|
|
|
|
|
_pttSilenceTimer?.cancel();
|
|
|
|
|
_pttSilenceTimer = null;
|
|
|
|
|
_pttLog('live: silence after speech, auto-stopping recording');
|
|
|
|
|
endPushToTalk().catchError((_) {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Root-mean-square amplitude (0..1) of a PCM16 mono little-endian chunk.
|
|
|
|
|
double _chunkRms(Uint8List chunk) {
|
|
|
|
|
final sampleCount = chunk.length ~/ 2;
|
|
|
|
|
if (sampleCount == 0) return 0;
|
|
|
|
|
@ -934,9 +931,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
pttMessages.add(_pttCurrentAssistantMsg!);
|
|
|
|
|
|
|
|
|
|
if (liveSessionActive && _pttIsLive) {
|
|
|
|
|
// Live Talk: let the bot finish speaking its TTS audio, then resume
|
|
|
|
|
// listening. _maybeRestartLiveListening is debounced so it waits for any
|
|
|
|
|
// audio clips that arrive right after this text response.
|
|
|
|
|
pttStatus = PttStatus.playingAudio;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
_maybeRestartLiveListening();
|
|
|
|
|
@ -952,8 +946,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
final bytes = base64Decode(params.first.toString());
|
|
|
|
|
_pttLog('event AudioReceived bytes=${bytes.length}');
|
|
|
|
|
if (liveSessionActive && _pttIsLive) {
|
|
|
|
|
// Live Talk: queue and play through the provider so we know when the
|
|
|
|
|
// bot finished speaking (to resume listening). No widget player.
|
|
|
|
|
_liveAudioQueue.add(bytes);
|
|
|
|
|
_playNextLiveClip();
|
|
|
|
|
} else {
|
|
|
|
|
@ -965,7 +957,7 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ----- Live Talk audio playback + auto-resume listening -----
|
|
|
|
|
// ----- Live Talk audio playback + auto-resume listening -----
|
|
|
|
|
|
|
|
|
|
void _playNextLiveClip() {
|
|
|
|
|
if (_liveIsPlaying || _liveAudioQueue.isEmpty) return;
|
|
|
|
|
@ -991,8 +983,6 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// After the bot stops speaking, resume listening. Debounced so trailing
|
|
|
|
|
/// audio clips (arriving just after) don't cause a premature restart.
|
|
|
|
|
void _maybeRestartLiveListening() {
|
|
|
|
|
if (!liveSessionActive || !_pttIsLive) return;
|
|
|
|
|
_liveRestartTimer?.cancel();
|
|
|
|
|
@ -1041,6 +1031,7 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
void dispose() {
|
|
|
|
|
_streamSub?.cancel();
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_stopSilenceDetection();
|
|
|
|
|
_liveRestartTimer?.cancel();
|
|
|
|
|
_livePlayer.dispose();
|
|
|
|
|
_abortMicStreaming();
|
|
|
|
|
|