Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
# iva_mobile

A new Flutter project.
Voice-to-text mobile client built with Flutter.

## Getting Started

This project is a starting point for a Flutter application.
### Dependencies

Audio and speech dependencies live in `pubspec.yaml`:

- `record` – microphone capture and amplitude stream.
- `speech_to_text` – on-device speech recognition with partial results.
- `provider` – MVVM state injection.

### Platform permissions

- Android: `android/app/src/main/AndroidManifest.xml` declares
`android.permission.RECORD_AUDIO`.
- iOS: `ios/Runner/Info.plist` includes `NSMicrophoneUsageDescription` and
`NSSpeechRecognitionUsageDescription` strings.

### Run locally

```
flutter pub get
flutter run
```

The app boots to the voice screen. Tap the microphone to request permission and
start recording. The waveform animates from live amplitude data and the
transcription updates as speech is recognized.

A few resources to get you started if this is your first Flutter project:

Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="iva_mobile"
android:name="${applicationName}"
Expand Down
16 changes: 10 additions & 6 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires microphone access to capture your voice.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to transcribe your voice into text.</string>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
Expand Down
98 changes: 98 additions & 0 deletions lib/core/services/audio_capture.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';

enum AudioPermissionStatus { granted, denied, restricted }

abstract class AudioCaptureService {
Future<AudioPermissionStatus> ensurePermission();
Future<void> start();
Future<void> pause();
Future<void> resume();
Future<void> stop();
Stream<double> get amplitudeStream; // 0.0 to 1.0
}

class AudioCaptureServiceImpl implements AudioCaptureService {
AudioCaptureServiceImpl({AudioRecorder? recorder})
: _recorder = recorder ?? AudioRecorder();

final AudioRecorder _recorder;
final StreamController<double> _levelController =
StreamController<double>.broadcast();
StreamSubscription<Amplitude>? _subscription;

@override
Stream<double> get amplitudeStream => _levelController.stream;

@override
Future<AudioPermissionStatus> ensurePermission() async {
final has = await _recorder.hasPermission();
return has ? AudioPermissionStatus.granted : AudioPermissionStatus.denied;
}

@override
Future<void> start() async {
if (!await _recorder.isRecording()) {
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/iva_rec_${DateTime.now().millisecondsSinceEpoch}.m4a',
);
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: file.path,
);
}
_subscription ??= _recorder
.onAmplitudeChanged(const Duration(milliseconds: 80))
.listen((amp) {
final normalized = _normalizeDb(amp.current);
if (!_levelController.isClosed) {
_levelController.add(normalized);
}
});
}

@override
Future<void> pause() async {
if (await _recorder.isRecording()) {
await _recorder.pause();
}
}

@override
Future<void> resume() async {
if (await _recorder.isPaused()) {
await _recorder.resume();
}
}

@override
Future<void> stop() async {
try {
await _subscription?.cancel();
} finally {
_subscription = null;
}
if (await _recorder.isRecording() || await _recorder.isPaused()) {
await _recorder.stop();
}
}

double _normalizeDb(double db) {
// Map [-45dB, 0dB] to [0, 1], clamp outside
const minDb = -45.0;
const maxDb = 0.0;
final clamped = db.clamp(minDb, maxDb);
return (clamped - minDb) / (maxDb - minDb);
}

@mustCallSuper
void dispose() {
_levelController.close();
_subscription?.cancel();
}
}
71 changes: 71 additions & 0 deletions lib/core/services/speech_recognition.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:async';

import 'package:speech_to_text/speech_to_text.dart' as stt;

abstract class SpeechRecognitionService {
Future<bool> initialize();
Future<void> startListening({bool partialResults = true});
Future<void> stopListening();
Future<void> cancel();
Stream<String> get transcriptionStream; // incremental text
}

class SpeechRecognitionServiceImpl implements SpeechRecognitionService {
SpeechRecognitionServiceImpl({stt.SpeechToText? engine})
: _engine = engine ?? stt.SpeechToText();

final stt.SpeechToText _engine;
final StreamController<String> _controller =
StreamController<String>.broadcast();

@override
Stream<String> get transcriptionStream => _controller.stream;

@override
Future<bool> initialize() async {
final available = await _engine.initialize(
onStatus: (_) {},
onError: (e) {},
);
return available;
}

@override
Future<void> startListening({bool partialResults = true}) async {
if (!_engine.isAvailable) {
final ok = await initialize();
if (!ok) return;
}
await _engine.listen(
onResult: (result) {
final text = result.recognizedWords;
if (!_controller.isClosed) {
_controller.add(text);
}
},
listenOptions: stt.SpeechListenOptions(
listenMode: stt.ListenMode.dictation,
partialResults: partialResults,
cancelOnError: true,
),
);
}

@override
Future<void> stopListening() async {
if (_engine.isListening) {
await _engine.stop();
}
}

@override
Future<void> cancel() async {
if (_engine.isListening) {
await _engine.cancel();
}
}

void dispose() {
_controller.close();
}
}
Loading