feat(voice): exclusivité au record-time + phrase de consentement enrichie

- Phrase de consentement (rec_reference_text) : fin retravaillée pour maximiser
  la couverture phonétique/prosodique du clonage CosyVoice (nasales, semi-voyelles,
  ch/j/r, exclamation + question) tout en gardant le préambule légal.
- Exclusivité DURE capturée à l'enregistrement (consentement/RGPD) : sélecteur de
  portée (exclusive→patient / non-exclusive / lier plus tard) ; picker patient
  peuplé via /profiles (défaut = profil actif). Écrit scope + owner_profile_id +
  owner_name dans le manifeste VoiceStorage → Kazeia-central applique la politique.
  Save désactivé si « exclusive » sans patient ; bascule « pending » si aucun profil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-21 08:52:13 +02:00
parent 8bcf9f8ebe
commit 4d73a77ebd
3 changed files with 143 additions and 4 deletions

View File

@ -36,6 +36,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.foundation.selection.selectable
import com.kazeia.admin.data.source.KazeiaProfilesClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -87,6 +95,21 @@ fun RecordVoiceScreen(
} }
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) } var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
// Exclusivité (Phase 1) : portée de la voix choisie au moment d'enregistrer.
val profilesClient = remember { KazeiaProfilesClient(context.contentResolver) }
var profiles by remember { mutableStateOf<List<KazeiaProfilesClient.ProfileRow>>(emptyList()) }
var scopeChoice by remember { mutableStateOf("exclusive") } // exclusive|global|pending
var ownerProfileId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val list = withContext(Dispatchers.IO) { profilesClient.queryAll() }
val active = withContext(Dispatchers.IO) { profilesClient.activeProfileId() }
profiles = list
// Défaut = patient actif (cas courant : enregistrer la voix d'un proche
// POUR le patient actif de cette tablette). Aucun profil → bascule pending.
ownerProfileId = active ?: list.firstOrNull()?.id
if (list.isEmpty()) scopeChoice = "pending"
}
val permLauncher = rememberLauncherForActivityResult( val permLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { granted -> permissionGranted = granted } ) { granted -> permissionGranted = granted }
@ -139,6 +162,99 @@ fun RecordVoiceScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// 1.5 Exclusivité : à qui cette voix est destinée (capture record-time,
// écrite dans le manifeste → Kazeia-central applique la politique).
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.rec_scope_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
val opts = listOf(
"exclusive" to stringResource(R.string.rec_scope_exclusive),
"global" to stringResource(R.string.rec_scope_global),
"pending" to stringResource(R.string.rec_scope_pending)
)
opts.forEach { (value, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = scopeChoice == value,
enabled = state != AudioRecorder.State.RECORDING,
onClick = { scopeChoice = value }
)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = scopeChoice == value,
onClick = { scopeChoice = value },
enabled = state != AudioRecorder.State.RECORDING
)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
if (scopeChoice == "exclusive") {
Spacer(Modifier.height(8.dp))
if (profiles.isEmpty()) {
Text(
stringResource(R.string.rec_scope_no_patients),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
var expanded by remember { mutableStateOf(false) }
val owner = profiles.firstOrNull { it.id == ownerProfileId }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (state != AudioRecorder.State.RECORDING) expanded = it
}
) {
OutlinedTextField(
value = owner?.displayName ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.rec_scope_patient_label)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.displayName) },
onClick = { ownerProfileId = p.id; expanded = false }
)
}
}
}
}
} else if (scopeChoice == "pending") {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.rec_scope_pending_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 2. Phrase de référence (vaut consentement enregistré) // 2. Phrase de référence (vaut consentement enregistré)
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@ -240,18 +356,23 @@ fun RecordVoiceScreen(
} }
// 5. Bouton save : visible quand audio capturé ET qualité OK // 5. Bouton save : visible quand audio capturé ET qualité OK
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true &&
(scopeChoice != "exclusive" || ownerProfileId != null)
if (state == AudioRecorder.State.FINISHED) { if (state == AudioRecorder.State.FINISHED) {
Button( Button(
onClick = { onClick = {
val samples = recorder.samples() val samples = recorder.samples()
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
val ownerName = profiles.firstOrNull { it.id == ownerProfileId }?.displayName
val saved = runCatching { val saved = runCatching {
storage.save( storage.save(
displayName = voiceName.trim(), displayName = voiceName.trim(),
samples = samples, samples = samples,
durationSeconds = durSec, durationSeconds = durSec,
referenceText = referenceText referenceText = referenceText,
scope = scopeChoice,
ownerProfileId = if (scopeChoice == "exclusive") ownerProfileId else null,
ownerName = if (scopeChoice == "exclusive") ownerName else null
) )
} }
scope.launch { scope.launch {

View File

@ -45,7 +45,15 @@ class VoiceStorage(private val context: Context) {
displayName: String, displayName: String,
samples: ShortArray, samples: ShortArray,
durationSeconds: Float, durationSeconds: Float,
referenceText: String referenceText: String,
// Exclusivité (Phase 1, capture record-time). scope ∈ {exclusive, global,
// pending}. Lu par Kazeia-central à l'ingestion → localisation de
// déploiement + assignation Profile.voiceId (cf. VOICE_DEPLOYMENT_SPEC §4).
// exclusive → ownerProfileId requis ; pending → propriétaire à lier plus
// tard (ni déployée ni assignable tant que non résolu) ; global → tous.
scope: String = "pending",
ownerProfileId: String? = null,
ownerName: String? = null
): RecordedVoice { ): RecordedVoice {
val ts = TS_FMT.format(System.currentTimeMillis()) val ts = TS_FMT.format(System.currentTimeMillis())
val id = "${slug(displayName)}_$ts" val id = "${slug(displayName)}_$ts"
@ -63,6 +71,9 @@ class VoiceStorage(private val context: Context) {
put("created_at", System.currentTimeMillis()) put("created_at", System.currentTimeMillis())
put("reference_text", referenceText) put("reference_text", referenceText)
put("source_device", "tablet-admin-recording") put("source_device", "tablet-admin-recording")
put("scope", scope)
ownerProfileId?.let { put("owner_profile_id", it) }
ownerName?.let { put("owner_name", it) }
} }
manifest.writeText(json.toString(2)) manifest.writeText(json.toString(2))

View File

@ -36,8 +36,15 @@
<string name="rec_name_label">Nom de la voix</string> <string name="rec_name_label">Nom de la voix</string>
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string> <string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string> <string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux.</string> <string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Aujourd\'hui, le ciel est bleu et le vent souffle doucement. J\'aime écouter le chant joyeux des oiseaux, près d\'un feu paisible. Quel bonheur tranquille ! Et vous, qu\'est-ce qui vous rend vraiment heureux ?</string>
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string> <string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
<string name="rec_scope_title">À qui est destinée cette voix ?</string>
<string name="rec_scope_exclusive">Exclusive à un patient</string>
<string name="rec_scope_global">Non-exclusive (tous les patients)</string>
<string name="rec_scope_pending">Lier à un patient plus tard</string>
<string name="rec_scope_patient_label">Patient propriétaire</string>
<string name="rec_scope_no_patients">Aucun patient chargé sur cette tablette — choisissez « Lier à un patient plus tard ».</string>
<string name="rec_scope_pending_hint">La voix sera enregistrée mais ni déployée ni utilisable tant qu\'un patient ne lui sera pas assigné.</string>
<string name="rec_start">Commencer l\'enregistrement</string> <string name="rec_start">Commencer l\'enregistrement</string>
<string name="rec_stop">Arrêter</string> <string name="rec_stop">Arrêter</string>
<string name="rec_replay">Réécouter</string> <string name="rec_replay">Réécouter</string>