feat(notes): Add audio recording and playback features

This commit is contained in:
cogwheel
2026-01-12 21:48:43 +05:30
parent a7e5bb3704
commit a371556a1c
73 changed files with 2296 additions and 125 deletions

View File

@@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:just_audio/just_audio.dart';
import '../../../core/services/api_service.dart';
import '../../../shared/utils/bytes_audio_source.dart';
// =============================================================================
// TTS Events
@@ -142,9 +143,15 @@ class TtsManager {
bool _handlersSet = false;
Completer<void>? _initCompleter;
// AudioPlayer for server TTS
// AudioPlayer for server TTS (using just_audio)
final AudioPlayer _player = AudioPlayer();
bool _playerConfigured = false;
StreamSubscription<PlayerState>? _playerStateSub;
/// Flag to suppress spurious TtsPaused events during chunk transitions.
/// When true, the player is actively switching audio sources and pause
/// events should not be emitted to listeners.
bool _isTransitioningChunks = false;
// API service for server TTS (must be set before using server TTS)
ApiService? _apiService;
@@ -222,22 +229,27 @@ class TtsManager {
// Initialize FlutterTts
await _ensureTtsInitialized();
// Configure AudioPlayer for all platforms
// Configure AudioPlayer for all platforms (using just_audio)
if (!_playerConfigured) {
_player.onPlayerComplete.listen((_) => _onServerAudioComplete());
_player.onPlayerStateChanged.listen((state) {
if (state == PlayerState.playing) {
_playerStateSub = _player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
_onServerAudioComplete();
}
if (state.playing) {
// Clear transition flag when playback actually starts.
// This ensures pause events aren't emitted during the brief window
// between play() returning and the player entering playing state.
_isTransitioningChunks = false;
_emitEvent(const TtsStarted());
} else if (state == PlayerState.paused) {
} else if (!state.playing &&
state.processingState == ProcessingState.ready &&
!_isTransitioningChunks) {
// Only emit pause when actually paused, ready, and NOT transitioning
// between chunks. During chunk transitions, the player briefly enters
// a ready-but-not-playing state which should not emit pause events.
_emitEvent(const TtsPaused());
}
});
// Android-specific audio context configuration
if (!kIsWeb && Platform.isAndroid) {
await _player.setAudioContext(
AudioContext(android: const AudioContextAndroid()),
);
}
_playerConfigured = true;
}
@@ -334,7 +346,7 @@ class TtsManager {
try {
if (session.useServerTts) {
await _player.resume();
await _player.play();
_emitEvent(const TtsResumed());
} else {
// Device TTS resume is handled by the native handler
@@ -367,6 +379,7 @@ class TtsManager {
/// Disposes the manager and releases resources.
Future<void> dispose() async {
await stop();
await _playerStateSub?.cancel();
await _player.dispose();
await _eventController.close();
}
@@ -690,9 +703,17 @@ class TtsManager {
_serverCurrentIndex = 0;
await _player.stop();
await _player.play(
BytesSource(firstChunk.bytes, mimeType: firstChunk.mimeType),
);
_isTransitioningChunks = true;
// Flag will be cleared by state listener when playing=true is received.
// This prevents race condition where flag is cleared before state fires.
try {
await _player.setAudioSource(BytesAudioSource(firstChunk.bytes, firstChunk.mimeType));
await _player.play();
} catch (e) {
// Reset flag on error to avoid suppressing future pause events
_isTransitioningChunks = false;
rethrow;
}
_emitEvent(const TtsChunkStarted(0));
// Prefetch remaining chunks in background
@@ -766,7 +787,16 @@ class TtsManager {
_serverCurrentIndex = nextIndex;
final chunk = _serverAudioBuffer[nextIndex];
await _player.play(BytesSource(chunk.bytes, mimeType: chunk.mimeType));
_isTransitioningChunks = true;
// Flag will be cleared by state listener when playing=true is received.
try {
await _player.setAudioSource(BytesAudioSource(chunk.bytes, chunk.mimeType));
await _player.play();
} catch (e) {
// Reset flag on error to avoid suppressing future pause events
_isTransitioningChunks = false;
rethrow;
}
_emitEvent(TtsChunkStarted(nextIndex));
}

View File

@@ -3,8 +3,8 @@ import 'dart:collection';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
import 'package:just_audio/just_audio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -12,6 +12,7 @@ import '../../../core/providers/app_providers.dart';
import '../../../core/services/background_streaming_handler.dart';
import '../../../core/services/callkit_service.dart';
import '../../../core/services/socket_service.dart';
import '../../../shared/utils/bytes_audio_source.dart';
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../providers/chat_providers.dart';
import 'text_to_speech_service.dart';
@@ -64,6 +65,7 @@ class VoiceCallService {
bool _listeningSuspendedForSpeech = false;
final Map<int, SpeechAudioChunk> _serverAudioBuffer = {};
final AudioPlayer _serverAudioPlayer = AudioPlayer();
StreamSubscription<PlayerState>? _serverAudioStateSub;
int _serverAudioSession = 0;
int _pendingServerAudioFetches = 0;
bool _serverPipelineActive = false;
@@ -102,8 +104,10 @@ class VoiceCallService {
// sentence/word callbacks are not required for call UI, but harmless
);
_serverAudioPlayer.onPlayerComplete.listen((_) {
_handleServerAudioComplete();
_serverAudioStateSub = _serverAudioPlayer.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
_handleServerAudioComplete();
}
});
unawaited(_tts.preloadServerDefaults());
@@ -721,9 +725,10 @@ class VoiceCallService {
await _prepareForSpeechPlayback();
_isSpeaking = true;
_updateState(VoiceCallState.speaking);
await _serverAudioPlayer.play(
BytesSource(chunk.bytes, mimeType: chunk.mimeType),
await _serverAudioPlayer.setAudioSource(
BytesAudioSource(chunk.bytes, chunk.mimeType),
);
await _serverAudioPlayer.play();
} catch (e) {
_isSpeaking = false;
_handleTtsError(e.toString());
@@ -1003,6 +1008,7 @@ class VoiceCallService {
_voiceInput.dispose();
await _tts.dispose();
await _serverAudioStateSub?.cancel();
await _serverAudioPlayer.dispose();
// Cancel notification