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" applicationId = "com.kazeia"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 6
versionName = "0.1.4" versionName = "0.1.5"
// WebDAV de distribution — injecté depuis local.properties. // WebDAV de distribution — injecté depuis local.properties.
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"") buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")

View File

@ -39,6 +39,8 @@ class KazeiaService : Service() {
companion object { companion object {
private const val TAG = "KazeiaService" private const val TAG = "KazeiaService"
private const val NOTIFICATION_ID = 1 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 // Diagnostic mode: when true, TTS is bypassed entirely. The chat
// bubble updates token-by-token as the LLM emits, so we can see // 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 // 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) voiceCommands = com.kazeia.conversation.VoiceCommandProcessor(this@KazeiaService)
log("Voice commands loaded: ${voiceCommands.getCommands().size} commands") log("Voice commands loaded: ${voiceCommands.getCommands().size} commands")
// Set default voice for Chatterbox // Voix = celle du PROFIL ACTIF (choisie dans l'app admin). Plus de
val defaultVoice = "${KazeiaApplication.MODELS_DIR}/../voix/damien.wav" // sélecteur côté patient. Re-appliquée quand le profil actif change
setVoice(defaultVoice) // ou que l'admin édite la voix d'un profil (ProfileStore notifie).
applyActiveProfileVoice()
if (!voiceProfileListenerRegistered) {
ProfileStore.get(applicationContext).addListener { applyActiveProfileVoice() }
voiceProfileListenerRegistered = true
}
addMessage(ChatMessage( addMessage(ChatMessage(
role = ChatMessage.Role.KAZEIA, 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 { private val conversationDao: ConversationDao by lazy {
ConversationDao(ConversationDb.get(applicationContext)) ConversationDao(ConversationDb.get(applicationContext))
} }
// Nom de la voix courante (basename du .wav), tracké pour le log de tour. // Nom de la voix courante (id sans extension), tracké pour le log de tour.
@Volatile private var currentVoiceName: String = "damien" @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 * 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) { /** Applique au moteur TTS la voix du PROFIL ACTIF (ou [DEFAULT_VOICE_ID] en
// Mémorise le nom de voix (basename sans extension) pour l'historique. * mode invité / profil sans voix). Source unique de vérité = l'app admin. */
currentVoiceName = voicePath.substringAfterLast('/').substringBeforeLast('.') private fun applyActiveProfileVoice() {
val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine if (!::tts.isInitialized) return
val libTts = tts as? com.kazeia.tts.KazeiaTtsEngine val vid = ProfileStore.get(applicationContext).activeProfile()
if (qwen != null) { ?.voiceId?.takeIf { it.isNotBlank() } ?: DEFAULT_VOICE_ID
// Hot-swap prefix/suffix embeddings — no model reload. Takes setVoiceId(vid)
// 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). /** Applique une voix par IDENTIFIANT (nom sans extension), résolu selon le
qwen.setVoice(voicePath) * moteur TTS actif : CosyVoice cosyvoice/<id>.cvps ; Qwen3 / lib
log("Voice set to: $voicePath") * ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */
} else if (libTts != null) { fun setVoiceId(voiceId: String) {
// Engine lib : le natif dérive le x_vector du WAV (speaker encoder). currentVoiceName = voiceId
libTts.setVoice(voicePath) val wavPath = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceId.wav"
log("Voice set to: $voicePath (lib)") 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() { fun interruptTts() {
if (_pipelineState.value is PipelineState.Speaking) { if (_pipelineState.value is PipelineState.Speaking) {
log("TTS interrupted by user") log("TTS interrupted by user")

View File

@ -90,7 +90,6 @@ class ChatActivity : AppCompatActivity() {
setupWindowInsets() setupWindowInsets()
setupRecyclerView() setupRecyclerView()
setupInputBar() setupInputBar()
setupVoiceSelector()
setupResourceMonitoring() setupResourceMonitoring()
setupQuitButton() setupQuitButton()
bindToService() 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() { private fun setupResourceMonitoring() {
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu) val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu) val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
@ -277,27 +252,10 @@ class ChatActivity : AppCompatActivity() {
} }
} }
private fun setupVoiceSelector() { // Sélecteur de voix RETIRÉ (2026-06-15) : la voix n'est plus choisie côté
val spinner = findViewById<Spinner>(R.id.spinnerVoice) ?: return // patient. Elle est définie par profil dans l'app admin et appliquée par le
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, voiceNames) // service (KazeiaService.applyActiveProfileVoice). L'orbe garde la couleur
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) // par défaut du service (flow voiceColor).
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<*>?) {}
}
}
private fun sendMessage() { private fun sendMessage() {
val text = binding.etMessage.text?.toString()?.trim() ?: return val text = binding.etMessage.text?.toString()?.trim() ?: return

View File

@ -70,47 +70,20 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<!-- Voice selector bar --> <!-- Sélecteur de voix RETIRÉ côté patient (2026-06-15) : la voix est
<LinearLayout désormais choisie par profil dans l'app admin et appliquée par le
android:id="@+id/voiceBar" service au profil actif. -->
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>
<!-- Central orb visualizer: Kazeia's visual "face". Takes the <!-- Central orb visualizer: Kazeia's visual "face". Takes the
top half of the chat area so it reads as the primary UI top half of the chat area so it reads as the primary UI
element; the message list sits below it and shows the element; the message list sits below it and shows the
word-by-word reveal of the current reply. Color is driven word-by-word reveal of the current reply. Color = défaut service. -->
by the selected voice (Damien=lavender, Elodie=rose, …). -->
<com.kazeia.ui.AudioVisualizerView <com.kazeia.ui.AudioVisualizerView
android:id="@+id/audioViz" android:id="@+id/audioViz"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:background="@color/kazeia_background" 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_constraintBottom_toTopOf="@id/rvMessages"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"