mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:23:01 +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
|
- 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
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
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
|
||||||
part of 'app_database.dart';
|
part of 'app_database.dart';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -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 '';
|
||||||
|
|||||||
@ -47,3 +47,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/public_domain/
|
- assets/public_domain/
|
||||||
|
- assets/demo_recordings/
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user