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"
|
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")}\"")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue