Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b588dd7e3b | |||
| 309df9d851 | |||
| f2e643d1fb | |||
| 6ac374621c | |||
| efbd306597 |
@@ -406,10 +406,11 @@ mit ONNX Runtime — kein API-Key, kein Cloud-Roundtrip, kein Cent Lizenzgebuehr
|
|||||||
und das Audio verlaesst das Geraet nie.
|
und das Audio verlaesst das Geraet nie.
|
||||||
|
|
||||||
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
|
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
|
||||||
- `Hey Jarvis` (Default)
|
- `Hey Jarvis` (Default, openWakeWord-Original)
|
||||||
- `Alexa`
|
- `Computer` (Star-Trek-Style, Community-Modell)
|
||||||
- `Hey Mycroft`
|
- `Alexa`, `Hey Mycroft`, `Hey Rhasspy` (openWakeWord-Originale)
|
||||||
- `Hey Rhasspy`
|
|
||||||
|
Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection).
|
||||||
|
|
||||||
**Bedienung:**
|
**Bedienung:**
|
||||||
- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
|
- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 606
|
versionCode 608
|
||||||
versionName "0.0.6.6"
|
versionName "0.0.6.8"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,19 @@ android {
|
|||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
|
||||||
|
// APK von ~136 MB auf ~35 MB — relevant weil ONNX Runtime + die anderen
|
||||||
|
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
|
||||||
|
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
include "arm64-v8a"
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
Binary file not shown.
@@ -42,8 +42,8 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
|
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
|
||||||
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
|
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
|
||||||
private const val EMBEDDING_DIM = 96
|
private const val EMBEDDING_DIM = 96
|
||||||
private const val WW_INPUT_FRAMES = 16 // 16 Embeddings = ~1.28s
|
|
||||||
private const val MEL_BINS = 32
|
private const val MEL_BINS = 32
|
||||||
|
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
|
||||||
}
|
}
|
||||||
|
|
||||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||||
@@ -54,6 +54,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private var melInputName: String = "input"
|
private var melInputName: String = "input"
|
||||||
private var embInputName: String = "input_1"
|
private var embInputName: String = "input_1"
|
||||||
private var wwInputName: String = "input"
|
private var wwInputName: String = "input"
|
||||||
|
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
|
||||||
|
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
|
||||||
|
// Wird beim init() aus den Modell-Metadaten gelesen.
|
||||||
|
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
|
||||||
|
|
||||||
// Konfiguration
|
// Konfiguration
|
||||||
private var threshold: Float = 0.5f
|
private var threshold: Float = 0.5f
|
||||||
@@ -100,7 +104,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
embInputName = embSession!!.inputNames.first()
|
embInputName = embSession!!.inputNames.first()
|
||||||
wwInputName = wwSession!!.inputNames.first()
|
wwInputName = wwSession!!.inputNames.first()
|
||||||
|
|
||||||
Log.i(TAG, "Init OK: model=$modelName threshold=$threshold patience=$patience " +
|
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
|
||||||
|
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
|
||||||
|
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
|
||||||
|
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
|
||||||
|
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
|
||||||
|
|
||||||
|
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
|
||||||
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
|
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -299,11 +309,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
val embRes = embSession!!.run(mapOf(embInputName to embIn))
|
val embRes = embSession!!.run(mapOf(embInputName to embIn))
|
||||||
val embOut = embRes.get(0).value
|
val embOut = embRes.get(0).value
|
||||||
embIn.close()
|
embIn.close()
|
||||||
// Erwartete Output-Form: (1, 96) → Array<FloatArray>
|
// Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
|
||||||
|
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val embArr = embOut as Array<FloatArray>
|
val embArr = embOut as Array<Array<Array<FloatArray>>>
|
||||||
embBuffer.addLast(embArr[0].copyOf())
|
embBuffer.addLast(embArr[0][0][0].copyOf())
|
||||||
while (embBuffer.size > WW_INPUT_FRAMES) embBuffer.removeFirst()
|
while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
|
||||||
embRes.close()
|
embRes.close()
|
||||||
|
|
||||||
melProcessedIdx += EMBEDDING_STRIDE
|
melProcessedIdx += EMBEDDING_STRIDE
|
||||||
@@ -319,9 +330,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) Klassifikation — sobald wir 16 Embeddings haben
|
// 3) Klassifikation — sobald wir 16 Embeddings haben
|
||||||
if (embBuffer.size < WW_INPUT_FRAMES) return
|
if (embBuffer.size < wwInputFrames) return
|
||||||
val flatEmb = FloatArray(WW_INPUT_FRAMES * EMBEDDING_DIM)
|
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
|
||||||
var p = 0
|
var p = 0
|
||||||
|
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
|
||||||
for (e in embBuffer) {
|
for (e in embBuffer) {
|
||||||
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
|
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
|
||||||
p += EMBEDDING_DIM
|
p += EMBEDDING_DIM
|
||||||
@@ -329,7 +341,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
val wwIn = OnnxTensor.createTensor(
|
val wwIn = OnnxTensor.createTensor(
|
||||||
env,
|
env,
|
||||||
FloatBuffer.wrap(flatEmb),
|
FloatBuffer.wrap(flatEmb),
|
||||||
longArrayOf(1L, WW_INPUT_FRAMES.toLong(), EMBEDDING_DIM.toLong()),
|
longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
|
||||||
)
|
)
|
||||||
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
|
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
|
||||||
val wwOut = wwRes.get(0).value
|
val wwOut = wwRes.get(0).value
|
||||||
|
|||||||
@@ -137,6 +137,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
Log.w(TAG, "play() sofort failed: ${e.message}")
|
Log.w(TAG, "play() sofort failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
|
||||||
|
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
||||||
|
var idleMs = 0L
|
||||||
|
val maxIdleMs = 30_000L
|
||||||
|
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
|
||||||
|
// Stille rein damit AudioTrack nicht underrunt waehrend die
|
||||||
|
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
|
||||||
|
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
|
||||||
|
val underrunGuardFrames = sampleRate / 10 // ~100ms
|
||||||
|
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
|
||||||
|
|
||||||
mainLoop@ while (!writerShouldStop) {
|
mainLoop@ while (!writerShouldStop) {
|
||||||
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
@@ -153,8 +164,33 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
}
|
}
|
||||||
break@mainLoop
|
break@mainLoop
|
||||||
}
|
}
|
||||||
|
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
|
||||||
|
// Puffer leerzulaufen droht. Spotify resumed sonst nach
|
||||||
|
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
|
||||||
|
if (playbackStarted) {
|
||||||
|
val framesWritten = bytesBuffered / streamBytesPerFrame
|
||||||
|
val framesPlayed = t.playbackHeadPosition.toLong()
|
||||||
|
val framesInBuffer = framesWritten - framesPlayed
|
||||||
|
if (framesInBuffer < underrunGuardFrames) {
|
||||||
|
val fillBytes = silenceFillFrames * streamBytesPerFrame
|
||||||
|
val silence = ByteArray(fillBytes)
|
||||||
|
var silOff = 0
|
||||||
|
while (silOff < silence.size && !writerShouldStop) {
|
||||||
|
val w = t.write(silence, silOff, silence.size - silOff)
|
||||||
|
if (w <= 0) break
|
||||||
|
silOff += w
|
||||||
|
}
|
||||||
|
bytesBuffered += silence.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idleMs += 50L
|
||||||
|
if (idleMs >= maxIdleMs) {
|
||||||
|
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
|
||||||
|
break@mainLoop
|
||||||
|
}
|
||||||
continue@mainLoop
|
continue@mainLoop
|
||||||
}
|
}
|
||||||
|
idleMs = 0L
|
||||||
|
|
||||||
// Pre-Roll Check: play() erst wenn genug gepuffert
|
// Pre-Roll Check: play() erst wenn genug gepuffert
|
||||||
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
|
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
|
||||||
|
|||||||
+15
-2
@@ -167,10 +167,23 @@ export CI=true
|
|||||||
|
|
||||||
if [ "$MODE" = "debug" ]; then
|
if [ "$MODE" = "debug" ]; then
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
|
OUT_DIR="app/build/outputs/apk/debug"
|
||||||
else
|
else
|
||||||
./gradlew assembleRelease
|
./gradlew assembleRelease
|
||||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
OUT_DIR="app/build/outputs/apk/release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
|
||||||
|
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
|
||||||
|
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
|
||||||
|
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
|
||||||
|
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
|
||||||
|
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
|
||||||
|
APK_PATH="$OUT_DIR/app-${MODE}.apk"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
|
||||||
|
cd ..
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.6.6",
|
"version": "0.0.6.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
|||||||
* werden — Diagnostic-Upload ist Phase 2. */
|
* werden — Diagnostic-Upload ist Phase 2. */
|
||||||
export const WAKE_KEYWORDS = [
|
export const WAKE_KEYWORDS = [
|
||||||
'hey_jarvis',
|
'hey_jarvis',
|
||||||
|
'computer',
|
||||||
'alexa',
|
'alexa',
|
||||||
'hey_mycroft',
|
'hey_mycroft',
|
||||||
'hey_rhasspy',
|
'hey_rhasspy',
|
||||||
@@ -46,6 +47,7 @@ export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
|
|||||||
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
||||||
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||||
hey_jarvis: 'Hey Jarvis',
|
hey_jarvis: 'Hey Jarvis',
|
||||||
|
computer: 'Computer',
|
||||||
alexa: 'Alexa',
|
alexa: 'Alexa',
|
||||||
hey_mycroft: 'Hey Mycroft',
|
hey_mycroft: 'Hey Mycroft',
|
||||||
hey_rhasspy: 'Hey Rhasspy',
|
hey_rhasspy: 'Hey Rhasspy',
|
||||||
|
|||||||
Reference in New Issue
Block a user