feat(voice): voix pilotée par profil (app admin) — retrait du sélecteur patient — v0.1.5

La voix n'est plus choisie côté patient ; elle est définie PAR PROFIL dans l'app
admin (le champ Profile.voiceId + le dropdown admin existaient déjà ; seul le
runtime ne l'appliquait pas).

App patient :
- ChatActivity : suppression de setupVoiceSelector() + des listes voiceFiles/
  voiceNames/voiceColors. activity_chat.xml : suppression de la voiceBar (spinner),
  l'orbe se contraint désormais sous tvStatus (couleur = défaut service).
- KazeiaService : nouvelle source unique = le profil actif.
  - applyActiveProfileVoice() lit ProfileStore.activeProfile().voiceId (défaut
    DEFAULT_VOICE_ID="damien" en invité), appelée à l'init ET via un listener
    ProfileStore → propagation LIVE quand l'admin édite la voix / change de profil.
  - setVoiceId(id) résout selon le moteur actif : CosyVoice → cosyvoice/<id>.cvps ;
    Qwen3/lib → ../voix/<id>.wav. setVoice(pathOrId) conservé (intents test) délègue.

Validé device (release v0.1.5, SM8750, moteur cosyvoice) :
- plus de barre de voix dans l'UI patient (confirmé).
- profil voice_id=elodie → service charge 'elodie' (≠ défaut) ; changement live
  via provider voice_id=damien → ré-appliqué sans redémarrage.
- moteur 'lib' charge toujours (pas de régression du stack gelé).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-15 07:51:42 +02:00
parent cca38ef50f
commit 359252bf5e
4 changed files with 49 additions and 101 deletions

View File

@ -44,8 +44,8 @@ android {
applicationId = "com.kazeia"
minSdk = 28
targetSdk = 36
versionCode = 5
versionName = "0.1.4"
versionCode = 6
versionName = "0.1.5"
// WebDAV de distribution — injecté depuis local.properties.
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")

View File

@ -39,6 +39,8 @@ class KazeiaService : Service() {
companion object {
private const val TAG = "KazeiaService"
private const val NOTIFICATION_ID = 1
// Voix par défaut si le profil actif n'en définit aucune (ou mode invité).
const val DEFAULT_VOICE_ID = "damien"
// Diagnostic mode: when true, TTS is bypassed entirely. The chat
// bubble updates token-by-token as the LLM emits, so we can see
// the raw LLM speed (tok/s) without any TTS coupling. Flip back
@ -874,9 +876,14 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
voiceCommands = com.kazeia.conversation.VoiceCommandProcessor(this@KazeiaService)
log("Voice commands loaded: ${voiceCommands.getCommands().size} commands")
// Set default voice for Chatterbox
val defaultVoice = "${KazeiaApplication.MODELS_DIR}/../voix/damien.wav"
setVoice(defaultVoice)
// Voix = celle du PROFIL ACTIF (choisie dans l'app admin). Plus de
// sélecteur côté patient. Re-appliquée quand le profil actif change
// ou que l'admin édite la voix d'un profil (ProfileStore notifie).
applyActiveProfileVoice()
if (!voiceProfileListenerRegistered) {
ProfileStore.get(applicationContext).addListener { applyActiveProfileVoice() }
voiceProfileListenerRegistered = true
}
addMessage(ChatMessage(
role = ChatMessage.Role.KAZEIA,
@ -950,8 +957,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
private val conversationDao: ConversationDao by lazy {
ConversationDao(ConversationDb.get(applicationContext))
}
// Nom de la voix courante (basename du .wav), tracké pour le log de tour.
@Volatile private var currentVoiceName: String = "damien"
// Nom de la voix courante (id sans extension), tracké pour le log de tour.
@Volatile private var currentVoiceName: String = DEFAULT_VOICE_ID
@Volatile private var voiceProfileListenerRegistered = false
/**
* Persiste un tour de conversation pour le profil actif. No-op en mode
@ -990,25 +998,34 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
}
}
fun setVoice(voicePath: String) {
// Mémorise le nom de voix (basename sans extension) pour l'historique.
currentVoiceName = voicePath.substringAfterLast('/').substringBeforeLast('.')
val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine
val libTts = tts as? com.kazeia.tts.KazeiaTtsEngine
if (qwen != null) {
// Hot-swap prefix/suffix embeddings — no model reload. Takes
// effect from the NEXT synthesized segment (current in-flight
// one, if any, finishes with the old voice since the arrays
// are already in its closure).
qwen.setVoice(voicePath)
log("Voice set to: $voicePath")
} else if (libTts != null) {
// Engine lib : le natif dérive le x_vector du WAV (speaker encoder).
libTts.setVoice(voicePath)
log("Voice set to: $voicePath (lib)")
/** Applique au moteur TTS la voix du PROFIL ACTIF (ou [DEFAULT_VOICE_ID] en
* mode invité / profil sans voix). Source unique de vérité = l'app admin. */
private fun applyActiveProfileVoice() {
if (!::tts.isInitialized) return
val vid = ProfileStore.get(applicationContext).activeProfile()
?.voiceId?.takeIf { it.isNotBlank() } ?: DEFAULT_VOICE_ID
setVoiceId(vid)
}
/** Applique une voix par IDENTIFIANT (nom sans extension), résolu selon le
* moteur TTS actif : CosyVoice cosyvoice/<id>.cvps ; Qwen3 / lib
* ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */
fun setVoiceId(voiceId: String) {
currentVoiceName = voiceId
val wavPath = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceId.wav"
when (val e = tts) {
is com.kazeia.tts.CosyVoiceTtsEngine -> { e.setVoice(voiceId); log("Voix (cosyvoice): $voiceId") }
is com.kazeia.tts.Qwen3TtsEngine -> { e.setVoice(wavPath); log("Voix (qwen3): $voiceId") }
is com.kazeia.tts.KazeiaTtsEngine -> { e.setVoice(wavPath); log("Voix (lib): $voiceId") }
else -> log("setVoiceId: moteur TTS inconnu — voix '$voiceId' ignorée")
}
}
/** Back-compat (intents de test `tts_voice`) : accepte un chemin OU un id et
* délègue à [setVoiceId] (qui résout selon le moteur actif). */
fun setVoice(voicePathOrId: String) =
setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.'))
fun interruptTts() {
if (_pipelineState.value is PipelineState.Speaking) {
log("TTS interrupted by user")

View File

@ -90,7 +90,6 @@ class ChatActivity : AppCompatActivity() {
setupWindowInsets()
setupRecyclerView()
setupInputBar()
setupVoiceSelector()
setupResourceMonitoring()
setupQuitButton()
bindToService()
@ -180,30 +179,6 @@ class ChatActivity : AppCompatActivity() {
}
}
private val voiceFiles = listOf(
"damien.wav", "elodie.wav", "jerome.wav", "richard.wav",
"amir.wav", "didier.wav", "sid.wav", "zelda.wav"
)
private val voiceNames = listOf(
"Damien", "Elodie", "Jerome", "Richard",
"Amir", "Didier", "Sid", "Zelda"
)
/** One color per speaker derived palette (core + halo + bars) is
* generated inside AudioVisualizerView. Chosen to be calm,
* perceptually distinct, and consistent in saturation so switching
* voices changes *hue* rather than *mood*. */
private val voiceColors = listOf(
0xFFBCA4E8.toInt(), // Damien — lavender
0xFFE8A4CC.toInt(), // Elodie — rose
0xFF82D5D0.toInt(), // Jerome — aqua
0xFFE8BFA4.toInt(), // Richard — amber sand
0xFF95D5A6.toInt(), // Amir — emerald
0xFF8FA2D4.toInt(), // Didier — indigo
0xFFE8B89A.toInt(), // Sid — peach
0xFFA4BEE8.toInt() // Zelda — periwinkle
)
private fun setupResourceMonitoring() {
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
@ -277,27 +252,10 @@ class ChatActivity : AppCompatActivity() {
}
}
private fun setupVoiceSelector() {
val spinner = findViewById<Spinner>(R.id.spinnerVoice) ?: return
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, voiceNames)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: android.view.View?, pos: Int, id: Long) {
val voicePath = "${com.kazeia.KazeiaApplication.MODELS_DIR}/../voix/${voiceFiles[pos]}"
kazeiaService?.setVoice(voicePath)
// Push the matching color to the service so the orb
// view picks it up; the view tweens from the previous
// color so voice changes don't snap visually.
val color = voiceColors[pos.coerceIn(voiceColors.indices)]
kazeiaService?.setVoiceColor(color)
binding.audioViz.setVoiceColor(color)
appendLog("Voix: ${voiceNames[pos]}")
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
// Sélecteur de voix RETIRÉ (2026-06-15) : la voix n'est plus choisie côté
// patient. Elle est définie par profil dans l'app admin et appliquée par le
// service (KazeiaService.applyActiveProfileVoice). L'orbe garde la couleur
// par défaut du service (flow voiceColor).
private fun sendMessage() {
val text = binding.etMessage.text?.toString()?.trim() ?: return

View File

@ -70,47 +70,20 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Voice selector bar -->
<LinearLayout
android:id="@+id/voiceBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:background="#F5F0FA"
app:layout_constraintTop_toBottomOf="@id/tvStatus"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Voix:"
android:textSize="13sp"
android:textColor="@color/text_secondary" />
<Spinner
android:id="@+id/spinnerVoice"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Sélecteur de voix RETIRÉ côté patient (2026-06-15) : la voix est
désormais choisie par profil dans l'app admin et appliquée par le
service au profil actif. -->
<!-- Central orb visualizer: Kazeia's visual "face". Takes the
top half of the chat area so it reads as the primary UI
element; the message list sits below it and shows the
word-by-word reveal of the current reply. Color is driven
by the selected voice (Damien=lavender, Elodie=rose, …). -->
word-by-word reveal of the current reply. Color = défaut service. -->
<com.kazeia.ui.AudioVisualizerView
android:id="@+id/audioViz"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/kazeia_background"
app:layout_constraintTop_toBottomOf="@id/voiceBar"
app:layout_constraintTop_toBottomOf="@id/tvStatus"
app:layout_constraintBottom_toTopOf="@id/rvMessages"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"