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:
parent
8bcf9f8ebe
commit
4d73a77ebd
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue