feat: make horatio audio work on android

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-30 20:18:33 +02:00
parent 97840b7eea
commit 864b82efb6
34 changed files with 660 additions and 13 deletions

149
horatio/deploy.sh Executable file
View File

@ -0,0 +1,149 @@
#!/bin/bash
# ============================================================================
# Horatio — build & deploy to Android device (BL-9000)
#
# Builds a release APK and installs it on the connected Android device.
# Supports wireless ADB (IP) or USB (auto-detect).
#
# Usage:
# ./deploy.sh # Build + install (auto-detect USB device)
# ./deploy.sh <phone_ip> # Build + install via wireless ADB
# ./deploy.sh --install-only # Skip build, install existing APK
# ./deploy.sh <ip> --install-only
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly APP_DIR="$SCRIPT_DIR/horatio_app"
readonly APK_PATH="$APP_DIR/build/app/outputs/flutter-apk/app-release.apk"
PHONE_IP=""
INSTALL_ONLY=false
# -- Argument parsing --------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--install-only) INSTALL_ONLY=true; shift ;;
-*) echo "Unknown flag: $1"; exit 1 ;;
*) PHONE_IP="$1"; shift ;;
esac
done
# -- Helpers -----------------------------------------------------------------
heading() {
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " $1"
echo "══════════════════════════════════════════════════════════════"
}
check_command() {
local cmd="$1"
local install_hint="$2"
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: '$cmd' not found. Install with: $install_hint"
exit 1
fi
}
# -- ADB connection ----------------------------------------------------------
get_device_flag() {
if [[ -n "$PHONE_IP" ]]; then
echo "-s ${PHONE_IP}:5555"
else
echo ""
fi
}
connect_device() {
check_command adb "pacman -S android-tools"
if [[ -n "$PHONE_IP" ]]; then
heading "Connecting to $PHONE_IP via wireless ADB"
adb connect "${PHONE_IP}:5555"
sleep 1
if ! adb devices | grep -q "$PHONE_IP"; then
echo "ERROR: Could not connect to ${PHONE_IP}:5555"
echo "Make sure wireless ADB is enabled and the phone is reachable."
exit 1
fi
echo "Connected."
else
heading "Detecting USB device"
local device_count
device_count=$(adb devices | grep -c 'device$' || true)
if [[ "$device_count" -eq 0 ]]; then
echo "ERROR: No Android device connected via USB."
echo "Connect BL-9000 with USB, or pass its IP: $0 <phone_ip>"
exit 1
elif [[ "$device_count" -gt 1 ]]; then
echo "ERROR: Multiple devices detected. Specify IP or disconnect extras."
adb devices
exit 1
fi
echo "Found device: $(adb devices | grep 'device$' | awk '{print $1}')"
fi
}
# -- Build -------------------------------------------------------------------
build_apk() {
heading "Building Horatio APK (release)"
check_command flutter "Install Flutter: https://flutter.dev/docs/get-started/install"
cd "$APP_DIR"
flutter pub get
flutter build apk --release
if [[ ! -f "$APK_PATH" ]]; then
echo "ERROR: APK not found at $APK_PATH"
exit 1
fi
local size
size=$(du -h "$APK_PATH" | awk '{print $1}')
echo "APK built: $APK_PATH ($size)"
}
# -- Install -----------------------------------------------------------------
install_apk() {
heading "Installing Horatio on device"
if [[ ! -f "$APK_PATH" ]]; then
echo "ERROR: No APK found at $APK_PATH"
echo "Run without --install-only to build first."
exit 1
fi
local device_flag
device_flag=$(get_device_flag)
# shellcheck disable=SC2086
adb $device_flag install -r "$APK_PATH"
echo ""
echo "Horatio installed successfully."
}
# -- Main --------------------------------------------------------------------
main() {
connect_device
if ! $INSTALL_ONLY; then
build_apk
fi
install_apk
echo ""
echo "Done. Launch Horatio on BL-9000."
}
main

View File

@ -15,7 +15,7 @@ migration:
- platform: root - platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: web - platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a

