jetzt als Hintergrund Service mit Wake-Word-Aktivierung

This commit is contained in:
2026-03-12 13:30:50 +01:00
parent 4c1407a61c
commit 2557039f84
20 changed files with 909 additions and 187 deletions

View File

@@ -0,0 +1,179 @@
package com.example.jarvis_stts
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener
import org.vosk.android.SpeechService
import java.io.File
import android.content.pm.ServiceInfo
import org.json.JSONObject
class JarvisService : Service(), RecognitionListener {
private var voskService: SpeechService? = null
private var voskModel: Model? = null
private var isInteracting = false
companion object {
const val CHANNEL_ID = "JarvisServiceChannel"
const val ACTION_START = "ACTION_START"
const val ACTION_PAUSE = "ACTION_PAUSE"
const val ACTION_RESUME = "ACTION_RESUME"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
// Für Android 14 (API 34) und höher müssen wir den Typ beim Starten mitgeben
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
1,
createNotification("J.A.R.V.I.S. hört zu..."),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
)
} else {
startForeground(1, createNotification("J.A.R.V.I.S. hört zu..."))
}
initVosk()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> resumeListening()
ACTION_PAUSE -> pauseListening()
ACTION_RESUME -> resumeListening()
}
return START_STICKY
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"J.A.R.V.I.S. Background Service",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String): android.app.Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("J.A.R.V.I.S. ist aktiv")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_btn_speak_now) // Standard Android Icon
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(1, createNotification(text))
}
private fun initVosk() {
val baseDir = getExternalFilesDir(null)
val modelFolder = File(baseDir, "model/model-de")
if (modelFolder.exists()) {
Log.d("JARVIS", "Modell gefunden unter: ${modelFolder.absolutePath}")
try {
// Wir laden das Modell und speichern es in der Klassen-Variable 'voskModel'
voskModel = Model(modelFolder.absolutePath)
Log.d("JARVIS", "Modell bereit, starte Hintergrund-Dienst...")
// Jetzt, wo das Modell da ist, können wir das Zuhören starten
resumeListening()
} catch (e: Exception) {
Log.e("JARVIS", "Vosk Fehler beim Modell-Laden: ${e.message}")
updateNotification("Fehler: Modell konnte nicht geladen werden")
}
} else {
Log.e("JARVIS", "ORDNER NICHT GEFUNDEN! Pfad: ${modelFolder.absolutePath}")
updateNotification("Fehler: Modell-Ordner fehlt")
}
}
private fun pauseListening() {
Log.d("JARVIS", "Service: Pausiere Zuhören")
voskService?.stop()
updateNotification("Pausiert (verarbeitet Anfrage...)")
}
private fun resumeListening() {
if (voskModel == null) return
Log.d("JARVIS", "Service: Starte Zuhören")
voskService?.stop()
// Die Liste klein halten ist gut, aber wir brauchen den exakten Treffer
val rec = Recognizer(voskModel, 16000.0f, "[\"jarvis\", \"[unk]\"]")
voskService = SpeechService(rec, 16000.0f)
voskService?.startListening(this)
isInteracting = false
updateNotification("Warte auf 'Jarvis'...")
}
// --- Vosk Listener ---
override fun onPartialResult(hypothesis: String) {
if (isInteracting) return
try {
val json = JSONObject(hypothesis)
// Bei onPartialResult heißt das Feld "partial"
val partialText = json.optString("partial").lowercase().trim()
// Wir reagieren NUR, wenn das Wort exakt "jarvis" ist
// Das verhindert, dass Wörter wie "Service" oder "Nachtisch" triggern
if (partialText == "jarvis" ) {
Log.d("JARVIS", "Service: WAKE WORD EXAKT ERKANNT: $partialText")
isInteracting = true
// Ein kurzer haptischer Feedback-Vibe wäre hier cool (optional)
pauseListening()
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("WAKE_WORD_TRIGGERED", true)
}
startActivity(intent)
}
} catch (e: Exception) {
Log.e("JARVIS", "Fehler beim Parsen des PartialResults: ${e.message}")
}
}
override fun onResult(hypothesis: String) {}
override fun onFinalResult(hypothesis: String) {}
override fun onError(e: Exception) { Log.e("JARVIS", "Service Error: ${e.message}") }
override fun onTimeout() {}
override fun onDestroy() {
super.onDestroy()
voskService?.stop()
voskService?.shutdown()
}
override fun onBind(intent: Intent?): IBinder? = null
}

