push to talk completed just have response delays
parent
c5533b7efa
commit
7bb4ae420d
@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:test_sa/controllers/providers/api/user_provider.dart';
|
||||
import 'package:test_sa/extensions/context_extension.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
|
||||
import '../controller/copilot_provider.dart';
|
||||
import '../widgets/copilot_message_bubble.dart';
|
||||
|
||||
/// Live Talk realtime voice screen for Copilot.
|
||||
/// Unlike Push-to-Talk (press & hold), this is a continuous conversation mode:
|
||||
/// tap once to start, the app keeps listening/responding automatically until
|
||||
/// the user taps again to stop.
|
||||
class CopilotLiveTalkPage extends StatefulWidget {
|
||||
const CopilotLiveTalkPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CopilotLiveTalkPage> createState() => _CopilotLiveTalkPageState();
|
||||
}
|
||||
|
||||
class _CopilotLiveTalkPageState extends State<CopilotLiveTalkPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
CopilotProvider? _provider;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_provider = context.read<CopilotProvider>();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = context.read<CopilotProvider>();
|
||||
provider.startNewPttConversation();
|
||||
provider.connectPushToTalkHub().catchError((_) {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_provider?.stopLiveTalk().catchError((_) {});
|
||||
_provider?.disconnectPushToTalkHub().catchError((_) {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _statusLabel(PttStatus status, bool isLiveActive) {
|
||||
switch (status) {
|
||||
case PttStatus.idle:
|
||||
return isLiveActive ? "Getting ready…" : "Tap to start Live Talk";
|
||||
case PttStatus.connecting:
|
||||
return "Connecting…";
|
||||
case PttStatus.recording:
|
||||
return "Listening… (pause to send)";
|
||||
case PttStatus.thinking:
|
||||
return "Thinking…";
|
||||
case PttStatus.playingAudio:
|
||||
return "Speaking…";
|
||||
case PttStatus.error:
|
||||
return "Something went wrong";
|
||||
}
|
||||
}
|
||||
|
||||
void _onMainButtonTap(CopilotProvider provider) {
|
||||
final status = provider.pttStatus;
|
||||
final isBusy = status == PttStatus.connecting || status == PttStatus.thinking;
|
||||
if (isBusy) return;
|
||||
|
||||
if (provider.liveSessionActive) {
|
||||
provider.stopLiveTalk().catchError((_) {});
|
||||
} else {
|
||||
provider.startLiveTalk(context.settingProvider.language!).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const DefaultAppBar(title: "Live Talk"),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Consumer<CopilotProvider>(
|
||||
builder: (context, provider, _) {
|
||||
if (provider.pttMessages.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 24,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
"atoms_logo".toSvgAsset(width: 54),
|
||||
Text(
|
||||
"Tap the mic to start a live conversation",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
).center;
|
||||
}
|
||||
|
||||
_scrollToBottom();
|
||||
final messages = provider.pttMessages;
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentMessage = messages[index];
|
||||
final bool showHeader = index == 0 || currentMessage.role != messages[index - 1].role;
|
||||
return CopilotMessageBubble(
|
||||
message: currentMessage,
|
||||
showHeader: showHeader,
|
||||
userAvatar: Provider.of<UserProvider>(context, listen: false).user?.profilePhotoName,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<CopilotProvider>(
|
||||
builder: (context, provider, _) {
|
||||
if (provider.pttErrorMessage == null) return const SizedBox();
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(provider.pttErrorMessage!, style: const TextStyle(color: Colors.red)),
|
||||
);
|
||||
},
|
||||
),
|
||||
SafeArea(
|
||||
child: Consumer<CopilotProvider>(
|
||||
builder: (context, provider, _) {
|
||||
final status = provider.pttStatus;
|
||||
final isActive = provider.liveSessionActive;
|
||||
final isRecording = status == PttStatus.recording;
|
||||
final isBusy = status == PttStatus.connecting || status == PttStatus.thinking;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_statusLabel(status, isActive),
|
||||
style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)),
|
||||
),
|
||||
16.height,
|
||||
GestureDetector(
|
||||
onTap: isBusy ? null : () => _onMainButtonTap(provider),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: isRecording || isActive ? 96 : 80,
|
||||
width: isRecording || isActive ? 96 : 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isRecording
|
||||
? Colors.red
|
||||
: isActive
|
||||
? AppColor.primary10.withValues(alpha: 0.8)
|
||||
: AppColor.primary10,
|
||||
),
|
||||
child: isBusy
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5),
|
||||
)
|
||||
: Icon(
|
||||
isActive ? Icons.stop_rounded : Icons.graphic_eq_rounded,
|
||||
color: Colors.white,
|
||||
size: isRecording || isActive ? 44 : 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
8.height,
|
||||
if (isActive)
|
||||
Text(
|
||||
"Tap to stop",
|
||||
style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)),
|
||||
),
|
||||
24.height,
|
||||
],
|
||||
).paddingOnly(top: 8, bottom: 8);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Parsed RIFF/WAVE chunks we care about from a single clip.
|
||||
class _WavChunks {
|
||||
final Uint8List? fmt;
|
||||
final Uint8List data;
|
||||
|
||||
const _WavChunks({required this.fmt, required this.data});
|
||||
}
|
||||
|
||||
_WavChunks _parseWav(Uint8List clip) {
|
||||
Uint8List? fmtChunk;
|
||||
final dataChunks = BytesBuilder();
|
||||
if (clip.length < 12) return _WavChunks(fmt: fmtChunk, data: dataChunks.toBytes());
|
||||
|
||||
final data = ByteData.sublistView(clip);
|
||||
int offset = 12; // skip "RIFF" size "WAVE"
|
||||
while (offset + 8 <= clip.length) {
|
||||
final chunkId = ascii.decode(clip.sublist(offset, offset + 4), allowInvalid: true);
|
||||
final chunkSize = data.getUint32(offset + 4, Endian.little);
|
||||
final chunkStart = offset + 8;
|
||||
final chunkEnd = (chunkStart + chunkSize).clamp(0, clip.length);
|
||||
|
||||
if (chunkId == 'fmt ') {
|
||||
fmtChunk ??= clip.sublist(chunkStart, chunkEnd);
|
||||
} else if (chunkId == 'data') {
|
||||
dataChunks.add(clip.sublist(chunkStart, chunkEnd));
|
||||
}
|
||||
|
||||
offset = chunkStart + chunkSize + (chunkSize.isOdd ? 1 : 0);
|
||||
}
|
||||
|
||||
return _WavChunks(fmt: fmtChunk, data: dataChunks.toBytes());
|
||||
}
|
||||
|
||||
/// Merges multiple small WAV clips (as received one-per-sentence from the
|
||||
/// Push-to-Talk hub's `AudioReceived` event) into a single playable WAV byte
|
||||
/// buffer.
|
||||
///
|
||||
/// Assumes all clips share the same PCM format (sample rate/channels/bit
|
||||
/// depth), which holds true since they all come from the same TTS voice
|
||||
/// within a single response.
|
||||
Uint8List mergeWavClips(List<Uint8List> clips) {
|
||||
if (clips.isEmpty) return Uint8List(0);
|
||||
if (clips.length == 1) return clips.first;
|
||||
|
||||
Uint8List? fmtChunk;
|
||||
final pcmData = BytesBuilder();
|
||||
|
||||
for (final clip in clips) {
|
||||
final parsed = _parseWav(clip);
|
||||
fmtChunk ??= parsed.fmt;
|
||||
pcmData.add(parsed.data);
|
||||
}
|
||||
|
||||
fmtChunk ??= Uint8List(16);
|
||||
final pcmBytes = pcmData.toBytes();
|
||||
final fmtSize = fmtChunk.length;
|
||||
final dataSize = pcmBytes.length;
|
||||
final riffSize = 4 + (8 + fmtSize) + (8 + dataSize);
|
||||
|
||||
final header = BytesBuilder();
|
||||
header.add(ascii.encode('RIFF'));
|
||||
header.add(_uint32le(riffSize));
|
||||
header.add(ascii.encode('WAVE'));
|
||||
header.add(ascii.encode('fmt '));
|
||||
header.add(_uint32le(fmtSize));
|
||||
header.add(fmtChunk);
|
||||
header.add(ascii.encode('data'));
|
||||
header.add(_uint32le(dataSize));
|
||||
|
||||
return Uint8List.fromList([...header.toBytes(), ...pcmBytes]);
|
||||
}
|
||||
|
||||
/// Computes a WAV clip's playback duration directly from its header
|
||||
/// (dataSize / byteRate), without needing to load it into an audio engine.
|
||||
Duration wavDuration(Uint8List clip) {
|
||||
final parsed = _parseWav(clip);
|
||||
final fmt = parsed.fmt;
|
||||
if (fmt == null || fmt.length < 12 || parsed.data.isEmpty) return Duration.zero;
|
||||
|
||||
final byteRate = ByteData.sublistView(fmt).getUint32(8, Endian.little);
|
||||
if (byteRate == 0) return Duration.zero;
|
||||
|
||||
final seconds = parsed.data.length / byteRate;
|
||||
return Duration(milliseconds: (seconds * 1000).round());
|
||||
}
|
||||
|
||||
Uint8List _uint32le(int value) {
|
||||
final b = ByteData(4);
|
||||
b.setUint32(0, value, Endian.little);
|
||||
return b.buffer.asUint8List();
|
||||
}
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import '../utils/wav_merge.dart';
|
||||
|
||||
/// A self-contained voice-message style player for Push-to-Talk responses,
|
||||
/// with play/pause, seek, playback speed, and mute controls.
|
||||
class TtsVoicePlayer extends StatefulWidget {
|
||||
final List<Uint8List> clips;
|
||||
|
||||
const TtsVoicePlayer({Key? key, required this.clips}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TtsVoicePlayer> createState() => _TtsVoicePlayerState();
|
||||
}
|
||||
|
||||
class _TtsVoicePlayerState extends State<TtsVoicePlayer> {
|
||||
static const List<double> _speeds = [1.0, 1.25, 1.5, 2.0, 0.75];
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
int _index = -1;
|
||||
Duration _clipPosition = Duration.zero;
|
||||
bool _playing = false;
|
||||
bool _userPaused = false;
|
||||
bool _muted = false;
|
||||
double _speed = 1.0;
|
||||
bool _sliderMoving = false;
|
||||
double? _previewTotalMs;
|
||||
bool _finished = false;
|
||||
|
||||
List<Duration> get _clipDurations => widget.clips.map(wavDuration).toList();
|
||||
|
||||
Duration get _totalDuration => _clipDurations.fold(Duration.zero, (a, b) => a + b);
|
||||
|
||||
Duration get _elapsedBeforeCurrent {
|
||||
if (_index <= 0) return Duration.zero;
|
||||
return _clipDurations.take(_index).fold(Duration.zero, (a, b) => a + b);
|
||||
}
|
||||
|
||||
Duration get _totalElapsed => _finished ? Duration.zero : _elapsedBeforeCurrent + _clipPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player.setReleaseMode(ReleaseMode.stop);
|
||||
_player.onPositionChanged.listen((p) {
|
||||
if (!_sliderMoving && mounted) setState(() => _clipPosition = p);
|
||||
});
|
||||
_player.onPlayerStateChanged.listen((state) {
|
||||
if (mounted) setState(() => _playing = state == PlayerState.playing);
|
||||
});
|
||||
_player.onPlayerComplete.listen((_) => _onClipComplete());
|
||||
_autoAdvanceIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TtsVoicePlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.clips.length != oldWidget.clips.length) {
|
||||
_autoAdvanceIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _autoAdvanceIfNeeded() {
|
||||
if (_userPaused || widget.clips.isEmpty) return;
|
||||
if (_index == -1) {
|
||||
_playClipAt(0);
|
||||
} else if (!_playing && _index == widget.clips.length - 2) {
|
||||
// We were waiting for more clips after finishing the previous-to-last one.
|
||||
_playClipAt(_index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void _onClipComplete() {
|
||||
if (_userPaused) return;
|
||||
if (_index + 1 < widget.clips.length) {
|
||||
_playClipAt(_index + 1);
|
||||
} else {
|
||||
setState(() {
|
||||
_playing = false;
|
||||
_finished = true;
|
||||
_clipPosition = Duration.zero;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playClipAt(int i) async {
|
||||
if (i < 0 || i >= widget.clips.length) return;
|
||||
setState(() {
|
||||
_index = i;
|
||||
_clipPosition = Duration.zero;
|
||||
_finished = false;
|
||||
});
|
||||
await _player.play(BytesSource(widget.clips[i], mimeType: 'audio/wav'));
|
||||
await _player.setPlaybackRate(_speed);
|
||||
if (_muted) await _player.setVolume(0);
|
||||
}
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
if (_playing) {
|
||||
_userPaused = true;
|
||||
await _player.pause();
|
||||
return;
|
||||
}
|
||||
_userPaused = false;
|
||||
if (_index == -1 || _finished) {
|
||||
await _playClipAt(0);
|
||||
return;
|
||||
}
|
||||
await _player.resume();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
setState(() => _muted = !_muted);
|
||||
await _player.setVolume(_muted ? 0 : 1);
|
||||
}
|
||||
|
||||
Future<void> _cycleSpeed() async {
|
||||
final idx = _speeds.indexOf(_speed);
|
||||
final next = _speeds[(idx + 1) % _speeds.length];
|
||||
setState(() => _speed = next);
|
||||
await _player.setPlaybackRate(next);
|
||||
}
|
||||
|
||||
Future<void> _seekToTotal(Duration target) async {
|
||||
final durations = _clipDurations;
|
||||
Duration acc = Duration.zero;
|
||||
for (var i = 0; i < durations.length; i++) {
|
||||
final clipEnd = acc + durations[i];
|
||||
final isLast = i == durations.length - 1;
|
||||
if (target < clipEnd || isLast) {
|
||||
final offset = target - acc;
|
||||
_userPaused = false;
|
||||
if (i != _index || _finished) {
|
||||
await _playClipAt(i);
|
||||
}
|
||||
await _player.seek(offset.isNegative ? Duration.zero : offset);
|
||||
return;
|
||||
}
|
||||
acc = clipEnd;
|
||||
}
|
||||
}
|
||||
|
||||
String _format(Duration? d) {
|
||||
final dur = d ?? Duration.zero;
|
||||
final m = dur.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = dur.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return "$m:$s";
|
||||
}
|
||||
|
||||
String get _speedLabel => _speed == _speed.roundToDouble() ? "${_speed.toInt()}x" : "${_speed}x";
|
||||
|
||||
IconData get _playIcon {
|
||||
if (_playing) return Icons.pause_rounded;
|
||||
if (_finished) return Icons.replay_rounded;
|
||||
return Icons.play_arrow_rounded;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalMs = _totalDuration.inMilliseconds.toDouble();
|
||||
final elapsedMs = (_previewTotalMs ?? _totalElapsed.inMilliseconds.toDouble()).clamp(0, totalMs == 0 ? 1 : totalMs);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.background(context),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColor.border2Color(context)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _togglePlayPause,
|
||||
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(_playIcon, color: AppColor.primary10, size: 26),
|
||||
),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2.0,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5.0),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
value: elapsedMs.toDouble(),
|
||||
min: 0,
|
||||
max: totalMs == 0 ? 1 : totalMs,
|
||||
activeColor: AppColor.primary10,
|
||||
inactiveColor: AppColor.border2Color(context),
|
||||
onChangeStart: (_) => _sliderMoving = true,
|
||||
onChanged: (value) => setState(() => _previewTotalMs = value),
|
||||
onChangeEnd: (value) async {
|
||||
_sliderMoving = false;
|
||||
_previewTotalMs = null;
|
||||
await _seekToTotal(Duration(milliseconds: value.round()));
|
||||
},
|
||||
),
|
||||
).expanded,
|
||||
Text(
|
||||
"${_format(Duration(milliseconds: elapsedMs.round()))}/${_format(_totalDuration)}",
|
||||
style: AppTextStyles.tinyFont.copyWith(color: AppColor.textStyleColor(context)),
|
||||
),
|
||||
4.width,
|
||||
IconButton(
|
||||
onPressed: _cycleSpeed,
|
||||
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: "Playback speed",
|
||||
icon: Text(
|
||||
_speedLabel,
|
||||
style: AppTextStyles.tinyFont.copyWith(color: AppColor.textStyleColor(context), fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _toggleMute,
|
||||
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: _muted ? "Unmute" : "Mute",
|
||||
icon: Icon(_muted ? Icons.volume_off_rounded : Icons.volume_up_rounded, size: 18, color: AppColor.icon2Color(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue