feat(audio): replace record package with mic_stream_recorder

This commit is contained in:
cogwheel0
2025-11-05 00:09:35 +05:30
parent 24c8c27d51
commit a3b5c4f5b7
3 changed files with 65 additions and 170 deletions

View File

@@ -4,7 +4,7 @@ import 'dart:io' show File, Platform;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:record/record.dart';
import 'package:mic_stream_recorder/mic_stream_recorder.dart';
import 'package:stts/stts.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -23,7 +23,7 @@ class LocaleName {
}
class VoiceInputService {
final AudioRecorder _recorder = AudioRecorder();
final MicStreamRecorder _recorder = MicStreamRecorder();
final Stt _speech = Stt();
final ApiService? _api;
bool _isInitialized = false;
@@ -31,14 +31,10 @@ class VoiceInputService {
bool _localSttAvailable = false;
SttPreference _preference = SttPreference.auto;
bool _usingServerStt = false;
bool _serverRecorderActive = false;
String? _serverRecordingPath;
String? _serverRecordingMimeType;
String? _selectedLocaleId;
List<LocaleName> _locales = const [];
StreamController<String>? _textStreamController;
String _currentText = '';
// Public stream for UI waveform visualization (emits partial text length as proxy)
StreamController<int>? _intensityController;
Stream<int> get intensityStream =>
_intensityController?.stream ?? const Stream<int>.empty();
@@ -46,12 +42,13 @@ class VoiceInputService {
Timer? _intensityDecayTimer;
Timer? _silenceTimer;
bool _hasDetectedSpeech = false;
int _amplitudeCallbackCount = 0;
Timer? _amplitudeFallbackTimer;
/// Public stream of partial/final transcript strings and special audio tokens.
Stream<String> get textStream =>
_textStreamController?.stream ?? const Stream<String>.empty();
Timer? _autoStopTimer;
StreamSubscription<Amplitude>? _ampSub;
StreamSubscription<double>? _ampSub;
StreamSubscription<SttRecognition>? _sttResultSub;
StreamSubscription<SttState>? _sttStateSub;
@@ -111,10 +108,7 @@ class VoiceInputService {
Future<bool> checkPermissions() async {
try {
// Prefer stts permission check which will request microphone permission
final mic = await _speech.hasPermission();
if (mic) return true;
return await _recorder.hasPermission();
return await _speech.hasPermission();
} catch (_) {
return false;
}
@@ -200,9 +194,6 @@ class VoiceInputService {
_intensityController = StreamController<int>.broadcast();
_lastIntensity = 0;
_usingServerStt = false;
_serverRecorderActive = false;
_serverRecordingPath = null;
_serverRecordingMimeType = null;
_startIntensityDecayTimer();
@@ -336,6 +327,9 @@ class VoiceInputService {
_silenceTimer?.cancel();
_silenceTimer = null;
_amplitudeFallbackTimer?.cancel();
_amplitudeFallbackTimer = null;
if (_usingServerStt) {
await _finalizeServerRecording();
} else {
@@ -356,9 +350,6 @@ class VoiceInputService {
await _closeControllers();
_usingServerStt = false;
_serverRecorderActive = false;
_serverRecordingPath = null;
_serverRecordingMimeType = null;
_hasDetectedSpeech = false;
}
@@ -417,52 +408,50 @@ class VoiceInputService {
}
Future<void> _startServerRecording() async {
final (path, mimeType) = await _createRecordingTarget();
_serverRecordingPath = path;
_serverRecordingMimeType = mimeType;
final config = RecordConfig(
encoder: AudioEncoder.aacLc,
sampleRate: 44100,
bitRate: 96000,
numChannels: 1,
noiseSuppress: true,
);
await _recorder.start(config, path: path);
_serverRecorderActive = true;
final path = await _createRecordingPath();
_hasDetectedSpeech = false;
await _recorder.startRecording(path);
await _ampSub?.cancel();
_ampSub = _recorder
.onAmplitudeChanged(const Duration(milliseconds: 140))
.listen((Amplitude amplitude) {
_amplitudeFallbackTimer?.cancel();
_amplitudeCallbackCount = 0;
_ampSub = _recorder.amplitudeStream.listen((amplitude) {
_amplitudeCallbackCount++;
if (!_isListening) return;
_lastIntensity = _amplitudeToIntensity(amplitude.current);
_lastIntensity = _normalizedToIntensity(amplitude);
try {
_intensityController?.add(_lastIntensity);
} catch (_) {}
// Detect silence and auto-stop for server-side STT
_handleServerAmplitude(amplitude.current);
}, onError: (_) {});
_handleServerAmplitude(amplitude);
});
_amplitudeFallbackTimer = Timer(const Duration(seconds: 1), () {
if (_amplitudeCallbackCount == 0) {
_silenceTimer = Timer(const Duration(seconds: 15), () {
if (_isListening && _usingServerStt) {
unawaited(_stopListening());
}
});
}
});
}
void _handleServerAmplitude(double? amplitude) {
void _handleServerAmplitude(double amplitude) {
if (!_usingServerStt || !_isListening) return;
// Threshold for detecting speech (in dB)
const double speechThreshold = -45.0;
final double currentDb = amplitude ?? -100.0;
const double speechThreshold = 0.55;
if (amplitude.isNaN || amplitude.isInfinite) return;
// If we detect speech, mark it and reset silence timer
if (currentDb > speechThreshold) {
if (amplitude > speechThreshold) {
_hasDetectedSpeech = true;
_silenceTimer?.cancel();
_silenceTimer = null;
} else if (_hasDetectedSpeech && _silenceTimer == null) {
// Start silence timer only after we've detected speech at least once
_silenceTimer = Timer(const Duration(seconds: 2), () {
_silenceTimer = Timer(const Duration(milliseconds: 800), () {
if (_isListening && _usingServerStt) {
unawaited(_stopListening());
}
@@ -470,53 +459,30 @@ class VoiceInputService {
}
}
Future<(String, String)> _createRecordingTarget() async {
Future<String> _createRecordingPath() async {
final directory = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
const extension = 'm4a';
final fileName = 'conduit_voice_$timestamp.$extension';
final path = p.join(directory.path, fileName);
return (path, 'audio/mp4');
final fileName = 'conduit_voice_$timestamp.m4a';
return p.join(directory.path, fileName);
}
Future<void> _finalizeServerRecording() async {
final api = _api;
if (api == null) {
return;
}
if (api == null) return;
String? path;
final path = await _recorder.stopRecording();
if (path == null || path.isEmpty) return;
final file = File(path);
try {
if (_serverRecorderActive && await _recorder.isRecording()) {
path = await _recorder.stop();
} else {
path = _serverRecordingPath;
}
} catch (_) {
path = _serverRecordingPath;
} finally {
_serverRecorderActive = false;
}
final resolvedPath = path;
if (resolvedPath == null || resolvedPath.isEmpty) {
return;
}
final file = File(resolvedPath);
try {
if (!await file.exists()) {
return;
}
if (!await file.exists()) return;
final bytes = await file.readAsBytes();
if (bytes.isEmpty) {
return;
}
if (bytes.isEmpty) return;
final response = await api.transcribeSpeech(
audioBytes: bytes,
fileName: p.basename(resolvedPath),
mimeType: _serverRecordingMimeType,
fileName: p.basename(path),
mimeType: 'audio/mp4',
language: _languageForServer(),
);
@@ -641,21 +607,9 @@ class VoiceInputService {
return null;
}
int _amplitudeToIntensity(double? value) {
if (value == null || value.isNaN || value.isInfinite) {
return 0;
}
const minDb = -55.0;
const maxDb = 0.0;
final double clamped = value.clamp(minDb, maxDb).toDouble();
final double normalized = ((clamped - minDb) / (maxDb - minDb)).clamp(
0.0,
1.0,
);
final int scaled = (normalized * 10).round();
if (scaled <= 0) return 0;
if (scaled >= 10) return 10;
return scaled;
int _normalizedToIntensity(double value) {
if (value.isNaN || value.isInfinite) return 0;
return (value * 10).round().clamp(0, 10);
}
Future<void> _closeControllers() async {
@@ -693,9 +647,6 @@ class VoiceInputService {
try {
_speech.dispose().catchError((_) {});
} catch (_) {}
try {
_recorder.dispose().catchError((_) {});
} catch (_) {}
}
}

View File

@@ -965,6 +965,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mic_stream_recorder:
dependency: "direct main"
description:
name: mic_stream_recorder
sha256: "73965991ef5cc93d2b0c1e6d590cbd567a853b9ee7b2d52de43a73f185bb0d9c"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
mime:
dependency: transitive
description:
@@ -1165,70 +1173,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record:
dependency: "direct main"
description:
name: record
sha256: "9dbc6ff3e784612f90a9b001373c45ff76b7a08abd2bd9fdf72c242320c8911c"
url: "https://pub.dev"
source: hosted
version: "6.1.1"
record_android:
dependency: transitive
description:
name: record_android
sha256: "854627cd78d8d66190377f98477eee06ca96ab7c9f2e662700daf33dbf7e6673"
url: "https://pub.dev"
source: hosted
version: "1.4.2"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "13e241ed9cbc220534a40ae6b66222e21288db364d96dd66fb762ebd3cb77c71"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: "235b1f1fb84e810f8149cc0c2c731d7d697f8d1c333b32cb820c449bf7bb72d8"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "2849068bb59072f300ad63ed146e543d66afaef8263edba4de4834fc7c8d4d35"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed
url: "https://pub.dev"
source: hosted
version: "1.4.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "4f0adf20c9ccafcc02d71111fd91fba1ca7b17a7453902593e5a9b25b74a5c56"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
riverpod:
dependency: transitive
description:

View File

@@ -44,7 +44,7 @@ dependencies:
flutter_animate: ^4.5.0
# Platform Features
record: ^6.1.1
mic_stream_recorder: ^1.1.2
stts: ^1.2.5
flutter_tts: ^4.2.3
audioplayers: ^6.5.1