mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:43:02 +02:00
feat: make horatio audio work on android
This commit is contained in:
parent
97840b7eea
commit
864b82efb6
149
horatio/deploy.sh
Executable file
149
horatio/deploy.sh
Executable 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
|
||||
@ -15,7 +15,7 @@ migration:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: web
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
|
||||
14
horatio/horatio_app/android/.gitignore
vendored
Normal file
14
horatio/horatio_app/android/.gitignore
vendored
Normal 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
|
||||
43
horatio/horatio_app/android/app/build.gradle.kts
Normal file
43
horatio/horatio_app/android/app/build.gradle.kts
Normal 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 = "../.."
|
||||
}
|
||||
@ -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>
|
||||
51
horatio/horatio_app/android/app/src/main/AndroidManifest.xml
Normal file
51
horatio/horatio_app/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@ -0,0 +1,5 @@
|
||||
package com.kuhy.horatio
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
24
horatio/horatio_app/android/build.gradle.kts
Normal file
24
horatio/horatio_app/android/build.gradle.kts
Normal 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)
|
||||
}
|
||||
2
horatio/horatio_app/android/gradle.properties
Normal file
2
horatio/horatio_app/android/gradle.properties
Normal file
@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
5
horatio/horatio_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
horatio/horatio_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
26
horatio/horatio_app/android/settings.gradle.kts
Normal file
26
horatio/horatio_app/android/settings.gradle.kts
Normal 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")
|
||||
@ -0,0 +1 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav
|
||||
@ -0,0 +1 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav
|
||||
@ -0,0 +1 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav
|
||||
@ -0,0 +1 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav
|
||||
3
horatio/horatio_app/devtools_options.yaml
Normal file
3
horatio/horatio_app/devtools_options.yaml
Normal 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:
|
||||
@ -1,4 +1,5 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
|
||||
part of 'app_database.dart';
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
|
||||
part of 'annotation_dao.dart';
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
|
||||
part of 'recording_dao.dart';
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// coverage:ignore-file
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Drift table for annotation history snapshots.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// coverage:ignore-file
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Drift table for line-level interpretive notes.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// coverage:ignore-file
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Drift table for per-line voice recordings.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// coverage:ignore-file
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Drift table for text-level delivery marks on script lines.
|
||||
|
||||
@ -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<void> _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<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].
|
||||
///
|
||||
/// 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<String> synthesiseDemoSpeech(
|
||||
String path,
|
||||
String text, {
|
||||
String? piperModel,
|
||||
bool? isMobile,
|
||||
Future<ByteData> 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<String> synthesiseDemoSpeech(
|
||||
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
|
||||
/// file already exists on disk.
|
||||
///
|
||||
|
||||
@ -44,6 +44,7 @@ class SpeechService {
|
||||
final Future<Directory> 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<bool> _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 '';
|
||||
|
||||
@ -47,3 +47,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/public_domain/
|
||||
- assets/demo_recordings/
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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', () {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user