View File

@@ -1,69 +0,0 @@
package com.example.jarvis_stts
import android.app.Service
import android.content.Intent
import android.os.IBinder
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.SpeechService
import org.vosk.android.RecognitionListener
import java.io.IOException
import java.io.File
class JarvisService : Service(), RecognitionListener {
private var speechService: SpeechService? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
setupVosk()
return START_STICKY // Sorgt dafür, dass der Service bei Beendung neu startet
}
private fun setupVosk() {
try {
// MainActivity entpackt nach "model", also greifen wir hier darauf zu:
val modelPath = File(filesDir, "model").absolutePath
val model = Model(modelPath)
// WICHTIG: Nutze hier "computer" ODER "jarvis",
// je nachdem was du in der MainActivity definiert hast.
val recognizer = Recognizer(model, 16000f, "[\"computer\", \"jarvis\", \"[unk]\"]")
speechService = SpeechService(recognizer, 16000f)
speechService?.startListening(this)
Log.d("JARVIS", "Service: Vosk hört jetzt zu...")
} catch (e: Exception) {
Log.e("JARVIS", "Service: Fehler beim Laden des Modells: ${e.message}")
}
}
override fun onResult(hypothesis: String?) {
// hypothesis ist ein JSON String, z.B.: { "text" : "jarvis" }
if (hypothesis != null && hypothesis.contains("jarvis")) {
println("WAKE WORD ERKANNT!")
// Hier triggerst du deine Antwort-Logik
}
}
override fun onPartialResult(hypothesis: String?) {
// Wird während des Sprechens aufgerufen
}
override fun onFinalResult(hypothesis: String?) {}
override fun onError(e: Exception?) {
e?.printStackTrace()
}
override fun onTimeout() {}
override fun onDestroy() {
super.onDestroy()
speechService?.stop()
speechService?.shutdown()
}
override fun onBind(intent: Intent?): IBinder? = null
}

View File

