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.TopAppBar
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -87,6 +95,21 @@ fun RecordVoiceScreen(
|
|||
}
|
||||
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(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted -> permissionGranted = granted }
|
||||
|
|
@ -139,6 +162,99 @@ fun RecordVoiceScreen(
|
|||
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é)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
|
|
@ -240,18 +356,23 @@ fun RecordVoiceScreen(
|
|||
}
|
||||
|
||||
// 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) {
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = recorder.samples()
|
||||
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
|
||||
val ownerName = profiles.firstOrNull { it.id == ownerProfileId }?.displayName
|
||||
val saved = runCatching {
|
||||
storage.save(
|
||||
displayName = voiceName.trim(),
|
||||
samples = samples,
|
||||
durationSeconds = durSec,
|
||||
referenceText = referenceText
|
||||
referenceText = referenceText,
|
||||
scope = scopeChoice,
|
||||
ownerProfileId = if (scopeChoice == "exclusive") ownerProfileId else null,
|
||||
ownerName = if (scopeChoice == "exclusive") ownerName else null
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,15 @@ class VoiceStorage(private val context: Context) {
|
|||
displayName: String,
|
||||
samples: ShortArray,
|
||||
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 {
|
||||
val ts = TS_FMT.format(System.currentTimeMillis())
|
||||
val id = "${slug(displayName)}_$ts"
|
||||
|
|
@ -63,6 +71,9 @@ class VoiceStorage(private val context: Context) {
|
|||
put("created_at", System.currentTimeMillis())
|
||||
put("reference_text", referenceText)
|
||||
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))
|
||||
|
||||
|
|
|
|||
|
|
@ -36,8 +36,15 @@
|
|||
<string name="rec_name_label">Nom de la voix</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_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_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_stop">Arrêter</string>
|
||||
<string name="rec_replay">Réécouter</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue