fix(wake-word): Embedding-Output ist rank-4, nicht rank-2 — Trigger funktioniert jetzt

Hauptursache warum kein Wake-Word je triggerte: das Google-Speech-
Embedding-Modell liefert (1,1,1,96), nicht (1,96). Der Cast
`as Array<FloatArray>` warf eine ClassCastException, die vom try/catch
geschluckt wurde — Pipeline lief still ins Leere.

Zusaetzlich:
- WW-Input-Frame-Count wird jetzt aus den Modell-Metadaten gelesen
  (variiert pro Keyword; hey_jarvis=16, computer_v2evtl. anders)
- "Computer" als Wake-Word erweitert (Community-Modell aus
  fwartner/home-assistant-wakewords-collection)

"ARIA" als Wake-Word: gibt's nicht fertig trainiert. Muesste ueber
das openWakeWord Colab-Notebook trainiert werden (~1h auf gratis-GPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-26 13:24:47 +02:00
parent f2e643d1fb
commit 309df9d851
4 changed files with 28 additions and 13 deletions

View File

@ -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**

View File

@ -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

View File

@ -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',