diff --git a/horatio/deploy.sh b/horatio/deploy.sh new file mode 100755 index 0000000..ac3ce19 --- /dev/null +++ b/horatio/deploy.sh @@ -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 # Build + install via wireless ADB +# ./deploy.sh --install-only # Skip build, install existing APK +# ./deploy.sh --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 " + 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 diff --git a/horatio/horatio_app/.metadata b/horatio/horatio_app/.metadata index 9114285..a8104ff 100644 --- a/horatio/horatio_app/.metadata +++ b/horatio/horatio_app/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a - - platform: web + - platform: android create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a diff --git a/horatio/horatio_app/android/.gitignore b/horatio/horatio_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/horatio/horatio_app/android/.gitignore @@ -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 diff --git a/horatio/horatio_app/android/app/build.gradle.kts b/horatio/horatio_app/android/app/build.gradle.kts new file mode 100644 index 0000000..ee8755c --- /dev/null +++ b/horatio/horatio_app/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/horatio/horatio_app/android/app/src/debug/AndroidManifest.xml b/horatio/horatio_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/horatio/horatio_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/horatio/horatio_app/android/app/src/main/AndroidManifest.xml b/horatio/horatio_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..957e6b3 --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/horatio/horatio_app/android/app/src/main/kotlin/com/kuhy/horatio/MainActivity.kt b/horatio/horatio_app/android/app/src/main/kotlin/com/kuhy/horatio/MainActivity.kt new file mode 100644 index 0000000..0dd6178 --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/kotlin/com/kuhy/horatio/MainActivity.kt @@ -0,0 +1,5 @@ +package com.kuhy.horatio + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/horatio/horatio_app/android/app/src/main/res/drawable-v21/launch_background.xml b/horatio/horatio_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/horatio/horatio_app/android/app/src/main/res/drawable/launch_background.xml b/horatio/horatio_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/horatio/horatio_app/android/app/src/main/res/values-night/styles.xml b/horatio/horatio_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/horatio/horatio_app/android/app/src/main/res/values/styles.xml b/horatio/horatio_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/horatio/horatio_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/horatio/horatio_app/android/app/src/profile/AndroidManifest.xml b/horatio/horatio_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/horatio/horatio_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/horatio/horatio_app/android/build.gradle.kts b/horatio/horatio_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/horatio/horatio_app/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/horatio/horatio_app/android/gradle.properties b/horatio/horatio_app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/horatio/horatio_app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/horatio/horatio_app/android/gradle/wrapper/gradle-wrapper.properties b/horatio/horatio_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/horatio/horatio_app/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/horatio/horatio_app/android/settings.gradle.kts b/horatio/horatio_app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/horatio/horatio_app/android/settings.gradle.kts @@ -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") diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav new file mode 120000 index 0000000..c140606 --- /dev/null +++ b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav @@ -0,0 +1 @@ +../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav new file mode 120000 index 0000000..75a8d9d --- /dev/null +++ b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav @@ -0,0 +1 @@ +../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav new file mode 120000 index 0000000..8a55c82 --- /dev/null +++ b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav @@ -0,0 +1 @@ +../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav new file mode 120000 index 0000000..4cc4e8f --- /dev/null +++ b/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav @@ -0,0 +1 @@ +../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav \ No newline at end of file diff --git a/horatio/horatio_app/devtools_options.yaml b/horatio/horatio_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/horatio/horatio_app/devtools_options.yaml @@ -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: diff --git a/horatio/horatio_app/lib/database/app_database.g.dart b/horatio/horatio_app/lib/database/app_database.g.dart index f1d89d7..648e264 100644 --- a/horatio/horatio_app/lib/database/app_database.g.dart +++ b/horatio/horatio_app/lib/database/app_database.g.dart @@ -1,4 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file part of 'app_database.dart'; diff --git a/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart b/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart index e6819c1..098f2d1 100644 --- a/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart +++ b/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart @@ -1,4 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file part of 'annotation_dao.dart'; diff --git a/horatio/horatio_app/lib/database/daos/recording_dao.g.dart b/horatio/horatio_app/lib/database/daos/recording_dao.g.dart index 4750c7b..f3f9d12 100644 --- a/horatio/horatio_app/lib/database/daos/recording_dao.g.dart +++ b/horatio/horatio_app/lib/database/daos/recording_dao.g.dart @@ -1,4 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file part of 'recording_dao.dart'; diff --git a/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart b/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart index 87bfb26..564d7e8 100644 --- a/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart +++ b/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:drift/drift.dart'; /// Drift table for annotation history snapshots. diff --git a/horatio/horatio_app/lib/database/tables/line_notes_table.dart b/horatio/horatio_app/lib/database/tables/line_notes_table.dart index 5d345f6..ddd9552 100644 --- a/horatio/horatio_app/lib/database/tables/line_notes_table.dart +++ b/horatio/horatio_app/lib/database/tables/line_notes_table.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:drift/drift.dart'; /// Drift table for line-level interpretive notes. diff --git a/horatio/horatio_app/lib/database/tables/line_recordings_table.dart b/horatio/horatio_app/lib/database/tables/line_recordings_table.dart index 52066b5..5b94123 100644 --- a/horatio/horatio_app/lib/database/tables/line_recordings_table.dart +++ b/horatio/horatio_app/lib/database/tables/line_recordings_table.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:drift/drift.dart'; /// Drift table for per-line voice recordings. diff --git a/horatio/horatio_app/lib/database/tables/text_marks_table.dart b/horatio/horatio_app/lib/database/tables/text_marks_table.dart index 8e5e602..32a27b1 100644 --- a/horatio/horatio_app/lib/database/tables/text_marks_table.dart +++ b/horatio/horatio_app/lib/database/tables/text_marks_table.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:drift/drift.dart'; /// Drift table for text-level delivery marks on script lines. diff --git a/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart index 505480b..133ac83 100644 --- a/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart +++ b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show ByteData, rootBundle; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:horatio_app/database/app_database.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/recording_service.dart'; import 'package:horatio_core/horatio_core.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; const _uuid = Uuid(); @@ -98,8 +100,7 @@ class _DemoAnnotationEditorScreenState late final AppDatabase _db; late final RecordingService _recordingService; late final AudioPlaybackService _playbackService; - final String _recordingsDir = - '${Platform.environment['HOME']}/.local/share/horatio/demo_recordings'; + String _recordingsDir = ''; bool _ready = false; bool _disposed = false; @@ -114,6 +115,7 @@ class _DemoAnnotationEditorScreenState } Future _seedAndMarkReady() async { + _recordingsDir = await resolveDemoRecordingsDir(); await _seed( _db.annotationDao, _db.recordingDao, @@ -151,11 +153,51 @@ class _DemoAnnotationEditorScreenState } } +/// Resolves the demo recordings directory, using path_provider on mobile. +@visibleForTesting +Future resolveDemoRecordingsDir({ + bool? isMobile, + Future 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 = { + '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> _textToAssets() { + final map = >{}; + 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]. /// -/// 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). +/// On Android/iOS, copies a pre-generated WAV from the bundled Flutter assets +/// so the demo works reliably regardless of TTS engine availability. +/// +/// 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 /// directly without running the full widget. @@ -164,7 +206,14 @@ Future synthesiseDemoSpeech( String path, String text, { String? piperModel, + bool? isMobile, + Future Function(String key)? loadAsset, }) async { + final effectiveLoadAsset = loadAsset ?? rootBundle.load; + if (isMobile ?? (Platform.isAndroid || Platform.isIOS)) { + await _copyBundledAsset(path, text, loadAsset: effectiveLoadAsset); + return path; + } final model = piperModel ?? '${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx'; @@ -182,6 +231,21 @@ Future synthesiseDemoSpeech( return path; } +/// Copies a pre-generated WAV from Flutter assets to [path]. +Future _copyBundledAsset( + String path, + String text, { + required Future 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 /// file already exists on disk. /// diff --git a/horatio/horatio_app/lib/services/speech_service.dart b/horatio/horatio_app/lib/services/speech_service.dart index d5502c7..4cbb86b 100644 --- a/horatio/horatio_app/lib/services/speech_service.dart +++ b/horatio/horatio_app/lib/services/speech_service.dart @@ -44,6 +44,7 @@ class SpeechService { final Future Function() _tempDirProvider; bool _initialised = false; bool _usesWhisper = false; + String _whisperCommand = 'whisper'; /// Whether speech recognition is available on this device. bool get isAvailable => _initialised; @@ -72,16 +73,41 @@ class SpeechService { } Future _initLinux() async { - // Check that whisper CLI is available. + // Check that whisper CLI is available via PATH. try { 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 { + // Process system itself is broken — no point trying fallback paths + // because _processRunner won't be able to run whisper either. return false; } - _usesWhisper = true; - return true; + // Fallback: check common pipx / system installation paths by running + // 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. @@ -129,7 +155,7 @@ class SpeechService { try { final result = await _processRunner( - 'whisper', + _whisperCommand, [path, '--model', 'base', '--output_format', 'txt', '--language', 'en'], ); if (result.exitCode != 0) return ''; diff --git a/horatio/horatio_app/pubspec.yaml b/horatio/horatio_app/pubspec.yaml index 0d4d325..f0f0ad6 100644 --- a/horatio/horatio_app/pubspec.yaml +++ b/horatio/horatio_app/pubspec.yaml @@ -47,3 +47,4 @@ flutter: uses-material-design: true assets: - assets/public_domain/ + - assets/demo_recordings/ diff --git a/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart index c7855ef..fa693bd 100644 --- a/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart +++ b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -179,12 +181,20 @@ void main() { 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 { final home = Platform.environment['HOME'] ?? '/root'; final model = '$home/.local/share/horatio/piper/en_US-lessac-high.onnx'; 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; } final path = '${tmpDir.path}/hamlet.wav'; @@ -197,6 +207,52 @@ void main() { expect(File(path).existsSync(), isTrue); 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', () { @@ -246,4 +302,23 @@ void main() { 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'); + }); + }); } diff --git a/horatio/horatio_app/test/services/speech_service_test.dart b/horatio/horatio_app/test/services/speech_service_test.dart index c9d74ed..ae853c2 100644 --- a/horatio/horatio_app/test/services/speech_service_test.dart +++ b/horatio/horatio_app/test/services/speech_service_test.dart @@ -122,6 +122,59 @@ void main() { 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 ----------------------------------------------- group('startListening', () { diff --git a/horatio/run.sh b/horatio/run.sh index 923a9c8..9e07285 100755 --- a/horatio/run.sh +++ b/horatio/run.sh @@ -251,6 +251,16 @@ app_codegen() { heading "Running drift codegen" cd "$APP_DIR" 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" } @@ -378,6 +388,16 @@ do_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() { @@ -398,8 +418,9 @@ main() { full) do_full ;; run) do_run ;; 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 ;; esac