@@ -3,9 +3,11 @@ package com.example.jarvis_stts
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.util.Log
import android.view.View
@@ -16,55 +18,59 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import okhttp3.*
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener
import org.vosk.android.SpeechService
import org.vosk.android.StorageService
import java.io.IOException
import java.util.Locale
import android.speech.tts.UtteranceProgressListener
import android.provider.Settings
import android.net.Uri
class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnInitListener {
class MainActivity : AppCompatActivity(), TextToSpeech.OnInitListener {
// UI Elemente
private lateinit var tvStatus: TextView
private lateinit var etUrl: EditText
private lateinit var spinnerVoices: Spinner
private lateinit var tts: TextToSpeech
// Vosk & Netzwerk
private var voskService: SpeechService? = null
private var voskModel: Model? = null
private val client = OkHttpClient()
private var webSocket: WebSocket? = null
private var isInteracting = false
// TTS Stimmen
private var availableVoices = mutableListOf<Voice>()
private var voiceNames = mutableListOf<String>()
// Launcher für Google Spracherkennung
private val speechRecognizerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
isInteracting = false // WICHTIG: Sperre wieder aufheben!
if (result.resultCode == RESULT_OK && result.data != null) {
val spokenText = result.data!!.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.get(0) ?: ""
tvStatus.text = "Ich: $spokenText"
webSocket?.send(spokenText)
// HIER STARTEN WIR VOSK NOCH NICHT! Wir warten auf die Antwort des Servers.
} else {
// Nur wenn wir nichts gesagt oder abgebrochen haben, geht Vosk direkt wieder an
startVosk()
private val speechRecognizerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK && result.data != null) {
val spokenText = result.data!!.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.get(0) ?: ""
tvStatus.text = "Ich: $spokenText"
webSocket?.send(spokenText)
// Hier warten wir auf den Server. Der Service bleibt pausiert.
} else {
// Abbruch oder Fehler -> Service soll wieder zuhören
tellServiceTo(JarvisService.ACTION_RESUME)
}
}
private fun checkOverlayPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(this)) {
Log.d("JARVIS", "Overlay-Berechtigung fehlt. Öffne Einstellungen...")
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
// Wirft den Nutzer in die Einstellungen.
// Nach der Rückkehr muss die App meist neu gestartet/fokussiert werden.
startActivity(intent)
Toast.makeText(this, "Bitte erlaube J.A.R.V.I.S., über anderen Apps zu erscheinen", Toast.LENGTH_LONG).show()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 1. UI initialisieren
tvStatus = findViewById(R.id.tvStatus)
etUrl = findViewById(R.id.etUrl)
spinnerVoices = findViewById(R.id.spinnerVoices)
@@ -73,7 +79,6 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
tts = TextToSpeech(this, this)
// 2. SharedPreferences (Server URL laden)
val prefs = getSharedPreferences("JarvisPrefs", MODE_PRIVATE)
etUrl.setText(prefs.getString("server_url", ""))
@@ -85,18 +90,41 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
}
}
btnSpeak.setOnClickListener {
voskService?.stop() // Stoppe Wake-Word, wenn man manuell klickt
startVoiceInput()
btnSpeak.setOnClickListener {
tellServiceTo(JarvisService.ACTION_PAUSE)
startVoiceInput()
}
// 3. Berechtigungen prüfen & Modell laden
checkPermissionsAndInit()
checkOverlayPermission() // <-- Hier aufrufen!
}
// Wird aufgerufen, wenn die App im Hintergrund war und vom Service geweckt wird
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent?.getBooleanExtra("WAKE_WORD_TRIGGERED", false) == true) {
Log.d("JARVIS", "MainActivity: Wake Word vom Service empfangen! Starte Google...")
// Kleiner Delay, damit die Audio-Hardware Zeit zum Umschalten hat
tvStatus.postDelayed({
startVoiceInput()
}, 500)
}
}
private fun checkPermissionsAndInit() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 1)
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
// Notification Permission für Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val missingPermissions = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isNotEmpty()) {
ActivityCompat.requestPermissions(this, missingPermissions.toTypedArray(), 1)
} else {
initVoskModel()
}
@@ -104,88 +132,48 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1 && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
if (requestCode == 1 && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
initVoskModel()
}
}
private fun initVoskModel() {
// "model-de" ist der Ordner in assets. "model" ist der Zielordner auf dem Handy.
StorageService.unpack(this, "model-de", "model",
{ model: Model ->
voskModel = model
Log.d("JARVIS", "Modell erfolgreich geladen!")
startVosk()
{ _: Model ->
Log.d("JARVIS", "Modell bereit, starte Hintergrund-Dienst...")
tellServiceTo(JarvisService.ACTION_START)
tvStatus.text = "Service läuft im Hintergrund!"
},
{ exception: IOException ->
Log.e("JARVIS", "Vosk Entpack-Fehler: ${exception.message}")
runOnUiThread { tvStatus.text = "Fehler: Modell nicht gefunden" }
{ exception: IOException ->
Log.e("JARVIS", "Vosk Entpack-Fehler: ${exception.message}")
tvStatus.text = "Fehler: Modell nicht gefunden"
}
)
}
private fun startVosk() {
try {
if (voskModel == null) return
// Alten Service sicherheitshalber beenden
voskService?.stop()
voskService?.shutdown()
// Wir horchen auf "computer" und "jarvis".
val rec = Recognizer(voskModel, 16000.0f, "[\"computer\", \"jarvis\", \"[unk]\"]")
voskService = SpeechService(rec, 16000.0f)
voskService?.startListening(this)
runOnUiThread { tvStatus.text = "Bereit (Warte auf 'Jarvis' oder 'Computer')" }
} catch (e: Exception) {
Log.e("JARVIS", "Vosk Start Fehler: ${e.message}")
private fun tellServiceTo(action: String) {
val intent = Intent(this, JarvisService::class.java).apply {
this.action = action
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
// --- Vosk RecognitionListener ---
override fun onPartialResult(hypothesis: String) {
if (isInteracting) return // Wenn wir schon dabei sind, ignoriere weiteres
val recognizedText = extractText(hypothesis)
if (recognizedText.contains("jarvis", true)) {
isInteracting = true // Sperre setzen
voskService?.stop()
tvStatus.postDelayed({
startVoiceInput()
}, 500)
}
}
private fun extractText(json: String): String {
// Hilft, den Text aus dem JSON {"partial" : "..."} zu ziehen
return json.substringAfter(": \"").substringBefore("\"")
}
override fun onResult(hypothesis: String) {
// Hier könnte man das finale Wort prüfen, falls Partial nicht reicht
}
override fun onFinalResult(hypothesis: String) {}
override fun onError(e: Exception) { Log.e("JARVIS", "Vosk Error: ${e.message}") }
override fun onTimeout() {}
// --- Google STT & TTS ---
private fun startVoiceInput() {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "de-DE")
putExtra(RecognizerIntent.EXTRA_PROMPT, "Ich höre dir zu...")
// Diese beiden sorgen dafür, dass Google geduldiger ist:
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2000L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 2000L)
}
try {
speechRecognizerLauncher.launch(intent)
} catch (e: Exception) {
startVosk()
tellServiceTo(JarvisService.ACTION_RESUME)
}
}
@@ -200,6 +188,12 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
runOnUiThread { tvStatus.text = "J.A.R.V.I.S.: $text" }
speakOut(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
runOnUiThread {
tvStatus.text = "Verbindungsfehler!"
tellServiceTo(JarvisService.ACTION_RESUME)
}
}
})
}
@@ -208,25 +202,19 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
tts.language = Locale.GERMAN
setupVoiceSpinner()
// NEU: Wir horchen darauf, wann Jarvis aufhört zu sprechen
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
// Jarvis fängt an zu sprechen
}
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
// Jarvis ist fertig! Wake-Word wieder aktivieren.
if (utteranceId == "TTS_DONE") {
// onDone läuft im Hintergrund, UI/Vosk Updates müssen in den Main Thread
runOnUiThread {
startVosk()
// Wenn Jarvis fertig gesprochen hat, lauschen wir wieder!
tellServiceTo(JarvisService.ACTION_RESUME)
}
}
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
runOnUiThread { startVosk() } // Bei einem Fehler auch wieder zuhören
runOnUiThread { tellServiceTo(JarvisService.ACTION_RESUME) }
}
})
}
@@ -253,18 +241,13 @@ class MainActivity : AppCompatActivity(), RecognitionListener, TextToSpeech.OnIn
}
private fun speakOut(text: String) {
// Wir können hier Vosk stoppen, damit Jarvis sich nicht selbst hört
voskService?.stop()
// Die ID "TTS_DONE" triggert unseren Listener, wenn der Text fertig gesprochen wurde
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "TTS_DONE")
}
override fun onDestroy() {
voskService?.stop()
voskService?.shutdown()
webSocket?.close(1000, "App Ende")
tts.shutdown()
// Wir lassen den Service ABSICHTLICH nicht stoppen, wenn die Activity zerstört wird!
super.onDestroy()
}
}