14
horatio/horatio_app/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,43 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.kuhy.horatio"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.kuhy.horatio"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Horatio"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.kuhy.horatio
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@ -0,0 +1 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav

View File

@ -0,0 +1 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav

View File

@ -0,0 +1 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav

View File

@ -0,0 +1 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav

View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -1,4 +1,5 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
part of 'app_database.dart'; part of 'app_database.dart';

View File

@ -1,4 +1,5 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
part of 'annotation_dao.dart'; part of 'annotation_dao.dart';

View File

@ -1,4 +1,5 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
part of 'recording_dao.dart'; part of 'recording_dao.dart';

View File

@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
/// Drift table for annotation history snapshots. /// Drift table for annotation history snapshots.

View File

@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
/// Drift table for line-level interpretive notes. /// Drift table for line-level interpretive notes.

View File

@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
/// Drift table for per-line voice recordings. /// Drift table for per-line voice recordings.

View File

@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
/// Drift table for text-level delivery marks on script lines. /// Drift table for text-level delivery marks on script lines.

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show ByteData, rootBundle;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:horatio_app/database/app_database.dart'; import 'package:horatio_app/database/app_database.dart';
import 'package:horatio_app/database/daos/annotation_dao.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart';
@ -10,6 +11,7 @@ import 'package:horatio_app/screens/annotation_editor_screen.dart';
import 'package:horatio_app/services/audio_playback_service.dart'; import 'package:horatio_app/services/audio_playback_service.dart';
import 'package:horatio_app/services/recording_service.dart'; import 'package:horatio_app/services/recording_service.dart';
import 'package:horatio_core/horatio_core.dart'; import 'package:horatio_core/horatio_core.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
const _uuid = Uuid(); const _uuid = Uuid();
@ -98,8 +100,7 @@ class _DemoAnnotationEditorScreenState
late final AppDatabase _db; late final AppDatabase _db;
late final RecordingService _recordingService; late final RecordingService _recordingService;
late final AudioPlaybackService _playbackService; late final AudioPlaybackService _playbackService;
final String _recordingsDir = String _recordingsDir = '';
'${Platform.environment['HOME']}/.local/share/horatio/demo_recordings';
bool _ready = false; bool _ready = false;
bool _disposed = false; bool _disposed = false;
@ -114,6 +115,7 @@ class _DemoAnnotationEditorScreenState
} }
Future<void> _seedAndMarkReady() async { Future<void> _seedAndMarkReady() async {
_recordingsDir = await resolveDemoRecordingsDir();
await _seed( await _seed(
_db.annotationDao, _db.annotationDao,
_db.recordingDao, _db.recordingDao,
@ -151,11 +153,51 @@ class _DemoAnnotationEditorScreenState
} }
} }
/// Resolves the demo recordings directory, using path_provider on mobile.
@visibleForTesting
Future<String> resolveDemoRecordingsDir({
bool? isMobile,
Future<Directory> Function()? getDocsDir,
}) async {
if (isMobile ?? (Platform.isAndroid || Platform.isIOS)) {
final dir = await (getDocsDir ?? getApplicationDocumentsDirectory)();
return '${dir.path}/demo_recordings';
}
return '${Platform.environment['HOME']}/.local/share/horatio/demo_recordings';
}
/// Map from WAV filename to the demo line text it represents.
///
/// Used on Android/iOS to look up the correct bundled asset for a given
/// line of text, and on desktop to decide whether to synthesise or copy.
@visibleForTesting
const demoAssetMap = <String, String>{
'hamlet_line0_take1.wav': 'To be, or not to be, that is the question:',
'hamlet_line0_take2.wav': 'To be, or not to be, that is the question:',
'hamlet_line0_take3.wav': 'To be, or not to be, that is the question:',
'hamlet_line1_take1.wav': "Whether 'tis nobler in the mind to suffer",
};
/// Reverse lookup: text list of bundled asset filenames.
Map<String, List<String>> _textToAssets() {
final map = <String, List<String>>{};
for (final entry in demoAssetMap.entries) {
map.putIfAbsent(entry.value, () => []).add(entry.key);
}
return map;
}
/// Counter used to pick the next bundled asset for a given line of text.
int _assetCounter = 0;
/// Synthesises [text] to a WAV file at [path] and returns [path]. /// Synthesises [text] to a WAV file at [path] and returns [path].
/// ///
/// Uses Piper TTS (neural, high-quality English voice) when the model file at /// On Android/iOS, copies a pre-generated WAV from the bundled Flutter assets
/// [piperModel] exists. Falls back to `espeak-ng` otherwise (always available /// so the demo works reliably regardless of TTS engine availability.
/// on the dev machine). ///
/// On desktop, uses Piper TTS (neural, high-quality English voice) when the
/// model file at [piperModel] exists. Falls back to `espeak-ng` otherwise
/// (always available on the dev machine).
/// ///
/// Exposed as `@visibleForTesting` so unit tests can exercise both code paths /// Exposed as `@visibleForTesting` so unit tests can exercise both code paths
/// directly without running the full widget. /// directly without running the full widget.
@ -164,7 +206,14 @@ Future<String> synthesiseDemoSpeech(
String path, String path,
String text, { String text, {
String? piperModel, String? piperModel,
bool? isMobile,
Future<ByteData> Function(String key)? loadAsset,
}) async { }) async {
final effectiveLoadAsset = loadAsset ?? rootBundle.load;
if (isMobile ?? (Platform.isAndroid || Platform.isIOS)) {
await _copyBundledAsset(path, text, loadAsset: effectiveLoadAsset);
return path;
}
final model = final model =
piperModel ?? piperModel ??
'${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx'; '${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx';
@ -182,6 +231,21 @@ Future<String> synthesiseDemoSpeech(
return path; return path;
} }
/// Copies a pre-generated WAV from Flutter assets to [path].
Future<void> _copyBundledAsset(
String path,
String text, {
required Future<ByteData> Function(String key) loadAsset,
}) async {
final assets = _textToAssets()[text];
if (assets == null || assets.isEmpty) return;
final asset = assets[_assetCounter++ % assets.length];
final data = await loadAsset('assets/demo_recordings/$asset');
await File(path).writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
}
/// Synthesises [text] to a WAV file at [path], skipping synthesis if the /// Synthesises [text] to a WAV file at [path], skipping synthesis if the
/// file already exists on disk. /// file already exists on disk.
/// ///

