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:
parent
cca38ef50f
commit
359252bf5e
|
|
@ -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")}\"")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue