feat: enhance voice call functionality and response handling

- Introduced a new boolean flag `_isSpeaking` in VoiceCallService to manage speaking state during voice interactions.
- Improved response handling by extracting incremental content from socket events and updating the accumulated response accordingly.
- Updated the chat page to include a voice call button, allowing users to initiate voice calls directly from the chat interface.
- Enhanced the modern chat input widget to support voice call functionality, providing a seamless user experience for initiating calls.
This commit is contained in:
cogwheel0
2025-10-08 19:09:57 +05:30
parent 7dd41ebf60
commit ea79a193be
3 changed files with 138 additions and 45 deletions

View File

@@ -190,6 +190,7 @@ class VoiceCallService {
}
String _accumulatedResponse = '';
bool _isSpeaking = false;
void _handleSocketEvent(
Map<String, dynamic> event,
@@ -204,7 +205,7 @@ class VoiceCallService {
final innerData = outerData['data'];
if (eventType == 'chat:completion' && innerData is Map<String, dynamic>) {
// Handle streaming content chunks
// Handle full content replacement (used by some models/backends)
if (innerData.containsKey('content')) {
final content = innerData['content']?.toString() ?? '';
if (content.isNotEmpty) {
@@ -213,18 +214,29 @@ class VoiceCallService {
}
}
// Check for completion using choices[0].finish_reason
// Handle streaming delta chunks (incremental updates)
if (innerData.containsKey('choices')) {
final choices = innerData['choices'] as List?;
if (choices != null && choices.isNotEmpty) {
final firstChoice = choices[0] as Map<String, dynamic>?;
final delta = firstChoice?['delta'];
final finishReason = firstChoice?['finish_reason'];
// Extract incremental content from delta
if (delta is Map<String, dynamic>) {
final deltaContent = delta['content']?.toString() ?? '';
if (deltaContent.isNotEmpty) {
_accumulatedResponse += deltaContent;
_responseController.add(_accumulatedResponse);
}
}
// Check for completion
if (finishReason == 'stop') {
if (_accumulatedResponse.isNotEmpty) {
if (_accumulatedResponse.isNotEmpty && !_isSpeaking) {
_speakResponse(_accumulatedResponse);
_accumulatedResponse = '';
} else {
} else if (_accumulatedResponse.isEmpty) {
// No response, restart listening
_startListening();
}
@@ -236,9 +248,11 @@ class VoiceCallService {
}
Future<void> _speakResponse(String response) async {
if (_isDisposed) return;
if (_isDisposed || _isSpeaking) return;
try {
_isSpeaking = true;
// Stop listening before speaking
await _voiceInput.stopListening();
await _transcriptSubscription?.cancel();
@@ -248,6 +262,7 @@ class VoiceCallService {
await _tts.speak(response);
// After speaking completes, _handleTtsComplete will restart listening
} catch (e) {
_isSpeaking = false;
_updateState(VoiceCallState.error);
// Restart listening even if TTS fails
await _startListening();
@@ -261,6 +276,7 @@ class VoiceCallService {
void _handleTtsComplete() {
if (_isDisposed) return;
_isSpeaking = false;
// After assistant finishes speaking, start listening for user again
_startListening();
}

View File

@@ -1445,16 +1445,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
actions: [
if (!_isSelectionMode) ...[
// Voice call button
IconButton(
icon: Icon(
CupertinoIcons.phone,
color: context.conduitTheme.textPrimary,
size: IconSize.appBar,
),
onPressed: _handleVoiceCall,
tooltip: 'Voice Call',
),
Padding(
padding: const EdgeInsets.only(right: Spacing.inputPadding),
child: IconButton(
@@ -1566,6 +1556,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
onSendMessage: (text) =>
_handleMessageSend(text, selectedModel),
onVoiceInput: null,
onVoiceCall: _handleVoiceCall,
onFileAttachment: _handleFileAttachment,
onImageAttachment: _handleImageAttachment,
onCameraCapture: () =>

View File

@@ -60,6 +60,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
final Function(String) onSendMessage;
final bool enabled;
final Function()? onVoiceInput;
final Function()? onVoiceCall;
final Function()? onFileAttachment;
final Function()? onImageAttachment;
final Function()? onCameraCapture;
@@ -69,6 +70,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
required this.onSendMessage,
this.enabled = true,
this.onVoiceInput,
this.onVoiceCall,
this.onFileAttachment,
this.onImageAttachment,
this.onCameraCapture,
@@ -831,6 +833,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
),
),
const SizedBox(width: Spacing.sm),
if (!_hasText && voiceAvailable && !isGenerating)
_buildMicButton(voiceAvailable),
if (!_hasText && voiceAvailable && !isGenerating)
const SizedBox(width: Spacing.sm),
_buildPrimaryButton(
_hasText,
isGenerating,
@@ -911,6 +917,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!_hasText && voiceAvailable && !isGenerating) ...[
_buildMicButton(voiceAvailable),
const SizedBox(width: Spacing.sm),
],
_buildPrimaryButton(
_hasText,
isGenerating,
@@ -1242,6 +1252,45 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
);
}
Widget _buildMicButton(bool voiceAvailable) {
final bool enabledMic = widget.enabled && voiceAvailable;
return Tooltip(
message: AppLocalizations.of(context)!.voiceInput,
child: Opacity(
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
onTap: enabledMic
? () {
HapticFeedback.selectionClick();
_toggleVoice();
}
: null,
child: SizedBox(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
child: Icon(
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
size: IconSize.large,
color: _isRecording
? context.conduitTheme.buttonPrimary
: (enabledMic
? context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
)),
),
),
),
),
),
);
}
Widget _buildPrimaryButton(
bool hasText,
bool isGenerating,
@@ -1296,7 +1345,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
);
}
// If there's text, render SEND variant; otherwise render MIC variant
// If there's text, render SEND variant; otherwise render VOICE CALL variant
if (hasText) {
return Tooltip(
message: enabled
@@ -1374,37 +1423,74 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
);
}
// MIC variant when no text is present
final bool enabledMic = widget.enabled && voiceAvailable;
// VOICE CALL variant when no text is present
final bool enabledVoiceCall = widget.enabled && widget.onVoiceCall != null;
return Tooltip(
message: AppLocalizations.of(context)!.voiceInput,
message: 'Voice Call',
child: Opacity(
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
onTap: enabledMic
? () {
HapticFeedback.selectionClick();
_toggleVoice();
}
: null,
child: SizedBox(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
child: Icon(
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
size: IconSize.large,
color: _isRecording
? context.conduitTheme.buttonPrimary
: (enabledMic
? context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
)),
opacity: enabledVoiceCall ? Alpha.primary : Alpha.disabled,
child: IgnorePointer(
ignoring: !enabledVoiceCall,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: enabledVoiceCall
? () {
PlatformUtils.lightHaptic();
widget.onVoiceCall!();
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
color: enabledVoiceCall
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: enabledVoiceCall
? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.8,
)
: context.conduitTheme.cardBorder.withValues(
alpha: 0.45,
),
width: BorderWidth.thin,
),
boxShadow: enabledVoiceCall
? <BoxShadow>[
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(
alpha:
Theme.of(context).brightness ==
Brightness.dark
? 0.36
: 0.18,
),
blurRadius: 18,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
),
child: Center(
child: Icon(
Platform.isIOS
? CupertinoIcons.waveform
: Icons.graphic_eq,
size: IconSize.large,
color: enabledVoiceCall
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
),
),
),
),