View File

@ -44,6 +44,7 @@ class SpeechService {
final Future<Directory> Function() _tempDirProvider; final Future<Directory> Function() _tempDirProvider;
bool _initialised = false; bool _initialised = false;
bool _usesWhisper = false; bool _usesWhisper = false;
String _whisperCommand = 'whisper';
/// Whether speech recognition is available on this device. /// Whether speech recognition is available on this device.
bool get isAvailable => _initialised; bool get isAvailable => _initialised;
@ -72,16 +73,41 @@ class SpeechService {
} }
Future<bool> _initLinux() async { Future<bool> _initLinux() async {
// Check that whisper CLI is available. // Check that whisper CLI is available via PATH.
try { try {
final result = await _processRunner('which', ['whisper']); final result = await _processRunner('which', ['whisper']);
if (result.exitCode != 0) return false; if (result.exitCode == 0) {
_whisperCommand = (result.stdout as String).trim();
_usesWhisper = true;
return true;
}
} on ProcessException { } on ProcessException {
// Process system itself is broken no point trying fallback paths
// because _processRunner won't be able to run whisper either.
return false; return false;
} }
_usesWhisper = true; // Fallback: check common pipx / system installation paths by running
return true; // them directly. VS Code and other GUI launchers may not include
// ~/.local/bin in PATH.
final home = Platform.environment['HOME'] ?? '';
for (final candidate in [
'$home/.local/bin/whisper',
'/usr/local/bin/whisper',
]) {
try {
final r = await _processRunner(candidate, ['--help']);
if (r.exitCode == 0) {
_whisperCommand = candidate;
_usesWhisper = true;
return true;
}
} on ProcessException {
continue;
}
}
return false;
} }
/// Starts listening. Calls [onResult] with partial/final transcriptions. /// Starts listening. Calls [onResult] with partial/final transcriptions.
@ -129,7 +155,7 @@ class SpeechService {
try { try {
final result = await _processRunner( final result = await _processRunner(
'whisper', _whisperCommand,
[path, '--model', 'base', '--output_format', 'txt', '--language', 'en'], [path, '--model', 'base', '--output_format', 'txt', '--language', 'en'],
); );
if (result.exitCode != 0) return ''; if (result.exitCode != 0) return '';

View File

@ -47,3 +47,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/public_domain/ - assets/public_domain/
- assets/demo_recordings/

View File

@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -179,12 +181,20 @@ void main() {
expect(File(path).lengthSync(), greaterThan(44)); // has audio data expect(File(path).lengthSync(), greaterThan(44)); // has audio data
}); });
test('default piperModel: uses HOME-based model path', () async {
final path = '${tmpDir.path}/default_model.wav';
final result = await synthesiseDemoSpeech(path, 'Hello.');
expect(result, path);
// Regardless of whether piper or espeak-ng runs, a file is created.
expect(File(path).existsSync(), isTrue);
});
test('piper path: creates a WAV file using the installed model', () async { test('piper path: creates a WAV file using the installed model', () async {
final home = Platform.environment['HOME'] ?? '/root'; final home = Platform.environment['HOME'] ?? '/root';
final model = final model =
'$home/.local/share/horatio/piper/en_US-lessac-high.onnx'; '$home/.local/share/horatio/piper/en_US-lessac-high.onnx';
if (!File(model).existsSync()) { if (!File(model).existsSync()) {
// Piper not installed \u2014 skip this path on machines without the model. // Piper not installed skip this path on machines without the model.
return; return;
} }
final path = '${tmpDir.path}/hamlet.wav'; final path = '${tmpDir.path}/hamlet.wav';
@ -197,6 +207,52 @@ void main() {
expect(File(path).existsSync(), isTrue); expect(File(path).existsSync(), isTrue);
expect(File(path).lengthSync(), greaterThan(44)); expect(File(path).lengthSync(), greaterThan(44));
}); });
test('mobile path: copies bundled asset to destination', () async {
final path =
'${tmpDir.path}/hamlet_line0_take1.wav';
final fakeWav = Uint8List.fromList([
// Minimal RIFF/WAVE header + 2 bytes of audio data.
...utf8.encode('RIFF'),
50, 0, 0, 0, // chunk size
...utf8.encode('WAVE'),
...utf8.encode('fmt '),
16, 0, 0, 0, // sub-chunk size
1, 0, // PCM
1, 0, // mono
0x22, 0x56, 0, 0, // 22050 Hz
0x44, 0xAC, 0, 0, // byte rate
2, 0, // block align
16, 0, // bits per sample
...utf8.encode('data'),
2, 0, 0, 0, // data size
0, 0, // audio sample
]);
final result = await synthesiseDemoSpeech(
path,
'To be, or not to be, that is the question:',
isMobile: true,
loadAsset: (_) async =>
ByteData.sublistView(fakeWav),
);
expect(result, path);
expect(File(path).existsSync(), isTrue);
expect(File(path).readAsBytesSync(), fakeWav);
});
test('mobile path: early return for unknown text', () async {
final path = '${tmpDir.path}/unknown.wav';
final result = await synthesiseDemoSpeech(
path,
'Unknown line that is not in demoAssetMap',
isMobile: true,
loadAsset: (_) async =>
ByteData.sublistView(Uint8List(0)),
);
expect(result, path);
// File should NOT be created because the text doesn't match any asset.
expect(File(path).existsSync(), isFalse);
});
}); });
group('synthesiseDemoSpeechCached', () { group('synthesiseDemoSpeechCached', () {
@ -246,4 +302,23 @@ void main() {
expect(called, isFalse); // synthesis was skipped expect(called, isFalse); // synthesis was skipped
}); });
}); });
group('resolveDemoRecordingsDir', () {
test('mobile path uses provided getDocsDir', () async {
final fakeDir = await Directory.systemTemp.createTemp('horatio_docs_');
addTearDown(() => fakeDir.delete(recursive: true));
final result = await resolveDemoRecordingsDir(
isMobile: true,
getDocsDir: () async => fakeDir,
);
expect(result, '${fakeDir.path}/demo_recordings');
});
test('desktop path uses HOME environment variable', () async {
final result = await resolveDemoRecordingsDir(isMobile: false);
final home = Platform.environment['HOME'] ?? '/root';
expect(result, '$home/.local/share/horatio/demo_recordings');
});
});
} }

View File

@ -122,6 +122,59 @@ void main() {
expect(result, isFalse); expect(result, isFalse);
}); });
// -- _initLinux fallback path ----------------------------------------------
test('initialise finds whisper via fallback path when which fails',
() async {
// Simulate `which whisper` returning non-zero (not in PATH) while the
// binary still exists at a fallback location.
final service = SpeechService(
processRunner: (exe, args) async {
if (exe == 'which') {
return ProcessResult(0, 1, '', 'not found');
}
// Fallback candidate runs successfully.
return ProcessResult(0, 0, '', '');
},
);
final result = await service.initialise();
expect(result, isTrue);
expect(service.usesWhisper, isTrue);
});
test('initialise returns false when which and all fallback candidates fail',
() async {
final service = SpeechService(
processRunner: (exe, args) async {
if (exe == 'which') {
return ProcessResult(0, 1, '', 'not found');
}
// All fallback candidates fail.
return ProcessResult(0, 127, '', 'not found');
},
);
final result = await service.initialise();
expect(result, isFalse);
expect(service.usesWhisper, isFalse);
});
test('initialise returns false when fallback candidates throw', () async {
var whichCalled = false;
final service = SpeechService(
processRunner: (exe, args) async {
if (exe == 'which') {
whichCalled = true;
return ProcessResult(0, 1, '', 'not found');
}
// Fallback candidates throw ProcessException.
throw ProcessException(exe, args);
},
);
final result = await service.initialise();
expect(whichCalled, isTrue);
expect(result, isFalse);
});
// -- startListening branches ----------------------------------------------- // -- startListening branches -----------------------------------------------
group('startListening', () { group('startListening', () {

View File

@ -251,6 +251,16 @@ app_codegen() {
heading "Running drift codegen" heading "Running drift codegen"
cd "$APP_DIR" cd "$APP_DIR"
dart run build_runner build --delete-conflicting-outputs dart run build_runner build --delete-conflicting-outputs
# Inject // coverage:ignore-file into generated .g.dart files so that
# flutter test --coverage excludes them automatically (VS Code reads
# the raw lcov.info without the awk filter in app_test).
local gfiles
gfiles=$(find "$APP_DIR/lib" -name '*.g.dart' -exec grep -L 'coverage:ignore-file' {} +) || true
if [[ -n "$gfiles" ]]; then
echo "$gfiles" | xargs sed -i '1a\// coverage:ignore-file'
fi
cache_step app_codegen "$h" cache_step app_codegen "$h"
} }
@ -378,6 +388,16 @@ do_web() {
app_web app_web
} }
do_deploy() {
check_deps
ensure_flutter
core_get
app_get
app_codegen
heading "Deploying to Android device (BL-9000)"
bash "$SCRIPT_DIR/deploy.sh" "$@"
}
# -- Main -------------------------------------------------------------------- # -- Main --------------------------------------------------------------------
main() { main() {
@ -398,8 +418,9 @@ main() {
full) do_full ;; full) do_full ;;
run) do_run ;; run) do_run ;;
web) do_web ;; web) do_web ;;
deploy) shift; do_deploy "$@" ;;
*) *)
echo "Usage: $0 [-f|--force] {analyze|test|dead-code|full|run|web}" echo "Usage: $0 [-f|--force] {analyze|test|dead-code|full|run|web|deploy}"
exit 1 exit 1
;; ;;
esac esac