Compare commits
22 Commits
main
...
chore/degr
| Author | SHA1 | Date |
|---|---|---|
|
|
2e6c55a068 | |
|
|
078804e2de | |
|
|
76ffbcb8fd | |
|
|
7fffd3d9df | |
|
|
4d73a77ebd | |
|
|
8bcf9f8ebe | |
|
|
613c7efc66 | |
|
|
ca530f6d85 | |
|
|
ecda7b5715 | |
|
|
a1c785bdf8 | |
|
|
a2fc01987b | |
|
|
22dbd2145f | |
|
|
66e81f0668 | |
|
|
3d002318d5 | |
|
|
a2f82f69f9 | |
|
|
577aad3b2f | |
|
|
471fb5d5fb | |
|
|
b11d8a96d4 | |
|
|
93a3953614 | |
|
|
d1baacd096 | |
|
|
0108f47c09 | |
|
|
0a72654093 |
|
|
@ -14,7 +14,7 @@
|
|||
<application
|
||||
android:label="Kazeia Admin"
|
||||
android:icon="@mipmap/ic_launcher_admin"
|
||||
android:roundIcon="@mipmap/ic_launcher_admin"
|
||||
android:roundIcon="@mipmap/ic_launcher_admin_round"
|
||||
android:theme="@style/Theme.KazeiaAdmin"
|
||||
android:supportsRtl="true"
|
||||
android:allowBackup="false">
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ private fun MainShell() {
|
|||
composable(AdminDestination.Prompts.route) {
|
||||
PromptsScreen()
|
||||
}
|
||||
composable(AdminDestination.Parameters.route) {
|
||||
com.kazeia.admin.ui.parameters.ParametersScreen()
|
||||
}
|
||||
composable(AdminDestination.Rag.route) {
|
||||
com.kazeia.admin.ui.rag.RagScreen()
|
||||
}
|
||||
|
|
@ -102,6 +105,9 @@ private fun MainShell() {
|
|||
composable(AdminDestination.Telemetry.route) {
|
||||
TelemetryScreen()
|
||||
}
|
||||
composable(AdminDestination.Updates.route) {
|
||||
com.kazeia.admin.ui.updates.UpdatesScreen()
|
||||
}
|
||||
composable(AdminDestination.System.route) {
|
||||
SystemScreen()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ class ConfigRepository(context: Context) {
|
|||
fun updateTts(enabled: Boolean): Boolean =
|
||||
client.updateConfig(ttsEnabled = enabled)
|
||||
|
||||
fun updateDebug(enabled: Boolean): Boolean =
|
||||
client.updateConfig(debugEnabled = enabled)
|
||||
|
||||
/** Applique un jeu de sampling à un rôle ("speaker"|"thinker"), avec le nom
|
||||
* du preset source ("" = réglages custom). */
|
||||
fun updateSampling(role: String, s: KazeiaConfigClient.Sampling, presetName: String): Boolean =
|
||||
client.updateSampling(role, s, presetName)
|
||||
|
||||
/** Remplace toute la bibliothèque de presets (création / édition / suppression). */
|
||||
fun updatePresets(presets: List<KazeiaConfigClient.Preset>): Boolean =
|
||||
client.updatePresets(presets)
|
||||
|
||||
fun updateSpeakerModel(modelId: String): Boolean =
|
||||
client.updateConfig(speakerModelId = modelId)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package com.kazeia.admin.data.source
|
|||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Client du ContentProvider Kazeia pour les URI `/config` et `/models`.
|
||||
|
|
@ -17,19 +20,41 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
|||
val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models")
|
||||
}
|
||||
|
||||
/** Jeu de paramètres d'échantillonnage d'un modèle (ou d'un preset). */
|
||||
data class Sampling(
|
||||
val temperature: Float = 0.7f,
|
||||
val topP: Float = 0.85f,
|
||||
val topK: Int = 40,
|
||||
val repeatPenalty: Float = 1.1f,
|
||||
val presencePenalty: Float = 0.0f,
|
||||
val frequencyPenalty: Float = 0.0f,
|
||||
val maxTokens: Int = 120
|
||||
)
|
||||
|
||||
/** Preset nommé réutilisable, applicable au Speaker ou au Thinker. */
|
||||
data class Preset(val name: String, val sampling: Sampling)
|
||||
|
||||
data class Config(
|
||||
val cascadeEnabled: Boolean,
|
||||
val ttsEnabled: Boolean,
|
||||
val speakerModelId: String,
|
||||
val speakerSystemPrompt: String,
|
||||
val speakerTemperature: Float,
|
||||
val speakerSampling: Sampling,
|
||||
val speakerPresetName: String,
|
||||
val thinkerModelId: String,
|
||||
val thinkerSystemPrompt: String,
|
||||
val thinkerTemperature: Float,
|
||||
val thinkerSampling: Sampling,
|
||||
val thinkerPresetName: String,
|
||||
val presets: List<Preset> = emptyList(),
|
||||
val ragEnabled: Boolean = false,
|
||||
val ragThreshold: Float = 0.82f,
|
||||
val ragTopK: Int = 3
|
||||
)
|
||||
val ragTopK: Int = 3,
|
||||
val debugEnabled: Boolean = false
|
||||
) {
|
||||
// Compat : anciens écrans qui lisaient juste la température.
|
||||
val speakerTemperature: Float get() = speakerSampling.temperature
|
||||
val thinkerTemperature: Float get() = thinkerSampling.temperature
|
||||
}
|
||||
|
||||
data class ModelInfo(
|
||||
val id: String,
|
||||
|
|
@ -43,6 +68,23 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
|||
val notes: String
|
||||
)
|
||||
|
||||
private fun Cursor.floatOr(col: String, d: Float) =
|
||||
getColumnIndex(col).let { if (it >= 0) getFloat(it) else d }
|
||||
private fun Cursor.intOr(col: String, d: Int) =
|
||||
getColumnIndex(col).let { if (it >= 0) getInt(it) else d }
|
||||
private fun Cursor.strOr(col: String, d: String) =
|
||||
getColumnIndex(col).let { if (it >= 0) getString(it) ?: d else d }
|
||||
|
||||
private fun Cursor.sampling(p: String) = Sampling(
|
||||
temperature = floatOr("${p}temperature", 0.7f),
|
||||
topP = floatOr("${p}top_p", 0.85f),
|
||||
topK = intOr("${p}top_k", 40),
|
||||
repeatPenalty = floatOr("${p}repeat_penalty", 1.1f),
|
||||
presencePenalty = floatOr("${p}presence_penalty", 0.0f),
|
||||
frequencyPenalty = floatOr("${p}frequency_penalty", 0.0f),
|
||||
maxTokens = intOr("${p}max_tokens", 120)
|
||||
)
|
||||
|
||||
fun queryConfig(): Config? = runCatching {
|
||||
resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
|
|
@ -51,17 +93,58 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
|||
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
|
||||
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
|
||||
speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")),
|
||||
speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")),
|
||||
speakerSampling = c.sampling("speaker_"),
|
||||
speakerPresetName = c.strOr("speaker_preset_name", ""),
|
||||
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
|
||||
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
|
||||
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")),
|
||||
ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false },
|
||||
ragThreshold = c.getColumnIndex("rag_threshold").let { if (it >= 0) c.getFloat(it) else 0.82f },
|
||||
ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 }
|
||||
thinkerSampling = c.sampling("thinker_"),
|
||||
thinkerPresetName = c.strOr("thinker_preset_name", ""),
|
||||
presets = parsePresets(c.strOr("presets_json", "[]")),
|
||||
ragEnabled = c.intOr("rag_enabled", 0) == 1,
|
||||
ragThreshold = c.floatOr("rag_threshold", 0.82f),
|
||||
ragTopK = c.intOr("rag_top_k", 3),
|
||||
debugEnabled = c.intOr("debug_enabled", 0) == 1
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun parsePresets(json: String): List<Preset> = runCatching {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
Preset(
|
||||
o.optString("name", ""),
|
||||
Sampling(
|
||||
o.optDouble("temperature", 0.7).toFloat(),
|
||||
o.optDouble("top_p", 0.85).toFloat(),
|
||||
o.optInt("top_k", 40),
|
||||
o.optDouble("repeat_penalty", 1.1).toFloat(),
|
||||
o.optDouble("presence_penalty", 0.0).toFloat(),
|
||||
o.optDouble("frequency_penalty", 0.0).toFloat(),
|
||||
o.optInt("max_tokens", 120)
|
||||
)
|
||||
)
|
||||
}
|
||||
}.filter { it.name.isNotBlank() }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun presetsToJson(presets: List<Preset>): String {
|
||||
val arr = JSONArray()
|
||||
presets.forEach { p ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("name", p.name)
|
||||
put("temperature", p.sampling.temperature)
|
||||
put("top_p", p.sampling.topP)
|
||||
put("top_k", p.sampling.topK)
|
||||
put("repeat_penalty", p.sampling.repeatPenalty)
|
||||
put("presence_penalty", p.sampling.presencePenalty)
|
||||
put("frequency_penalty", p.sampling.frequencyPenalty)
|
||||
put("max_tokens", p.sampling.maxTokens)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
fun queryModels(): List<ModelInfo> = runCatching {
|
||||
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<ModelInfo>()
|
||||
|
|
@ -89,29 +172,49 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
|||
ttsEnabled: Boolean? = null,
|
||||
speakerModelId: String? = null,
|
||||
speakerSystemPrompt: String? = null,
|
||||
speakerTemperature: Float? = null,
|
||||
thinkerModelId: String? = null,
|
||||
thinkerSystemPrompt: String? = null,
|
||||
thinkerTemperature: Float? = null,
|
||||
ragEnabled: Boolean? = null,
|
||||
ragThreshold: Float? = null,
|
||||
ragTopK: Int? = null
|
||||
ragTopK: Int? = null,
|
||||
debugEnabled: Boolean? = null
|
||||
): Boolean {
|
||||
val v = ContentValues()
|
||||
cascadeEnabled?.let { v.put("cascade_enabled", it) }
|
||||
ttsEnabled?.let { v.put("tts_enabled", it) }
|
||||
speakerModelId?.let { v.put("speaker_model_id", it) }
|
||||
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
|
||||
speakerTemperature?.let { v.put("speaker_temperature", it) }
|
||||
thinkerModelId?.let { v.put("thinker_model_id", it) }
|
||||
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
|
||||
thinkerTemperature?.let { v.put("thinker_temperature", it) }
|
||||
ragEnabled?.let { v.put("rag_enabled", it) }
|
||||
ragThreshold?.let { v.put("rag_threshold", it) }
|
||||
ragTopK?.let { v.put("rag_top_k", it) }
|
||||
debugEnabled?.let { v.put("debug_enabled", it) }
|
||||
return push(v)
|
||||
}
|
||||
|
||||
/** Applique un jeu de sampling (+ nom de preset, "" = custom) à un rôle. */
|
||||
fun updateSampling(role: String, s: Sampling, presetName: String): Boolean {
|
||||
val p = "${role}_" // "speaker" | "thinker"
|
||||
val v = ContentValues().apply {
|
||||
put("${p}temperature", s.temperature)
|
||||
put("${p}top_p", s.topP)
|
||||
put("${p}top_k", s.topK)
|
||||
put("${p}repeat_penalty", s.repeatPenalty)
|
||||
put("${p}presence_penalty", s.presencePenalty)
|
||||
put("${p}frequency_penalty", s.frequencyPenalty)
|
||||
put("${p}max_tokens", s.maxTokens)
|
||||
put("${p}preset_name", presetName)
|
||||
}
|
||||
return push(v)
|
||||
}
|
||||
|
||||
/** Remplace toute la bibliothèque de presets. */
|
||||
fun updatePresets(presets: List<Preset>): Boolean =
|
||||
push(ContentValues().apply { put("presets_json", presetsToJson(presets)) })
|
||||
|
||||
private fun push(v: ContentValues): Boolean {
|
||||
if (v.size() == 0) return true
|
||||
return runCatching {
|
||||
resolver.update(CONFIG_URI, v, null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
return runCatching { resolver.update(CONFIG_URI, v, null, null) > 0 }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Client des mises à jour à la demande de l'app patient (ContentProvider
|
||||
* `call(update_check|update_install)` + query `/updates`). Le provisioning
|
||||
* (download modèles + APK) s'exécute côté patient ; l'admin déclenche + suit.
|
||||
*/
|
||||
class KazeiaUpdateClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
private val ANY_URI: Uri = Uri.parse("content://$AUTHORITY/updates")
|
||||
const val CALL_CHECK = "update_check"
|
||||
const val CALL_INSTALL = "update_install"
|
||||
}
|
||||
|
||||
/** Reflète [com.kazeia.dist.UpdateStatus] côté patient. */
|
||||
data class UpdateInfo(
|
||||
val phase: String, // IDLE/CHECKING/AVAILABLE/UPTODATE/DOWNLOADING/INSTALLING/DONE/ERROR
|
||||
val appUpdateAvailable: Boolean,
|
||||
val appVersionName: String,
|
||||
val appSize: Long,
|
||||
val contentCount: Int,
|
||||
val contentSize: Long,
|
||||
val progressDone: Long,
|
||||
val progressTotal: Long,
|
||||
val label: String,
|
||||
val error: String,
|
||||
val lastCheck: Long
|
||||
)
|
||||
|
||||
/** True si l'app patient est joignable (provider présent). */
|
||||
fun reachable(): Boolean = query() != null
|
||||
|
||||
fun check(): Boolean = call(CALL_CHECK)
|
||||
fun install(): Boolean = call(CALL_INSTALL)
|
||||
|
||||
private fun call(method: String): Boolean = runCatching {
|
||||
resolver.call(ANY_URI, method, null, null)?.getBoolean("accepted", false) ?: false
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun query(): UpdateInfo? = runCatching {
|
||||
resolver.query(ANY_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
fun s(n: String) = c.getString(c.getColumnIndexOrThrow(n))
|
||||
fun i(n: String) = c.getInt(c.getColumnIndexOrThrow(n))
|
||||
fun l(n: String) = c.getLong(c.getColumnIndexOrThrow(n))
|
||||
UpdateInfo(
|
||||
phase = s("phase"),
|
||||
appUpdateAvailable = i("app_update_available") == 1,
|
||||
appVersionName = s("app_version_name"),
|
||||
appSize = l("app_size"),
|
||||
contentCount = i("content_count"),
|
||||
contentSize = l("content_size"),
|
||||
progressDone = l("progress_done"),
|
||||
progressTotal = l("progress_total"),
|
||||
label = s("label"),
|
||||
error = s("error"),
|
||||
lastCheck = l("last_check")
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -8,7 +8,9 @@ import androidx.compose.material.icons.outlined.Build
|
|||
import androidx.compose.material.icons.outlined.GraphicEq
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.People
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material.icons.outlined.RecordVoiceOver
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
|
|
@ -23,9 +25,11 @@ enum class AdminDestination(
|
|||
Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver),
|
||||
Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People),
|
||||
Prompts( "prompts", com.kazeia.admin.R.string.nav_prompts, Icons.AutoMirrored.Outlined.Article),
|
||||
Parameters("parameters", com.kazeia.admin.R.string.nav_parameters, Icons.Outlined.Tune),
|
||||
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
|
||||
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
|
||||
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
|
||||
Updates( "updates", com.kazeia.admin.R.string.nav_updates, Icons.Outlined.SystemUpdate),
|
||||
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,356 @@
|
|||
package com.kazeia.admin.ui.parameters
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.data.repository.ConfigRepository
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient.Preset
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient.Sampling
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ParametersScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { ConfigRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
var reachable by remember { mutableStateOf(true) }
|
||||
var presets by remember { mutableStateOf<List<Preset>>(emptyList()) }
|
||||
var speaker by remember { mutableStateOf(Sampling()) }
|
||||
var thinker by remember { mutableStateOf(Sampling()) }
|
||||
var speakerPreset by remember { mutableStateOf("") }
|
||||
var thinkerPreset by remember { mutableStateOf("") }
|
||||
|
||||
fun reload() {
|
||||
val cfg = repo.config()
|
||||
if (cfg == null) { reachable = false; loaded = true; return }
|
||||
reachable = true
|
||||
presets = cfg.presets
|
||||
speaker = cfg.speakerSampling; speakerPreset = cfg.speakerPresetName
|
||||
thinker = cfg.thinkerSampling; thinkerPreset = cfg.thinkerPresetName
|
||||
loaded = true
|
||||
}
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Paramètres d'échantillonnage") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
if (!loaded) return@Scaffold
|
||||
if (!reachable) {
|
||||
Column(Modifier.fillMaxSize().padding(32.dp), verticalArrangement = Arrangement.Center) {
|
||||
Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
EngineNoticeCard()
|
||||
|
||||
RoleCard(
|
||||
title = "Speaker", subtitle = "LLM qui répond au patient",
|
||||
sampling = speaker, presetName = speakerPreset, presets = presets,
|
||||
onChange = { speaker = it; speakerPreset = "" },
|
||||
onApplyPreset = { p -> speaker = p.sampling; speakerPreset = p.name },
|
||||
onSave = {
|
||||
val ok = repo.updateSampling("speaker", speaker, speakerPreset)
|
||||
scope.launch { snackbar.showSnackbar(if (ok) "Speaker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
|
||||
}
|
||||
)
|
||||
|
||||
RoleCard(
|
||||
title = "Thinker", subtitle = "LLM d'analyse (cascade)",
|
||||
sampling = thinker, presetName = thinkerPreset, presets = presets,
|
||||
onChange = { thinker = it; thinkerPreset = "" },
|
||||
onApplyPreset = { p -> thinker = p.sampling; thinkerPreset = p.name },
|
||||
onSave = {
|
||||
val ok = repo.updateSampling("thinker", thinker, thinkerPreset)
|
||||
scope.launch { snackbar.showSnackbar(if (ok) "Thinker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
|
||||
}
|
||||
)
|
||||
|
||||
PresetLibraryCard(
|
||||
presets = presets,
|
||||
onSave = { updated ->
|
||||
val ok = repo.updatePresets(updated)
|
||||
if (ok) reload()
|
||||
scope.launch { snackbar.showSnackbar(if (ok) "Bibliothèque de presets enregistrée." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EngineNoticeCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("ℹ️ Effet sur la génération", fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Aujourd'hui le moteur n'applique que « Tokens max ». Température, top-p/k et " +
|
||||
"pénalités sont enregistrés et prendront effet dès que le moteur expose " +
|
||||
"l'échantillonnage (mise à jour à venir).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoleCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
sampling: Sampling,
|
||||
presetName: String,
|
||||
presets: List<Preset>,
|
||||
onChange: (Sampling) -> Unit,
|
||||
onApplyPreset: (Preset) -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Appliquer un preset
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
|
||||
OutlinedTextField(
|
||||
value = if (presetName.isNotBlank()) presetName else "Personnalisé",
|
||||
onValueChange = {}, readOnly = true,
|
||||
label = { Text("Preset appliqué") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (presets.isEmpty()) {
|
||||
DropdownMenuItem(text = { Text("Aucun preset") }, onClick = { expanded = false }, enabled = false)
|
||||
}
|
||||
presets.forEach { p ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(p.name) },
|
||||
onClick = { expanded = false; onApplyPreset(p) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SamplingEditor(sampling, onChange)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(onClick = onSave) { Text("Appliquer au $title") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SamplingEditor(s: Sampling, onChange: (Sampling) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
SliderRow("Température", s.temperature, 0f, 2f) { onChange(s.copy(temperature = it)) }
|
||||
SliderRow("Top-p", s.topP, 0f, 1f) { onChange(s.copy(topP = it)) }
|
||||
IntSliderRow("Top-k", s.topK, 0, 100) { onChange(s.copy(topK = it)) }
|
||||
SliderRow("Pénalité répétition", s.repeatPenalty, 1f, 2f) { onChange(s.copy(repeatPenalty = it)) }
|
||||
SliderRow("Pénalité présence", s.presencePenalty, 0f, 2f) { onChange(s.copy(presencePenalty = it)) }
|
||||
SliderRow("Pénalité fréquence", s.frequencyPenalty, 0f, 2f) { onChange(s.copy(frequencyPenalty = it)) }
|
||||
IntSliderRow("Tokens max ✓", s.maxTokens, 16, 512) { onChange(s.copy(maxTokens = it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SliderRow(label: String, value: Float, min: Float, max: Float, onChange: (Float) -> Unit) {
|
||||
Column {
|
||||
Row {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
String.format("%.2f", value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value.coerceIn(min, max),
|
||||
onValueChange = { onChange((it * 20).roundToInt() / 20f) }, // pas 0.05
|
||||
valueRange = min..max
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntSliderRow(label: String, value: Int, min: Int, max: Int, onChange: (Int) -> Unit) {
|
||||
Column {
|
||||
Row {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Text("$value", style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
Slider(
|
||||
value = value.coerceIn(min, max).toFloat(),
|
||||
onValueChange = { onChange(it.roundToInt()) },
|
||||
valueRange = min.toFloat()..max.toFloat()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetLibraryCard(presets: List<Preset>, onSave: (List<Preset>) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Bibliothèque de presets", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("Modèles réutilisables, applicables au Speaker ou au Thinker.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
var editing by remember { mutableStateOf<Int?>(null) } // index en édition
|
||||
|
||||
presets.forEachIndexed { i, p ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(p.name, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"t=${"%.2f".format(p.sampling.temperature)} · top-p=${"%.2f".format(p.sampling.topP)} · " +
|
||||
"rep=${"%.2f".format(p.sampling.repeatPenalty)} · max=${p.sampling.maxTokens}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { editing = if (editing == i) null else i }) {
|
||||
Text(if (editing == i) "Fermer" else "Éditer")
|
||||
}
|
||||
TextButton(onClick = { onSave(presets.filterIndexed { j, _ -> j != i }) }) {
|
||||
Text("Suppr.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
if (editing == i) {
|
||||
var draft by remember(i) { mutableStateOf(p.sampling) }
|
||||
var name by remember(i) { mutableStateOf(p.name) }
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Nom du preset") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SamplingEditor(draft) { draft = it }
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
editing = null
|
||||
onSave(presets.toMutableList().also { it[i] = Preset(name.ifBlank { p.name }, draft) })
|
||||
}
|
||||
) { Text("Enregistrer") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Nouveau preset
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
if (!creating) {
|
||||
TextButton(onClick = { creating = true }) { Text("+ Nouveau preset") }
|
||||
} else {
|
||||
var newName by remember { mutableStateOf("") }
|
||||
var newSampling by remember { mutableStateOf(Sampling()) }
|
||||
OutlinedTextField(
|
||||
value = newName, onValueChange = { newName = it },
|
||||
label = { Text("Nom du nouveau preset") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SamplingEditor(newSampling) { newSampling = it }
|
||||
Row {
|
||||
TextButton(onClick = { creating = false }) { Text("Annuler") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = {
|
||||
creating = false
|
||||
onSave(presets + Preset(newName.trim(), newSampling))
|
||||
}
|
||||
) { Text("Créer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,6 +138,21 @@ fun SystemScreen() {
|
|||
}
|
||||
)
|
||||
|
||||
DebugCard(
|
||||
enabled = cfg.debugEnabled,
|
||||
onToggle = { newValue ->
|
||||
val ok = repo.updateDebug(newValue)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Mode debug ${if (newValue) "activé" else "désactivé"} — appliqué immédiatement côté patient."
|
||||
else "Échec : Kazeia patient injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ModelSection(
|
||||
title = "Speaker",
|
||||
subtitle = "LLM qui répond au patient",
|
||||
|
|
@ -373,6 +388,45 @@ private fun TtsCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Mode debug",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (enabled)
|
||||
"ACTIVÉ — le bouton debug (logs + métriques CPU/GPU/NPU/RAM) est visible dans l'app patient."
|
||||
else
|
||||
"DÉSACTIVÉ — écran patient épuré, bouton debug masqué.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = StateReady,
|
||||
checkedTrackColor = StateReady.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ModelSection(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
package com.kazeia.admin.ui.updates
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.data.source.KazeiaUpdateClient
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UpdatesScreen() {
|
||||
val context = LocalContext.current
|
||||
val client = remember { KazeiaUpdateClient(context.contentResolver) }
|
||||
var info by remember { mutableStateOf<KazeiaUpdateClient.UpdateInfo?>(null) }
|
||||
var reachable by remember { mutableStateOf(true) }
|
||||
|
||||
// Poll continu de l'état (le worker tourne côté patient).
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val q = client.query()
|
||||
reachable = q != null
|
||||
info = q
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val phase = info?.phase ?: "IDLE"
|
||||
val busy = phase == "CHECKING" || phase == "DOWNLOADING" || phase == "INSTALLING"
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Mises à jour") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (!reachable) {
|
||||
Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
return@Column
|
||||
}
|
||||
|
||||
StatusCard(info, busy)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(enabled = !busy, onClick = { client.check() }) {
|
||||
Text("Vérifier les mises à jour")
|
||||
}
|
||||
val hasUpdate = info?.let { it.appUpdateAvailable || it.contentCount > 0 } == true
|
||||
Button(enabled = !busy && hasUpdate, onClick = { client.install() }) {
|
||||
Text("Installer")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Les mises à jour ne se font plus automatiquement : elles se déclenchent " +
|
||||
"uniquement ici. L'installation de l'application affichera un dialogue " +
|
||||
"système à confirmer sur la tablette.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(info: KazeiaUpdateClient.UpdateInfo?, busy: Boolean) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
val phase = info?.phase ?: "IDLE"
|
||||
val (title, detail) = when (phase) {
|
||||
"IDLE" -> "Aucune vérification" to "Touchez « Vérifier » pour interroger le serveur."
|
||||
"CHECKING" -> "Vérification en cours…" to ""
|
||||
"UPTODATE" -> "Tout est à jour ✓" to "Aucune mise à jour disponible."
|
||||
"AVAILABLE" -> "Mises à jour disponibles" to buildAvailable(info)
|
||||
"DOWNLOADING" -> "Téléchargement…" to (info?.label ?: "")
|
||||
"INSTALLING" -> "Installation…" to (info?.label ?: "")
|
||||
"DONE" -> "Mise à jour terminée ✓" to "L'application redémarrera si elle a été mise à jour."
|
||||
"ERROR" -> "Échec" to (info?.error ?: "Erreur inconnue.")
|
||||
else -> phase to ""
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (detail.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(detail, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (busy) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
val total = info?.progressTotal ?: 0L
|
||||
val done = info?.progressDone ?: 0L
|
||||
if (total > 0L) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (done.toFloat() / total).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("${human(done)} / ${human(total)}", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAvailable(info: KazeiaUpdateClient.UpdateInfo?): String {
|
||||
if (info == null) return ""
|
||||
val parts = mutableListOf<String>()
|
||||
if (info.appUpdateAvailable) parts += "Application v${info.appVersionName} (${human(info.appSize)})"
|
||||
if (info.contentCount > 0) parts += "${info.contentCount} composant(s) modèle/voix (${human(info.contentSize)})"
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun human(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes o"
|
||||
val kb = bytes / 1024.0
|
||||
if (kb < 1024) return "%.0f Ko".format(kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024) return "%.1f Mo".format(mb)
|
||||
return "%.2f Go".format(mb / 1024.0)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fond adaptatif Kazeia Admin : dégradé radial violet-ardoise sombre — distingue
|
||||
l'outil de gestion de l'app patiente (fond plus clair), même famille. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0 L108,0 L108,108 L0,108 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="46"
|
||||
android:gradientRadius="82">
|
||||
<item android:offset="0" android:color="#FF2A2740" />
|
||||
<item android:offset="0.6" android:color="#FF1C1B2B" />
|
||||
<item android:offset="1" android:color="#FF100F19" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
@ -1,18 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Avant-plan adaptatif Kazeia Admin : l'orbe Kazeia cerné d'un anneau d'engrenage
|
||||
(8 dents) — même identité que l'app patiente, signal « console / contrôle ».
|
||||
Tout reste dans la zone sûre (dents jusqu'au rayon 36 du centre). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple "K" + gear-like halo for admin -->
|
||||
|
||||
<!-- Dents de l'engrenage (8, par rotation autour du centre 54,54) -->
|
||||
<group android:rotation="0" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="45" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="90" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="135" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="180" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="225" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="270" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="315" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
|
||||
<!-- Anneau de l'engrenage -->
|
||||
<path
|
||||
android:fillColor="#BCA4E8"
|
||||
android:pathData="M54 28 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0" />
|
||||
<path
|
||||
android:fillColor="#BCA4E8"
|
||||
android:pathData="M40 42 L40 80 L48 80 L48 64 L60 80 L70 80 L56 62 L70 42 L60 42 L48 58 L48 42 Z" />
|
||||
<path
|
||||
android:strokeColor="#7A6BAA"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeColor="#FF9C8FC9"
|
||||
android:strokeWidth="4.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54 16 a38 38 0 1 0 0 76 a38 38 0 1 0 0 -76" />
|
||||
android:pathData="M24,54 a30,30 0 1,0 60,0 a30,30 0 1,0 -60,0" />
|
||||
|
||||
<!-- Orbe central (rayon 22) -->
|
||||
<path android:pathData="M32,54 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="46"
|
||||
android:centerY="44"
|
||||
android:gradientRadius="34">
|
||||
<item android:offset="0" android:color="#FFF6F1FF" />
|
||||
<item android:offset="0.34" android:color="#FFC9B6F0" />
|
||||
<item android:offset="0.7" android:color="#FF9879D8" />
|
||||
<item android:offset="1" android:color="#FF6243A0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Reflet spéculaire -->
|
||||
<path android:pathData="M42,45 a5,5 0 1,0 10,0 a5,5 0 1,0 -10,0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="47"
|
||||
android:centerY="45"
|
||||
android:gradientRadius="5">
|
||||
<item android:offset="0" android:color="#E6FFFFFF" />
|
||||
<item android:offset="1" android:color="#00FFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Icône thématisée (Android 13+) : silhouette orbe + engrenage, teintée système. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group android:rotation="0" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="45" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="90" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="135" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="180" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="225" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="270" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
<group android:rotation="315" android:pivotX="54" android:pivotY="54">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
|
||||
</group>
|
||||
|
||||
<!-- Anneau (annulus, evenOdd) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M23,54 a31,31 0 1,0 62,0 a31,31 0 1,0 -62,0 Z M28,54 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z" />
|
||||
|
||||
<!-- Orbe -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M32,54 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0 Z" />
|
||||
</vector>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_admin_background"/>
|
||||
<background android:drawable="@drawable/ic_launcher_admin_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_admin_background"/>
|
||||
<background android:drawable="@drawable/ic_launcher_admin_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<string name="nav_voices">Voix</string>
|
||||
<string name="nav_profiles">Profils patients</string>
|
||||
<string name="nav_prompts">Prompts</string>
|
||||
<string name="nav_parameters">Paramètres</string>
|
||||
<string name="nav_updates">Mises à jour</string>
|
||||
<string name="nav_rag">Base documentaire</string>
|
||||
<string name="nav_history">Historique</string>
|
||||
<string name="nav_telemetry">Telemetry</string>
|
||||
|
|
@ -34,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>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ android {
|
|||
applicationId = "com.kazeia"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 11
|
||||
versionName = "0.1.10"
|
||||
versionCode = 15
|
||||
versionName = "0.2.3"
|
||||
|
||||
// WebDAV de distribution — injecté depuis local.properties.
|
||||
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
|
||||
|
|
@ -106,6 +106,21 @@ android {
|
|||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// DJL `tokenizers` embarque des natifs desktop (macOS/Windows) inutiles
|
||||
// sur Android (~23 Mo) ; seul l'arm64 de `tokenizer-native` sert. On les
|
||||
// exclut pour alléger l'APK (payload self-update OTA).
|
||||
excludes += "native/lib/osx-**"
|
||||
excludes += "native/lib/win-**"
|
||||
excludes += "native/lib/linux-**"
|
||||
// JNA (transitif via sqlcipher/DJL) embarque des natifs aix/darwin/win
|
||||
// (~2,3 Mo) inutiles sur Android — aucun natif android-aarch64 fourni et
|
||||
// JNA non utilisé dans le code. APK = binaires utiles uniquement.
|
||||
excludes += "com/sun/jna/**"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent
|
||||
|
|
@ -146,10 +161,17 @@ dependencies {
|
|||
implementation("com.qualcomm.qti:qnn-litert-delegate:2.44.0")
|
||||
implementation("com.qualcomm.qti:qnn-runtime:2.44.0")
|
||||
|
||||
// ExecuTorch JNI dependencies (for TTS CP on NPU)
|
||||
implementation("com.facebook.fbjni:fbjni:0.7.0")
|
||||
implementation("com.facebook.soloader:nativeloader:0.10.5")
|
||||
implementation(files("libs/executorch.jar"))
|
||||
// (ExecuTorch jar + fbjni + soloader retirés 2026-06-18 : seul le TTS legacy
|
||||
// Qwen3 les utilisait, supprimé avec la bascule CosyVoice. Le LLM .pte passe
|
||||
// par libkazeia_pte autonome, pas par org.pytorch.executorch.)
|
||||
|
||||
// Tokeniseur HF (DJL) — pour PteLlmEngine (.pte NPU) : tokenise le ChatML Qwen
|
||||
// côté Kotlin puis passe les IDs au natif (tokeniseur natif de l'engine cassé pour Qwen).
|
||||
// L'artefact `tokenizers` ne porte QUE les natifs desktop (osx/win) ; le natif Android
|
||||
// arm64 (libdjl_tokenizer.so) vit dans l'AAR `ai.djl.android:tokenizer-native`, dont la
|
||||
// seule version publiée est 0.33.0 → on aligne les DEUX sur 0.33.0 (DJL exige l'égalité).
|
||||
implementation("ai.djl.huggingface:tokenizers:0.33.0")
|
||||
implementation("ai.djl.android:tokenizer-native:0.33.0")
|
||||
|
||||
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
android:extractNativeLibs="true"
|
||||
android:label="Kazeia"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Kazeia">
|
||||
|
||||
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
|
||||
|
|
@ -74,24 +75,6 @@
|
|||
android:value="AI inference service for emotional support chatbot" />
|
||||
</service>
|
||||
|
||||
<!-- v2 rewrite : ChatActivityV2 test page + KazeiaServiceV2 (Phase 1 STT) -->
|
||||
<activity
|
||||
android:name=".v2.ChatActivityV2"
|
||||
android:exported="true"
|
||||
android:label="Kazeia v2"
|
||||
android:screenOrientation="unspecified"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".v2.KazeiaServiceV2"
|
||||
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- WorkManager : déclare le type FGS dataSync sur son service foreground
|
||||
(requis API 34+ pour le worker de téléchargement modèles). -->
|
||||
<service
|
||||
|
|
|
|||
|
|
@ -27,23 +27,25 @@ class KazeiaApplication : Application() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
|
||||
// QNN HTP sur DSP (.pte LLM ExecuTorch + TTS) : le skel `libQnnHtpV79Skel.so`
|
||||
// est relayé au CDSP par FastRPC, qui le cherche via ADSP_LIBRARY_PATH. Sans
|
||||
// ça, le DSP ne trouve pas le skel (errno 2) → device_handle échoue (14001) →
|
||||
// chargement .pte renvoie handle 0. On pointe sur le dossier natif extrait de
|
||||
// l'APK (où vit le skel) + chemins DSP système. Doit être posé AVANT toute
|
||||
// ouverture de session QNN (bench, KazeiaService, TTS). Cf. Qwen3TtsEngine.
|
||||
try {
|
||||
val nativeLibDir = applicationInfo.nativeLibraryDir
|
||||
android.system.Os.setenv(
|
||||
"ADSP_LIBRARY_PATH",
|
||||
"$nativeLibDir;/vendor/dsp/cdsp;/vendor/lib/rfsa/adsp;/vendor/dsp",
|
||||
true
|
||||
)
|
||||
} catch (_: Throwable) {}
|
||||
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
|
||||
// requires its own initialization sequence. Loading libs out of order causes
|
||||
// native crashes. Unity handles lib loading internally in onCreate().
|
||||
|
||||
// Bench QNN options : déclenché par `touch <files>/bench_qnn.trigger` puis relance app.
|
||||
// Charge WhisperHybridEngine + transcrit /files/bench_audio/*.wav (3×, médiane).
|
||||
val trig = java.io.File(getExternalFilesDir(null), "bench_qnn.trigger")
|
||||
if (trig.exists()) {
|
||||
trig.delete()
|
||||
Thread {
|
||||
com.kazeia.stt.BenchQnnHarness.runBench(
|
||||
ctx = this@KazeiaApplication,
|
||||
audioDir = java.io.File(getExternalFilesDir(null), "bench_audio"),
|
||||
modelDir = "$MODELS_DIR/whisper-small-sm8750"
|
||||
)
|
||||
}.start()
|
||||
}
|
||||
// Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app.
|
||||
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
|
||||
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
|
||||
|
|
@ -51,13 +53,6 @@ class KazeiaApplication : Application() {
|
|||
trigLlm.delete()
|
||||
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
|
||||
}
|
||||
// Bench TTS (lib CPU-only) : `touch <files>/bench_tts.trigger`, relance app.
|
||||
// Charge libkazeia_tts + 1 synthèse FR vers WAV.
|
||||
val trigTts = java.io.File(getExternalFilesDir(null), "bench_tts.trigger")
|
||||
if (trigTts.exists()) {
|
||||
trigTts.delete()
|
||||
Thread { com.kazeia.tts.BenchTtsHarness.runBench(this@KazeiaApplication) }.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,32 @@ class ConfigStore private constructor(private val file: File) {
|
|||
data class ModelConfig(
|
||||
val modelId: String,
|
||||
val systemPrompt: String,
|
||||
val temperature: Float
|
||||
// Paramètres d'échantillonnage. `temperature` historique conservé ; les
|
||||
// autres ajoutés par la refonte presets 2026-06-18. ⚠ seul maxTokens est
|
||||
// honoré par le moteur natif actuel — le reste prend effet quand le JNI
|
||||
// expose le sampling (cf. docs/SAMPLING_ENGINE_SPEC.md). presetName = nom
|
||||
// du preset appliqué (vide = réglages custom).
|
||||
val temperature: Float,
|
||||
val topP: Float = 0.85f,
|
||||
val topK: Int = 40,
|
||||
val repeatPenalty: Float = 1.1f,
|
||||
val presencePenalty: Float = 0.0f,
|
||||
val frequencyPenalty: Float = 0.0f,
|
||||
val maxTokens: Int = 120,
|
||||
val presetName: String = ""
|
||||
)
|
||||
|
||||
/** Modèle d'échantillonnage nommé, réutilisable, applicable au Speaker ou au
|
||||
* Thinker. Géré (CRUD) depuis l'app admin, persisté dans la config. */
|
||||
data class SamplingPreset(
|
||||
val name: String,
|
||||
val temperature: Float,
|
||||
val topP: Float,
|
||||
val topK: Int,
|
||||
val repeatPenalty: Float,
|
||||
val presencePenalty: Float,
|
||||
val frequencyPenalty: Float,
|
||||
val maxTokens: Int
|
||||
)
|
||||
|
||||
data class RuntimeConfig(
|
||||
|
|
@ -36,27 +61,21 @@ class ConfigStore private constructor(private val file: File) {
|
|||
/** Synthèse vocale active. Si false : pas de TTS, réponse affichée
|
||||
* progressivement (token par token). Pilotable depuis l'app admin. */
|
||||
val ttsEnabled: Boolean = true,
|
||||
/** Backend STT : "prod" = WhisperHybridEngine gelé, "lib" = libkazeia_stt
|
||||
* (Kazeia-Engine unifié, A/B identique sur set 6 audios mais à valider plus large).
|
||||
* Pilotable depuis l'app admin / provider. Defaults safe = "prod". */
|
||||
/** Backend STT : VESTIGIAL depuis le retrait du backend "lib" (2026-06-18,
|
||||
* non validé). KazeiaService charge toujours WhisperHybridEngine (NPU, gelé).
|
||||
* Champ conservé pour compat config/télémétrie. */
|
||||
val sttEngine: String = "prod",
|
||||
/** Backend LLM : "lib" = EngineLlmAdapter (libkazeia_engine + GGUF Qwen3.5,
|
||||
* q35-lmq4 par défaut) — défaut depuis 2026-06-15 : on n'utilise PLUS le
|
||||
* .pte avec kazeia-engine (directive). "prod" = ExecuTorchLlmEngine (.pte) —
|
||||
* conservé en repli d'urgence uniquement ; sur cette plateforme le runner QNN
|
||||
* du .pte échoue au load (« Failed to load llm runner [1] »), donc à éviter.
|
||||
* "lib" valide device : load q35-lmq4.gguf 2.6s + génération FR cohérente. */
|
||||
/** Backend LLM : VESTIGIAL depuis la refonte 2026-06-17. Le dispatch ne passe
|
||||
* plus par cette valeur : KazeiaService charge tout LLM via UnifiedLlmAdapter
|
||||
* qui auto-détecte le format par MAGIC-BYTE (GGUF→CPU i8mm, .pte→NPU V79). Le
|
||||
* modèle effectif est choisi par `speaker.modelId` (ModelRegistry). Champ
|
||||
* conservé pour compat config/télémétrie. */
|
||||
val llmEngine: String = "lib",
|
||||
/** Backend TTS : "lib" = libkazeia_tts unifié (SEUL compatible avec le
|
||||
* libllama.so fork ql embarqué depuis 2026-06-02), "prod" = Qwen3TtsEngine
|
||||
* legacy — CRASHE (SIGSEGV nativeInit, dérive ABI llama_context_params) tant
|
||||
* que les .so legacy ne sont pas recompilés contre les headers fork ql.
|
||||
* "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues, CPU-only) —
|
||||
* câblé mais NON activable tant que la collision libggml des libs livrées
|
||||
* n'est pas levée côté engine (cf CosyVoiceTtsEngine.kt).
|
||||
* Default "lib" depuis 2026-06-11 : l'ancien défaut "prod" mettait toute
|
||||
* install fraîche en crash-loop au démarrage du service. */
|
||||
val ttsEngine: String = "lib",
|
||||
/** Backend TTS : "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues,
|
||||
* CPU-only) = SEUL moteur depuis la bascule 2026-06-18 (legacy Qwen3-TTS/lib
|
||||
* retirés). Champ conservé pour compat config ; KazeiaService ne charge que
|
||||
* CosyVoice. */
|
||||
val ttsEngine: String = "cosyvoice",
|
||||
/** RAG (récupération de contexte injecté au prompt). Default OFF : tant que
|
||||
* kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré,
|
||||
* l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */
|
||||
|
|
@ -65,7 +84,14 @@ class ConfigStore private constructor(private val file: File) {
|
|||
* pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */
|
||||
val ragThreshold: Float = 0.82f,
|
||||
/** Nombre max de chunks récupérés/injectés par tour. */
|
||||
val ragTopK: Int = 3
|
||||
val ragTopK: Int = 3,
|
||||
/** Mode debug : quand true, l'app patient affiche le bouton qui ouvre le
|
||||
* panneau de logs + métriques système (CPU/GPU/NPU/RAM). Quand false
|
||||
* (défaut prod), le bouton est masqué — l'écran reste épuré pour le
|
||||
* patient. Pilotable depuis l'app admin. */
|
||||
val debugEnabled: Boolean = false,
|
||||
/** Bibliothèque de presets de sampling réutilisables (gérée depuis l'admin). */
|
||||
val presets: List<SamplingPreset> = defaultPresets()
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
|
@ -107,22 +133,34 @@ class ConfigStore private constructor(private val file: File) {
|
|||
return store
|
||||
}
|
||||
|
||||
/** Presets de sampling d'usine — inspirés du tuning Kaz (binôme) + profils
|
||||
* thérapeutiques. Repère pour l'admin ; éditables/supprimables. */
|
||||
fun defaultPresets(): List<SamplingPreset> = listOf(
|
||||
SamplingPreset("Équilibré (défaut)", 0.7f, 0.85f, 40, 1.1f, 0.0f, 0.0f, 120),
|
||||
SamplingPreset("Kaz chaleureux", 0.4f, 0.85f, 40, 1.05f, 0.2f, 0.2f, 256),
|
||||
SamplingPreset("Factuel / froid", 0.3f, 0.70f, 40, 1.2f, 0.1f, 0.1f, 200),
|
||||
SamplingPreset("Analyse (Thinker)", 0.2f, 0.70f, 40, 1.15f, 0.0f, 0.0f, 128),
|
||||
SamplingPreset("Créatif", 0.9f, 0.95f, 60, 1.05f, 0.3f, 0.3f, 200)
|
||||
)
|
||||
|
||||
fun defaultConfig(): RuntimeConfig = RuntimeConfig(
|
||||
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
|
||||
speaker = ModelConfig(
|
||||
modelId = ModelRegistry.defaultSpeaker().id,
|
||||
systemPrompt = DEFAULT_SPEAKER_PROMPT,
|
||||
temperature = 0.7f
|
||||
temperature = 0.7f,
|
||||
maxTokens = 450 // réponse Speaker longue (valeur prod historique)
|
||||
),
|
||||
thinker = ModelConfig(
|
||||
modelId = ModelRegistry.defaultThinker().id,
|
||||
systemPrompt = DEFAULT_THINKER_PROMPT,
|
||||
temperature = 0.0f
|
||||
temperature = 0.0f,
|
||||
maxTokens = 128 // bloc clinique court
|
||||
),
|
||||
ttsEnabled = true,
|
||||
sttEngine = "prod",
|
||||
llmEngine = "prod",
|
||||
ttsEngine = "lib" // legacy "prod" = SIGSEGV avec le libllama fork ql embarqué
|
||||
ttsEngine = "cosyvoice" // bascule 2026-06-18 : CosyVoice = seul TTS prod
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -173,19 +211,52 @@ class ConfigStore private constructor(private val file: File) {
|
|||
root.put("rag_enabled", cfg.ragEnabled)
|
||||
root.put("rag_threshold", cfg.ragThreshold.toDouble())
|
||||
root.put("rag_top_k", cfg.ragTopK)
|
||||
root.put("speaker", JSONObject().apply {
|
||||
put("model_id", cfg.speaker.modelId)
|
||||
put("system_prompt", cfg.speaker.systemPrompt)
|
||||
put("temperature", cfg.speaker.temperature)
|
||||
root.put("debug_enabled", cfg.debugEnabled)
|
||||
root.put("speaker", modelToJson(cfg.speaker))
|
||||
root.put("thinker", modelToJson(cfg.thinker))
|
||||
root.put("presets", org.json.JSONArray().apply {
|
||||
cfg.presets.forEach { p ->
|
||||
put(JSONObject().apply {
|
||||
put("name", p.name)
|
||||
put("temperature", p.temperature)
|
||||
put("top_p", p.topP)
|
||||
put("top_k", p.topK)
|
||||
put("repeat_penalty", p.repeatPenalty)
|
||||
put("presence_penalty", p.presencePenalty)
|
||||
put("frequency_penalty", p.frequencyPenalty)
|
||||
put("max_tokens", p.maxTokens)
|
||||
})
|
||||
root.put("thinker", JSONObject().apply {
|
||||
put("model_id", cfg.thinker.modelId)
|
||||
put("system_prompt", cfg.thinker.systemPrompt)
|
||||
put("temperature", cfg.thinker.temperature)
|
||||
}
|
||||
})
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
private fun modelToJson(m: ModelConfig) = JSONObject().apply {
|
||||
put("model_id", m.modelId)
|
||||
put("system_prompt", m.systemPrompt)
|
||||
put("temperature", m.temperature)
|
||||
put("top_p", m.topP)
|
||||
put("top_k", m.topK)
|
||||
put("repeat_penalty", m.repeatPenalty)
|
||||
put("presence_penalty", m.presencePenalty)
|
||||
put("frequency_penalty", m.frequencyPenalty)
|
||||
put("max_tokens", m.maxTokens)
|
||||
put("preset_name", m.presetName)
|
||||
}
|
||||
|
||||
private fun modelFromJson(js: JSONObject, d: ModelConfig) = ModelConfig(
|
||||
modelId = js.optString("model_id", d.modelId),
|
||||
systemPrompt = js.optString("system_prompt", d.systemPrompt),
|
||||
temperature = js.optDouble("temperature", d.temperature.toDouble()).toFloat(),
|
||||
topP = js.optDouble("top_p", d.topP.toDouble()).toFloat(),
|
||||
topK = js.optInt("top_k", d.topK),
|
||||
repeatPenalty = js.optDouble("repeat_penalty", d.repeatPenalty.toDouble()).toFloat(),
|
||||
presencePenalty = js.optDouble("presence_penalty", d.presencePenalty.toDouble()).toFloat(),
|
||||
frequencyPenalty = js.optDouble("frequency_penalty", d.frequencyPenalty.toDouble()).toFloat(),
|
||||
maxTokens = js.optInt("max_tokens", d.maxTokens),
|
||||
presetName = js.optString("preset_name", d.presetName)
|
||||
)
|
||||
|
||||
private fun fromJson(text: String): RuntimeConfig {
|
||||
val root = JSONObject(text)
|
||||
val defaults = defaultConfig()
|
||||
|
|
@ -198,20 +269,25 @@ class ConfigStore private constructor(private val file: File) {
|
|||
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
||||
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
|
||||
ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
|
||||
speaker = root.optJSONObject("speaker")?.let { js ->
|
||||
ModelConfig(
|
||||
modelId = js.optString("model_id", defaults.speaker.modelId),
|
||||
systemPrompt = js.optString("system_prompt", defaults.speaker.systemPrompt),
|
||||
temperature = js.optDouble("temperature", defaults.speaker.temperature.toDouble()).toFloat()
|
||||
debugEnabled = root.optBoolean("debug_enabled", defaults.debugEnabled),
|
||||
speaker = root.optJSONObject("speaker")?.let { modelFromJson(it, defaults.speaker) } ?: defaults.speaker,
|
||||
thinker = root.optJSONObject("thinker")?.let { modelFromJson(it, defaults.thinker) } ?: defaults.thinker,
|
||||
presets = root.optJSONArray("presets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { js ->
|
||||
SamplingPreset(
|
||||
name = js.optString("name", ""),
|
||||
temperature = js.optDouble("temperature", 0.7).toFloat(),
|
||||
topP = js.optDouble("top_p", 0.85).toFloat(),
|
||||
topK = js.optInt("top_k", 40),
|
||||
repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(),
|
||||
presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(),
|
||||
frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(),
|
||||
maxTokens = js.optInt("max_tokens", 120)
|
||||
)
|
||||
} ?: defaults.speaker,
|
||||
thinker = root.optJSONObject("thinker")?.let { js ->
|
||||
ModelConfig(
|
||||
modelId = js.optString("model_id", defaults.thinker.modelId),
|
||||
systemPrompt = js.optString("system_prompt", defaults.thinker.systemPrompt),
|
||||
temperature = js.optDouble("temperature", defaults.thinker.temperature.toDouble()).toFloat()
|
||||
)
|
||||
} ?: defaults.thinker
|
||||
}
|
||||
}.filter { it.name.isNotBlank() }
|
||||
} ?: defaults.presets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,27 +54,11 @@ object ModelRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
// Catalogue refonte 2026-06-17 (cf dist/LLM_INTEGRATION.md §2). Chargement unifié
|
||||
// via UnifiedLlmAdapter (magic-byte) : GGUF→CPU i8mm, .pte→NPU V79 (libkazeia_pte).
|
||||
// .pte en SOUS-DOSSIER <id>/hybrid_llama_qnn.pte (+ tokenizer.json) : le nom du
|
||||
// dossier porte l'auto-détection decoder_model_version (qwen3/qwen2_5) côté natif.
|
||||
val ALL: List<ModelInfo> = listOf(
|
||||
ModelInfo(
|
||||
id = "qwen3-4b-seq512",
|
||||
displayName = "Qwen3-4B (seq=512)",
|
||||
pteFile = "hybrid_llama_qnn_4b.pte",
|
||||
tokenizerFile = "tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 3000,
|
||||
maxSeqLen = 512,
|
||||
notes = "Speaker historique, budget contexte serré"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen3-4b-seq1024",
|
||||
displayName = "Qwen3-4B (seq=1024) ★",
|
||||
pteFile = "hybrid_llama_qnn_4b_seq1024.pte",
|
||||
tokenizerFile = "tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 3000,
|
||||
maxSeqLen = 1024,
|
||||
notes = "Speaker recommandé — production stable 2026-05-14"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen3.5-4b",
|
||||
displayName = "Qwen3.5-4B (GGUF) ★",
|
||||
|
|
@ -83,43 +67,58 @@ object ModelRegistry {
|
|||
role = Role.SPEAKER,
|
||||
sizeMb = 2380,
|
||||
maxSeqLen = 4096,
|
||||
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.",
|
||||
notes = "Speaker PAR DÉFAUT — hybride GatedDeltaNet (non exportable .pte), GGUF CPU i8mm. Thinking-off.",
|
||||
backend = Backend.GGUF,
|
||||
ggufFile = "q35-lmq4.gguf",
|
||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen2.5-7b-instruct",
|
||||
displayName = "Qwen2.5-7B Instruct (GGUF)",
|
||||
pteFile = "",
|
||||
tokenizerFile = "",
|
||||
id = "qwen3-4b",
|
||||
displayName = "Qwen3-4B (.pte NPU)",
|
||||
pteFile = "qwen3_4b_seq1024/hybrid_llama_qnn.pte",
|
||||
tokenizerFile = "qwen3_4b_seq1024/tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 4683,
|
||||
maxSeqLen = 4096,
|
||||
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.",
|
||||
backend = Backend.GGUF,
|
||||
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||
sizeMb = 3100,
|
||||
maxSeqLen = 1024,
|
||||
notes = "Dense Qwen3-4B sur NPU. Prefill 313 tok/s, RAM ÷2, sans root. Decode 15.7.",
|
||||
backend = Backend.PTE,
|
||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen3-8b",
|
||||
displayName = "Qwen3-8B (.pte NPU)",
|
||||
pteFile = "qwen3_8b_seq512/hybrid_llama_qnn.pte",
|
||||
tokenizerFile = "qwen3_8b_seq512/tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 5500,
|
||||
maxSeqLen = 512,
|
||||
notes = "Dense Qwen3-8B sur NPU. Decode 10.5, prefill 219, RAM ~3.1 G.",
|
||||
backend = Backend.PTE,
|
||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen2.5-7b",
|
||||
displayName = "Qwen2.5-7B (.pte NPU)",
|
||||
pteFile = "qwen2_5_7b_seq512/hybrid_llama_qnn.pte",
|
||||
tokenizerFile = "qwen2_5_7b_seq512/tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 5000,
|
||||
maxSeqLen = 512,
|
||||
notes = "Dense Qwen2.5-7B sur NPU (decoder qwen2_5). Decode 8.0 (embeddings non-tied), RAM ~5.1 G.",
|
||||
backend = Backend.PTE,
|
||||
chatTemplate = ChatTemplate.PLAIN_CHATML
|
||||
),
|
||||
ModelInfo(
|
||||
id = "guard06b",
|
||||
displayName = "Qwen3Guard 0.6B",
|
||||
pteFile = "hybrid_llama_qnn_guard06b.pte",
|
||||
tokenizerFile = "tokenizer_guard06b.json",
|
||||
role = Role.THINKER,
|
||||
sizeMb = 672,
|
||||
maxSeqLen = 512,
|
||||
notes = "Thinker léger, ION ~700 MB, cascade safe"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "guard4b",
|
||||
displayName = "Qwen3Guard 4B",
|
||||
pteFile = "hybrid_llama_qnn_guard4b.pte",
|
||||
tokenizerFile = "tokenizer_guard.json",
|
||||
displayName = "Qwen3Guard 4B (.pte NPU)",
|
||||
pteFile = "guard4b/hybrid_llama_qnn.pte",
|
||||
tokenizerFile = "guard4b/tokenizer.json",
|
||||
role = Role.THINKER,
|
||||
sizeMb = 3000,
|
||||
sizeMb = 3100,
|
||||
maxSeqLen = 512,
|
||||
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM."
|
||||
notes = "Thinker cascade sur NPU. Decode 17.2, prefill 166. Guard-4B + Speaker tiennent en 16 G.",
|
||||
backend = Backend.PTE,
|
||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -127,11 +126,12 @@ object ModelRegistry {
|
|||
|
||||
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
|
||||
|
||||
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte
|
||||
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque.
|
||||
// Défaut Speaker = Qwen3.5-4B GGUF (hybride, décision refonte 2026-06-17).
|
||||
// Repli : tout autre Speaker présent sur disque.
|
||||
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
|
||||
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
|
||||
|
||||
fun defaultThinker(): ModelInfo = byId("guard06b")
|
||||
// Défaut Thinker (cascade) = Guard-4B .pte NPU.
|
||||
fun defaultThinker(): ModelInfo = byId("guard4b")
|
||||
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ data class SamplingParams(
|
|||
val temperature: Float = 0.7f,
|
||||
val topP: Float = 0.85f,
|
||||
val topK: Int = 40,
|
||||
val repetitionPenalty: Float = 1.2f
|
||||
val repetitionPenalty: Float = 1.2f,
|
||||
// Pénalités OpenAI-style (présence/fréquence). Réglables via les presets admin.
|
||||
// ⚠ effectives seulement quand le moteur natif expose le sampling (cf. docs
|
||||
// SAMPLING_ENGINE_SPEC) ; aujourd'hui le JNI n'honore que maxNewTokens.
|
||||
val presencePenalty: Float = 0.0f,
|
||||
val frequencyPenalty: Float = 0.0f
|
||||
)
|
||||
|
||||
data class GenerationResult(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
|||
private var inputKind: String = ""
|
||||
private var inputPayload: String = ""
|
||||
|
||||
/** Récepteur optionnel du récap par tour (1 ligne lisible). Branché par
|
||||
* KazeiaService sur son `log()` → visible dans le panneau debug patient,
|
||||
* pas seulement dans logcat. Découple PipelineMetrics du service. */
|
||||
@Volatile var summarySink: ((String) -> Unit)? = null
|
||||
|
||||
/** Récepteur du bloc temps COMPLET (multi-lignes, 1 string) pour un encart
|
||||
* debug PERSISTANT (vs les logs qui défilent). Mis à jour à chaque fin de tour. */
|
||||
@Volatile var timingsSink: ((String) -> Unit)? = null
|
||||
|
||||
fun begin(kind: String, payload: String) {
|
||||
t0 = System.currentTimeMillis()
|
||||
inputKind = kind
|
||||
|
|
@ -94,29 +103,67 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
|||
.append('\n')
|
||||
}
|
||||
|
||||
// Derived KPIs that matter for perceived latency.
|
||||
// Derived KPIs that matter for perceived latency. Per-stage durations
|
||||
// (and not just cumulative timestamps) so STT / Thinker / Speaker / TTS
|
||||
// are each isolable.
|
||||
val sttStart = firstT("STT.start") ?: 0L
|
||||
val sttEnd = firstT("STT.end")
|
||||
val thinkerStart = firstT("THINKER.start")
|
||||
val thinkerEnd = firstT("THINKER.end")
|
||||
val llmStart = firstT("LLM.start")
|
||||
val llmFirstTok = firstT("LLM.first_token")
|
||||
val llmEnd = firstT("LLM.end")
|
||||
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
|
||||
val lastAudio = lastT("TTS.seg.play_end")
|
||||
|
||||
sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----")
|
||||
if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd")
|
||||
if (llmStart != null && llmFirstTok != null)
|
||||
sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}")
|
||||
if (llmStart != null && llmEnd != null)
|
||||
sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}")
|
||||
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long")
|
||||
if (firstAudio != null && lastAudio != null)
|
||||
sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}")
|
||||
sb.appendLine("[PIPELINE] Total wall time : $total")
|
||||
val sttDur = sttEnd?.let { it - sttStart }
|
||||
val thinkerDur = if (thinkerStart != null && thinkerEnd != null) thinkerEnd - thinkerStart else null
|
||||
val speakerTtft = if (llmStart != null && llmFirstTok != null) llmFirstTok - llmStart else null
|
||||
val speakerGen = if (llmStart != null && llmEnd != null) llmEnd - llmStart else null
|
||||
// TTS « synthèse 1ʳᵉ phrase » = Speaker fini → 1er son audible.
|
||||
val ttsSynth = if (llmEnd != null && firstAudio != null) firstAudio - llmEnd else null
|
||||
val playback = if (firstAudio != null && lastAudio != null) lastAudio - firstAudio else null
|
||||
|
||||
sb.appendLine("[PIPELINE] ---- KPIs (durées par étape, ms) ----")
|
||||
if (sttDur != null) sb.appendLine("[PIPELINE] STT : $sttDur")
|
||||
if (thinkerDur != null) sb.appendLine("[PIPELINE] Thinker (cascade) : $thinkerDur")
|
||||
if (speakerTtft != null) sb.appendLine("[PIPELINE] Speaker time-to-1st-tok: $speakerTtft")
|
||||
if (speakerGen != null) sb.appendLine("[PIPELINE] Speaker génération : $speakerGen")
|
||||
if (ttsSynth != null) sb.appendLine("[PIPELINE] TTS → 1er son : $ttsSynth")
|
||||
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← attente perçue")
|
||||
if (playback != null) sb.appendLine("[PIPELINE] Lecture audio : $playback")
|
||||
sb.appendLine("[PIPELINE] Total : $total")
|
||||
|
||||
// Single log call so it shows up contiguously even if the logger
|
||||
// interleaves other threads.
|
||||
Log.i(tag, sb.toString().trimEnd())
|
||||
|
||||
// Bloc temps par étape (ne liste que les étapes réellement mesurées).
|
||||
// Construit une fois, envoyé aux DEUX puits : summarySink (flot de logs) et
|
||||
// timingsSink (encart persistant qui ne défile pas).
|
||||
// GÉNÉRATION seule : on ne compte que le temps pour PRODUIRE la réponse
|
||||
// (STT · Thinker · Speaker · TTS jusqu'au 1er son). La durée de LECTURE
|
||||
// audio est exclue (ce n'est pas de la génération, elle dépend de la
|
||||
// longueur du texte). Les lignes s'additionnent exactement au TOTAL gén.
|
||||
// Le TTFT est un REPÈRE inclus dans Speaker, pas un poste à additionner.
|
||||
run {
|
||||
val b = StringBuilder("⏱─── GÉNÉRATION ($status) ───\n")
|
||||
fun row(name: String, value: String) { b.append(" ${name.padEnd(11)} $value\n") }
|
||||
var gen = 0L
|
||||
if (sttDur != null) { row("STT", "$sttDur ms"); gen += sttDur }
|
||||
if (thinkerDur != null) { row("Thinker", "$thinkerDur ms"); gen += thinkerDur }
|
||||
if (speakerGen != null) {
|
||||
row("Speaker", "$speakerGen ms" + (speakerTtft?.let { " (dont 1er token $it)" } ?: ""))
|
||||
gen += speakerGen
|
||||
}
|
||||
if (ttsSynth != null) { row("TTS→son", "$ttsSynth ms"); gen += ttsSynth }
|
||||
b.append(" ──────────\n")
|
||||
b.append(" ${"TOTAL gén.".padEnd(11)} $gen ms")
|
||||
val block = b.toString()
|
||||
runCatching { timingsSink?.invoke(block) }
|
||||
summarySink?.let { sink -> block.lineSequence().forEach { runCatching { sink(it) } } }
|
||||
}
|
||||
|
||||
// Push a structured snapshot to TelemetryHolder so the admin app
|
||||
// can pull it via ContentProvider. Best-effort — if anything goes
|
||||
// wrong here it must not affect the pipeline.
|
||||
|
|
@ -129,15 +176,14 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
|||
inputKind = inputKind,
|
||||
status = status,
|
||||
totalMs = total,
|
||||
sttDurationMs = sttEnd,
|
||||
llmTtftMs = if (llmStart != null && llmFirstTok != null)
|
||||
llmFirstTok - llmStart else null,
|
||||
llmGenerationMs = if (llmStart != null && llmEnd != null)
|
||||
llmEnd - llmStart else null,
|
||||
sttDurationMs = sttDur,
|
||||
thinkerMs = thinkerDur,
|
||||
llmTtftMs = speakerTtft,
|
||||
llmGenerationMs = speakerGen,
|
||||
llmTokens = tokensMatch,
|
||||
ttsSynthMs = ttsSynth,
|
||||
timeToFirstAudioMs = firstAudio,
|
||||
audioPlaybackMs = if (firstAudio != null && lastAudio != null)
|
||||
lastAudio - firstAudio else null
|
||||
audioPlaybackMs = playback
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,17 @@ interface TtsEngine {
|
|||
text: String,
|
||||
language: String = "fr",
|
||||
onStart: (() -> Unit)? = null,
|
||||
onComplete: (() -> Unit)? = null
|
||||
onComplete: (() -> Unit)? = null,
|
||||
// Émis quand CHAQUE phrase commence à jouer, avec son texte, sa durée
|
||||
// audio, et les features visuelles (enveloppe RMS + spectrogramme N
|
||||
// bandes) calculées sur son PCM. Pilote l'affichage progressif de la
|
||||
// bulle et l'orbe AudioVisualizerView. Null = pas de retour par phrase.
|
||||
onSegment: ((
|
||||
sentence: String,
|
||||
durationMs: Long,
|
||||
rmsEnvelope: FloatArray,
|
||||
spectrogram: Array<FloatArray>
|
||||
) -> Unit)? = null
|
||||
)
|
||||
fun stop()
|
||||
fun release()
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ class ProvisioningManager(context: Context) {
|
|||
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
|
||||
*/
|
||||
fun hasCoreModels(): Boolean {
|
||||
val llm = File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b.pte").exists() ||
|
||||
File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b_seq1024.pte").exists()
|
||||
val talker = File(KazeiaPaths.modelsDir, "talker_f32.gguf").exists() ||
|
||||
File(KazeiaPaths.modelsDir, "talker_f16.gguf").exists()
|
||||
// Cœur post-refonte 2026-06-17 : Speaker GGUF (q35-lmq4) + Whisper STT.
|
||||
// (Le LLM .pte est devenu une alternative admin ; le TTS est provisionné
|
||||
// à part — cf #286 productisation CosyVoice.)
|
||||
val llm = File(KazeiaPaths.modelsDir, "q35-lmq4.gguf").exists()
|
||||
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
|
||||
return llm && talker && whisper
|
||||
return llm && whisper
|
||||
}
|
||||
|
||||
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
/**
|
||||
* État live des mises à jour, piloté à la demande depuis l'app admin (plus
|
||||
* d'update automatique au lancement — cf OnboardingActivity).
|
||||
*
|
||||
* Singleton en mémoire : le [UpdateWorker] (process patient) écrit, le
|
||||
* ContentProvider lit (même process). Si le process meurt entre deux étapes,
|
||||
* l'état repart à IDLE — l'admin n'a qu'à relancer « Vérifier ». Suffisant
|
||||
* pour le flux check → poll → install → poll piloté côté admin.
|
||||
*/
|
||||
object UpdateStatus {
|
||||
enum class Phase { IDLE, CHECKING, AVAILABLE, UPTODATE, DOWNLOADING, INSTALLING, DONE, ERROR }
|
||||
|
||||
@Volatile var phase: Phase = Phase.IDLE
|
||||
@Volatile var appUpdateAvailable: Boolean = false
|
||||
@Volatile var appVersionName: String = ""
|
||||
@Volatile var appSize: Long = 0L
|
||||
@Volatile var contentCount: Int = 0 // composants modèles/voix à (re)télécharger
|
||||
@Volatile var contentSize: Long = 0L
|
||||
@Volatile var progressDone: Long = 0L
|
||||
@Volatile var progressTotal: Long = 0L
|
||||
@Volatile var label: String = ""
|
||||
@Volatile var error: String = ""
|
||||
@Volatile var lastCheck: Long = 0L
|
||||
|
||||
fun resetForRun() {
|
||||
progressDone = 0L; progressTotal = 0L; label = ""; error = ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
/**
|
||||
* Worker de mise à jour À LA DEMANDE (déclenché par l'app admin via le
|
||||
* ContentProvider — `call("update_check")` / `call("update_install")`).
|
||||
*
|
||||
* - mode CHECK : récupère le catalogue, calcule ce qui est dispo (APK +
|
||||
* composants modèles/voix périmés) → publie dans [UpdateStatus].
|
||||
* - mode INSTALL : télécharge les composants périmés, puis (si dispo) télécharge
|
||||
* l'APK et lance l'installation système (PackageInstaller).
|
||||
*
|
||||
* Réutilise [ProvisioningManager] (contenu) et [ApkUpdater] (APK). Aucune
|
||||
* mise à jour automatique au lancement : tout passe par ici.
|
||||
*/
|
||||
class UpdateWorker(
|
||||
private val appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val manager = ProvisioningManager(appContext)
|
||||
private val apk = ApkUpdater(appContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val install = inputData.getString(KEY_MODE) == MODE_INSTALL
|
||||
UpdateStatus.resetForRun()
|
||||
UpdateStatus.phase = UpdateStatus.Phase.CHECKING
|
||||
|
||||
val catalog = manager.fetchCatalog() ?: run {
|
||||
UpdateStatus.phase = UpdateStatus.Phase.ERROR
|
||||
UpdateStatus.error = "Serveur de mise à jour injoignable."
|
||||
return Result.failure()
|
||||
}
|
||||
val voices = manager.fetchVoicesCatalog()
|
||||
|
||||
val appUpd = apk.availableUpdate(catalog)
|
||||
val pending = manager.pending(catalog) + (voices?.let { manager.pending(it) } ?: emptyList())
|
||||
|
||||
UpdateStatus.appUpdateAvailable = appUpd != null
|
||||
UpdateStatus.appVersionName = appUpd?.versionName ?: ""
|
||||
UpdateStatus.appSize = appUpd?.size ?: 0L
|
||||
UpdateStatus.contentCount = pending.size
|
||||
UpdateStatus.contentSize = pending.sumOf { it.totalSize }
|
||||
UpdateStatus.lastCheck = System.currentTimeMillis()
|
||||
|
||||
if (!install) {
|
||||
UpdateStatus.phase = if (appUpd == null && pending.isEmpty())
|
||||
UpdateStatus.Phase.UPTODATE else UpdateStatus.Phase.AVAILABLE
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// ── INSTALL ──────────────────────────────────────────────────────────
|
||||
// 1. Contenu (modèles + voix), s'il y en a.
|
||||
if (pending.isNotEmpty()) {
|
||||
UpdateStatus.phase = UpdateStatus.Phase.DOWNLOADING
|
||||
val res = manager.provision(catalog) { p ->
|
||||
UpdateStatus.label = p.componentLabel
|
||||
UpdateStatus.progressDone = p.overallDone
|
||||
UpdateStatus.progressTotal = p.overallTotal
|
||||
}
|
||||
if (res is ProvisioningManager.Outcome.Failed) {
|
||||
UpdateStatus.phase = UpdateStatus.Phase.ERROR
|
||||
UpdateStatus.error = res.reason
|
||||
return Result.failure()
|
||||
}
|
||||
if (voices != null) manager.provision(voices) { p ->
|
||||
UpdateStatus.label = p.componentLabel
|
||||
UpdateStatus.progressDone = p.overallDone
|
||||
UpdateStatus.progressTotal = p.overallTotal
|
||||
}
|
||||
}
|
||||
|
||||
// 2. APK — téléchargement + installation système (dialogue PackageInstaller).
|
||||
if (appUpd != null) {
|
||||
UpdateStatus.phase = UpdateStatus.Phase.INSTALLING
|
||||
UpdateStatus.label = "Téléchargement de l'application v${appUpd.versionName}…"
|
||||
val file = apk.downloadApk(appUpd) { done, total ->
|
||||
UpdateStatus.progressDone = done; UpdateStatus.progressTotal = total
|
||||
}
|
||||
if (file == null) {
|
||||
UpdateStatus.phase = UpdateStatus.Phase.ERROR
|
||||
UpdateStatus.error = "Échec du téléchargement de l'APK."
|
||||
return Result.failure()
|
||||
}
|
||||
UpdateStatus.label = "Installation — confirmez le dialogue système."
|
||||
apk.install(file) // ouvre le dialogue d'installation sur la tablette
|
||||
}
|
||||
|
||||
UpdateStatus.phase = UpdateStatus.Phase.DONE
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "kazeia_update"
|
||||
const val KEY_MODE = "mode"
|
||||
const val MODE_CHECK = "check"
|
||||
const val MODE_INSTALL = "install"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
// Harness LLM ADB-pilotable, mirror de BenchQnnHarness STT.
|
||||
// Harness LLM ADB-pilotable (bench LLM in-app).
|
||||
// Déclenchement : `touch <files>/bench_llm.trigger`, relance app.
|
||||
// Charge EngineLlmEngine (lib CPU-only) + génère 3 prompts FR, log texte + tps + nThreads honoré.
|
||||
// Valide le chemin refonte 2026-06-17 : UnifiedLlmAdapter (magic-byte) sur
|
||||
// 1) le GGUF par défaut (Qwen3.5-4B, CPU i8mm + session cache-préfixe)
|
||||
// 2) CHAQUE .pte présent sur disque (NPU V79 : libkazeia_pte + tokeniseur DJL arm64)
|
||||
// Log : texte généré + prefill/decode + tok/s, pour chaque modèle.
|
||||
package com.kazeia.llm
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.kazeia.KazeiaApplication
|
||||
import com.kazeia.config.ModelRegistry
|
||||
import com.kazeia.core.SamplingParams
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
object BenchLlmHarness {
|
||||
private const val TAG = "BenchLlm"
|
||||
|
|
@ -16,30 +21,61 @@ object BenchLlmHarness {
|
|||
"Je n'arrive plus à parler à ma femme"
|
||||
)
|
||||
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
|
||||
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple."
|
||||
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. /no_think"
|
||||
|
||||
fun runBench(ctx: Context) = runBlocking {
|
||||
Log.i(TAG, "########## BENCH LLM (UnifiedLlmAdapter, refonte 2026-06-17) ##########")
|
||||
|
||||
// Modèles à tester : GGUF par défaut + tous les .pte présents sur disque.
|
||||
// ORDRE DIAG (2026-06-18) : on charge les gros .pte multi-contextes (8B/7B) EN PREMIER,
|
||||
// dans un process frais, pour isoler "contention in-process après modèles précédents"
|
||||
// vs "limite in-app fondamentale". Trigger fichier bench_llm_pte_first pour activer.
|
||||
val pteFirst = java.io.File(ctx.getExternalFilesDir(null), "bench_llm_pte_first").exists()
|
||||
val models = ModelRegistry.availableOnDisk().let { all ->
|
||||
if (pteFirst) all.sortedBy { m ->
|
||||
when (m.id) { "qwen3-8b" -> 0; "qwen2.5-7b" -> 1; "qwen3-4b" -> 2; "guard4b" -> 3; else -> 9 }
|
||||
} else all
|
||||
}
|
||||
if (pteFirst) Log.i(TAG, ">>> ORDRE DIAG : .pte multi-contexte EN PREMIER (process frais)")
|
||||
if (models.isEmpty()) { Log.e(TAG, "aucun modèle sur disque (ni GGUF ni .pte)"); return@runBlocking }
|
||||
|
||||
for (m in models) {
|
||||
val path = if (m.backend == ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath()
|
||||
val tkPath = if (m.backend == ModelRegistry.Backend.GGUF) null else m.tokenizerPath()
|
||||
Log.i(TAG, "===== ${m.id} (${m.backend}, seq=${m.maxSeqLen}) : $path =====")
|
||||
|
||||
fun runBench(ctx: Context) {
|
||||
val gguf = "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
|
||||
Log.i(TAG, "=== load q35-lmq4 ===")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val eng = try {
|
||||
EngineLlmEngine(gguf, ctx = 2048, nThreads = 6)
|
||||
UnifiedLlmAdapter(
|
||||
modelPath = path,
|
||||
systemPrompt = SYS,
|
||||
ctx = m.maxSeqLen,
|
||||
nThreads = 6,
|
||||
tokenizerPath = tkPath,
|
||||
onLog = { Log.i(TAG, it) }
|
||||
).also { it.load(path, com.kazeia.core.LlmConfig()) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "load FAIL: ${e.message}"); return
|
||||
Log.e(TAG, " LOAD FAIL ${m.id}: ${e.message}", e); continue
|
||||
}
|
||||
Log.i(TAG, "load OK (${System.currentTimeMillis() - t0}ms)")
|
||||
// warmup
|
||||
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {}
|
||||
Log.i(TAG, " load OK (${System.currentTimeMillis() - t0}ms)")
|
||||
|
||||
// warmup (1 tour court, hors mesure)
|
||||
try { eng.generate("Bonjour", SamplingParams(maxNewTokens = 16)) { true } } catch (_: Throwable) {}
|
||||
|
||||
for ((i, p) in PROMPTS.withIndex()) {
|
||||
val ts = System.currentTimeMillis()
|
||||
val out = try { eng.generate(SYS, p, 96) } catch (e: Exception) { "[ERR ${e.message}]" }
|
||||
val r = try {
|
||||
eng.generate(p, SamplingParams(maxNewTokens = 96, temperature = 0.7f)) { true }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, " [${i + 1}] gen FAIL: ${e.message}", e); continue
|
||||
}
|
||||
val ms = System.currentTimeMillis() - ts
|
||||
val nWords = out.trim().split(Regex("\\s+")).size
|
||||
val tps = if (ms > 0) nWords * 1000f / ms else 0f
|
||||
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
|
||||
Log.i(TAG, " '${out.trim()}'")
|
||||
Log.i(TAG, " [${i + 1}/${PROMPTS.size}] '$p' → ${ms}ms (${"%.1f".format(r.tokensPerSecond)} tok/s)")
|
||||
Log.i(TAG, " « ${r.text} »")
|
||||
}
|
||||
eng.release()
|
||||
Log.i(TAG, "=== Bench end ===")
|
||||
Log.i(TAG, "===== fin ${m.id} =====")
|
||||
}
|
||||
Log.i(TAG, "########## BENCH LLM end ##########")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import com.kazeia.core.GenerationResult
|
||||
import com.kazeia.core.LlmConfig
|
||||
import com.kazeia.core.LlmEngine
|
||||
import com.kazeia.core.SamplingParams
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine,
|
||||
* GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon)
|
||||
* derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible
|
||||
* piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF).
|
||||
*
|
||||
* Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs.
|
||||
* Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")`
|
||||
* jette UnsatisfiedLinkError au 1er accès à `EngineJni` → KazeiaService doit catch
|
||||
* et tomber en repli sur le path prod.
|
||||
*
|
||||
* Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod).
|
||||
*/
|
||||
class EngineLlmAdapter(
|
||||
private val modelPath: String,
|
||||
private val systemPrompt: String,
|
||||
private val ctxLen: Int = 4096,
|
||||
private val nThreads: Int = 6,
|
||||
// true = ChatML standard sans <think> (Qwen2.5…) via generateRaw ;
|
||||
// false = bloc thinking-off Qwen3.5 injecté côté natif (generate).
|
||||
private val plainChatml: Boolean = false,
|
||||
private val onLog: ((String) -> Unit)? = null
|
||||
) : LlmEngine {
|
||||
private var engine: EngineLlmEngine? = null
|
||||
// Session = cache de préfixe KV : le system prompt n'est prefillé QU'UNE fois
|
||||
// (à la création), puis chaque tour ne prefille que le message user → ~2,5×
|
||||
// moins de latence/tour (mesuré ~2991→~1190 ms sur q35-lmq4) + streaming.
|
||||
// Pas créée pour plainChatml (Qwen2.5 = template ChatML différent → generateRaw).
|
||||
// Stateless : reset() avant chaque ask (comportement historique sans mémoire
|
||||
// LLM) — retirer ce reset activerait la mémoire conversationnelle.
|
||||
private var session: LlmSession? = null
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
|
||||
if (engine != null) return@withContext
|
||||
val t0 = System.currentTimeMillis()
|
||||
val e = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
|
||||
engine = e
|
||||
if (!plainChatml) session = e.newSession(systemPrompt) // prefille le system 1×
|
||||
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads} session=${session != null}")
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = engine != null
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val s = session
|
||||
val out: String = if (s != null) {
|
||||
// Cache de préfixe + MÉMOIRE conversationnelle : le system reste prefillé,
|
||||
// chaque ask ne prefille que le user et conserve l'historique en KV.
|
||||
// (NB : on NE reset PAS par tour — sessionReset+ask renvoie vide,
|
||||
// bug natif n_past non restauré ; reset boundary à rétablir côté engine.)
|
||||
val sb = StringBuilder()
|
||||
s.ask(prompt, params.maxNewTokens) { piece ->
|
||||
sb.append(piece); onToken?.invoke(piece) ?: true
|
||||
}
|
||||
sb.toString().trim()
|
||||
} else {
|
||||
// plainChatml (Qwen2.5) : ChatML manuel, generateRaw bloquant.
|
||||
val p = buildString {
|
||||
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>assistant\n")
|
||||
}
|
||||
e.generateRaw(p, params.maxNewTokens).trim().also { onToken?.invoke(it) }
|
||||
}
|
||||
// Timing réel fourni par l'engine (prefill/decode séparés). tps = débit
|
||||
// DÉCODE (fini l'artefact "prefill inclus" qui donnait ~3 tok/s).
|
||||
val st = e.lastStats()
|
||||
val n = if (st.nTokens > 0) st.nTokens.toInt() else out.split(Regex("\\s+")).size
|
||||
val ms: Long = (st.prefillMs + st.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
|
||||
onLog?.invoke("[ENGINE] prefill=${st.prefillMs}ms decode=${st.decodeMs}ms tok=${st.nTokens} (${"%.1f".format(st.decodeTokPerSec)} tok/s decode)")
|
||||
GenerationResult(out, n, ms, st.decodeTokPerSec.toFloat())
|
||||
}
|
||||
|
||||
/** Démarre une conversation VIERGE (garde le system prefillé). À appeler aux
|
||||
* bornes de conversation (changement de patient, effacement du chat) — sinon
|
||||
* la mémoire d'un patient fuiterait vers le suivant. sessionReset natif corrigé
|
||||
* 2026-06-15 (clear + re-prefill system sur archi hybride où le rewind partiel
|
||||
* échoue), donc on l'utilise directement. */
|
||||
fun resetSession() { session?.reset() }
|
||||
|
||||
override fun release() { session = null; engine?.release(); engine = null }
|
||||
}
|
||||
|
|
@ -22,15 +22,7 @@ class EngineJni {
|
|||
external fun sessionReset(h: Long) // repart du checkpoint système
|
||||
external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens]
|
||||
|
||||
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
|
||||
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
|
||||
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
|
||||
// pipeline Talker→CP→Decoder. Retours conventionnels : 0 = OK, négatif = erreur.
|
||||
external fun nEmbd(h: Long): Int // taille d'un embed (1024 pour Talker-0.6B)
|
||||
external fun nVocab(h: Long): Int // 3072 pour le Talker
|
||||
external fun resetEmbeds(h: Long) // KV clear + pos=0, à appeler en début de génération TTS
|
||||
external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int
|
||||
external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int
|
||||
// (Bindings TTS-Talker Qwen3-TTS retirés 2026-06-18 : TTS = CosyVoice, lib dédiée.)
|
||||
|
||||
// -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur.
|
||||
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge).
|
||||
|
|
|
|||
|
|
@ -1,326 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.kazeia.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.pytorch.executorch.extension.llm.LlmCallback
|
||||
import org.pytorch.executorch.extension.llm.LlmModule
|
||||
|
||||
/**
|
||||
* LLM Engine using ExecuTorch LlmModule in-process — **no root required**.
|
||||
*
|
||||
* Runs Qwen3-4B via `org.pytorch.executorch.extension.llm.LlmModule`, which
|
||||
* wraps the same C++ TextLlmRunner as the standalone qnn_llama_runner binary
|
||||
* but inside the app's own process. The QNN HTP backend works because the
|
||||
* DSP fastrpc service accepts the Zygote-forked app process (unlike
|
||||
* ProcessBuilder-spawned subprocesses which lose supplementary GIDs on exec
|
||||
* and get rejected by the fastrpc credential checks).
|
||||
*
|
||||
* Model + tokenizer live in /data/local/tmp/kazeia-et/ (readable by the app
|
||||
* on this device's permissive SELinux policy). libexecutorch.so + QNN libs
|
||||
* are bundled in jniLibs.
|
||||
*
|
||||
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
|
||||
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
|
||||
*/
|
||||
/**
|
||||
* Cascade Option E (in-process multi-LlmModule) :
|
||||
* Le même process app peut instancier 2 ExecuTorchLlmEngine
|
||||
* (Speaker + Thinker) qui partagent automatiquement le `QnnBackendBundle`
|
||||
* via le singleton `QnnBackendUnifiedRegistry` (cf
|
||||
* executorch/backends/qualcomm/runtime/backends/QnnBackendUnifiedRegistry.cpp).
|
||||
* Aucune contention DSP — chaque instance crée son propre `QnnContext` mais
|
||||
* réutilise le même handle fastrpc.
|
||||
*/
|
||||
class ExecuTorchLlmEngine(
|
||||
private val context: Context,
|
||||
private val onLog: ((String) -> Unit)? = null,
|
||||
private val modelPath: String = DEFAULT_MODEL_PATH,
|
||||
private val tokenizerPath: String = DEFAULT_TOKENIZER_PATH,
|
||||
private val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
|
||||
private val temperature: Float = 0.7f,
|
||||
private val displayName: String = "Qwen3-4B Speaker"
|
||||
) : LlmEngine {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExecuTorchLLM"
|
||||
// /no_think disables Qwen3's chain-of-thought block.
|
||||
// UNE OU DEUX phrases courtes (5-8 mots) — l'utilisateur veut des réponses
|
||||
// brèves. Validation émotionnelle, pas de remplissage. La 1ʳᵉ phrase est
|
||||
// synthétisée pendant que la 2ᵉ est générée → audio démarre tôt.
|
||||
const val DEFAULT_SYSTEM_PROMPT = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
|
||||
|
||||
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
|
||||
// sur tablette dev, stockage externe app sinon). Fichier régulier, pas
|
||||
// de symlink : SELinux ColorOS sans Magisk bloque l'accès app aux
|
||||
// symlinks dans /data/local/tmp/. cf feedback_selinux_symlink.
|
||||
// Test 2026-05-13 : Speaker seq=1024 (re-export 2026-05-04).
|
||||
val MODEL_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
|
||||
val DEFAULT_MODEL_PATH: String get() = "$MODEL_DIR/hybrid_llama_qnn_4b_seq1024.pte"
|
||||
val DEFAULT_TOKENIZER_PATH: String get() = "$MODEL_DIR/tokenizer.json"
|
||||
// seqLen = budget TOTAL (prefill + decode). Doit correspondre au
|
||||
// max_seq_len avec lequel le .pte a été compilé.
|
||||
const val MODEL_SEQ_LEN = 1024
|
||||
}
|
||||
|
||||
private var llmModule: LlmModule? = null
|
||||
private var modelName = ""
|
||||
private var loaded = false
|
||||
|
||||
private fun nlog(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
onLog?.invoke("[LLM] $msg")
|
||||
}
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!File(this@ExecuTorchLlmEngine.modelPath).exists()) {
|
||||
nlog("ERROR: model not found at ${this@ExecuTorchLlmEngine.modelPath}")
|
||||
return@withContext
|
||||
}
|
||||
if (!File(tokenizerPath).exists()) {
|
||||
nlog("ERROR: tokenizer not found at $tokenizerPath")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
val t0 = System.currentTimeMillis()
|
||||
// MODEL_TYPE_QNN_LLAMA=4 selects the Qualcomm runner path in
|
||||
// jni_layer_llama.cpp, which uses example::Runner (same code
|
||||
// as the qnn_llama_runner binary) instead of the generic
|
||||
// TextLLMRunner. Our .pte was exported with
|
||||
// --decoder_model qwen3-4b which requires this path.
|
||||
val MODEL_TYPE_QNN_LLAMA = 4
|
||||
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, this@ExecuTorchLlmEngine.modelPath, tokenizerPath, temperature)
|
||||
nlog("[$displayName] LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
|
||||
|
||||
// Load the PTE into QNN HTP (calls the native load()).
|
||||
val loadResult = llmModule!!.load()
|
||||
if (loadResult != 0) {
|
||||
nlog("ERROR: LlmModule.load() returned $loadResult")
|
||||
llmModule = null
|
||||
return@withContext
|
||||
}
|
||||
nlog("[$displayName] LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
|
||||
|
||||
loaded = true
|
||||
modelName = displayName
|
||||
nlog("[$displayName] Ready")
|
||||
} catch (e: Throwable) {
|
||||
nlog("ERROR: LlmModule init failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
llmModule = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = loaded && llmModule != null
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = generateWithSystem(prompt, null, params, onToken, tag = "SPEAKER")
|
||||
|
||||
/**
|
||||
* Cascade Option B : permet de passer un system prompt différent par appel
|
||||
* (par ex. SYS_THINKER pour la passe d'analyse, puis le system prompt par
|
||||
* défaut pour la passe Speaker). `null` = utilise le `systemPrompt`
|
||||
* d'instance. Le `tag` préfixe les logs pour distinguer les passes
|
||||
* cascadées dans logcat.
|
||||
*/
|
||||
suspend fun generateWithSystem(
|
||||
prompt: String,
|
||||
systemPromptOverride: String?,
|
||||
params: SamplingParams = SamplingParams(),
|
||||
onToken: ((String) -> Boolean)? = null,
|
||||
tag: String = "LLM"
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
val mod = llmModule ?: throw IllegalStateException("Model not loaded")
|
||||
|
||||
// Logger local taggué pour cette passe (THINKER, SPEAKER, …).
|
||||
val nl: (String) -> Unit = { msg ->
|
||||
Log.i(TAG, "[$tag] $msg")
|
||||
onLog?.invoke("[$tag] $msg")
|
||||
}
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val fullPrompt = buildChatTemplate(prompt, systemPromptOverride ?: systemPrompt)
|
||||
nl("Prompt: '${prompt.take(80)}'")
|
||||
|
||||
// Clear KV cache → cur_pos_ = 0. Indispensable pour le cascade B :
|
||||
// sans ça, le cur_pos_ accumule entre passes (Thinker → Speaker) et
|
||||
// entre tours, et l'assertion ExecuTorch
|
||||
// `cur_pos_+num_prompt_tokens < seq_len` finit par exploser après
|
||||
// 2-3 messages. Le prompt est repassé en entier à chaque appel donc
|
||||
// clear le KV n'altère pas le contexte conversationnel.
|
||||
try { mod.resetContext() } catch (e: Throwable) {
|
||||
nl("WARN: resetContext() failed: ${e.message}")
|
||||
}
|
||||
|
||||
val responseBuilder = StringBuilder()
|
||||
var firstTokenMs = -1L
|
||||
// Track whether we're inside a <think>…</think> block so the upstream
|
||||
// SentenceStreamer / TTS doesn't get fed reasoning tokens. Even with
|
||||
// /no_think in the system prompt Qwen3 still emits empty <think></think>
|
||||
// wrappers for ~3 tokens before the real answer.
|
||||
var inThink = false
|
||||
val tokenScan = StringBuilder() // small lookahead to spot tag boundaries
|
||||
|
||||
// Singleton special tokens that should never reach the TTS streamer
|
||||
// (they leak when the model wraps its reply or signals end-of-turn).
|
||||
val stripTokens = listOf("<|im_start|>", "<|im_end|>", "<|endoftext|>")
|
||||
val maxTagLen = listOf("<think>", "</think>", "<|im_start|>", "<|im_end|>", "<|endoftext|>")
|
||||
.maxOf { it.length }
|
||||
|
||||
val cb = object : LlmCallback {
|
||||
override fun onResult(result: String) {
|
||||
if (firstTokenMs < 0) firstTokenMs = System.currentTimeMillis() - startTime
|
||||
responseBuilder.append(result)
|
||||
|
||||
// Forward to caller only outside <think> blocks, and strip
|
||||
// singleton special tokens. We accumulate a tiny lookahead buffer
|
||||
// so tag tokens that arrive split ("<thi", "nk>") still match.
|
||||
tokenScan.append(result)
|
||||
while (true) {
|
||||
if (!inThink) {
|
||||
val open = tokenScan.indexOf("<think>")
|
||||
if (open < 0) {
|
||||
// No <think> open pending — strip any singleton tokens
|
||||
// that fully landed in the buffer, then flush prose
|
||||
// up to a safe point preserving lookahead.
|
||||
for (tok in stripTokens) {
|
||||
var idx = tokenScan.indexOf(tok)
|
||||
while (idx >= 0) {
|
||||
tokenScan.delete(idx, idx + tok.length)
|
||||
idx = tokenScan.indexOf(tok)
|
||||
}
|
||||
}
|
||||
val safe = tokenScan.length - maxTagLen
|
||||
if (safe > 0) {
|
||||
onToken?.invoke(tokenScan.substring(0, safe))
|
||||
tokenScan.delete(0, safe)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Flush the prose before the <think> tag, then enter think mode.
|
||||
if (open > 0) onToken?.invoke(tokenScan.substring(0, open))
|
||||
tokenScan.delete(0, open + "<think>".length)
|
||||
inThink = true
|
||||
} else {
|
||||
val close = tokenScan.indexOf("</think>")
|
||||
if (close < 0) {
|
||||
// Drop all buffered chars except a small tail in case
|
||||
// the closing tag is split across tokens.
|
||||
val keep = "</think>".length - 1
|
||||
if (tokenScan.length > keep) tokenScan.delete(0, tokenScan.length - keep)
|
||||
break
|
||||
}
|
||||
tokenScan.delete(0, close + "</think>".length)
|
||||
inThink = false
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onStats(stats: String) {
|
||||
nl("stats: ${stats.take(200)}")
|
||||
}
|
||||
}
|
||||
|
||||
// seqLen est le budget TOTAL (prefill + decode) pour QNN runner.
|
||||
// Doit matcher le max_seq_len du .pte compilé (cf MODEL_SEQ_LEN).
|
||||
// Test 2026-05-13 : seq=1024 + reload-after-turn (Speaking state →
|
||||
// release+reload caché derrière TTS playback) pour éviter
|
||||
// l'accumulation cur_pos qui causait l'OOM précédent.
|
||||
val seqLen = MODEL_SEQ_LEN
|
||||
val rc = try {
|
||||
// echo=false so onResult() only receives the generated completion,
|
||||
// not the prompt tokens echoed back — otherwise the sentence
|
||||
// streamer would feed '<|im_start|>user …' to the TTS.
|
||||
mod.generate(fullPrompt, seqLen, cb, /* echo */ false)
|
||||
} catch (e: Throwable) {
|
||||
nl("generate() threw: ${e.message}")
|
||||
-1
|
||||
}
|
||||
|
||||
// Drain any leftover prose buffered during <think>-suppression so the
|
||||
// last sentence reaches the TTS even if it ran past the closing tag.
|
||||
if (!inThink && tokenScan.isNotEmpty()) {
|
||||
onToken?.invoke(tokenScan.toString())
|
||||
tokenScan.clear()
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val rawText = responseBuilder.toString()
|
||||
val responseText = cleanResponse(rawText)
|
||||
val tokenCount = rawText.length / 4 // rough estimate without a tokenizer
|
||||
val rate = if (elapsed > 0) (tokenCount * 1000f) / elapsed else 0f
|
||||
|
||||
nl("Response: '${responseText.take(80)}'")
|
||||
nl("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
|
||||
|
||||
GenerationResult(
|
||||
text = responseText,
|
||||
tokenCount = tokenCount,
|
||||
timeMs = elapsed,
|
||||
tokensPerSecond = rate
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen3 chat template matching qnn_llama_runner.cpp's get_formatted_prompt()
|
||||
* for DecoderModelVersion::kQwen3. Note the user-first-then-system ordering
|
||||
* (quirky but required — the runner binary produces the same layout and our
|
||||
* .pte was trained with it). Terminates with `<|im_start|>assistant` with
|
||||
* no trailing newline, matching the binary exactly.
|
||||
*/
|
||||
private fun buildChatTemplate(userInput: String, sysPrompt: String = systemPrompt): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("<|im_start|>user\n").append(userInput).append("<|im_end|>\n")
|
||||
if (sysPrompt.isNotEmpty()) {
|
||||
sb.append("<|im_start|>system\n").append(sysPrompt).append("<|im_end|>\n")
|
||||
}
|
||||
sb.append("<|im_start|>assistant")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Strip <think>…</think>, special tokens, and leading/trailing whitespace. */
|
||||
private fun cleanResponse(raw: String): String {
|
||||
var text = raw
|
||||
val thinkEnd = text.indexOf("</think>")
|
||||
if (thinkEnd >= 0) {
|
||||
text = text.substring(thinkEnd + "</think>".length)
|
||||
} else if (text.indexOf("<think>") >= 0) {
|
||||
nlog("WARN: <think> block never closed")
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
.replace("<|im_start|>", "")
|
||||
.replace("<|im_end|>", "")
|
||||
.replace("<|endoftext|>", "")
|
||||
.replace("<think>", "")
|
||||
.replace("</think>", "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
try { llmModule?.resetNative() } catch (_: Throwable) {}
|
||||
llmModule = null
|
||||
loaded = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated reload-after-turn workaround. Test 2026-05-13 sur seq=1024 .pte
|
||||
* a montré que (1) resetContext() au début de generate() suffit (cur_pos
|
||||
* remis à 0 via Runner::reset côté C++), et (2) le release()+load()
|
||||
* échoue avec ExecutorchInvalidArgumentException "Failed to load llm runner: [1]"
|
||||
* sur la 2ᵉ instanciation (probable bug singleton QnnBackendUnifiedRegistry).
|
||||
* Conservé pour rétro-compat ; ne plus appeler.
|
||||
*/
|
||||
@Deprecated("Use resetContext() — already called at start of generate()")
|
||||
suspend fun reloadAfterTurn() {
|
||||
release()
|
||||
load("", com.kazeia.core.LlmConfig())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
/**
|
||||
* JNI bridge to Qualcomm Genie SDK (libGenie.so).
|
||||
* Native implementation in jni/genie_jni.cpp
|
||||
*/
|
||||
object GenieJni {
|
||||
|
||||
init {
|
||||
System.loadLibrary("Genie")
|
||||
System.loadLibrary("genie_jni")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Genie dialog from a JSON config file.
|
||||
* @param configPath path to genie_config.json
|
||||
* @return handle (pointer) to the dialog, or 0 on failure
|
||||
*/
|
||||
external fun createDialog(configPath: String): Long
|
||||
|
||||
/**
|
||||
* Send a query to the dialog and get a response.
|
||||
* @param dialogHandle handle from createDialog
|
||||
* @param prompt the text prompt
|
||||
* @param callback called with each decoded token; return false to stop
|
||||
* @return full response string
|
||||
*/
|
||||
external fun query(dialogHandle: Long, prompt: String, callback: TokenCallback?): String
|
||||
|
||||
/**
|
||||
* Set a stop sequence for the dialog.
|
||||
* @param dialogHandle handle from createDialog
|
||||
* @param stopSequence the stop sequence string
|
||||
*/
|
||||
external fun setStopSequence(dialogHandle: Long, stopSequence: String)
|
||||
|
||||
/**
|
||||
* Free the dialog resources.
|
||||
* @param dialogHandle handle from createDialog
|
||||
*/
|
||||
external fun freeDialog(dialogHandle: Long)
|
||||
|
||||
/**
|
||||
* Get Genie API version.
|
||||
* @return "major.minor"
|
||||
*/
|
||||
external fun getVersion(): String
|
||||
|
||||
interface TokenCallback {
|
||||
/** Called for each generated token. Return false to stop generation. */
|
||||
fun onToken(token: String): Boolean
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import android.util.Log
|
||||
import com.kazeia.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class GenieLlmEngine : LlmEngine {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GenieLlmEngine"
|
||||
}
|
||||
|
||||
private var dialogHandle: Long = 0
|
||||
private var loaded = false
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "Loading Genie model from $modelPath")
|
||||
val configFile = "$modelPath/genie_config.json"
|
||||
dialogHandle = GenieJni.createDialog(configFile)
|
||||
if (dialogHandle == 0L) {
|
||||
throw RuntimeException("Failed to create Genie dialog from $configFile")
|
||||
}
|
||||
// Set stop sequences for chat
|
||||
GenieJni.setStopSequence(dialogHandle, "Patient:")
|
||||
GenieJni.setStopSequence(dialogHandle, "\nPatient")
|
||||
loaded = true
|
||||
Log.i(TAG, "Genie model loaded, handle=$dialogHandle, version=${GenieJni.getVersion()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = loaded
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
if (!loaded) throw IllegalStateException("Model not loaded")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
var tokenCount = 0
|
||||
|
||||
val callback = if (onToken != null) {
|
||||
object : GenieJni.TokenCallback {
|
||||
override fun onToken(token: String): Boolean {
|
||||
tokenCount++
|
||||
return onToken(token)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
val response = GenieJni.query(dialogHandle, prompt, callback)
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
if (tokenCount == 0) tokenCount = response.split(" ").size
|
||||
|
||||
GenerationResult(
|
||||
text = response,
|
||||
tokenCount = tokenCount,
|
||||
timeMs = elapsed,
|
||||
tokensPerSecond = if (elapsed > 0) tokenCount * 1000f / elapsed else 0f
|
||||
)
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
if (dialogHandle != 0L) {
|
||||
GenieJni.freeDialog(dialogHandle)
|
||||
dialogHandle = 0
|
||||
loaded = false
|
||||
Log.i(TAG, "Genie model released")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer
|
||||
import java.io.File
|
||||
import java.nio.file.Paths
|
||||
|
||||
// Chargeur LLM agnostique au format. Détecte par magic-byte et instancie le bon moteur :
|
||||
// - GGUF -> GgufLlmEngine (libkazeia_engine / libllama, CPU-i8mm). TOUS les modèles,
|
||||
// dont l'hybride Qwen3.5-4B (GatedDeltaNet) qui ne s'exporte PAS en .pte.
|
||||
// - .pte -> PteLlmEngine (ExecuTorch + QNN, NPU V79, lib kazeia_pte). Denses UNIQUEMENT
|
||||
// (Qwen3-4B, Qwen3-8B, Qwen2.5-7B, Qwen3Guard-4B). Chemin in-app autorisé
|
||||
// (libQnn Maven, sans root). RAM ~÷2, prefill NPU rapide ; decode ~= CPU.
|
||||
//
|
||||
// Parc validé on-device (17/06, SM8750/V79, decode tok/s) :
|
||||
// Qwen3-4B .pte 15.7 | Qwen3-8B .pte 10.5 | Qwen3Guard-4B .pte 17.2 | Qwen2.5-7B .pte 8.0
|
||||
// Qwen3.5-4B GGUF 16.6 (hybride, CPU-i8mm)
|
||||
|
||||
interface LlmEngine {
|
||||
fun generate(sys: String, usr: String, max: Int = 96): String
|
||||
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean)
|
||||
fun lastStats(): GenStats
|
||||
fun reset()
|
||||
fun release()
|
||||
}
|
||||
|
||||
object LlmLoader {
|
||||
private fun magic(path: String): ByteArray =
|
||||
ByteArray(8).also { b -> File(path).inputStream().use { it.read(b) } }
|
||||
|
||||
private fun ByteArray.has(off: Int, tag: String): Boolean =
|
||||
tag.indices.all { i -> off + i < size && this[off + i] == tag[i].code.toByte() }
|
||||
|
||||
// GGUF -> "GGUF" aux octets 0-3. ExecuTorch .pte -> "ET12" à l'octet 4.
|
||||
// tokenizerPath : requis pour .pte (tokeniseur HF externe). Défaut = tokenizer.json voisin du .pte.
|
||||
fun load(path: String, ctx: Int = 4096, nThreads: Int = 6, tokenizerPath: String? = null): LlmEngine {
|
||||
val m = magic(path)
|
||||
return when {
|
||||
m.has(0, "GGUF") -> GgufLlmEngine(path, ctx, nThreads)
|
||||
m.has(4, "ET12") -> {
|
||||
val tk = tokenizerPath ?: (File(path).parent ?: ".") + "/tokenizer.json"
|
||||
PteLlmEngine(path, tk, seqLen = ctx)
|
||||
}
|
||||
else -> error("Format LLM inconnu (ni GGUF ni ExecuTorch .pte) : $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptateur GGUF : délègue au moteur natif existant (libkazeia_engine / libllama, CPU-i8mm).
|
||||
class GgufLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) : LlmEngine {
|
||||
private val e = EngineLlmEngine(model, ctx, nThreads)
|
||||
override fun generate(sys: String, usr: String, max: Int) = e.generate(sys, usr, max)
|
||||
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) =
|
||||
e.generateStream(sys, usr, max, onToken)
|
||||
override fun lastStats() = e.lastStats()
|
||||
override fun reset() = e.reset()
|
||||
override fun release() = e.release()
|
||||
}
|
||||
|
||||
// JNI ExecuTorch+QNN. Lib `kazeia_pte` = wrapper natif du runner ExecuTorch (rebuild QAIRT 2.42).
|
||||
// jniLibs/arm64-v8a doit aussi contenir : libqnn_executorch_backend.so (2.42) +
|
||||
// libQnnHtp/System/Prepare/HtpV79Stub/HtpNetRunExtensions.so (QAIRT 2.42) + libQnnHtpV79Skel.so.
|
||||
// Set figé : /opt/Kazeia/pte_prep_work/ship_runtime/.
|
||||
internal object ExecuTorchJni {
|
||||
external fun load(ptePath: String, tokenizerPath: String, seqLen: Int, evalMode: Int): Long
|
||||
external fun generateFromIds(handle: Long, promptIds: LongArray, maxNewTokens: Int): LongArray
|
||||
external fun lastStats(handle: Long): LongArray // [prefillMs, decodeMs, nGenerated]
|
||||
external fun free(handle: Long)
|
||||
init { System.loadLibrary("kazeia_pte") }
|
||||
}
|
||||
|
||||
// Adaptateur ExecuTorch+QNN (.pte) pour les denses sur NPU V79.
|
||||
// La tokenisation est faite ICI (côté Kotlin) car le tokeniseur natif est cassé pour Qwen
|
||||
// (regex lookahead RE2/PCRE2) : on applique le template ChatML, on encode en IDs, et on passe
|
||||
// les IDs au natif (eval_mode 1 hybride). Le natif décode greedy jusqu'à EOS et renvoie les IDs générés.
|
||||
class PteLlmEngine(ptePath: String, tokenizerJson: String, private val seqLen: Int = 4096) : LlmEngine {
|
||||
private val tok = HuggingFaceTokenizer.newInstance(Paths.get(tokenizerJson))
|
||||
private val h = ExecuTorchJni.load(ptePath, tokenizerJson, seqLen, /*evalMode=hybrid*/ 1)
|
||||
|
||||
init { require(h != 0L) { "échec chargement .pte : $ptePath" } }
|
||||
|
||||
// Template ChatML Qwen ; les balises sont dans le texte -> encode sans special tokens auto.
|
||||
private fun chatml(sys: String, usr: String): LongArray {
|
||||
val p = buildString {
|
||||
if (sys.isNotEmpty()) append("<|im_start|>system\n").append(sys).append("<|im_end|>\n")
|
||||
append("<|im_start|>user\n").append(usr).append("<|im_end|>\n<|im_start|>assistant\n")
|
||||
}
|
||||
return tok.encode(p, /*addSpecialTokens=*/false, /*withOverflowingTokens=*/false).ids
|
||||
}
|
||||
|
||||
override fun generate(sys: String, usr: String, max: Int): String {
|
||||
val outIds = ExecuTorchJni.generateFromIds(h, chatml(sys, usr), max)
|
||||
return tok.decode(outIds, /*skipSpecialTokens=*/true).trim()
|
||||
}
|
||||
|
||||
// Streaming réel = variante native à callback de token (TODO côté JNI).
|
||||
// En attendant : génération bloquante puis émission en un bloc.
|
||||
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) {
|
||||
onToken(generate(sys, usr, max))
|
||||
}
|
||||
|
||||
override fun lastStats(): GenStats =
|
||||
ExecuTorchJni.lastStats(h).let { GenStats(it[0], it[1], it[2]) }
|
||||
|
||||
override fun reset() { /* runner sans état persistant entre appels */ }
|
||||
override fun release() { ExecuTorchJni.free(h); tok.close() }
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import com.kazeia.core.GenerationResult
|
||||
import com.kazeia.core.LlmConfig
|
||||
import com.kazeia.core.SamplingParams
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pont unique app ↔ moteur LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md).
|
||||
* Expose l'interface app `com.kazeia.core.LlmEngine` au-dessus des 2 moteurs de l'engine,
|
||||
* choisis par MAGIC-BYTE (comme LlmLoader) :
|
||||
*
|
||||
* - **GGUF** ("GGUF"@0) → `EngineLlmEngine` (libkazeia_engine, CPU i8mm) AVEC session
|
||||
* cache-préfixe KV (system prefillé 1×, streaming) — pour le hybride Qwen3.5-4B + tout GGUF.
|
||||
* - **.pte** ("ET12"@4) → `PteLlmEngine` (ExecuTorch+QNN, NPU V79, libkazeia_pte, tokenis. DJL)
|
||||
* — pour les denses Qwen3-4B/8B, Qwen2.5-7B, Qwen3Guard-4B. Chemin NPU autorisé sans root.
|
||||
*
|
||||
* Remplace ExecuTorchLlmEngine (ancien .pte) + EngineLlmAdapter (GGUF). `generateWithSystem`
|
||||
* porte le prompt système PAR TOUR (cascade : bullets du Thinker ; hot-reload prompt Speaker).
|
||||
*/
|
||||
class UnifiedLlmAdapter(
|
||||
private val modelPath: String,
|
||||
private val systemPrompt: String,
|
||||
private val ctx: Int = 4096,
|
||||
private val nThreads: Int = 6,
|
||||
private val tokenizerPath: String? = null,
|
||||
private val onLog: ((String) -> Unit)? = null
|
||||
) : com.kazeia.core.LlmEngine {
|
||||
|
||||
private companion object {
|
||||
// Bloc de raisonnement Qwen3.5 en tête de réponse (thinking-off => vide). À strip côté .pte.
|
||||
val THINK_BLOCK = Regex("""^\s*<think>.*?</think>\s*""", RegexOption.DOT_MATCHES_ALL)
|
||||
}
|
||||
|
||||
private enum class Fmt { GGUF, PTE }
|
||||
@Volatile private var fmt: Fmt? = null
|
||||
private var gguf: EngineLlmEngine? = null
|
||||
private var session: LlmSession? = null
|
||||
private var pte: PteLlmEngine? = null
|
||||
|
||||
private fun detect(path: String): Fmt {
|
||||
val b = ByteArray(8)
|
||||
File(path).inputStream().use { it.read(b) }
|
||||
fun at(off: Int, s: String) = s.indices.all { i -> off + i < b.size && b[off + i] == s[i].code.toByte() }
|
||||
return when {
|
||||
at(0, "GGUF") -> Fmt.GGUF
|
||||
at(4, "ET12") -> Fmt.PTE
|
||||
else -> error("Format LLM inconnu (ni GGUF ni ET12 .pte) : $path")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
|
||||
if (fmt != null) return@withContext
|
||||
val t0 = System.currentTimeMillis()
|
||||
val name = File(this@UnifiedLlmAdapter.modelPath).name
|
||||
when (detect(this@UnifiedLlmAdapter.modelPath).also { fmt = it }) {
|
||||
Fmt.GGUF -> {
|
||||
val e = EngineLlmEngine(this@UnifiedLlmAdapter.modelPath, ctx, nThreads)
|
||||
gguf = e
|
||||
session = e.newSession(systemPrompt) // cache-préfixe KV (system prefillé 1×)
|
||||
onLog?.invoke("[LLM] GGUF chargé en ${System.currentTimeMillis() - t0}ms (session) : $name")
|
||||
}
|
||||
Fmt.PTE -> {
|
||||
val tk = tokenizerPath ?: (File(this@UnifiedLlmAdapter.modelPath).parent ?: ".") + "/tokenizer.json"
|
||||
pte = PteLlmEngine(this@UnifiedLlmAdapter.modelPath, tk, seqLen = ctx)
|
||||
onLog?.invoke("[LLM] .pte NPU chargé en ${System.currentTimeMillis() - t0}ms : $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = fmt != null
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = generateWithSystem(prompt, null, params, onToken)
|
||||
|
||||
/** Génère avec prompt système optionnel PAR TOUR. null = prompt système par défaut
|
||||
* (chemin nominal GGUF = session cache-préfixe). Non-null = override (cascade). */
|
||||
suspend fun generateWithSystem(
|
||||
prompt: String,
|
||||
systemPromptOverride: String?,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?,
|
||||
tag: String = "" // étiquette de log (compat call-sites SPEAKER/THINKER)
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
// Anti-effondrement decode CPU (GGUF, doc §7.2) : priorité grands cœurs.
|
||||
try { android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_FOREGROUND) } catch (_: Exception) {}
|
||||
val t0 = System.currentTimeMillis()
|
||||
val out: String
|
||||
val stats: GenStats
|
||||
when (fmt) {
|
||||
Fmt.GGUF -> {
|
||||
val e = gguf ?: error("UnifiedLlmAdapter GGUF non chargé")
|
||||
val sb = StringBuilder()
|
||||
if (systemPromptOverride == null) {
|
||||
// nominal : session (cache-préfixe), stateless par tour
|
||||
val s = session!!
|
||||
s.reset()
|
||||
s.ask(prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
|
||||
} else {
|
||||
// override par tour (cascade) : mono-tour avec le system fourni
|
||||
e.generateStream(systemPromptOverride, prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
|
||||
}
|
||||
out = sb.toString().trim()
|
||||
stats = e.lastStats()
|
||||
}
|
||||
Fmt.PTE -> {
|
||||
val e = pte ?: error("UnifiedLlmAdapter .pte non chargé")
|
||||
val raw = e.generate(systemPromptOverride ?: systemPrompt, prompt, params.maxNewTokens)
|
||||
// Le décode .pte conserve le bloc <think>…</think> (thinking-off => bloc vide) :
|
||||
// ce ne sont PAS des special tokens Qwen3.5, donc skipSpecialTokens ne les retire pas.
|
||||
// Le moteur GGUF, lui, le strippe nativement. On normalise ici pour que les 2 backends
|
||||
// se comportent pareil (sinon le TTS lit « <think> </think> » et la cascade parse faux).
|
||||
out = THINK_BLOCK.replace(raw, "").trim()
|
||||
onToken?.invoke(out) // streaming .pte = un bloc (génération bloquante, doc §10)
|
||||
stats = e.lastStats()
|
||||
}
|
||||
null -> error("UnifiedLlmAdapter non chargé")
|
||||
}
|
||||
val n = if (stats.nTokens > 0) stats.nTokens.toInt() else out.split(Regex("\\s+")).size
|
||||
val ms = (stats.prefillMs + stats.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
|
||||
onLog?.invoke("[LLM] prefill=${stats.prefillMs}ms decode=${stats.decodeMs}ms tok=${stats.nTokens} (${"%.1f".format(stats.decodeTokPerSec)} tok/s)")
|
||||
GenerationResult(out, n, ms, stats.decodeTokPerSec.toFloat())
|
||||
}
|
||||
|
||||
/** Conversation vierge (bornes : changement de patient, CLEAR_CHAT). GGUF (cache) ; no-op .pte. */
|
||||
fun resetSession() { session?.reset() }
|
||||
|
||||
override fun release() {
|
||||
session = null
|
||||
gguf?.release(); gguf = null
|
||||
pte?.release(); pte = null
|
||||
fmt = null
|
||||
}
|
||||
}
|
||||
|
|
@ -160,26 +160,13 @@ class KazeiaPipeline {
|
|||
val ttsEngine = tts ?: return
|
||||
_pipelineState.value = PipelineState.Speaking
|
||||
try {
|
||||
val qwen = ttsEngine as? com.kazeia.tts.Qwen3TtsEngine
|
||||
if (qwen != null) {
|
||||
qwen.onSegmentPlaying = onSegmentPlaying
|
||||
qwen.startStreamingSession()
|
||||
val streamer = com.kazeia.tts.SentenceStreamer { raw ->
|
||||
// Strip emoji / non-speakable pictographs before TTS
|
||||
// so a standalone "😊" doesn't become its own noisy
|
||||
// segment. The chat bubble keeps the original text —
|
||||
// only the audio path sees the cleaned version.
|
||||
val spoken = stripNonSpeakable(raw).trim()
|
||||
if (spoken.isNotEmpty()) qwen.enqueueSentence(spoken)
|
||||
}
|
||||
streamer.append(text)
|
||||
streamer.flush()
|
||||
qwen.endStreamingSession()
|
||||
} else {
|
||||
// CosyVoice : lecture batch (synthèse N+1 pendant lecture N EN INTERNE).
|
||||
// onSegment propage le retour par-phrase (texte + features visuelles)
|
||||
// pour le reveal mot-par-mot et l'orbe — perdu lors de la bascule
|
||||
// CosyVoice quand la branche streaming Qwen3 a été retirée (bug #2/#3).
|
||||
ttsEngine.synthesizeAndPlay(text, context.language,
|
||||
onComplete = { _pipelineState.value = PipelineState.Idle }
|
||||
)
|
||||
}
|
||||
onComplete = { _pipelineState.value = PipelineState.Idle },
|
||||
onSegment = onSegmentPlaying)
|
||||
} catch (e: Exception) {
|
||||
log("TTS error: ${e.message}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ import com.kazeia.conversation.ConversationManager
|
|||
import com.kazeia.conversation.PromptBuilder
|
||||
import com.kazeia.conversation.StoppingCriteria
|
||||
import com.kazeia.core.*
|
||||
import com.kazeia.llm.ExecuTorchLlmEngine
|
||||
import com.kazeia.llm.GenieLlmEngine
|
||||
import com.kazeia.profiles.ConversationDao
|
||||
import com.kazeia.profiles.ConversationDb
|
||||
import com.kazeia.profiles.ProfileStore
|
||||
|
|
@ -56,15 +54,10 @@ class KazeiaService : Service() {
|
|||
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
|
||||
}
|
||||
|
||||
private lateinit var llm: LlmEngine
|
||||
// Cascade Option E v2 : 2 ExecuTorchLlmEngine cohabitants in-process.
|
||||
// - llm = Speaker Qwen3-4B (.pte ~3.3 GB ION, ~4.4 GB total)
|
||||
// - thinkerEngine = Guard-0.6B (.pte ~700 MB ION) lazy-loaded au 1er PTT
|
||||
// Total ION ~5.1 GB, marge OK pour TTS pic. QnnBackendUnifiedRegistry
|
||||
// singleton partage le QnnBackendBundle (handle fastrpc DSP) entre les 2
|
||||
// contextes — pas de collision DSP, pas de subprocess. Cf
|
||||
// project_kazeia_cascade_option_e dans la mémoire persistante.
|
||||
private var thinkerEngine: com.kazeia.llm.ExecuTorchLlmEngine? = null
|
||||
private lateinit var llm: LlmEngine // Speaker (UnifiedLlmAdapter : GGUF défaut ou .pte)
|
||||
// Cascade (off par défaut) : 2ᵉ moteur Thinker via UnifiedLlmAdapter,
|
||||
// lazy au 1er tour (défaut Guard-4B .pte). nThreads réduit pour le Speaker.
|
||||
private var thinkerEngine: com.kazeia.llm.UnifiedLlmAdapter? = null
|
||||
private val thinkerLock = Object()
|
||||
|
||||
// Config runtime (cascade, prompts, modèles) pilotée par l'admin app via
|
||||
|
|
@ -124,14 +117,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms")
|
||||
}
|
||||
|
||||
private suspend fun reloadLlmAfterTurn() = llmMutex.withLock {
|
||||
val ll = llm as? com.kazeia.llm.ExecuTorchLlmEngine ?: return@withLock
|
||||
if (!ll.isLoaded()) return@withLock
|
||||
val t0 = System.currentTimeMillis()
|
||||
log("[LLM] reload pour reset cur_pos (état propre prochain tour)…")
|
||||
ll.reloadAfterTurn()
|
||||
log("[LLM] reloaded in ${System.currentTimeMillis() - t0}ms")
|
||||
}
|
||||
// reloadAfterTurn supprimé avec la refonte LLM 2026-06-17 : l'ancien hack
|
||||
// ExecuTorchLlmEngine (reset cur_pos) n'a plus lieu d'être. UnifiedLlmAdapter
|
||||
// gère l'état par tour (session reset GGUF ; .pte sans état persistant).
|
||||
private suspend fun reloadLlmAfterTurn() { /* no-op */ }
|
||||
private lateinit var tts: TtsEngine
|
||||
|
||||
private lateinit var audioPlayback: AudioPlaybackManager
|
||||
|
|
@ -143,7 +132,19 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// Per-interaction timing tracer. One instance, reset at every begin().
|
||||
// All stage timings for a single user turn (STT+LLM+TTS+playback) end
|
||||
// up in a single grep-friendly log section tagged "[PIPELINE]".
|
||||
private val metrics = com.kazeia.core.PipelineMetrics(TAG)
|
||||
private val metrics = com.kazeia.core.PipelineMetrics(TAG).apply {
|
||||
// Récap par tour (STT · Thinker · Speaker · TTS · total) routé vers les logs
|
||||
// du service → visible dans le panneau debug patient, pas que dans logcat.
|
||||
summarySink = { msg -> log(msg) }
|
||||
// + encart PERSISTANT (ne défile pas) : input · output · temps du dernier tour.
|
||||
timingsSink = { block ->
|
||||
_lastTurnPanel.value = buildString {
|
||||
append("🗣 Vous : "); append(lastTurnInput.ifBlank { "—" }); append("\n\n")
|
||||
append("🤖 Kazeia : "); append(lastTurnOutput.ifBlank { "—" }); append("\n\n")
|
||||
append(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor
|
||||
|
||||
// New modular pipeline
|
||||
|
|
@ -158,6 +159,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||
val logs: StateFlow<List<String>> = _logs
|
||||
|
||||
// Encart « dernier tour » persistant (input · output · temps par étape).
|
||||
private val _lastTurnPanel = MutableStateFlow("⏱ dernier tour : —")
|
||||
val lastTurnPanel: StateFlow<String> = _lastTurnPanel
|
||||
@Volatile private var lastTurnInput = ""
|
||||
@Volatile private var lastTurnOutput = ""
|
||||
|
||||
// AI workload tracking: which component is active (for monitoring)
|
||||
data class AiWorkload(
|
||||
val sttActive: Boolean = false,
|
||||
|
|
@ -214,10 +221,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
val loadingState: StateFlow<LoadingState> = _loadingState
|
||||
|
||||
/**
|
||||
* Cascade E v2 : crée la 2ᵉ ExecuTorchLlmEngine (Thinker = Guard-0.6B) dans
|
||||
* le MÊME process app. Lazy au 1er PTT (~1-2 s de load 0.6B). Le
|
||||
* QnnBackendUnifiedRegistry singleton réutilise le QnnBackendBundle du
|
||||
* Speaker → un seul handle fastrpc DSP, 2 contextes/graphes coexistent.
|
||||
* Cascade : crée le 2ᵉ moteur (Thinker, défaut Guard-4B .pte NPU) via
|
||||
* UnifiedLlmAdapter dans le même process, lazy au 1er tour. Off par défaut
|
||||
* (cascadeEnabled=false). nThreads réduit pour laisser des cœurs au Speaker.
|
||||
*/
|
||||
private fun ensureThinkerEngine() {
|
||||
synchronized(thinkerLock) {
|
||||
|
|
@ -228,14 +234,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…")
|
||||
val tStart = System.currentTimeMillis()
|
||||
val memBefore = readMemAvailableMb()
|
||||
val e = com.kazeia.llm.ExecuTorchLlmEngine(
|
||||
context = this@KazeiaService,
|
||||
onLog = { msg -> log("[THINKER] $msg") },
|
||||
modelPath = info.ptePath(),
|
||||
tokenizerPath = info.tokenizerPath(),
|
||||
// Thinker via UnifiedLlmAdapter : Guard-4B .pte (NPU) ou tout GGUF du registre.
|
||||
val isPte = info.backend == com.kazeia.config.ModelRegistry.Backend.PTE
|
||||
val e = com.kazeia.llm.UnifiedLlmAdapter(
|
||||
modelPath = if (isPte) info.ptePath() else info.ggufPath(),
|
||||
systemPrompt = cfg.thinker.systemPrompt,
|
||||
temperature = cfg.thinker.temperature,
|
||||
displayName = "${info.displayName} Thinker"
|
||||
ctx = if (isPte) info.maxSeqLen else 4096,
|
||||
nThreads = 4, // cascade : laisser des cœurs au Speaker
|
||||
tokenizerPath = if (isPte) info.tokenizerPath() else null,
|
||||
onLog = { msg -> log("[THINKER] $msg") }
|
||||
)
|
||||
try {
|
||||
runBlocking { e.load("", com.kazeia.core.LlmConfig()) }
|
||||
|
|
@ -282,290 +289,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Auto-pipeline trigger via: am startservice -n com.kazeia/.service.KazeiaService --ez run_pipeline true
|
||||
if (intent?.getBooleanExtra("run_pipeline", false) == true) {
|
||||
log("Auto-pipeline triggered via intent")
|
||||
// Cancel any running pipeline to avoid blocking
|
||||
currentPipelineJob?.cancel()
|
||||
currentPipelineJob = serviceScope.launch(Dispatchers.IO) {
|
||||
// Wait for TTS to be loaded
|
||||
while (!::tts.isInitialized || tts !is com.kazeia.tts.Qwen3TtsEngine) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
val embedsPath = "/data/local/tmp/kazeia/models/qwen3-tts-npu/full_pipeline_embeds.bin"
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
|
||||
val audio = qwenTts.generateFromEmbeds(embedsPath)
|
||||
log("Pipeline done: ${audio.size} samples (${audio.size/24000f}s)")
|
||||
// Audio is played by the TTS engine internally
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("stream_llm_mock")?.let { text ->
|
||||
// Stage 3 validation path without a live LLM: feed the given text
|
||||
// to the SentenceStreamer word-by-word with a short artificial
|
||||
// inter-token pause so we can observe the "sentence N plays
|
||||
// while sentence N+1 is generating" behaviour end-to-end. Used
|
||||
// in debug builds where the LLM is disabled (echo mode).
|
||||
// Optional companion extra `tts_voice` selects voice prefix file
|
||||
// before generation (used by chantier ggml-cpu reference dump).
|
||||
val voiceOverride = intent.getStringExtra("tts_voice")
|
||||
log("Stream LLM mock: '${text.take(60)}${if (text.length>60) "..." else ""}'${voiceOverride?.let { " voice=$it" } ?: ""}")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
|
||||
if (qwenTts == null) {
|
||||
// Backend non-Qwen3 (ex. KazeiaTtsEngine lib) : pas de
|
||||
// streaming par phrase, on synthétise le texte complet
|
||||
// via l'interface et on joue. Sert au test E2E du wiring.
|
||||
if (!::tts.isInitialized) { log("Stream mock: TTS not ready"); return@launch }
|
||||
_pipelineState.value = PipelineState.Speaking
|
||||
val tS = System.currentTimeMillis()
|
||||
tts.synthesizeAndPlay(text, "fr",
|
||||
onComplete = {
|
||||
log("Stream mock (lib) done at ${System.currentTimeMillis() - tS}ms")
|
||||
_pipelineState.value = PipelineState.Idle
|
||||
})
|
||||
return@launch
|
||||
}
|
||||
if (voiceOverride != null) {
|
||||
qwenTts.setVoice(voiceOverride)
|
||||
log("Voice set to: $voiceOverride")
|
||||
}
|
||||
qwenTts.startStreamingSession()
|
||||
val tStart = System.currentTimeMillis()
|
||||
var firstSentenceLogged = false
|
||||
val streamer = com.kazeia.tts.SentenceStreamer { sentence ->
|
||||
if (!firstSentenceLogged) {
|
||||
log("First sentence at ${System.currentTimeMillis() - tStart}ms: '${sentence.take(60)}'")
|
||||
firstSentenceLogged = true
|
||||
}
|
||||
qwenTts.enqueueSentence(sentence)
|
||||
}
|
||||
// Fake LLM: word by word, 50 ms per word. Real LLM on
|
||||
// device emits ~15-25 tokens/s which is similar.
|
||||
for (word in text.split(" ")) {
|
||||
streamer.append("$word ")
|
||||
kotlinx.coroutines.delay(50)
|
||||
}
|
||||
streamer.flush()
|
||||
qwenTts.endStreamingSession()
|
||||
log("Stream LLM mock done at ${System.currentTimeMillis() - tStart}ms")
|
||||
} catch (e: Exception) {
|
||||
log("Stream LLM mock error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("stream_llm")?.let { prompt ->
|
||||
// Stage 3 end-to-end: LLM generates tokens → SentenceStreamer
|
||||
// splits at terminal punctuation → each sentence is enqueued
|
||||
// to the TTS streaming session → audio plays while the LLM
|
||||
// keeps generating the next sentence. Removes the old "wait
|
||||
// for full response, then start TTS" pattern which gave a
|
||||
// long silence after the user stopped speaking.
|
||||
log("Stream LLM: '${prompt.take(60)}${if (prompt.length>60) "..." else ""}'")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: run {
|
||||
log("Stream LLM: TTS is not Qwen3 engine, abort"); return@launch
|
||||
}
|
||||
if (!::llm.isInitialized || !llm.isLoaded()) {
|
||||
log("Stream LLM: LLM not ready"); return@launch
|
||||
}
|
||||
// Set pipeline state to Speaking so the continuous-
|
||||
// listening mic loop (line ~824) drops frames during
|
||||
// TTS playback. Without this, the mic picks up the
|
||||
// tablet speaker and feeds our own TTS back into STT,
|
||||
// creating an infinite loop.
|
||||
_pipelineState.value = PipelineState.Speaking
|
||||
qwenTts.startStreamingSession()
|
||||
val tStart = System.currentTimeMillis()
|
||||
var firstSentenceLogged = false
|
||||
val streamer = com.kazeia.tts.SentenceStreamer { sentence ->
|
||||
if (!firstSentenceLogged) {
|
||||
log("First sentence at ${System.currentTimeMillis() - tStart}ms: '${sentence.take(60)}'")
|
||||
firstSentenceLogged = true
|
||||
}
|
||||
qwenTts.enqueueSentence(sentence)
|
||||
}
|
||||
val result = llm.generate(
|
||||
prompt = prompt,
|
||||
params = com.kazeia.core.SamplingParams(maxNewTokens = 120, temperature = 0.7f),
|
||||
onToken = { token ->
|
||||
streamer.append(token)
|
||||
true // keep generating
|
||||
}
|
||||
)
|
||||
streamer.flush()
|
||||
log("LLM done: ${result.tokenCount} tokens in ${result.timeMs}ms")
|
||||
qwenTts.endStreamingSession()
|
||||
log("Stream LLM total: ${System.currentTimeMillis() - tStart}ms")
|
||||
} catch (e: Exception) {
|
||||
log("Stream LLM error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
// Back to Idle so the next mic frame is accepted.
|
||||
_pipelineState.value = PipelineState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("stream_text")?.let { text ->
|
||||
// Stage 2 streaming from arbitrary text: BPE tokenize on-device,
|
||||
// look up embeds in the full Qwen3 vocab, run the existing
|
||||
// interleaved Hexagon generation loop, and play each segment
|
||||
// as soon as it's decoded. No PC-side prep required.
|
||||
log("Stream text: '${text.take(60)}${if (text.length>60) "..." else ""}'")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
|
||||
val sr = 24000
|
||||
val track = android.media.AudioTrack.Builder()
|
||||
.setAudioAttributes(android.media.AudioAttributes.Builder()
|
||||
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build())
|
||||
.setAudioFormat(android.media.AudioFormat.Builder()
|
||||
.setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sr)
|
||||
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build())
|
||||
.setBufferSizeInBytes(sr * 4)
|
||||
.setTransferMode(android.media.AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
track.play()
|
||||
val tStart = System.currentTimeMillis()
|
||||
var firstLogged = false
|
||||
qwenTts.synthesizeTextStreaming(text) { segIdx, audio ->
|
||||
if (!firstLogged) {
|
||||
log("First audio out at ${System.currentTimeMillis() - tStart}ms (seg ${segIdx+1})")
|
||||
firstLogged = true
|
||||
}
|
||||
track.write(audio, 0, audio.size)
|
||||
}
|
||||
track.stop(); track.release()
|
||||
log("Stream text done at ${System.currentTimeMillis() - tStart}ms")
|
||||
} catch (e: Exception) {
|
||||
log("Stream text error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("stream_pipeline")?.let { embedsPath ->
|
||||
// Stage 1 streaming pipeline: generate segment-by-segment and play each
|
||||
// segment the moment it's ready via an AudioTrack MODE_STREAM. First audio
|
||||
// arrives ~5s after request (time to generate 1 segment) instead of ~15s
|
||||
// for the whole phrase. Per-segment WAVs + concatenated full WAV are
|
||||
// written to /data/local/tmp/kazeia/kazeia_stream_seg*.wav and _full.wav
|
||||
// by the engine itself — the service only handles playback.
|
||||
log("Stream pipeline from pre-computed embeds: $embedsPath")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
|
||||
val sr = 24000
|
||||
val track = android.media.AudioTrack.Builder()
|
||||
.setAudioAttributes(android.media.AudioAttributes.Builder()
|
||||
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build())
|
||||
.setAudioFormat(android.media.AudioFormat.Builder()
|
||||
.setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sr)
|
||||
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build())
|
||||
.setBufferSizeInBytes(sr * 4) // 2s mono pcm16 buffer, plenty for seg handoff
|
||||
.setTransferMode(android.media.AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
track.play()
|
||||
val tStart = System.currentTimeMillis()
|
||||
var firstAudioLogged = false
|
||||
qwenTts.generateFromEmbedsHexagonStreaming(embedsPath) { segIdx, audio ->
|
||||
if (!firstAudioLogged) {
|
||||
log("First audio out at ${System.currentTimeMillis() - tStart}ms (seg ${segIdx+1})")
|
||||
firstAudioLogged = true
|
||||
}
|
||||
track.write(audio, 0, audio.size)
|
||||
}
|
||||
// Let AudioTrack drain the written samples before releasing.
|
||||
track.stop()
|
||||
track.release()
|
||||
log("Stream pipeline done at ${System.currentTimeMillis() - tStart}ms")
|
||||
} catch (e: Exception) {
|
||||
log("Stream pipeline error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("full_pipeline")?.let { embedsPath ->
|
||||
val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav"
|
||||
log("Full pipeline from pre-computed embeds: $embedsPath")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
|
||||
if (qwenTts != null) {
|
||||
val audio = qwenTts.generateFromEmbeds(embedsPath)
|
||||
if (audio.isNotEmpty()) {
|
||||
val f = java.io.File(savePath)
|
||||
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
|
||||
val dataSize = audio.size * 2
|
||||
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
|
||||
out.writeBytes("WAVEfmt ")
|
||||
out.writeInt(Integer.reverseBytes(16))
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeInt(Integer.reverseBytes(24000))
|
||||
out.writeInt(Integer.reverseBytes(48000))
|
||||
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
|
||||
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
|
||||
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
for (s in audio) bb.putShort(s)
|
||||
out.write(bb.array())
|
||||
}
|
||||
log("Full pipeline WAV: $savePath (${audio.size} samples, ${audio.size/24000f}s)")
|
||||
} else {
|
||||
log("Full pipeline: empty audio")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("Full pipeline error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("decode_codes")?.let { codesPath ->
|
||||
val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav"
|
||||
val realTokens = intent.getIntExtra("real_tokens", 58)
|
||||
log("Decoding precomputed codes: $codesPath → $savePath ($realTokens tokens)")
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
|
||||
if (qwenTts != null) {
|
||||
val audio = qwenTts.testWithPrecomputedCodes(codesPath, realTokens)
|
||||
log("Decoded: ${audio.size} samples, ${audio.size * 1000L / 24000}ms")
|
||||
// Write WAV
|
||||
val f = java.io.File(savePath)
|
||||
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
|
||||
val dataSize = audio.size * 2
|
||||
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
|
||||
out.writeBytes("WAVEfmt ")
|
||||
out.writeInt(Integer.reverseBytes(16))
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeInt(Integer.reverseBytes(24000))
|
||||
out.writeInt(Integer.reverseBytes(48000))
|
||||
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
|
||||
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
|
||||
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
for (s in audio) bb.putShort(s)
|
||||
out.write(bb.array())
|
||||
}
|
||||
log("WAV saved: $savePath (${audio.size} samples)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("Decode error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
intent?.getStringExtra("tts_text")?.let { text ->
|
||||
val savePath = intent.getStringExtra("save_wav")
|
||||
if (savePath != null) {
|
||||
|
|
@ -708,6 +431,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// (admin app → ContentProvider.update → ConfigStore.save → listener)
|
||||
val store = com.kazeia.config.ConfigStore.get(this)
|
||||
runtimeConfig = store.current()
|
||||
_debugMode.value = runtimeConfig.debugEnabled // gate UI debug (bouton logs/métriques)
|
||||
store.addListener { new -> handleConfigChange(new) }
|
||||
registerConfigReloadReceiver()
|
||||
registerRagReloadReceiver()
|
||||
|
|
@ -755,37 +479,20 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
|
||||
val nativeLibDir = applicationInfo.nativeLibraryDir
|
||||
|
||||
// Dispatch backend TTS via ttsEngine. "prod" = Qwen3TtsEngine legacy
|
||||
// (incompat ABI avec libllama fork-ql -> SIGSEGV sur install fraîche,
|
||||
// cf feedback_tts_engine_default_lib). "lib" = KazeiaTtsEngine
|
||||
// (libkazeia_tts CPU). "cosyvoice" = CosyVoiceTtsEngine (clonage vocal
|
||||
// 9 langues, CPU-only) — NON validé device tant que la collision
|
||||
// libggml n'est pas levée côté libs engine (cf CosyVoiceTtsEngine).
|
||||
when (runtimeConfig.ttsEngine) {
|
||||
"cosyvoice" -> {
|
||||
// TTS = CosyVoice (seul moteur depuis la bascule 2026-06-18 ; les
|
||||
// moteurs legacy Qwen3-TTS/lib ont été retirés). Clonage vocal 9
|
||||
// langues, CPU-only, RTF ~1.0.
|
||||
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
|
||||
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
|
||||
cv.load()
|
||||
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
|
||||
tts = cv
|
||||
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
|
||||
}
|
||||
"lib" -> {
|
||||
_loadingState.value = LoadingState(15, "TTS engine…")
|
||||
val libTts = com.kazeia.tts.KazeiaTtsEngine(this@KazeiaService) { msg -> log("[TTS] $msg") }
|
||||
libTts.load()
|
||||
if (!libTts.isLoaded()) throw IllegalStateException("KazeiaTtsEngine load failed")
|
||||
tts = libTts
|
||||
log("TTS: KazeiaTtsEngine loaded (libkazeia_tts CPU-only)")
|
||||
}
|
||||
else -> {
|
||||
_loadingState.value = LoadingState(15, "TTS Qwen3…")
|
||||
val qwenTts = com.kazeia.tts.Qwen3TtsEngine(nativeLibDir, this@KazeiaService) { msg -> log("[TTS] $msg") }
|
||||
qwenTts.load("$modelsDir/qwen3-tts-npu")
|
||||
if (!qwenTts.isLoaded()) throw IllegalStateException("Qwen3-TTS load failed (no fallback in frozen stack)")
|
||||
tts = qwenTts
|
||||
log("TTS: Qwen3-TTS loaded (Hexagon NPU + ggml-cpu)")
|
||||
}
|
||||
// Pré-chauffe le cache voix EN TÂCHE DE FOND (chevauche le reste du
|
||||
// chargement + la 1ʳᵉ phrase de l'usager) → le 1er vrai tour part
|
||||
// chaud (~3 s) au lieu de ~10 s (remplissage de cache à froid).
|
||||
serviceScope.launch(Dispatchers.IO) {
|
||||
runCatching { cv.warmup() }
|
||||
}
|
||||
_loadingState.value = LoadingState(20, "TTS OK")
|
||||
|
||||
|
|
@ -797,18 +504,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// pendant TTS (caché derrière le playback) et release pendant
|
||||
// les phases LLM/TTS où Whisper ne sert pas.
|
||||
_loadingState.value = LoadingState(25, "STT Whisper (lazy)…")
|
||||
// Backend STT pilotable via ConfigStore.sttEngine. Default "prod" =
|
||||
// WhisperHybridEngine gelé. "lib" = KazeiaSttAdapter (libkazeia_stt.so).
|
||||
val sttBackend = runtimeConfig.sttEngine
|
||||
val npuStt: com.kazeia.core.SttEngine = when (sttBackend) {
|
||||
"lib" -> com.kazeia.stt.KazeiaSttAdapter(
|
||||
"$modelsDir/whisper-small-sm8750"
|
||||
) { msg -> log("[STT] $msg") }
|
||||
else -> WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
|
||||
}
|
||||
// STT = WhisperHybridEngine (NPU, gelé) — SEUL backend depuis le retrait
|
||||
// du backend "lib" (KazeiaSttAdapter/libkazeia_stt) non validé, 2026-06-18.
|
||||
// ConfigStore.sttEngine est désormais vestigial (compat config).
|
||||
val npuStt: com.kazeia.core.SttEngine = WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
|
||||
sttModelPath = "$modelsDir/whisper-small-sm8750"
|
||||
stt = npuStt
|
||||
log("Whisper créé backend='$sttBackend' (lazy load au 1er PTT)")
|
||||
log("Whisper créé (lazy load au 1er PTT)")
|
||||
addMessage(ChatMessage(
|
||||
role = ChatMessage.Role.SYSTEM,
|
||||
text = "STT: Whisper-Small NPU"
|
||||
|
|
@ -817,47 +519,28 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// LLM = ExecuTorch QNN. Modèle choisi dans la config runtime
|
||||
// (admin app peut le changer à chaud). Fallback sur le
|
||||
// Speaker par défaut du registre si l'id config est inconnu.
|
||||
// LLM = Kazeia-Engine GGUF (llama.cpp upstream + Hexagon), Speaker
|
||||
// Qwen3.5-9B prefill NPU / decode CPU. Plus de .pte (-30% RAM).
|
||||
// Backend LLM pilotable via ConfigStore.llmEngine. Default "prod" =
|
||||
// ExecuTorch .pte (Qwen3 dense). "lib" = EngineLlmAdapter (libkazeia_engine,
|
||||
// GGUF Qwen3.5). Fallback gracieux sur prod si la lib échoue au load
|
||||
// (libllama fork ql encore absente du jniLibs tant que swap coordonné non fait).
|
||||
val llmBackend = runtimeConfig.llmEngine
|
||||
_loadingState.value = LoadingState(50, "LLM backend=$llmBackend…")
|
||||
// LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md) :
|
||||
// UnifiedLlmAdapter détecte le format par magic-byte et charge le bon
|
||||
// moteur — GGUF (Qwen3.5-4B hybride, défaut) sur CPU i8mm avec cache-
|
||||
// préfixe, ou .pte (denses Qwen3-4B/8B, Qwen2.5-7B) sur NPU V79 (sans
|
||||
// root). Le flag ConfigStore.llmEngine n'est plus déterminant (auto-détecté).
|
||||
val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker()
|
||||
val llmLib: com.kazeia.core.LlmEngine? = if (llmBackend == "lib") try {
|
||||
// Si le Speaker sélectionné est un GGUF du registre (ex. Qwen2.5-7B),
|
||||
// on le charge ; sinon fallback sur le GGUF moteur par défaut (q35-lmq4).
|
||||
val gguf = if (sp.backend == com.kazeia.config.ModelRegistry.Backend.GGUF && sp.fileExists())
|
||||
sp.ggufPath()
|
||||
else
|
||||
"${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
|
||||
val plain = sp.chatTemplate == com.kazeia.config.ModelRegistry.ChatTemplate.PLAIN_CHATML
|
||||
log("[LLM] lib GGUF=$gguf (speaker=${sp.id}, plainChatml=$plain)")
|
||||
com.kazeia.llm.EngineLlmAdapter(gguf, runtimeConfig.speaker.systemPrompt,
|
||||
plainChatml = plain, onLog = { msg -> log(msg) })
|
||||
} catch (e: Throwable) {
|
||||
log("[LLM] lib instantiation failed (${e.message}) — fallback .pte")
|
||||
null
|
||||
} else null
|
||||
llm = llmLib ?: ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
|
||||
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
|
||||
runtimeConfig.speaker.temperature, sp.displayName)
|
||||
_loadingState.value = LoadingState(50, "LLM ${sp.id}…")
|
||||
val isPte = sp.backend == com.kazeia.config.ModelRegistry.Backend.PTE
|
||||
val mPath = if (isPte) sp.ptePath() else sp.ggufPath()
|
||||
val tkPath = if (isPte) sp.tokenizerPath() else null
|
||||
val ctxLen = if (isPte) sp.maxSeqLen else 4096
|
||||
log("[LLM] speaker=${sp.id} backend=${sp.backend} path=$mPath ctx=$ctxLen")
|
||||
llm = com.kazeia.llm.UnifiedLlmAdapter(
|
||||
mPath, runtimeConfig.speaker.systemPrompt,
|
||||
ctx = ctxLen, nThreads = 6, tokenizerPath = tkPath,
|
||||
onLog = { msg -> log(msg) }
|
||||
)
|
||||
try {
|
||||
llm.load("", com.kazeia.core.LlmConfig())
|
||||
log("LLM créé backend='${if (llmLib != null) "lib" else "prod"}'")
|
||||
} catch (e: Exception) {
|
||||
log("LLM load failed (${e.message}) — fallback echo")
|
||||
if (llmLib != null) {
|
||||
// lib loadLibrary("kazeia_engine") a échoué (libllama fork ql absente) ;
|
||||
// on retombe sur .pte pour ne pas perdre l'app
|
||||
log("[LLM] retry on prod .pte")
|
||||
llm = ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
|
||||
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
|
||||
runtimeConfig.speaker.temperature, sp.displayName)
|
||||
try { llm.load("", com.kazeia.core.LlmConfig()) } catch (_: Exception) {}
|
||||
}
|
||||
log("LLM créé : ${sp.id} (${sp.backend})")
|
||||
} catch (e: Throwable) {
|
||||
log("LLM load failed (${e.message}) — mode écho jusqu'au prochain essai")
|
||||
}
|
||||
|
||||
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
|
||||
|
|
@ -1010,7 +693,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
|
||||
if (nowId != lastActiveProfileId) {
|
||||
lastActiveProfileId = nowId
|
||||
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
|
||||
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
|
||||
log("[LLM] profil actif changé → mémoire de session réinitialisée")
|
||||
}
|
||||
}
|
||||
|
|
@ -1029,11 +712,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
* ../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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1043,8 +723,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
fun setVoice(voicePathOrId: String) =
|
||||
setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.'))
|
||||
|
||||
// True dès que le 1er son de la réponse est audible (la 1ʳᵉ phrase TTS joue).
|
||||
// Pendant la génération LLM + la synthèse TTS (avant tout son), l'entrée est
|
||||
// VERROUILLÉE : un tap/bruit ne doit pas perturber le pipeline (half-duplex).
|
||||
@Volatile private var audiblePlayback = false
|
||||
|
||||
/** Le pipeline produit la réponse mais rien n'est encore audible (LLM ou TTS
|
||||
* en cours) → on ignore toute entrée utilisateur jusqu'au 1er son / fin de tour. */
|
||||
private fun producingBeforeAudio(): Boolean {
|
||||
val s = _pipelineState.value
|
||||
val busy = s is PipelineState.Thinking || s is PipelineState.Transcribing ||
|
||||
s is PipelineState.Transcribed || s is PipelineState.TokenGenerated ||
|
||||
s is PipelineState.Speaking
|
||||
return busy && !audiblePlayback
|
||||
}
|
||||
|
||||
fun interruptTts() {
|
||||
if (_pipelineState.value is PipelineState.Speaking) {
|
||||
// N'interrompre QUE si la réponse est réellement en train de jouer.
|
||||
if (_pipelineState.value is PipelineState.Speaking && audiblePlayback) {
|
||||
log("TTS interrupted by user")
|
||||
tts.stop()
|
||||
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
|
||||
|
|
@ -1054,7 +750,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
@Volatile private var pttRecording = false
|
||||
|
||||
fun toggleListening() {
|
||||
// If TTS is speaking, interrupt it
|
||||
// Verrou half-duplex : pendant la génération LLM / synthèse TTS (avant le 1er
|
||||
// son), on ignore le bouton micro — sinon un tap casse la synthèse en cours
|
||||
// (« play() on uninitialized AudioTrack », tour avorté). On réaccepte l'entrée
|
||||
// une fois l'audio audible (l'usager peut alors interrompre).
|
||||
if (producingBeforeAudio()) {
|
||||
log("toggleListening ignoré — génération/synthèse en cours (entrée verrouillée)")
|
||||
return
|
||||
}
|
||||
// Audio en cours → l'usager a le droit d'interrompre.
|
||||
if (_pipelineState.value is PipelineState.Speaking) {
|
||||
log("Interrupting TTS")
|
||||
tts.stop()
|
||||
|
|
@ -1284,6 +988,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
var wasMuted = false
|
||||
var lastBusyMs = 0L
|
||||
|
||||
// Pré-roll : ~300 ms de frames sous le seuil gardées en anneau pour
|
||||
// capturer l'attaque consonantique (« b », « p », « t »). Sans ça le
|
||||
// buffer speech ne démarrait qu'APRÈS le franchissement du seuil RMS,
|
||||
// mangeant le début de chaque phrase (bug #1, « 1ʳᵉ phrase ratée »).
|
||||
val preRoll = ArrayDeque<ShortArray>()
|
||||
val preRollMax = 3
|
||||
|
||||
while (_isListening.value) {
|
||||
val read = recorder.read(frame, 0, frameSize)
|
||||
if (read != frameSize) continue
|
||||
|
|
@ -1302,13 +1013,16 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
|| st is PipelineState.Transcribed
|
||||
|| st is PipelineState.TokenGenerated
|
||||
val nowMs = System.currentTimeMillis()
|
||||
// Tail buffer : 800 ms après la fin du Speaking pour
|
||||
// absorber la reverb / queue MediaPlayer qui peut continuer
|
||||
// à émettre du son alors que pipelineState est passé Idle.
|
||||
// Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
|
||||
// joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
|
||||
// échantillons) — il n'y a plus de queue MediaPlayer à absorber.
|
||||
// 800 ms (calibré pour l'ancien MediaPlayer) mangeait le début de
|
||||
// la réponse de l'usager juste après que Kazeia a fini de parler
|
||||
// (bug #1). 300 ms suffit pour la latence HP/DAC résiduelle.
|
||||
if (machineBusy) {
|
||||
lastBusyMs = nowMs
|
||||
}
|
||||
val inTailBuffer = (nowMs - lastBusyMs) < 800
|
||||
val inTailBuffer = (nowMs - lastBusyMs) < 300
|
||||
|
||||
if (machineBusy || inTailBuffer) {
|
||||
if (isSpeechActive) {
|
||||
|
|
@ -1354,6 +1068,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
|
||||
if (isSpeech && !inWarmup) {
|
||||
silenceFrameCount = 0
|
||||
if (speechFrameCount == 0 && preRoll.isNotEmpty()) {
|
||||
// Début d'un candidat speech : injecter l'attaque captée
|
||||
// juste avant le franchissement du seuil (sinon clippée).
|
||||
for (pf in preRoll) speechBuffer.add(pf)
|
||||
preRoll.clear()
|
||||
}
|
||||
speechFrameCount++
|
||||
speechBuffer.add(frame.copyOf())
|
||||
|
||||
|
|
@ -1459,6 +1179,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// No speech, reset
|
||||
speechBuffer.clear()
|
||||
speechFrameCount = 0
|
||||
// Alimente le pré-roll (hors warmup) : on garde les
|
||||
// dernières frames calmes pour préfixer l'attaque de la
|
||||
// prochaine phrase (bug #1).
|
||||
if (!inWarmup) {
|
||||
preRoll.addLast(frame.copyOf())
|
||||
while (preRoll.size > preRollMax) preRoll.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1543,7 +1270,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
"CLEAR_CHAT" -> {
|
||||
_messages.value = emptyList()
|
||||
// Vide aussi la mémoire conversationnelle du LLM (session engine).
|
||||
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
|
||||
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
|
||||
addMessage(ChatMessage(
|
||||
role = ChatMessage.Role.SYSTEM,
|
||||
text = "Conversation effacée."
|
||||
|
|
@ -1656,41 +1383,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
metrics.begin("text", text)
|
||||
}
|
||||
serviceScope.launch {
|
||||
// Special test commands — cancel previous pipeline first
|
||||
if (text.trim().lowercase().let { it.startsWith("pipeline") || it.startsWith("!pipeline") || it.startsWith("\\!pipeline") || it == "go" }) {
|
||||
currentPipelineJob?.cancel()
|
||||
currentPipelineJob = kotlin.coroutines.coroutineContext[kotlinx.coroutines.Job]
|
||||
val embedsPath = "/data/local/tmp/kazeia/models/qwen3-tts-npu/full_pipeline_embeds.bin"
|
||||
val wavPath = "/data/local/tmp/kazeia/tts_output.wav"
|
||||
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Running full pipeline..."))
|
||||
try {
|
||||
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
|
||||
val audio = qwenTts?.generateFromEmbeds(embedsPath) ?: ShortArray(0)
|
||||
if (audio.isNotEmpty()) {
|
||||
val f = java.io.File(wavPath)
|
||||
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
|
||||
val dataSize = audio.size * 2
|
||||
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
|
||||
out.writeBytes("WAVEfmt "); out.writeInt(Integer.reverseBytes(16))
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
|
||||
out.writeInt(Integer.reverseBytes(24000)); out.writeInt(Integer.reverseBytes(48000))
|
||||
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
|
||||
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
|
||||
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
|
||||
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
for (s in audio) bb.putShort(s)
|
||||
out.write(bb.array())
|
||||
}
|
||||
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "WAV: $wavPath (${audio.size/24000f}s)"))
|
||||
} else {
|
||||
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline: empty audio"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline error: ${e.message}"))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check voice commands before processing
|
||||
if (handleVoiceCommand(text)) return@launch
|
||||
|
|
@ -1702,6 +1394,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
|
||||
private suspend fun processLlmResponse(patientMessage: String) {
|
||||
_pipelineState.value = PipelineState.Thinking
|
||||
lastTurnInput = patientMessage // encart « dernier tour »
|
||||
lastTurnOutput = ""
|
||||
conversationManager.onNewTurn()
|
||||
|
||||
// Historique : tour patient persisté avant toute génération (no-op
|
||||
|
|
@ -1765,9 +1459,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
val result = tE.generateWithSystem(
|
||||
prompt = "Message du patient : $patientMessage",
|
||||
systemPromptOverride = cfg.thinker.systemPrompt,
|
||||
params = com.kazeia.core.SamplingParams(
|
||||
maxNewTokens = 128, temperature = cfg.thinker.temperature
|
||||
),
|
||||
params = samplingFrom(cfg.thinker),
|
||||
onToken = null,
|
||||
tag = "THINKER"
|
||||
)
|
||||
|
|
@ -1802,7 +1494,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// À 4 chars/tok ≈ 200 chars max user message safe avec cascade.
|
||||
// Si message plus long → on tombe en mono Speaker (pas de bullets).
|
||||
val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) {
|
||||
com.kazeia.llm.ExecuTorchLlmEngine.DEFAULT_SYSTEM_PROMPT +
|
||||
com.kazeia.config.ConfigStore.DEFAULT_SPEAKER_PROMPT +
|
||||
"\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" +
|
||||
cascadeBullets
|
||||
} else {
|
||||
|
|
@ -1831,19 +1523,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
|
||||
}
|
||||
|
||||
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate,
|
||||
// so the first complete sentence emitted by the LLM (on terminal
|
||||
// punctuation) is enqueued for synthesis while the LLM is still
|
||||
// decoding the rest of the response. Saves ~LLM-wall-time of TTFA
|
||||
// when the first sentence finishes before the LLM does (common for
|
||||
// short conversational answers). Non-Qwen TTS backends that lack
|
||||
// the enqueueSentence plumbing fall through to the legacy post-LLM
|
||||
// pipeline.speakText batch call.
|
||||
val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine
|
||||
// NB CosyVoice : PAS de streaming LLM→TTS. LLM decode et synthèse CosyVoice
|
||||
// sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher étrangle le
|
||||
// LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM finir vite
|
||||
// (session ~1,1s) PUIS synthesizeAndPlay (chemin legacy) qui streame déjà
|
||||
// TTS = CosyVoice (CPU) : PAS de streaming LLM→TTS. LLM decode et synthèse
|
||||
// CosyVoice sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher
|
||||
// étrangle le LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM
|
||||
// finir vite (session ~1,1s) PUIS pipeline.speakText (batch) qui streame déjà
|
||||
// phrase-par-phrase EN INTERNE (synthèse N+1 pendant lecture N, sans contention).
|
||||
// Bulle vide + signal Thinking sur le visualizer : la couleur de l'orbe
|
||||
// pulse pendant que le LLM réfléchit, plus de "..." textuels parasites.
|
||||
|
|
@ -1867,6 +1550,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
metrics.mark("PIPELINE.first_audio",
|
||||
"seg=$segIdx dur=${durationMs}ms")
|
||||
_pipelineState.value = PipelineState.Speaking
|
||||
// 1er son audible → on déverrouille l'interruption (l'usager peut
|
||||
// désormais couper la réponse). Cf. producingBeforeAudio().
|
||||
audiblePlayback = true
|
||||
}
|
||||
metrics.mark("TTS.seg.play_start",
|
||||
"seg=$segIdx sentence=\"${sentence.take(40)}\" dur=${durationMs}ms")
|
||||
|
|
@ -1904,23 +1590,11 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// as soon as the user finishes speaking, covering the LLM's TTFT.
|
||||
addMessage(bubble)
|
||||
val llmStreamStart = System.currentTimeMillis()
|
||||
val sentenceCounter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
// TTS coupé (llmOnly) → pas de session de synthèse ouverte ; la bulle
|
||||
// se remplit token par token dans onToken (voir plus bas).
|
||||
// Streaming LLM→TTS réservé à Qwen3 (NPU/Hexagon : pas de contention CPU avec
|
||||
// le LLM). CosyVoice (CPU) passe par le chemin batch (voir note plus haut).
|
||||
val ttsStreamer: com.kazeia.tts.SentenceStreamer? =
|
||||
if (qwen != null && !llmOnly) {
|
||||
qwen.onSegmentPlaying = onSegPlay
|
||||
qwen.startStreamingSession()
|
||||
com.kazeia.tts.SentenceStreamer { raw ->
|
||||
val sIdx = sentenceCounter.getAndIncrement()
|
||||
val dt = System.currentTimeMillis() - llmStreamStart
|
||||
val spoken = pipeline.stripNonSpeakable(raw).trim()
|
||||
log("[LLM.sentence] #$sIdx +${dt}ms → '$spoken'")
|
||||
if (spoken.isNotEmpty()) qwen.enqueueSentence(spoken)
|
||||
}
|
||||
} else null
|
||||
// TTS coupé (llmOnly) → pas de session ; la bulle se remplit token par token.
|
||||
// CosyVoice (CPU) : pas de streaming LLM→TTS (contention CPU). Le LLM décode
|
||||
// vite, puis synthèse batch via pipeline.speakText (qui streame déjà
|
||||
// phrase-par-phrase EN INTERNE). ttsStreamer reste donc null.
|
||||
val ttsStreamer: com.kazeia.tts.SentenceStreamer? = null
|
||||
|
||||
// In LLM_ONLY_MODE, kill the typing dots as soon as the first token
|
||||
// arrives so the bubble starts revealing text immediately. Outside
|
||||
|
|
@ -1970,17 +1644,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
}
|
||||
!stoppingCriteria.shouldStop(responseBuilder.toString())
|
||||
}
|
||||
val sParams = SamplingParams(
|
||||
maxNewTokens = 450,
|
||||
temperature = conversationManager.currentTemperature()
|
||||
)
|
||||
// Si bullets cascade dispo → injection dans system prompt via
|
||||
// generateWithSystem (cast vers ExecuTorchLlmEngine). Sinon
|
||||
// generateWithSystem aussi pour propager le prompt système Speaker
|
||||
// de la config runtime (hot-reload depuis admin app).
|
||||
// Sampling Speaker depuis la config (preset admin). maxTokens honoré par
|
||||
// le moteur ; les autres knobs prendront effet avec l'API sampling natif.
|
||||
val sParams = samplingFrom(cfg.speaker)
|
||||
// generateWithSystem propage le prompt système Speaker par tour (bullets
|
||||
// cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF
|
||||
// (mono-tour si override) comme pour .pte.
|
||||
val llmRef = llm
|
||||
val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt
|
||||
val result = if (llmRef is com.kazeia.llm.ExecuTorchLlmEngine) {
|
||||
val result = if (llmRef is com.kazeia.llm.UnifiedLlmAdapter) {
|
||||
llmRef.generateWithSystem(
|
||||
prompt = prompt,
|
||||
systemPromptOverride = speakerPromptToUse,
|
||||
|
|
@ -1997,6 +1669,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
|
||||
_aiWorkload.value = _aiWorkload.value.copy(llmActive = false)
|
||||
val responseText = result.text.trim()
|
||||
lastTurnOutput = responseText // encart « dernier tour »
|
||||
log("LLM prompt: '$prompt'")
|
||||
log("LLM raw response: '${result.text}'")
|
||||
log("LLM clean response: '$responseText'")
|
||||
|
|
@ -2008,15 +1681,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// onToken ; on finalise avec la réponse nettoyée.
|
||||
updateMessageText(bubble.id, responseText)
|
||||
metrics.end("llm_only")
|
||||
} else if (ttsStreamer != null && qwen != null) {
|
||||
// Streaming path: tail fragment (if any) + drain playback.
|
||||
ttsStreamer.flush()
|
||||
qwen.endStreamingSession()
|
||||
revealJobs.forEach { try { it.join() } catch (_: Exception) {} }
|
||||
updateMessageText(bubble.id, responseText)
|
||||
metrics.end("ok")
|
||||
} else {
|
||||
// Legacy batch path for non-Qwen TTS engines.
|
||||
// CosyVoice : synthèse batch (speakText streame en interne).
|
||||
_pipelineState.value = PipelineState.Speaking
|
||||
pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram ->
|
||||
onSegPlay(sentence, durationMs, envelope, spectrogram)
|
||||
|
|
@ -2033,9 +1699,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
totalMs = result.timeMs
|
||||
)
|
||||
} else {
|
||||
// Empty response — close any open streaming session cleanly
|
||||
// and drop the placeholder bubble.
|
||||
try { qwen?.endStreamingSession() } catch (_: Exception) {}
|
||||
// Empty response — drop the placeholder bubble.
|
||||
_messages.value = _messages.value.filter { it.id != bubble.id }
|
||||
metrics.end("empty_response")
|
||||
}
|
||||
|
|
@ -2044,7 +1708,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
log("ERROR: LLM generation error: ${e.message}")
|
||||
metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"")
|
||||
metrics.end("error")
|
||||
try { qwen?.endStreamingSession() } catch (_: Exception) {}
|
||||
_pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM")
|
||||
addMessage(ChatMessage(
|
||||
role = ChatMessage.Role.SYSTEM,
|
||||
|
|
@ -2052,14 +1715,17 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
))
|
||||
} finally {
|
||||
firstSegmentSeen.set(true)
|
||||
audiblePlayback = false // fin de tour : ré-verrouille pour le tour suivant
|
||||
try { typingJob.cancel() } catch (_: Exception) {}
|
||||
_pipelineState.value = if (_isListening.value)
|
||||
PipelineState.Listening else PipelineState.Idle
|
||||
if (!_isListening.value) {
|
||||
// L'orbe DOIT quitter Thinking/Speaking en fin de tour, y compris en
|
||||
// écoute continue — sinon il reste figé coloré (« réfléchit » alors
|
||||
// qu'il a fini). La boucle de capture réémettra Listening(micRms) au
|
||||
// fil des frames si on écoute encore. (Bug #3, reset jadis conditionnel.)
|
||||
_visualizerSignal.value = VisualizerSignal.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessage(message: ChatMessage) {
|
||||
_messages.value = _messages.value + message
|
||||
|
|
@ -2195,9 +1861,27 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
* libération immédiate.
|
||||
* - Thinker model_id changé en cascade ON → release, sera lazy-loadé.
|
||||
*/
|
||||
/** ModelConfig (preset admin) → SamplingParams pour la génération. ⚠ seul
|
||||
* maxNewTokens est honoré par le moteur natif actuel ; le reste est prêt et
|
||||
* prendra effet dès que le JNI expose le sampling (docs/SAMPLING_ENGINE_SPEC). */
|
||||
private fun samplingFrom(m: com.kazeia.config.ConfigStore.ModelConfig) =
|
||||
com.kazeia.core.SamplingParams(
|
||||
maxNewTokens = m.maxTokens,
|
||||
temperature = m.temperature,
|
||||
topP = m.topP,
|
||||
topK = m.topK,
|
||||
repetitionPenalty = m.repeatPenalty,
|
||||
presencePenalty = m.presencePenalty,
|
||||
frequencyPenalty = m.frequencyPenalty
|
||||
)
|
||||
|
||||
private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) {
|
||||
val old = runtimeConfig
|
||||
runtimeConfig = new
|
||||
if (new.debugEnabled != old.debugEnabled) {
|
||||
_debugMode.value = new.debugEnabled // affiche/masque le bouton debug côté patient
|
||||
log("[CONFIG] debug ${old.debugEnabled}→${new.debugEnabled}")
|
||||
}
|
||||
log("[CONFIG] applied : cascade ${old.cascadeEnabled}→${new.cascadeEnabled}, " +
|
||||
"speaker ${old.speaker.modelId}→${new.speaker.modelId}, " +
|
||||
"thinker ${old.thinker.modelId}→${new.thinker.modelId}")
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
// A/B harness : WhisperHybridEngine (prod) vs SttEngine (Kazeia-Engine lib unifiée)
|
||||
// sur les mêmes WAVs PCM16 mono 16 kHz. 3 runs/audio, médiane.
|
||||
// Déclenchement : `touch <files>/bench_qnn.trigger`, relance app.
|
||||
package com.kazeia.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
|
||||
object BenchQnnHarness {
|
||||
private const val TAG = "BenchQnn"
|
||||
|
||||
fun runBench(ctx: Context, audioDir: File, modelDir: String) {
|
||||
val wavs = audioDir.listFiles { _, n -> n.endsWith(".wav") }?.sortedBy { it.name }
|
||||
?: run { Log.e(TAG, "no WAVs in $audioDir"); return }
|
||||
|
||||
// -------- A/ WhisperHybridEngine (prod patché QNN) --------
|
||||
Log.i(TAG, "=== A/ WhisperHybridEngine (prod) ===")
|
||||
val prodTimes = mutableMapOf<String, Long>()
|
||||
val prodTexts = mutableMapOf<String, String>()
|
||||
run {
|
||||
val eng = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir) { Log.i(TAG, it) }
|
||||
runBlocking { eng.load(modelPath = modelDir) }
|
||||
if (!eng.isLoaded()) { Log.e(TAG, "WhisperHybridEngine load FAILED"); return@run }
|
||||
wavs.firstOrNull()?.let { readWavPcm16(it)?.let { p -> runBlocking { eng.transcribe(p, "fr") } } }
|
||||
for (f in wavs) {
|
||||
val pcm = readWavPcm16(f) ?: continue
|
||||
val t = mutableListOf<Long>(); var text = ""
|
||||
repeat(3) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
text = runBlocking { eng.transcribe(pcm, "fr") }.text
|
||||
t.add(System.currentTimeMillis() - t0)
|
||||
}
|
||||
t.sort()
|
||||
prodTimes[f.name] = t[1]; prodTexts[f.name] = text
|
||||
Log.i(TAG, "[PROD] ${f.name}: median=${t[1]}ms '${text}'")
|
||||
}
|
||||
eng.release()
|
||||
}
|
||||
|
||||
// -------- B/ SttEngine (libkazeia_stt) --------
|
||||
Log.i(TAG, "=== B/ SttEngine (libkazeia_stt) ===")
|
||||
val libTimes = mutableMapOf<String, Long>()
|
||||
val libTexts = mutableMapOf<String, String>()
|
||||
val libSteps = mutableMapOf<String, Triple<Int, Int, Int>>()
|
||||
run {
|
||||
val eng = try { SttEngine(modelDir, useHtp = true, nThreads = 6) }
|
||||
catch (e: Exception) { Log.e(TAG, "SttEngine load FAILED: ${e.message}"); return@run }
|
||||
wavs.firstOrNull()?.let { readWavPcm16(it)?.let { p -> try { eng.transcribe(p, "fr") } catch (_: Exception) {} } }
|
||||
for (f in wavs) {
|
||||
val pcm = readWavPcm16(f) ?: continue
|
||||
val t = mutableListOf<Long>(); var r: SttResult? = null
|
||||
repeat(3) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
try { r = eng.transcribe(pcm, "fr") } catch (e: Exception) { Log.e(TAG, "transcribe fail ${f.name}: ${e.message}"); return@repeat }
|
||||
t.add(System.currentTimeMillis() - t0)
|
||||
}
|
||||
if (r == null) continue
|
||||
t.sort()
|
||||
val rr = r!!
|
||||
libTimes[f.name] = t[1]; libTexts[f.name] = rr.text
|
||||
libSteps[f.name] = Triple(rr.melMs, rr.encoderMs, rr.decoderMs)
|
||||
Log.i(TAG, "[LIB ] ${f.name}: median=${t[1]}ms mel=${rr.melMs} enc=${rr.encoderMs} dec=${rr.decoderMs} ntok=${rr.nTokens} '${rr.text}'")
|
||||
}
|
||||
eng.release()
|
||||
}
|
||||
|
||||
// -------- A/B récap --------
|
||||
Log.i(TAG, "=== A/B récap ===")
|
||||
var prodTot = 0L; var libTot = 0L; var audioTot = 0L
|
||||
var same = 0; var near = 0
|
||||
for (f in wavs) {
|
||||
val a = prodTimes[f.name] ?: continue
|
||||
val b = libTimes[f.name] ?: continue
|
||||
val durMs = (readWavPcm16(f)?.size ?: 0) * 1000L / 16000
|
||||
prodTot += a; libTot += b; audioTot += durMs
|
||||
val ta = (prodTexts[f.name] ?: "").trim().lowercase()
|
||||
val tb = (libTexts[f.name] ?: "").trim().lowercase()
|
||||
val isSame = ta == tb
|
||||
val isNear = !isSame && ta.replace(Regex("\\W+"), "") == tb.replace(Regex("\\W+"), "")
|
||||
if (isSame) same++ else if (isNear) near++
|
||||
val mark = if (isSame) "≡" else if (isNear) "~" else "≠"
|
||||
Log.i(TAG, " ${f.name.padEnd(14)} prod=${a}ms lib=${b}ms Δ=${b - a}ms $mark")
|
||||
Log.i(TAG, " prod='${prodTexts[f.name]}'")
|
||||
Log.i(TAG, " lib ='${libTexts[f.name]}'")
|
||||
}
|
||||
if (audioTot > 0) {
|
||||
Log.i(TAG, "TOTAL prod=${prodTot}ms (RTF ${"%.3f".format(prodTot.toDouble() / audioTot)}) lib=${libTot}ms (RTF ${"%.3f".format(libTot.toDouble() / audioTot)})")
|
||||
}
|
||||
Log.i(TAG, "ACCURACY identique=$same/${wavs.size} proche(ponct.)=$near diff=${wavs.size - same - near}")
|
||||
Log.i(TAG, "=== Bench end ===")
|
||||
}
|
||||
|
||||
private fun readWavPcm16(f: File): ShortArray? = try {
|
||||
val b = f.readBytes()
|
||||
var off = 44
|
||||
for (i in 12 until b.size - 8)
|
||||
if (b[i] == 'd'.code.toByte() && b[i+1] == 'a'.code.toByte() &&
|
||||
b[i+2] == 't'.code.toByte() && b[i+3] == 'a'.code.toByte()) { off = i + 8; break }
|
||||
val n = (b.size - off) / 2
|
||||
ShortArray(n) { i -> ((b[off + 2*i].toInt() and 0xFF) or (b[off + 2*i + 1].toInt() shl 8)).toShort() }
|
||||
} catch (e: Exception) { Log.e(TAG, "wav fail ${f.name}: ${e.message}"); null }
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package com.kazeia.stt
|
||||
|
||||
import com.kazeia.core.SttEngine as CoreSttEngine
|
||||
import com.kazeia.core.TranscriptionResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Adapter qui expose la lib unifiée Kazeia-Engine STT
|
||||
* (com.kazeia.stt.SttEngine, façade native de libkazeia_stt.so) derrière
|
||||
* l'interface prod com.kazeia.core.SttEngine. Sert au swap réversible piloté
|
||||
* par le flag config `sttEngine` ("prod" | "lib").
|
||||
*
|
||||
* Mesuré 2026-06-01, A/B sur 6 audios FR 3 s mono 16k vs WhisperHybridEngine :
|
||||
* - accuracy 6/6 identique au texte prod
|
||||
* - RTF global −21 % (gain decodeur-lourd, mel DFT 400 plus cher que prod FFT 512)
|
||||
* - RAM ~−30 % (≈379 MB vs 545)
|
||||
* Cf [[project-stt-engine-ab-in-app]] pour le détail.
|
||||
*/
|
||||
class KazeiaSttAdapter(
|
||||
private val modelDir: String,
|
||||
private val useHtp: Boolean = true,
|
||||
private val nThreads: Int = 6,
|
||||
private val onLog: ((String) -> Unit)? = null
|
||||
) : CoreSttEngine {
|
||||
private var engine: SttEngine? = null
|
||||
|
||||
override suspend fun load(modelPath: String?) = withContext(Dispatchers.IO) {
|
||||
if (engine != null) return@withContext
|
||||
val t0 = System.currentTimeMillis()
|
||||
val dir = modelPath ?: modelDir
|
||||
engine = SttEngine(dir, useHtp = useHtp, nThreads = nThreads)
|
||||
onLog?.invoke("KazeiaSttAdapter loaded in ${System.currentTimeMillis() - t0}ms (path=$dir)")
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = engine != null
|
||||
|
||||
override suspend fun transcribe(audioData: ShortArray, language: String): TranscriptionResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val e = engine ?: throw IllegalStateException("KazeiaSttAdapter not loaded")
|
||||
val r = e.transcribe(audioData, language = language)
|
||||
TranscriptionResult(
|
||||
text = r.text,
|
||||
confidence = 1.0f,
|
||||
language = language,
|
||||
durationMs = r.totalMs.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
override fun release() { engine?.release(); engine = null }
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package com.kazeia.stt
|
||||
|
||||
// Engine STT Whisper-Small (Qualcomm AI Hub HfWhisper KV-cache) in-process.
|
||||
// Backend : ONNX Runtime + QNN ExecutionProvider (HTP V79 sur SM8750).
|
||||
// Empreinte RAM : ~545 MB (encoder ctx 201 MB + decoder ctx 345 MB).
|
||||
//
|
||||
// Bench prod (Whisper-Small, FR, audio 1.6s) : ~825 ms, RTF 0.51.
|
||||
// Bench port C++ (audio 5s, FR) : ~2000 ms, RTF 0.40 — voir CLAUDE.md/STT.
|
||||
//
|
||||
// Le bridge natif est dans dist/jni/kazeia_stt_jni.cpp ; build via
|
||||
// dist/build_kazeia_stt.sh.
|
||||
|
||||
class SttJni {
|
||||
external fun nativeLoad(
|
||||
modelDir: String,
|
||||
useHtp: Boolean,
|
||||
nThreads: Int,
|
||||
maxDecodeSteps: Int
|
||||
): Long
|
||||
|
||||
/** Renvoie une chaîne : "err|text|mel_ms|enc_ms|dec_ms|total_ms|n_tokens|rtf" */
|
||||
external fun nativeTranscribe(
|
||||
handle: Long,
|
||||
pcm: ShortArray,
|
||||
sampleRate: Int,
|
||||
language: String,
|
||||
forceTranscribe: Boolean
|
||||
): String
|
||||
|
||||
external fun nativeFree(handle: Long)
|
||||
|
||||
external fun nativeVadNew(
|
||||
sampleRate: Int, frameSize: Int, rmsThreshold: Int,
|
||||
minSpeechFrames: Int, silenceEndFrames: Int
|
||||
): Long
|
||||
|
||||
/** 0 = silence, 1 = speech in progress, 2 = end_of_speech declenched */
|
||||
external fun nativeVadPush(handle: Long, pcm: ShortArray): Int
|
||||
external fun nativeVadReset(handle: Long)
|
||||
external fun nativeVadFree(handle: Long)
|
||||
|
||||
companion object { init { System.loadLibrary("kazeia_stt") } }
|
||||
}
|
||||
|
||||
data class SttResult(
|
||||
val text: String,
|
||||
val melMs: Int,
|
||||
val encoderMs: Int,
|
||||
val decoderMs: Int,
|
||||
val totalMs: Int,
|
||||
val nTokens: Int,
|
||||
val rtf: Float
|
||||
)
|
||||
|
||||
class SttEngine(
|
||||
modelDir: String,
|
||||
useHtp: Boolean = true,
|
||||
nThreads: Int = 6,
|
||||
maxDecodeSteps: Int = 200
|
||||
) {
|
||||
private val jni = SttJni()
|
||||
private val h = jni.nativeLoad(modelDir, useHtp, nThreads, maxDecodeSteps)
|
||||
init { require(h != 0L) { "SttEngine: load FAILED (modelDir=$modelDir, useHtp=$useHtp)" } }
|
||||
|
||||
/**
|
||||
* Transcrit un buffer PCM16 mono. Le sample rate doit être 16000 (resample externe sinon).
|
||||
* @param language "fr", "en", "auto", ... (cf Whisper langues, 99 codes ISO).
|
||||
* @param forceTranscribe true -> override <|translate|> en <|transcribe|>.
|
||||
*/
|
||||
@Throws(IllegalStateException::class)
|
||||
fun transcribe(
|
||||
pcm: ShortArray,
|
||||
language: String = "fr",
|
||||
sampleRate: Int = 16000,
|
||||
forceTranscribe: Boolean = true
|
||||
): SttResult {
|
||||
val raw = jni.nativeTranscribe(h, pcm, sampleRate, language, forceTranscribe)
|
||||
// Format : "err|text|mel|enc|dec|total|n_tokens|rtf"
|
||||
// limit=8 conserve un éventuel '|' dans le texte (au cas où) dans le 2e champ.
|
||||
val parts = raw.split('|', limit = 8)
|
||||
if (parts.size < 8) error("SttEngine.transcribe: format inattendu '$raw'")
|
||||
val err = parts[0].toInt()
|
||||
if (err != 0) error("SttEngine.transcribe err=$err raw='$raw'")
|
||||
return SttResult(
|
||||
text = parts[1],
|
||||
melMs = parts[2].toInt(),
|
||||
encoderMs = parts[3].toInt(),
|
||||
decoderMs = parts[4].toInt(),
|
||||
totalMs = parts[5].toInt(),
|
||||
nTokens = parts[6].toInt(),
|
||||
rtf = parts[7].toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
fun release() { jni.nativeFree(h) }
|
||||
}
|
||||
|
||||
/**
|
||||
* VAD RMS énergie compatible drop-in avec VadStage.kt prod.
|
||||
* Paramètres par défaut : 16 kHz, frame 100 ms (1600 samples), seuil PCM16 brut 150,
|
||||
* 3 frames pour déclencher parole (300 ms), 8 frames pour fin (800 ms).
|
||||
*
|
||||
* Usage typique :
|
||||
* val vad = SttVad()
|
||||
* audioRecord.read(buf) { pcm ->
|
||||
* when (vad.push(pcm)) {
|
||||
* SttVad.SILENCE -> { ... }
|
||||
* SttVad.SPEECH -> { ... // bufferise }
|
||||
* SttVad.END_OF_SPEECH -> { stt.transcribe(buffer) ; vad.reset() ; buffer.clear() }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class SttVad(
|
||||
sampleRate: Int = 16000,
|
||||
frameSize: Int = 1600,
|
||||
rmsThreshold: Int = 150,
|
||||
minSpeechFrames: Int = 3,
|
||||
silenceEndFrames: Int = 8
|
||||
) {
|
||||
private val jni = SttJni()
|
||||
private val h = jni.nativeVadNew(sampleRate, frameSize, rmsThreshold,
|
||||
minSpeechFrames, silenceEndFrames)
|
||||
fun push(pcm: ShortArray): Int = jni.nativeVadPush(h, pcm)
|
||||
fun reset() { jni.nativeVadReset(h) }
|
||||
fun release() { jni.nativeVadFree(h) }
|
||||
|
||||
companion object {
|
||||
const val SILENCE = 0
|
||||
const val SPEECH = 1
|
||||
const val END_OF_SPEECH = 2
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import android.content.UriMatcher
|
|||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Debug
|
||||
import android.os.Process
|
||||
import java.io.File
|
||||
|
|
@ -42,6 +43,10 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
const val PATH_RAG = "rag"
|
||||
const val PATH_RAG_STATUS = "rag_status"
|
||||
const val PATH_RAG_QUERY = "rag_query"
|
||||
const val PATH_UPDATES = "updates"
|
||||
// Méthodes ContentProvider.call() pour les MAJ à la demande (pilotées admin).
|
||||
const val CALL_UPDATE_CHECK = "update_check"
|
||||
const val CALL_UPDATE_INSTALL = "update_install"
|
||||
const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
|
||||
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
|
||||
const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG"
|
||||
|
|
@ -64,6 +69,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
private const val CODE_RAG_ITEM = 16
|
||||
private const val CODE_RAG_STATUS = 17
|
||||
private const val CODE_RAG_QUERY = 18
|
||||
private const val CODE_UPDATES = 19
|
||||
|
||||
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
|
||||
// sur tablette dev, stockage externe app sinon). Non-const.
|
||||
|
|
@ -73,6 +79,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
|
||||
private const val PREFIX_SUFFIX = "_voice_prefix.bin"
|
||||
private const val SUFFIX_SUFFIX = "_voice_suffix.bin"
|
||||
// Voix CosyVoice (prod) : artefacts `.cvps` enrôlés. C'est l'inventaire réel
|
||||
// depuis la bascule ; les `_voice_*.bin` ci-dessus sont des vestiges Qwen3.
|
||||
private val VOICE_CVPS_DIR: String
|
||||
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/cosyvoice"
|
||||
private const val CVPS_SUFFIX = ".cvps"
|
||||
}
|
||||
|
||||
private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||
|
|
@ -97,6 +108,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM)
|
||||
addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS)
|
||||
addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY)
|
||||
addURI(AUTHORITY, PATH_UPDATES, CODE_UPDATES)
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
|
@ -124,9 +136,49 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
|
||||
CODE_RAG_STATUS -> ragStatusCursor()
|
||||
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
|
||||
CODE_UPDATES -> updatesCursor()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** État live des mises à jour (lu par l'admin pendant check/install). */
|
||||
private fun updatesCursor(): Cursor {
|
||||
val s = com.kazeia.dist.UpdateStatus
|
||||
val cols = arrayOf(
|
||||
"phase", "app_update_available", "app_version_name", "app_size",
|
||||
"content_count", "content_size", "progress_done", "progress_total",
|
||||
"label", "error", "last_check"
|
||||
)
|
||||
return MatrixCursor(cols).apply {
|
||||
addRow(arrayOf(
|
||||
s.phase.name,
|
||||
if (s.appUpdateAvailable) 1 else 0,
|
||||
s.appVersionName, s.appSize,
|
||||
s.contentCount, s.contentSize,
|
||||
s.progressDone, s.progressTotal,
|
||||
s.label, s.error, s.lastCheck
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** RPC pilotées admin : déclenche un check ou une installation de MAJ. */
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
val ctx = context ?: return null
|
||||
val mode = when (method) {
|
||||
CALL_UPDATE_CHECK -> com.kazeia.dist.UpdateWorker.MODE_CHECK
|
||||
CALL_UPDATE_INSTALL -> com.kazeia.dist.UpdateWorker.MODE_INSTALL
|
||||
else -> return null
|
||||
}
|
||||
val req = androidx.work.OneTimeWorkRequestBuilder<com.kazeia.dist.UpdateWorker>()
|
||||
.setInputData(androidx.work.workDataOf(com.kazeia.dist.UpdateWorker.KEY_MODE to mode))
|
||||
.build()
|
||||
androidx.work.WorkManager.getInstance(ctx).enqueueUniqueWork(
|
||||
com.kazeia.dist.UpdateWorker.NAME,
|
||||
androidx.work.ExistingWorkPolicy.REPLACE,
|
||||
req
|
||||
)
|
||||
return Bundle().apply { putBoolean("accepted", true) }
|
||||
}
|
||||
|
||||
private fun ragStatusCursor(): Cursor {
|
||||
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||
val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending")
|
||||
|
|
@ -256,23 +308,51 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
val cols = arrayOf(
|
||||
"cascade_enabled", "tts_enabled",
|
||||
"stt_engine", "llm_engine", "tts_engine", "rag_enabled",
|
||||
"rag_threshold", "rag_top_k",
|
||||
"rag_threshold", "rag_top_k", "debug_enabled",
|
||||
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
|
||||
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
|
||||
"speaker_top_p", "speaker_top_k", "speaker_repeat_penalty",
|
||||
"speaker_presence_penalty", "speaker_frequency_penalty",
|
||||
"speaker_max_tokens", "speaker_preset_name",
|
||||
"thinker_model_id", "thinker_system_prompt", "thinker_temperature",
|
||||
"thinker_top_p", "thinker_top_k", "thinker_repeat_penalty",
|
||||
"thinker_presence_penalty", "thinker_frequency_penalty",
|
||||
"thinker_max_tokens", "thinker_preset_name",
|
||||
"presets_json"
|
||||
)
|
||||
val cursor = MatrixCursor(cols)
|
||||
val s = cfg.speaker; val t = cfg.thinker
|
||||
cursor.addRow(arrayOf(
|
||||
if (cfg.cascadeEnabled) 1 else 0,
|
||||
if (cfg.ttsEnabled) 1 else 0,
|
||||
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
||||
if (cfg.ragEnabled) 1 else 0,
|
||||
cfg.ragThreshold, cfg.ragTopK,
|
||||
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
||||
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
||||
if (cfg.debugEnabled) 1 else 0,
|
||||
s.modelId, s.systemPrompt, s.temperature,
|
||||
s.topP, s.topK, s.repeatPenalty, s.presencePenalty, s.frequencyPenalty, s.maxTokens, s.presetName,
|
||||
t.modelId, t.systemPrompt, t.temperature,
|
||||
t.topP, t.topK, t.repeatPenalty, t.presencePenalty, t.frequencyPenalty, t.maxTokens, t.presetName,
|
||||
presetsToJson(cfg.presets)
|
||||
))
|
||||
return cursor
|
||||
}
|
||||
|
||||
/** Sérialise la bibliothèque de presets en JSON (1 colonne `presets_json`). */
|
||||
private fun presetsToJson(presets: List<com.kazeia.config.ConfigStore.SamplingPreset>): String {
|
||||
val arr = org.json.JSONArray()
|
||||
presets.forEach { p ->
|
||||
arr.put(org.json.JSONObject().apply {
|
||||
put("name", p.name); put("temperature", p.temperature)
|
||||
put("top_p", p.topP); put("top_k", p.topK)
|
||||
put("repeat_penalty", p.repeatPenalty)
|
||||
put("presence_penalty", p.presencePenalty)
|
||||
put("frequency_penalty", p.frequencyPenalty)
|
||||
put("max_tokens", p.maxTokens)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
private fun modelsCursor(): Cursor {
|
||||
val cols = arrayOf(
|
||||
"id", "display_name", "pte_path", "tokenizer_path",
|
||||
|
|
@ -361,20 +441,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
values.getAsBoolean("rag_enabled") else cur.ragEnabled,
|
||||
ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold,
|
||||
ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK,
|
||||
speaker = com.kazeia.config.ConfigStore.ModelConfig(
|
||||
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
||||
systemPrompt = values.getAsString("speaker_system_prompt")
|
||||
?: cur.speaker.systemPrompt,
|
||||
temperature = values.getAsFloat("speaker_temperature")
|
||||
?: cur.speaker.temperature
|
||||
),
|
||||
thinker = com.kazeia.config.ConfigStore.ModelConfig(
|
||||
modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId,
|
||||
systemPrompt = values.getAsString("thinker_system_prompt")
|
||||
?: cur.thinker.systemPrompt,
|
||||
temperature = values.getAsFloat("thinker_temperature")
|
||||
?: cur.thinker.temperature
|
||||
)
|
||||
debugEnabled = if (values.containsKey("debug_enabled"))
|
||||
values.getAsBoolean("debug_enabled") else cur.debugEnabled,
|
||||
speaker = mergeModel(values, "speaker_", cur.speaker),
|
||||
thinker = mergeModel(values, "thinker_", cur.thinker),
|
||||
presets = values.getAsString("presets_json")?.let { presetsFromJson(it) } ?: cur.presets
|
||||
)
|
||||
store.save(newCfg)
|
||||
ctx.contentResolver.notifyChange(uri, null)
|
||||
|
|
@ -382,6 +453,42 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
return 1
|
||||
}
|
||||
|
||||
/** Fusionne les colonnes `<prefix>*` d'un ContentValues dans un ModelConfig
|
||||
* (merge partiel : un champ absent reste inchangé). */
|
||||
private fun mergeModel(
|
||||
v: ContentValues, p: String, cur: com.kazeia.config.ConfigStore.ModelConfig
|
||||
) = com.kazeia.config.ConfigStore.ModelConfig(
|
||||
modelId = v.getAsString("${p}model_id") ?: cur.modelId,
|
||||
systemPrompt = v.getAsString("${p}system_prompt") ?: cur.systemPrompt,
|
||||
temperature = v.getAsFloat("${p}temperature") ?: cur.temperature,
|
||||
topP = v.getAsFloat("${p}top_p") ?: cur.topP,
|
||||
topK = v.getAsInteger("${p}top_k") ?: cur.topK,
|
||||
repeatPenalty = v.getAsFloat("${p}repeat_penalty") ?: cur.repeatPenalty,
|
||||
presencePenalty = v.getAsFloat("${p}presence_penalty") ?: cur.presencePenalty,
|
||||
frequencyPenalty = v.getAsFloat("${p}frequency_penalty") ?: cur.frequencyPenalty,
|
||||
maxTokens = v.getAsInteger("${p}max_tokens") ?: cur.maxTokens,
|
||||
presetName = v.getAsString("${p}preset_name") ?: cur.presetName
|
||||
)
|
||||
|
||||
private fun presetsFromJson(text: String): List<com.kazeia.config.ConfigStore.SamplingPreset> =
|
||||
runCatching {
|
||||
val arr = org.json.JSONArray(text)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { js ->
|
||||
com.kazeia.config.ConfigStore.SamplingPreset(
|
||||
name = js.optString("name", ""),
|
||||
temperature = js.optDouble("temperature", 0.7).toFloat(),
|
||||
topP = js.optDouble("top_p", 0.85).toFloat(),
|
||||
topK = js.optInt("top_k", 40),
|
||||
repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(),
|
||||
presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(),
|
||||
frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(),
|
||||
maxTokens = js.optInt("max_tokens", 120)
|
||||
)
|
||||
}
|
||||
}.filter { it.name.isNotBlank() }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun upsertProfile(values: ContentValues): Int {
|
||||
val ctx = context ?: return 0
|
||||
val store = com.kazeia.profiles.ProfileStore.get(ctx)
|
||||
|
|
@ -424,26 +531,32 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
private fun voicesCursor(): Cursor {
|
||||
val cols = arrayOf(
|
||||
"id", "name", "state",
|
||||
"wav_exists", "prefix_exists", "suffix_exists",
|
||||
"wav_exists", "prefix_exists", "suffix_exists", "cvps_exists",
|
||||
"wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at"
|
||||
)
|
||||
val cursor = MatrixCursor(cols)
|
||||
|
||||
// Set des ids = union (wav basenames) ∪ (prefix bin basenames)
|
||||
// Inventaire = réalité CosyVoice : union (WAV = « à convertir ») ∪
|
||||
// (`.cvps` = « prête »). Les embeddings Qwen3 (`_voice_*.bin`) NE sont plus
|
||||
// comptés dans l'union : vestiges morts depuis la bascule, ils faisaient
|
||||
// apparaître des voix « prête » fantômes sans aucun `.cvps`.
|
||||
val wavDir = File(VOICE_WAV_DIR)
|
||||
val embDir = File(VOICE_EMB_DIR)
|
||||
val cvpsDir = File(VOICE_CVPS_DIR)
|
||||
val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray())
|
||||
.associateBy { it.nameWithoutExtension.lowercase() }
|
||||
val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray())
|
||||
.associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() }
|
||||
val ids = (wavIds.keys + prefixIds.keys).sorted()
|
||||
val cvpsIds = (cvpsDir.listFiles { f -> f.name.endsWith(CVPS_SUFFIX) } ?: emptyArray())
|
||||
.associateBy { it.name.removeSuffix(CVPS_SUFFIX).lowercase() }
|
||||
val ids = (wavIds.keys + cvpsIds.keys).sorted()
|
||||
|
||||
for (id in ids) {
|
||||
val wav = wavIds[id]
|
||||
val cvps = cvpsIds[id]
|
||||
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
|
||||
val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX")
|
||||
// « ready » = `.cvps` déployé (jouable par CosyVoice) ; « recorded » =
|
||||
// WAV seul (à convertir/enrôler par Kazeia-central) ; sinon « error ».
|
||||
val state = when {
|
||||
prefix.exists() && suffix.exists() -> "ready"
|
||||
cvps != null -> "ready"
|
||||
wav != null -> "recorded"
|
||||
else -> "error"
|
||||
}
|
||||
|
|
@ -455,10 +568,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
if (wav != null) 1 else 0,
|
||||
if (prefix.exists()) 1 else 0,
|
||||
if (suffix.exists()) 1 else 0,
|
||||
if (cvps != null) 1 else 0,
|
||||
wav?.length() ?: 0L,
|
||||
durSec,
|
||||
wav?.absolutePath ?: "",
|
||||
wav?.lastModified() ?: prefix.lastModified()
|
||||
wav?.lastModified() ?: cvps?.lastModified() ?: 0L
|
||||
))
|
||||
}
|
||||
return cursor
|
||||
|
|
@ -617,15 +731,15 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
private fun turnsCursor(): Cursor {
|
||||
val cols = arrayOf(
|
||||
"timestamp", "input_kind", "status", "total_ms",
|
||||
"stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
|
||||
"time_to_first_audio_ms", "audio_playback_ms"
|
||||
"stt_ms", "thinker_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
|
||||
"tts_ms", "time_to_first_audio_ms", "audio_playback_ms"
|
||||
)
|
||||
val cursor = MatrixCursor(cols)
|
||||
for (t in TelemetryHolder.recentTurns()) {
|
||||
cursor.addRow(arrayOf(
|
||||
t.timestamp, t.inputKind, t.status, t.totalMs,
|
||||
t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
|
||||
t.timeToFirstAudioMs, t.audioPlaybackMs
|
||||
t.sttDurationMs, t.thinkerMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
|
||||
t.ttsSynthMs, t.timeToFirstAudioMs, t.audioPlaybackMs
|
||||
))
|
||||
}
|
||||
return cursor
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ object TelemetryHolder {
|
|||
val status: String, // "ok" | "llm_only" | "empty_response" | "error"
|
||||
val totalMs: Long,
|
||||
val sttDurationMs: Long?,
|
||||
val llmTtftMs: Long?,
|
||||
val llmGenerationMs: Long?,
|
||||
val thinkerMs: Long? = null, // cascade Thinker (analyse silencieuse), si actif
|
||||
val llmTtftMs: Long?, // Speaker : temps jusqu'au 1er token
|
||||
val llmGenerationMs: Long?, // Speaker : génération complète
|
||||
val llmTokens: Int?,
|
||||
val ttsSynthMs: Long? = null, // TTS : Speaker fini → 1er son (synthèse 1ʳᵉ phrase)
|
||||
val timeToFirstAudioMs: Long?,
|
||||
val audioPlaybackMs: Long?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Calcule les features visuelles d'un buffer PCM float mono pour piloter l'orbe
|
||||
* [com.kazeia.ui.AudioVisualizerView] en mode Speaking :
|
||||
* - enveloppe RMS (1 valeur par frame, 0..1) → pulsation du contour ;
|
||||
* - spectrogramme [BANDS] bandes log-espacées (0..1) → modes de Fourier du bord.
|
||||
*
|
||||
* Sans dépendance externe (FFT radix-2 maison). Coût négligeable face à la
|
||||
* synthèse neuronale CosyVoice (~quelques centaines de FFT de 1024 par phrase).
|
||||
*
|
||||
* Remplace l'ancien calcul de l'engine Qwen3 (archivé lors de la bascule
|
||||
* CosyVoice) ; le callback par-phrase qui l'alimentait avait été perdu, ce qui
|
||||
* laissait l'orbe figé et masquait le texte (cf. fix bug #2/#3).
|
||||
*/
|
||||
object AudioFeatures {
|
||||
/** Doit rester aligné sur AudioVisualizerView.SPECTRUM_BANDS. */
|
||||
const val BANDS = 12
|
||||
|
||||
/** @return Pair(envelope[nFrames] 0..1, spectrogram[nFrames][BANDS] 0..1). */
|
||||
fun analyze(
|
||||
pcm: FloatArray,
|
||||
sampleRate: Int,
|
||||
windowMs: Int = 40,
|
||||
hopMs: Int = 20
|
||||
): Pair<FloatArray, Array<FloatArray>> {
|
||||
if (pcm.isEmpty()) return FloatArray(0) to emptyArray()
|
||||
val hop = (sampleRate * hopMs / 1000).coerceAtLeast(1)
|
||||
var win = 1
|
||||
val want = (sampleRate * windowMs / 1000).coerceAtLeast(2)
|
||||
while (win < want) win = win shl 1
|
||||
val half = win / 2
|
||||
val nFrames = ((pcm.size - 1) / hop) + 1
|
||||
if (nFrames <= 0) return FloatArray(0) to emptyArray()
|
||||
|
||||
// Bornes de bandes log-espacées sur les bins [1 .. half].
|
||||
val edges = IntArray(BANDS + 1)
|
||||
val lnLo = ln(1.0); val lnHi = ln(half.toDouble())
|
||||
for (b in 0..BANDS) {
|
||||
val frac = b.toFloat() / BANDS
|
||||
edges[b] = Math.exp(lnLo + frac * (lnHi - lnLo)).toInt().coerceIn(1, half)
|
||||
}
|
||||
|
||||
val hann = DoubleArray(win) { 0.5 - 0.5 * cos(2.0 * PI * it / (win - 1)) }
|
||||
val re = DoubleArray(win)
|
||||
val im = DoubleArray(win)
|
||||
val envelope = FloatArray(nFrames)
|
||||
val spectro = Array(nFrames) { FloatArray(BANDS) }
|
||||
var envMax = 1e-6f
|
||||
var bandMax = 1e-6f
|
||||
|
||||
for (f in 0 until nFrames) {
|
||||
val start = f * hop
|
||||
var sumSq = 0.0; var count = 0
|
||||
for (i in 0 until win) {
|
||||
val idx = start + i
|
||||
val x = if (idx < pcm.size) pcm[idx].toDouble() else 0.0
|
||||
re[i] = x * hann[i]; im[i] = 0.0
|
||||
if (idx < pcm.size) { sumSq += x * x; count++ }
|
||||
}
|
||||
val rms = if (count > 0) sqrt(sumSq / count).toFloat() else 0f
|
||||
envelope[f] = rms
|
||||
if (rms > envMax) envMax = rms
|
||||
|
||||
fft(re, im)
|
||||
val row = spectro[f]
|
||||
for (b in 0 until BANDS) {
|
||||
val k0 = edges[b]
|
||||
val k1 = edges[b + 1].coerceAtLeast(k0 + 1)
|
||||
var acc = 0.0; var n = 0
|
||||
var k = k0
|
||||
while (k < k1 && k <= half) {
|
||||
acc += sqrt(re[k] * re[k] + im[k] * im[k]); n++; k++
|
||||
}
|
||||
val v = if (n > 0) (acc / n).toFloat() else 0f
|
||||
row[b] = v
|
||||
if (v > bandMax) bandMax = v
|
||||
}
|
||||
}
|
||||
|
||||
val envInv = 1f / envMax
|
||||
val bInv = 1f / bandMax
|
||||
for (f in 0 until nFrames) {
|
||||
envelope[f] = (envelope[f] * envInv).coerceIn(0f, 1f)
|
||||
val row = spectro[f]
|
||||
for (b in 0 until BANDS) row[b] = (row[b] * bInv).coerceIn(0f, 1f)
|
||||
}
|
||||
return envelope to spectro
|
||||
}
|
||||
|
||||
/** FFT radix-2 in-place (Cooley-Tukey), `re.size` doit être une puissance de 2. */
|
||||
private fun fft(re: DoubleArray, im: DoubleArray) {
|
||||
val n = re.size
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||
j = j or bit
|
||||
if (i < j) {
|
||||
val tr = re[i]; re[i] = re[j]; re[j] = tr
|
||||
val ti = im[i]; im[i] = im[j]; im[j] = ti
|
||||
}
|
||||
}
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val ang = -2.0 * PI / len
|
||||
val wlr = cos(ang); val wli = sin(ang)
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
var wr = 1.0; var wi = 0.0
|
||||
val halfLen = len / 2
|
||||
for (k in 0 until halfLen) {
|
||||
val ur = re[i + k]; val ui = im[i + k]
|
||||
val a = re[i + k + halfLen]; val b = im[i + k + halfLen]
|
||||
val vr = a * wr - b * wi
|
||||
val vi = a * wi + b * wr
|
||||
re[i + k] = ur + vr; im[i + k] = ui + vi
|
||||
re[i + k + halfLen] = ur - vr; im[i + k + halfLen] = ui - vi
|
||||
val nwr = wr * wlr - wi * wli
|
||||
wi = wr * wli + wi * wlr; wr = nwr
|
||||
}
|
||||
i += len
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
// Harness TTS ADB-pilotable, mirror du LLM bench.
|
||||
// Déclenchement : `touch <files>/bench_tts.trigger`, relance app.
|
||||
// Charge libkazeia_tts CPU-only + 1 synthèse FR → fichier WAV.
|
||||
package com.kazeia.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
object BenchTtsHarness {
|
||||
private const val TAG = "BenchTts"
|
||||
|
||||
fun runBench(ctx: Context) {
|
||||
val talker = "/data/local/tmp/kz-engine/talker_f32.gguf"
|
||||
val vocab = "/data/local/tmp/kz-engine/Qwen3-4B-Q4_0.gguf"
|
||||
val dump = "/data/local/tmp/kz-engine/tts_dump"
|
||||
val outWav = "${ctx.getExternalFilesDir(null)}/tts_bench_out.wav"
|
||||
|
||||
Log.i(TAG, "=== load libkazeia_tts CPU-only ===")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val jni = TtsJni()
|
||||
val h = try {
|
||||
jni.nativeLoad(talker, vocab, dump, /*useHtp=*/false, /*nThreads=*/6, /*cpUseCache=*/true)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "load THROW: ${e.message}"); return
|
||||
}
|
||||
if (h == 0L) { Log.e(TAG, "load FAIL (handle=0). talker=$talker vocab=$vocab dump=$dump"); return }
|
||||
Log.i(TAG, "load OK in ${System.currentTimeMillis()-t0}ms, handle=$h")
|
||||
|
||||
val text = "Bonsoir Pierre, comment vous sentez-vous aujourd'hui ?"
|
||||
Log.i(TAG, "=== synthèse : '$text' → $outWav ===")
|
||||
val ts = System.currentTimeMillis()
|
||||
val r = try {
|
||||
jni.nativeSynthesize(
|
||||
h, text, outWav,
|
||||
/*maxSteps=*/256, /*seed=*/42,
|
||||
0.9f, 50, 0.95f, 1.10f, // cp sampling
|
||||
0.9f, 50, 0.95f, 1.10f // talker sampling
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "synth THROW: ${e.message}"); jni.nativeFree(h); return
|
||||
}
|
||||
val wall = System.currentTimeMillis() - ts
|
||||
// IntArray = [err, frames, total_ms, prefill_ms, talker_ms, cp_ms, decoder_ms]
|
||||
if (r == null || r.size < 7) { Log.e(TAG, "synth bad result: ${r?.toList()}"); jni.nativeFree(h); return }
|
||||
val err = r[0]; val frames = r[1]; val totalMs = r[2]
|
||||
val prefillMs = r[3]; val talkerMs = r[4]; val cpMs = r[5]; val decoderMs = r[6]
|
||||
val audioMs = frames * 80 // 12.5 fps => 80 ms/frame
|
||||
val rtf = if (audioMs > 0) totalMs.toFloat() / audioMs else 0f
|
||||
Log.i(TAG, "synth err=$err frames=$frames audio=${audioMs}ms total=${totalMs}ms (wall=${wall}ms)")
|
||||
Log.i(TAG, " prefill=$prefillMs talker=$talkerMs cp=$cpMs decoder=$decoderMs RTF=${"%.2f".format(rtf)}")
|
||||
Log.i(TAG, " WAV écrit : $outWav (taille=${java.io.File(outWav).length()} octets)")
|
||||
|
||||
jni.nativeFree(h)
|
||||
Log.i(TAG, "=== Bench end ===")
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,8 @@ import android.media.AudioTrack
|
|||
import com.kazeia.core.TtsEngine
|
||||
import com.kazeia.core.TtsResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
|
@ -58,6 +59,10 @@ class CosyVoiceTtsEngine(
|
|||
private val voices = HashMap<String, Long>()
|
||||
@Volatile private var currentVoiceId: String = defaultVoiceId
|
||||
@Volatile private var audioTrack: AudioTrack? = null
|
||||
// Sérialise nativeSynthesize : le pré-chauffage (warmup) et une vraie synthèse
|
||||
// ne doivent jamais tourner en parallèle sur le même handle voix.
|
||||
private val synthMutex = Mutex()
|
||||
@Volatile private var warmedVoices = HashSet<String>()
|
||||
|
||||
private fun base(): String = com.kazeia.KazeiaApplication.MODELS_DIR
|
||||
private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
|
||||
|
|
@ -104,8 +109,18 @@ class CosyVoiceTtsEngine(
|
|||
|
||||
private fun voiceHandleOrThrow(): Long {
|
||||
require(handle != 0L) { "CosyVoice: not loaded" }
|
||||
val v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId)
|
||||
require(v != 0L) { "CosyVoice: aucune voix chargée ('$currentVoiceId')" }
|
||||
var v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId)
|
||||
// Repli gracieux : une voix configurée mais non déployée (.cvps absent) ne
|
||||
// doit JAMAIS rendre le TTS muet — on bascule sur la voix par défaut
|
||||
// (toujours livrée). Sinon un profil pointant une voix manquante coupe
|
||||
// toute parole en silence (cf. incident "zelda" non déployé).
|
||||
if (v == 0L && currentVoiceId != defaultVoiceId) {
|
||||
log("voix '$currentVoiceId' indisponible → repli sur défaut '$defaultVoiceId'")
|
||||
v = voices[defaultVoiceId] ?: ensureVoice(defaultVoiceId)
|
||||
}
|
||||
require(v != 0L) {
|
||||
"CosyVoice: aucune voix chargée ('$currentVoiceId' ni défaut '$defaultVoiceId')"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
|
@ -114,31 +129,45 @@ class CosyVoiceTtsEngine(
|
|||
val v = voiceHandleOrThrow()
|
||||
val out = ArrayList<Float>()
|
||||
for (s in sentences(text)) {
|
||||
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f) ?: continue
|
||||
val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) } ?: continue
|
||||
pcm.forEach { out.add(it) }
|
||||
}
|
||||
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
|
||||
TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR)
|
||||
}
|
||||
|
||||
override suspend fun synthesizeAndPlay(
|
||||
text: String,
|
||||
language: String,
|
||||
onStart: (() -> Unit)?,
|
||||
onComplete: (() -> Unit)?
|
||||
) {
|
||||
val v = voiceHandleOrThrow()
|
||||
val segs = sentences(text)
|
||||
if (segs.isEmpty()) { onComplete?.invoke(); return }
|
||||
/**
|
||||
* Pré-chauffe le cache K/V du prompt voix : la 1ʳᵉ synthèse d'une voix remplit
|
||||
* ce cache (~70 % du coût flow), d'où un 1er énoncé lent (RTF ~1.9, mesuré
|
||||
* 10→8→5 s à mesure que le cache chauffe), puis RTF ~1. En synthétisant une
|
||||
* phrase courte JETÉE en tâche de fond au démarrage, le 1er VRAI tour part déjà
|
||||
* chaud. Idempotent par voix ; sérialisé par synthMutex (pas de course avec une
|
||||
* vraie synthèse). Best-effort : un échec ne casse rien.
|
||||
*/
|
||||
suspend fun warmup(voiceId: String? = null) {
|
||||
if (handle == 0L) return
|
||||
val vid = voiceId ?: currentVoiceId
|
||||
if (vid in warmedVoices) return
|
||||
val v = voices[vid] ?: ensureVoice(vid)
|
||||
if (v == 0L) return
|
||||
withContext(Dispatchers.Default) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val ok = synthMutex.withLock {
|
||||
runCatching { CosyVoiceJni.nativeSynthesize(v, "Bonjour, je suis là.", 1.0f) }.isSuccess
|
||||
}
|
||||
if (ok) { warmedVoices.add(vid); log("warmup voix '$vid' OK (${System.currentTimeMillis() - t0}ms)") }
|
||||
}
|
||||
}
|
||||
|
||||
// AudioTrack flottant 24 kHz mono en MODE_STREAM : on commence à jouer dès
|
||||
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le
|
||||
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour
|
||||
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts).
|
||||
/** Construit un AudioTrack 24 kHz mono float MODE_STREAM. Renvoie null (et
|
||||
* libère) si le HAL le rend NON initialisé, pour permettre un retry au lieu
|
||||
* d'un play() qui lèverait. */
|
||||
private fun buildTrackOrNull(): AudioTrack? {
|
||||
val minBuf = AudioTrack.getMinBufferSize(
|
||||
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
|
||||
).coerceAtLeast(SR * 4) // ~2 s de marge mono float
|
||||
val track = AudioTrack.Builder()
|
||||
val t = try {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
|
|
@ -151,6 +180,43 @@ class CosyVoiceTtsEngine(
|
|||
.setBufferSizeInBytes(minBuf)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
} catch (e: Throwable) {
|
||||
log("AudioTrack build a échoué: ${e.message}"); return null
|
||||
}
|
||||
if (t.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
return null
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
override suspend fun synthesizeAndPlay(
|
||||
text: String,
|
||||
language: String,
|
||||
onStart: (() -> Unit)?,
|
||||
onComplete: (() -> Unit)?,
|
||||
onSegment: ((String, Long, FloatArray, Array<FloatArray>) -> Unit)?
|
||||
) {
|
||||
val v = voiceHandleOrThrow()
|
||||
val segs = sentences(text)
|
||||
if (segs.isEmpty()) { onComplete?.invoke(); return }
|
||||
|
||||
// AudioTrack flottant 24 kHz mono en MODE_STREAM : on commence à jouer dès
|
||||
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le
|
||||
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour
|
||||
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts).
|
||||
// Le HAL audio peut rendre un AudioTrack NON initialisé quand on le crée
|
||||
// juste après une interruption TTS + réouverture micro (sortie en
|
||||
// transition) → play() lèverait « uninitialized AudioTrack » et la réponse
|
||||
// serait muette. On valide STATE_INITIALIZED et on retente brièvement.
|
||||
val track = buildTrackOrNull() ?: run {
|
||||
kotlinx.coroutines.delay(120)
|
||||
buildTrackOrNull()
|
||||
}
|
||||
if (track == null) {
|
||||
log("AudioTrack non initialisé (HAL occupé) après retry — réponse muette")
|
||||
onComplete?.invoke(); return
|
||||
}
|
||||
audioTrack = track
|
||||
|
||||
var totalFrames = 0
|
||||
|
|
@ -159,14 +225,35 @@ class CosyVoiceTtsEngine(
|
|||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
for ((i, s) in segs.withIndex()) {
|
||||
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f)
|
||||
// Interruption (l'usager re-tape le micro pendant la synthèse) :
|
||||
// stop() libère le track et met audioTrack=null. On le détecte par
|
||||
// identité et on abandonne PROPREMENT, au lieu d'appeler play()/write
|
||||
// sur un track mort → « play() on uninitialized AudioTrack ».
|
||||
if (audioTrack !== track) { log("TTS interrompu pendant la synthèse — abandon"); break }
|
||||
val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) }
|
||||
if (pcm == null || pcm.isEmpty()) continue
|
||||
if (audioTrack !== track) { log("TTS interrompu — pas de lecture"); break }
|
||||
if (!started) {
|
||||
started = true
|
||||
onStart?.invoke()
|
||||
track.play()
|
||||
try { track.play() } catch (e: Throwable) {
|
||||
log("play() impossible (interrompu ?): ${e.message}"); break
|
||||
}
|
||||
log("1er son à ${System.currentTimeMillis() - t0}ms (phrase 1/${segs.size})")
|
||||
}
|
||||
// Callback par-phrase : la phrase précédente a drainé (write
|
||||
// bloquant ci-dessous), donc on est ~au démarrage audio de
|
||||
// celle-ci. Pilote le reveal texte + l'orbe. Best-effort :
|
||||
// une erreur de features ne casse pas la lecture.
|
||||
onSegment?.let { cb ->
|
||||
val durMs = pcm.size * 1000L / SR
|
||||
val (env, spec) = try {
|
||||
AudioFeatures.analyze(pcm, SR)
|
||||
} catch (e: Throwable) {
|
||||
log("features err: ${e.message}"); FloatArray(0) to emptyArray()
|
||||
}
|
||||
if (env.isNotEmpty() && spec.isNotEmpty()) cb(s, durMs, env, spec)
|
||||
}
|
||||
// write bloquant (MODE_STREAM) : régule le débit sur la lecture.
|
||||
var off = 0
|
||||
while (off < pcm.size) {
|
||||
|
|
@ -183,17 +270,16 @@ class CosyVoiceTtsEngine(
|
|||
|
||||
if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return }
|
||||
|
||||
// Attend le drain réel des échantillons écrits avant de rendre la main.
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
cont.invokeOnCancellation { stopInternal(track) }
|
||||
track.setNotificationMarkerPosition(totalFrames)
|
||||
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
|
||||
override fun onMarkerReached(t: AudioTrack?) { if (cont.isActive) cont.resume(Unit) }
|
||||
override fun onPeriodicNotification(t: AudioTrack?) {}
|
||||
})
|
||||
// MODE_STREAM : stop() laisse drainer ce qui est déjà écrit puis atteint le marker.
|
||||
try { track.stop() } catch (_: Exception) {}
|
||||
}
|
||||
// Drain DÉTERMINISTE : à ce point tout le PCM est écrit, mais les writes
|
||||
// bloquants laissent ~1 buffer (≈1 s) ENCORE non joué. On lit la position
|
||||
// de lecture (sans Looper — onMarkerReached ne se déclenche pas sur
|
||||
// Dispatchers.Default) pour savoir combien il reste, et on attend EXACTEMENT
|
||||
// cette durée avant de libérer. PAS de stop()/flush avant la fin (ça
|
||||
// jetterait le buffer → phrase coupée). Au plus près de la dernière syllabe,
|
||||
// sans queue morte ni troncature.
|
||||
val played = try { track.playbackHeadPosition } catch (_: Exception) { totalFrames }
|
||||
val remainMs = ((totalFrames - played).coerceAtLeast(0)) * 1000L / SR
|
||||
if (remainMs > 0) kotlinx.coroutines.delay(remainMs + 150L) // laisse jouer le reste
|
||||
stopInternal(track)
|
||||
onComplete?.invoke()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
// Adapter core.TtsEngine -> libkazeia_tts (engine CPU-only unifié).
|
||||
// Remplace Qwen3TtsEngine quand ttsEngine="lib". Synthèse vers WAV temp via
|
||||
// TtsJni, lecture PCM, playback AudioTrack (MODE_STATIC, ColorOS-safe).
|
||||
//
|
||||
// Assets requis (résolus sous baseDir) : talker_f32.gguf, Qwen3-4B-Q4_0.gguf,
|
||||
// tts_dump/ (text_embed.bin, tp_*, cp_*.gguf, qwen3tts_decoder.gguf, ...).
|
||||
package com.kazeia.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import com.kazeia.core.TtsEngine
|
||||
import com.kazeia.core.TtsResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class KazeiaTtsEngine(
|
||||
private val context: Context,
|
||||
private val log: (String) -> Unit = {}
|
||||
) : TtsEngine {
|
||||
|
||||
companion object {
|
||||
private const val SR = 24000
|
||||
private const val MAX_STEPS = 256
|
||||
private const val SEED = 42
|
||||
// En dessous de ce |sample|, une frame de 80 ms est considérée silence.
|
||||
private const val SILENCE_THRESHOLD = 200
|
||||
private const val STAGING_DIR = "/data/local/tmp/kz-engine"
|
||||
}
|
||||
|
||||
private val jni = TtsJni()
|
||||
private var handle: Long = 0L
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private val sampling = TtsSampling()
|
||||
// Voix demandée (chemin WAV). Réappliquée après chaque load (le x_vector est
|
||||
// mémorisé natif par handle, et le handle change au reload).
|
||||
@Volatile private var pendingVoiceWav: String? = null
|
||||
|
||||
private fun baseDir(): String {
|
||||
val models = com.kazeia.KazeiaApplication.MODELS_DIR
|
||||
return if (File("$models/tts_dump/qwen3tts_decoder.gguf").exists()) models else STAGING_DIR
|
||||
}
|
||||
|
||||
override suspend fun load(modelPath: String?, voiceId: String?) {
|
||||
if (handle != 0L) return
|
||||
withContext(Dispatchers.Default) {
|
||||
val base = baseDir()
|
||||
val talker = "$base/talker_f32.gguf"
|
||||
val vocab = "$base/Qwen3-4B-Q4_0.gguf"
|
||||
val dump = "$base/tts_dump"
|
||||
log("load: base=$base")
|
||||
val t0 = System.currentTimeMillis()
|
||||
handle = jni.nativeLoad(talker, vocab, dump, /*useHtp=*/false, /*nThreads=*/6, /*cpUseCache=*/true)
|
||||
if (handle == 0L) {
|
||||
log("load FAIL (handle=0). talker=$talker vocab=$vocab dump=$dump")
|
||||
error("KazeiaTtsEngine load failed (handle=0)")
|
||||
}
|
||||
log("load OK in ${System.currentTimeMillis() - t0}ms")
|
||||
// Réapplique la voix demandée avant le load (sinon le x_vector natif,
|
||||
// indexé par handle, est perdu au reload et on retombe sur damien).
|
||||
pendingVoiceWav?.let { applyVoice(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = handle != 0L
|
||||
|
||||
/**
|
||||
* Hot-swap de voix "basé sur le WAV" : le natif dérive le x_vector du fichier
|
||||
* WAV (downmix + resample 24k embarqués) via le speaker encoder, et l'utilise
|
||||
* pour tous les synth suivants. [voiceWavPath] = chemin vers un .wav de voix
|
||||
* (ex: .../voix/elodie.wav). Effet au prochain segment synthétisé.
|
||||
*/
|
||||
fun setVoice(voiceWavPath: String) {
|
||||
pendingVoiceWav = voiceWavPath
|
||||
if (handle != 0L) applyVoice(voiceWavPath)
|
||||
}
|
||||
|
||||
private fun applyVoice(voiceWavPath: String) {
|
||||
if (!File(voiceWavPath).exists()) {
|
||||
log("setVoice: WAV introuvable: $voiceWavPath — voix inchangée")
|
||||
return
|
||||
}
|
||||
val ok = try { jni.nativeSetVoiceWav(handle, voiceWavPath) } catch (e: Exception) {
|
||||
log("setVoice: nativeSetVoiceWav exception: ${e.message}"); false
|
||||
}
|
||||
log(if (ok) "Voix basculée sur ${File(voiceWavPath).nameWithoutExtension}"
|
||||
else "setVoice: encodage échoué pour $voiceWavPath (speaker encoder absent ?) — voix inchangée")
|
||||
}
|
||||
|
||||
override suspend fun synthesize(text: String, language: String): TtsResult =
|
||||
withContext(Dispatchers.Default) {
|
||||
require(handle != 0L) { "KazeiaTtsEngine: not loaded" }
|
||||
val outWav = File(context.cacheDir, "kz_tts_${System.nanoTime()}.wav")
|
||||
val r = jni.nativeSynthesize(
|
||||
handle, text, outWav.absolutePath, MAX_STEPS, SEED,
|
||||
sampling.cpTemp, sampling.cpTopK, sampling.cpTopP, sampling.cpRepPenalty,
|
||||
sampling.talkerTemp, sampling.talkerTopK, sampling.talkerTopP, sampling.talkerRepPenalty
|
||||
)
|
||||
if (r == null || r.size < 7 || r[0] != 0) {
|
||||
outWav.delete()
|
||||
error("KazeiaTtsEngine.synthesize err=${r?.getOrNull(0)} text=\"$text\"")
|
||||
}
|
||||
log("synth frames=${r[1]} total=${r[2]}ms (talker=${r[4]} cp=${r[5]} dec=${r[6]})")
|
||||
val pcm = readWavPcm(outWav)
|
||||
outWav.delete()
|
||||
val trimmed = trimTrailingSilence(pcm)
|
||||
TtsResult(
|
||||
audioData = trimmed,
|
||||
sampleRate = SR,
|
||||
durationMs = (trimmed.size * 1000L / SR)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun synthesizeAndPlay(
|
||||
text: String,
|
||||
language: String,
|
||||
onStart: (() -> Unit)?,
|
||||
onComplete: (() -> Unit)?
|
||||
) {
|
||||
val result = synthesize(text, language)
|
||||
if (result.audioData.isEmpty()) { onComplete?.invoke(); return }
|
||||
onStart?.invoke()
|
||||
// Suspend jusqu'à la FIN réelle de la lecture (marker AudioTrack), pas
|
||||
// seulement le démarrage. Sans ça, l'appelant (pipeline.speakText)
|
||||
// repasse l'état Speaking→Idle dès track.play(), le micro se démute
|
||||
// et capture le WAV qui joue encore → écho/boucle.
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
cont.invokeOnCancellation { stop() }
|
||||
playAudio(result.audioData) { if (cont.isActive) cont.resume(Unit) }
|
||||
}
|
||||
onComplete?.invoke()
|
||||
}
|
||||
|
||||
private fun playAudio(audioData: ShortArray, onComplete: () -> Unit) {
|
||||
stop()
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build())
|
||||
.setAudioFormat(AudioFormat.Builder()
|
||||
.setSampleRate(SR)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build())
|
||||
.setBufferSizeInBytes(audioData.size * 2)
|
||||
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||
.build()
|
||||
audioTrack = track
|
||||
track.write(audioData, 0, audioData.size)
|
||||
track.setNotificationMarkerPosition(audioData.size)
|
||||
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
|
||||
override fun onMarkerReached(t: AudioTrack?) { onComplete() }
|
||||
override fun onPeriodicNotification(t: AudioTrack?) {}
|
||||
})
|
||||
track.play()
|
||||
}
|
||||
|
||||
private fun readWavPcm(wav: File): ShortArray {
|
||||
val bytes = wav.readBytes()
|
||||
if (bytes.size <= 44) return ShortArray(0)
|
||||
// En-tête PCM canonique 44 octets (écrit par le JNI).
|
||||
val dataBytes = bytes.size - 44
|
||||
val n = dataBytes / 2
|
||||
val bb = ByteBuffer.wrap(bytes, 44, dataBytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
return ShortArray(n) { bb.short }
|
||||
}
|
||||
|
||||
// Coupe le silence de fin (le talker pad jusqu'à MAX_STEPS si EOS n'est pas
|
||||
// émis -> sans ça la lecture/orbe traînerait plusieurs secondes de blanc).
|
||||
private fun trimTrailingSilence(pcm: ShortArray): ShortArray {
|
||||
var end = pcm.size
|
||||
while (end > 0 && kotlin.math.abs(pcm[end - 1].toInt()) < SILENCE_THRESHOLD) end--
|
||||
if (end == pcm.size) return pcm
|
||||
// Garde ~120 ms de queue pour ne pas couper sec.
|
||||
val tail = (SR * 120 / 1000)
|
||||
val keep = minOf(pcm.size, end + tail)
|
||||
return pcm.copyOfRange(0, keep)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
audioTrack?.let {
|
||||
try { it.pause(); it.flush(); it.stop() } catch (_: Exception) {}
|
||||
try { it.release() } catch (_: Exception) {}
|
||||
}
|
||||
audioTrack = null
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
stop()
|
||||
if (handle != 0L) { try { jni.nativeFree(handle) } catch (_: Exception) {}; handle = 0L }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
/** NEON SIMD optimized operations for TTS head argmax. */
|
||||
object NeonOps {
|
||||
init { System.loadLibrary("neon_ops") }
|
||||
|
||||
/** Argmax of hidden @ headWeights.T for one head. */
|
||||
external fun headArgmax(hidden: FloatArray, headWeights: FloatArray, vocab: Int, dim: Int): Int
|
||||
|
||||
/** Batch argmax for all heads at once (avoids JNI overhead per head). */
|
||||
external fun headArgmaxBatch(hidden: FloatArray, allHeads: FloatArray, numHeads: Int, vocab: Int, dim: Int): IntArray
|
||||
|
||||
/** Argmax with offset into a large weight buffer (avoids array copy). */
|
||||
external fun headArgmaxOffset(hidden: FloatArray, allHeads: FloatArray, offset: Int, vocab: Int, dim: Int): Int
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Byte-level BPE tokenizer compatible with Qwen2/Qwen3 tokenizer.
|
||||
*
|
||||
* Why this file exists:
|
||||
* The app needs to tokenize arbitrary LLM-generated French text on-device
|
||||
* for the TTS pipeline (the reduced 1050-token table shipped previously
|
||||
* couldn't handle free-form text). The reference is the HuggingFace
|
||||
* Qwen2TokenizerFast — a GPT-2-style byte-level BPE. We reimplement it
|
||||
* in Kotlin rather than linking libtokenizers.so so there's no new
|
||||
* native dependency and the numbers are easy to audit.
|
||||
*
|
||||
* Algorithm (identical to GPT-2 / Qwen2):
|
||||
* 1. Pre-tokenize the input with a regex that groups contractions,
|
||||
* words, numbers, and whitespace into "word chunks" that BPE never
|
||||
* merges across.
|
||||
* 2. UTF-8 encode each chunk, then map each byte (0..255) to one of
|
||||
* 256 printable Unicode code points via a fixed "byte encoder" table
|
||||
* — this is the trick that lets a byte-level vocab fit inside JSON
|
||||
* without invalid or control characters.
|
||||
* 3. Apply BPE: repeatedly find the pair with the lowest rank in the
|
||||
* merges list and merge it, until no more merges apply. Look up each
|
||||
* resulting super-token in vocab.json to get the final token IDs.
|
||||
*
|
||||
* Bit-perfect compatibility notes:
|
||||
* - The pre-tokenize regex below MUST match the one in tokenizer_config.json.
|
||||
* Qwen2 uses the GPT-2 pattern with a couple of Unicode property
|
||||
* extensions; Java regex supports these directly.
|
||||
* - Byte encoder is canonical (see bytesToUnicode()).
|
||||
* - Merges are rank-ordered: lower line number = higher priority, matching
|
||||
* HuggingFace's `merges.txt` file ordering.
|
||||
* - We do NOT add BOS/EOS or chat-template specials — the TTS prefill
|
||||
* prepends its own 9-embed voice prefix that already handles role tokens.
|
||||
*/
|
||||
class Qwen3BpeTokenizer private constructor(
|
||||
private val vocab: HashMap<String, Int>,
|
||||
private val merges: HashMap<Pair<String, String>, Int>,
|
||||
private val byteEncoder: IntArray,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "Qwen3BPE"
|
||||
|
||||
// Qwen2/Qwen3 pre-tokenization regex. This is the exact pattern used
|
||||
// by the HuggingFace Qwen2Tokenizer (adapted from GPT-2). Matches
|
||||
// contractions ('s|'d|'ll|'ve|'re|'t), word chunks with a leading
|
||||
// optional non-word char + letters, runs of digits, runs of
|
||||
// punctuation, and whitespace runs with boundary semantics.
|
||||
//
|
||||
// Note on the character classes: Android's Pattern does NOT support
|
||||
// UNICODE_CHARACTER_CLASS — so plain \\p{L} / \\p{N} would collapse
|
||||
// to ASCII-only and break French accents ("é" → wrong token). Use
|
||||
// \\p{IsAlphabetic} and \\p{IsDigit} instead; those ARE Unicode-aware
|
||||
// out of the box (they map to ICU's IsAlphabetic / IsDigit properties
|
||||
// in both JDK and Android runtimes). Output matches Python's Qwen2
|
||||
// tokenizer on French text.
|
||||
private val PRE_TOKENIZE_PATTERN: Pattern = Pattern.compile(
|
||||
"'s|'d|'ll|'ve|'re|'t" +
|
||||
"|[^\\r\\n\\p{IsAlphabetic}\\p{IsDigit}]?\\p{IsAlphabetic}+" +
|
||||
"|\\p{IsDigit}{1,3}" +
|
||||
"| ?[^\\s\\p{IsAlphabetic}\\p{IsDigit}]+[\\r\\n]*" +
|
||||
"|\\s*[\\r\\n]+" +
|
||||
"|\\s+(?!\\S)" +
|
||||
"|\\s+"
|
||||
)
|
||||
|
||||
/**
|
||||
* GPT-2 byte encoder: maps 0..255 → a printable Unicode codepoint.
|
||||
* Ensures every possible byte has a visible, JSON-safe representation
|
||||
* so a byte-level vocab can be stored as strings in vocab.json.
|
||||
*/
|
||||
private fun bytesToUnicode(): IntArray {
|
||||
val bs = mutableListOf<Int>()
|
||||
// Printable ASCII and common Latin blocks.
|
||||
bs.addAll(('!'.code..'~'.code).toList())
|
||||
bs.addAll(('¡'.code..'¬'.code).toList())
|
||||
bs.addAll(('®'.code..'ÿ'.code).toList())
|
||||
val cs = bs.toMutableList()
|
||||
// Every byte not in bs gets mapped to a code point past 255 so no
|
||||
// existing character collides with it.
|
||||
var n = 0
|
||||
val map = IntArray(256)
|
||||
for (b in 0..255) {
|
||||
if (b in bs) {
|
||||
map[b] = b
|
||||
} else {
|
||||
map[b] = 256 + n
|
||||
bs.add(b) // placeholder only, we've already recorded cs from the frozen snapshot
|
||||
cs.add(256 + n)
|
||||
n += 1
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
fun load(modelDir: String): Qwen3BpeTokenizer {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val vocabFile = File("$modelDir/vocab.json")
|
||||
val mergesFile = File("$modelDir/merges.txt")
|
||||
require(vocabFile.exists()) { "vocab.json missing at $modelDir" }
|
||||
require(mergesFile.exists()) { "merges.txt missing at $modelDir" }
|
||||
|
||||
val vocabJson = JSONObject(vocabFile.readText())
|
||||
val vocab = HashMap<String, Int>(vocabJson.length())
|
||||
val keys = vocabJson.keys()
|
||||
while (keys.hasNext()) {
|
||||
val k = keys.next()
|
||||
vocab[k] = vocabJson.getInt(k)
|
||||
}
|
||||
|
||||
val merges = HashMap<Pair<String, String>, Int>()
|
||||
var rank = 0
|
||||
mergesFile.useLines { lines ->
|
||||
for (line in lines) {
|
||||
// Skip header / blanks. Qwen's merges.txt starts with
|
||||
// "#version" which we simply filter out.
|
||||
if (line.isBlank() || line.startsWith("#")) continue
|
||||
val sp = line.indexOf(' ')
|
||||
if (sp < 0) continue
|
||||
merges[Pair(line.substring(0, sp), line.substring(sp + 1))] = rank
|
||||
rank++
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Loaded vocab=${vocab.size} merges=${merges.size} in ${System.currentTimeMillis()-t0}ms")
|
||||
return Qwen3BpeTokenizer(vocab, merges, bytesToUnicode())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single pre-tokenized word (UTF-8 bytes encoded via the byte
|
||||
* encoder into a string) into token IDs via BPE merges. Caches results so
|
||||
* repeated common words (spaces, punctuation) only BPE once.
|
||||
*/
|
||||
private val bpeCache = HashMap<String, IntArray>()
|
||||
|
||||
private fun bpeEncode(byteEncodedWord: String): IntArray {
|
||||
bpeCache[byteEncodedWord]?.let { return it }
|
||||
|
||||
// Start with one "sub-token" per Unicode code point (code points,
|
||||
// not chars — surrogate pairs are handled automatically since the
|
||||
// byte encoder only produces BMP codepoints by construction).
|
||||
val parts = ArrayList<String>(byteEncodedWord.length)
|
||||
var i = 0
|
||||
while (i < byteEncodedWord.length) {
|
||||
val cp = byteEncodedWord.codePointAt(i)
|
||||
parts.add(String(Character.toChars(cp)))
|
||||
i += Character.charCount(cp)
|
||||
}
|
||||
if (parts.size < 2) {
|
||||
val id = vocab[parts.getOrElse(0) { "" }] ?: vocab["<unk>"] ?: 0
|
||||
val out = intArrayOf(id)
|
||||
bpeCache[byteEncodedWord] = out
|
||||
return out
|
||||
}
|
||||
|
||||
// Greedy lowest-rank merge, classic BPE. We scan for the pair with
|
||||
// the smallest rank, merge ALL its occurrences, then repeat. This
|
||||
// matches HF's reference implementation.
|
||||
while (parts.size > 1) {
|
||||
var bestRank = Int.MAX_VALUE
|
||||
var bestIdx = -1
|
||||
for (k in 0 until parts.size - 1) {
|
||||
val r = merges[Pair(parts[k], parts[k + 1])] ?: continue
|
||||
if (r < bestRank) { bestRank = r; bestIdx = k }
|
||||
}
|
||||
if (bestIdx < 0) break
|
||||
// Merge all non-overlapping occurrences of that exact pair.
|
||||
val a = parts[bestIdx]; val b = parts[bestIdx + 1]
|
||||
val merged = a + b
|
||||
var k = 0
|
||||
val out = ArrayList<String>(parts.size - 1)
|
||||
while (k < parts.size) {
|
||||
if (k < parts.size - 1 && parts[k] == a && parts[k + 1] == b) {
|
||||
out.add(merged); k += 2
|
||||
} else {
|
||||
out.add(parts[k]); k += 1
|
||||
}
|
||||
}
|
||||
parts.clear(); parts.addAll(out)
|
||||
}
|
||||
|
||||
val ids = IntArray(parts.size)
|
||||
for (k in parts.indices) {
|
||||
ids[k] = vocab[parts[k]] ?: vocab["<unk>"] ?: 0
|
||||
}
|
||||
bpeCache[byteEncodedWord] = ids
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode text → token IDs using the full Qwen3 vocabulary. Does NOT
|
||||
* prepend BOS/EOS — callers can add specials themselves. Unicode
|
||||
* characters outside ASCII (e.g. French accents) are UTF-8 encoded and
|
||||
* go through the byte encoder, so "é" and "ï" tokenize the same way as
|
||||
* they do in Python.
|
||||
*/
|
||||
fun encode(text: String): IntArray {
|
||||
val all = ArrayList<Int>(text.length / 2 + 4)
|
||||
val matcher = PRE_TOKENIZE_PATTERN.matcher(text)
|
||||
while (matcher.find()) {
|
||||
val chunk = matcher.group()
|
||||
// UTF-8 encode the chunk, then map each raw byte to its Unicode
|
||||
// "byte-encoded" character. This produces the exact string that
|
||||
// BPE merges operate on.
|
||||
val bytes = chunk.toByteArray(Charsets.UTF_8)
|
||||
val sb = StringBuilder(bytes.size)
|
||||
for (b in bytes) sb.appendCodePoint(byteEncoder[b.toInt() and 0xff])
|
||||
val ids = bpeEncode(sb.toString())
|
||||
for (id in ids) all.add(id)
|
||||
}
|
||||
return all.toIntArray()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,91 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Décodeur Qwen3-TTS pur ggml C++. Remplace la voie .pte ExecuTorch.
|
||||
*
|
||||
* Pipeline complet codes [16, T] long → wav [T*1920] PCM16 fait en C++ :
|
||||
* Quantizer + pre_conv + pre_transformer + 2× upsample + BigVGAN.
|
||||
*
|
||||
* Validé bit-close vs PyTorch reference (max_abs_diff 2.1e-3, RMSE 1.0e-4)
|
||||
* sur Damien FR. Compute ~4.85s pour 4.64s audio sur OnePlus Pad 3 8 threads
|
||||
* (RTF 1.05) — équivalent à ExecuTorch CPU XNNPACK / 2 grâce aux optims
|
||||
* ARM features (i8mm, dotprod, fp16) + F16 conv weights + layout uniforme.
|
||||
*/
|
||||
class Qwen3TtsGgmlDecoder(private val ggufPath: String) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TtsDecoderGgml"
|
||||
init {
|
||||
try {
|
||||
System.loadLibrary("tts_decoder_ggml")
|
||||
Log.i(TAG, "loaded libtts_decoder_ggml.so")
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.e(TAG, "loadLibrary failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var handle: Long = 0L
|
||||
|
||||
fun load(): Boolean {
|
||||
if (handle != 0L) return true
|
||||
// Test 2026-05-03 : GGML_NUM_THREADS=4 ne réduisait PAS le pic
|
||||
// (ggml fait du work-sharing sur tensors uniques, pas de réplication
|
||||
// par thread). Reverté → laisse default 8 (plus rapide).
|
||||
handle = nativeCreate(ggufPath)
|
||||
if (handle == 0L) {
|
||||
Log.e(TAG, "nativeCreate returned 0 (load failed)")
|
||||
return false
|
||||
}
|
||||
Log.i(TAG, "decoder loaded from $ggufPath, handle=0x${java.lang.Long.toHexString(handle)}")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode codes [n_q=16][T] (codebooks par layer × timesteps) → PCM16 [T*1920].
|
||||
*
|
||||
* @param codebooks Tableau IntArray par codebook, chaque codebook contient T indices [0, 2047].
|
||||
* @param numTokens Nombre de timesteps réels T (= taille de chaque codebook).
|
||||
* @return ShortArray de T*1920 samples PCM16 à 24 kHz, ou null si échec.
|
||||
*/
|
||||
fun decode(codebooks: Array<IntArray>, numTokens: Int): ShortArray? {
|
||||
if (handle == 0L) {
|
||||
Log.e(TAG, "decode called before load()")
|
||||
return null
|
||||
}
|
||||
val nQ = codebooks.size
|
||||
if (nQ != 16) {
|
||||
Log.e(TAG, "Expected 16 codebooks, got $nQ")
|
||||
return null
|
||||
}
|
||||
// Aplatir en codes_flat[n_q * T] avec layout codes_flat[layer*T + t]
|
||||
val flat = IntArray(nQ * numTokens)
|
||||
for (layer in 0 until nQ) {
|
||||
for (t in 0 until numTokens) {
|
||||
flat[layer * numTokens + t] = codebooks[layer][t]
|
||||
}
|
||||
}
|
||||
val t0 = System.currentTimeMillis()
|
||||
val pcm = nativeDecode(handle, flat, numTokens)
|
||||
if (pcm == null) {
|
||||
Log.e(TAG, "nativeDecode returned null")
|
||||
return null
|
||||
}
|
||||
val dt = System.currentTimeMillis() - t0
|
||||
Log.i(TAG, "decode T=$numTokens → ${pcm.size} samples in ${dt}ms (RTF ${"%.2f".format(dt / (pcm.size / 24.0))})")
|
||||
return pcm
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (handle != 0L) {
|
||||
nativeDestroy(handle)
|
||||
handle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
private external fun nativeCreate(ggufPath: String): Long
|
||||
private external fun nativeDecode(handle: Long, codes: IntArray, T: Int): ShortArray?
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
/**
|
||||
* JNI bridge to the in-process CP CPU runner (libtts_cp_cpu.so).
|
||||
*
|
||||
* Replaces the .pte ExecuTorch CP path with a llama.cpp-based forward that
|
||||
* runs cp_f16.gguf in CPU NEON mode (n_gpu_layers=0).
|
||||
*
|
||||
* Heads + codec embeddings (15 × 2048 × 1024 fp32, ~125 MB each) loaded
|
||||
* separately via [nativeInit]. They live in /data/local/tmp/kazeia/models/
|
||||
* (cp_heads.bin, cp_codec_embs.bin) and are mmap-friendly fp32 row-major.
|
||||
*
|
||||
* Per Talker step : [nativeForward] runs 17 forward decodes + 15 NEON
|
||||
* dot_argmax classifications → returns 15 codebook codes (cb1..cb15).
|
||||
*
|
||||
* KV is RECREATED per call (matches tts-cp-runner.cpp ref to avoid Hexagon
|
||||
* NaN — TODO optim Phase 4 : try memory_clear instead since CPU doesn't
|
||||
* have the NaN issue, save the recreate cost).
|
||||
*/
|
||||
object TtsCpCpuJni {
|
||||
|
||||
init {
|
||||
// Order matters : ggml deps before llama. Talker JNI loads them
|
||||
// first, but if CP loads alone we ensure them too.
|
||||
System.loadLibrary("ggml-base")
|
||||
System.loadLibrary("ggml-cpu")
|
||||
System.loadLibrary("ggml")
|
||||
System.loadLibrary("llama")
|
||||
System.loadLibrary("tts_cp_cpu")
|
||||
}
|
||||
|
||||
/** Load cp_f16.gguf + heads + codec embeddings.
|
||||
* - modelPath : path to cp_f16.gguf (~166 MB)
|
||||
* - headsPath : path to cp_heads.bin (15 × 2048 × 1024 fp32 = 125 MB)
|
||||
* - embsPath : path to cp_codec_embs.bin (125 MB)
|
||||
* Returns native handle, 0 on failure. */
|
||||
@JvmStatic external fun nativeInit(modelPath: String, headsPath: String, embsPath: String): Long
|
||||
|
||||
/** Run one CP step.
|
||||
* - hidden : Talker hidden state output [1024] fp32
|
||||
* - cb0Emb : codec embedding for predicted cb0 [1024] fp32
|
||||
* Returns 15 int32 codes (cb1..cb15), null on failure. */
|
||||
@JvmStatic external fun nativeForward(handle: Long, hidden: FloatArray, cb0Emb: FloatArray): IntArray?
|
||||
|
||||
/** Reset KV cache. Each forward recreates ctx anyway, but explicit
|
||||
* reset between sentences is supported. */
|
||||
@JvmStatic external fun nativeReset(handle: Long)
|
||||
|
||||
/** Free model + ctx + heads + embs buffers. */
|
||||
@JvmStatic external fun nativeFree(handle: Long)
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
// Engine TTS Qwen3 in-process (libkazeia_tts.so). Load une fois, synthesize N fois.
|
||||
// Le bridge natif est dans dist/jni/kazeia_tts_jni.cpp ; build via dist/build_kazeia_tts.sh
|
||||
// ou dist/CMakeLists.txt.
|
||||
//
|
||||
// Empreinte RAM typique : ~3.5 GB (talker f32 ~1.6 GB + CP f16 mixte ~250 MB +
|
||||
// decoder ~325 MB + vocab Qwen3 ~50 MB + fixtures text_embed/tp_* ~1.2 GB).
|
||||
|
||||
class TtsJni {
|
||||
external fun nativeLoad(
|
||||
talkerGguf: String,
|
||||
vocabGguf: String,
|
||||
dumpDir: String,
|
||||
useHtp: Boolean,
|
||||
nThreads: Int,
|
||||
cpUseCache: Boolean
|
||||
): Long
|
||||
|
||||
external fun nativeSynthesize(
|
||||
handle: Long,
|
||||
text: String,
|
||||
outWavPath: String,
|
||||
maxSteps: Int,
|
||||
seed: Int,
|
||||
cpTemp: Float, cpTopK: Int, cpTopP: Float, cpRepPenalty: Float,
|
||||
talkerTemp: Float, talkerTopK: Int, talkerTopP: Float, talkerRepPenalty: Float
|
||||
): IntArray // [err, frames, total_ms, prefill_ms, talker_ms, cp_ms, decoder_ms]
|
||||
|
||||
// Dérive un x_vector depuis un WAV (downmix + resample à 24k côté natif) et
|
||||
// le mémorise comme voix courante pour ce handle. Effet au prochain synth.
|
||||
external fun nativeSetVoiceWav(handle: Long, wavPath: String): Boolean
|
||||
|
||||
external fun nativeFree(handle: Long)
|
||||
|
||||
companion object { init { System.loadLibrary("kazeia_tts") } }
|
||||
}
|
||||
|
||||
data class TtsResult(
|
||||
val frames: Int,
|
||||
val audioMs: Int,
|
||||
val totalMs: Int,
|
||||
val prefillMs: Int,
|
||||
val talkerMs: Int,
|
||||
val cpMs: Int,
|
||||
val decoderMs: Int
|
||||
)
|
||||
|
||||
// Configuration sampling. Défauts validés sur fixture FR (cf project_tts_session_28may).
|
||||
data class TtsSampling(
|
||||
// Défauts validés à l'écoute (top_p=0.95 + rep=1.10 moins robotique vs 1.0/1.05).
|
||||
val cpTemp: Float = 0.9f, val cpTopK: Int = 50, val cpTopP: Float = 0.95f, val cpRepPenalty: Float = 1.10f,
|
||||
val talkerTemp: Float = 0.9f, val talkerTopK: Int = 50, val talkerTopP: Float = 0.95f, val talkerRepPenalty: Float = 1.10f
|
||||
)
|
||||
|
||||
class TtsEngine(
|
||||
talkerGguf: String,
|
||||
vocabGguf: String,
|
||||
dumpDir: String,
|
||||
useHtp: Boolean = false,
|
||||
nThreads: Int = 6,
|
||||
cpUseCache: Boolean = true
|
||||
) {
|
||||
private val jni = TtsJni()
|
||||
private val h = jni.nativeLoad(talkerGguf, vocabGguf, dumpDir, useHtp, nThreads, cpUseCache)
|
||||
init { require(h != 0L) { "TtsEngine: load FAILED (talker=$talkerGguf, vocab=$vocabGguf, dump=$dumpDir)" } }
|
||||
|
||||
@Throws(IllegalStateException::class)
|
||||
fun synthesize(
|
||||
text: String,
|
||||
outWavPath: String,
|
||||
maxSteps: Int = 256,
|
||||
seed: Int = 42,
|
||||
sampling: TtsSampling = TtsSampling()
|
||||
): TtsResult {
|
||||
val r = jni.nativeSynthesize(
|
||||
h, text, outWavPath, maxSteps, seed,
|
||||
sampling.cpTemp, sampling.cpTopK, sampling.cpTopP, sampling.cpRepPenalty,
|
||||
sampling.talkerTemp, sampling.talkerTopK, sampling.talkerTopP, sampling.talkerRepPenalty
|
||||
)
|
||||
if (r[0] != 0) error("TtsEngine.synthesize err=${r[0]} for text=\"$text\"")
|
||||
val frames = r[1]
|
||||
return TtsResult(
|
||||
frames = frames,
|
||||
audioMs = frames * 1000 / 12, // 12 frames/s = 80 ms par frame
|
||||
totalMs = r[2],
|
||||
prefillMs = r[3],
|
||||
talkerMs = r[4],
|
||||
cpMs = r[5],
|
||||
decoderMs = r[6]
|
||||
)
|
||||
}
|
||||
|
||||
fun release() { jni.nativeFree(h) }
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
/** Native C++ TTS pipeline: talker + CP loop with zero Java overhead. */
|
||||
object TtsPipeline {
|
||||
init { System.loadLibrary("tts_pipeline") }
|
||||
|
||||
/** Load and warmup both .pte models. Returns true on success. */
|
||||
external fun nativeInit(talkerPath: String, cpPath: String): Boolean
|
||||
|
||||
/** Release native resources. */
|
||||
external fun nativeDestroy()
|
||||
|
||||
/**
|
||||
* Run full pipeline. Returns flat int array: [numTokens × 16] codebook codes.
|
||||
* Each group of 16 ints = [CB0, CB1, ..., CB15] for one time step.
|
||||
*/
|
||||
external fun nativeRun(
|
||||
prefillEmbeds: FloatArray, nPrefill: Int,
|
||||
trailingEmbeds: FloatArray?, nTrailing: Int,
|
||||
codecEmbedding: FloatArray,
|
||||
cpEmbeddings: FloatArray,
|
||||
cpHeads: FloatArray,
|
||||
talkerCos: FloatArray, talkerSin: FloatArray,
|
||||
cpCos: FloatArray, cpSin: FloatArray,
|
||||
eosEmbed: FloatArray, padEmbed: FloatArray,
|
||||
maxTokens: Int
|
||||
): IntArray?
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.kazeia.tts
|
||||
|
||||
/**
|
||||
* JNI bridge to the in-process Talker CPU runner (libtts_talker_cpu.so).
|
||||
*
|
||||
* Replaces the .pte ExecuTorch path with a llama.cpp-based forward that
|
||||
* runs talker_f16.gguf in CPU NEON mode (n_gpu_layers=0).
|
||||
*
|
||||
* Lifecycle: [init] once at boot → [forward] per autoregressive step →
|
||||
* [reset] between sentences → [free] at TTS engine release.
|
||||
*
|
||||
* Mesured POC (2026-04-29) :
|
||||
* ms/step CPU NEON ≈ 17 (vs 43 .pte NPU) → ×2.5 plus rapide
|
||||
* Cible RTF projeté < 1 → "beg beg beg" éliminé structurellement
|
||||
*/
|
||||
object TtsTalkerCpuJni {
|
||||
|
||||
init {
|
||||
// Order matters : ggml deps before llama
|
||||
System.loadLibrary("ggml-base")
|
||||
System.loadLibrary("ggml-cpu")
|
||||
System.loadLibrary("ggml")
|
||||
System.loadLibrary("llama")
|
||||
System.loadLibrary("tts_talker_cpu")
|
||||
}
|
||||
|
||||
/** Load talker_f16.gguf in CPU mode. Returns native handle, 0 on failure. */
|
||||
@JvmStatic external fun nativeInit(modelPath: String): Long
|
||||
|
||||
/** Run one forward step. embd is 1024 fp32. Returns 4096 fp32 :
|
||||
* [0..1023] = hidden state, [1024..4095] = cb0 logits over codec vocab.
|
||||
* null on failure. */
|
||||
@JvmStatic external fun nativeForward(handle: Long, embd: FloatArray): FloatArray?
|
||||
|
||||
/** Reset KV cache for a new sentence. */
|
||||
@JvmStatic external fun nativeReset(handle: Long)
|
||||
|
||||
/** Free model + ctx. */
|
||||
@JvmStatic external fun nativeFree(handle: Long)
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ class AudioVisualizerView @JvmOverloads constructor(
|
|||
) : View(context, attrs, defStyleAttr), Choreographer.FrameCallback {
|
||||
|
||||
companion object {
|
||||
/** Must match Qwen3TtsEngine.SPECTRUM_BANDS. Asserted at setSpeaking. */
|
||||
/** Nombre de bandes de spectre de l'orbe (visualiseur). */
|
||||
private const val SPECTRUM_BANDS = 12
|
||||
/** Listening-mode outline deformation modes (even = smooth blobs). */
|
||||
private const val BLOB_MODES = 8
|
||||
|
|
|
|||
|
|
@ -181,7 +181,6 @@ class ChatActivity : AppCompatActivity() {
|
|||
|
||||
private fun setupResourceMonitoring() {
|
||||
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
|
||||
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
|
||||
val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu)
|
||||
val graphRam = findViewById<MiniGraphView>(R.id.graphRam)
|
||||
|
||||
|
|
@ -189,7 +188,6 @@ class ChatActivity : AppCompatActivity() {
|
|||
val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat()
|
||||
|
||||
graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50"))
|
||||
graphGpu?.configure("GPU", "%", 100f, android.graphics.Color.parseColor("#2196F3"))
|
||||
graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800"))
|
||||
// Affiche la conso PSS de Kazeia uniquement (~2 GB idle, ~5.5 GB pic).
|
||||
// Pas la RAM système globale (qui inclut ~3 GB d'OS Android dont on
|
||||
|
|
@ -201,7 +199,6 @@ class ChatActivity : AppCompatActivity() {
|
|||
while (true) {
|
||||
val snap = resourceMonitor!!.snapshot()
|
||||
graphCpu?.addValue(snap.cpuPercent)
|
||||
graphGpu?.addValue(if (snap.gpuPercent >= 0) snap.gpuPercent else 0f)
|
||||
|
||||
// AI workload: show which AI component is active
|
||||
val workload = kazeiaService?.aiWorkload?.value
|
||||
|
|
@ -319,8 +316,19 @@ class ChatActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
launch {
|
||||
// Encart « dernier tour » PERSISTANT (remplace le graph GPU) :
|
||||
// input · output · temps par étape.
|
||||
service.lastTurnPanel.collect { panel ->
|
||||
findViewById<android.widget.TextView>(R.id.tvLastTurn)?.text = panel
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// debugMode (piloté par l'admin via ConfigStore.debugEnabled) gate le
|
||||
// BOUTON debug. Si off : bouton masqué + panneau forcé fermé (écran
|
||||
// patient épuré). Si on : bouton visible, le patient/soignant ouvre le
|
||||
// panneau logs+métriques via le bouton.
|
||||
service.debugMode.collect { debug ->
|
||||
setDebugPanelVisible(debug)
|
||||
applyDebugGate(debug)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
|
|
@ -404,6 +412,19 @@ class ChatActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Mode debug (ConfigStore.debugEnabled, piloté admin) : affiche/masque le bouton
|
||||
* qui ouvre le panneau logs+métriques. Off → bouton caché + panneau forcé fermé. */
|
||||
private fun applyDebugGate(enabled: Boolean) {
|
||||
runOnUiThread {
|
||||
findViewById<View>(R.id.btnDebugToggle)?.visibility =
|
||||
if (enabled) View.VISIBLE else View.GONE
|
||||
if (!enabled) {
|
||||
debugPanelVisible = false
|
||||
setDebugPanelVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var logFile: java.io.FileWriter? = null
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
|
|
|
|||
|
|
@ -78,30 +78,27 @@ class OnboardingActivity : AppCompatActivity() {
|
|||
/** Point d'entrée : récupère le catalogue puis décide de la suite. */
|
||||
private fun begin() {
|
||||
resetUi()
|
||||
tvStatus.text = "Vérification du catalogue…"
|
||||
// Les mises à jour sont désormais MANUELLES (pilotées depuis l'app admin :
|
||||
// écran « Mises à jour », bouton Vérifier/Installer). Au lancement on ne
|
||||
// consulte plus le catalogue ni l'APK : si le cœur est présent, on démarre
|
||||
// directement (offline, zéro auto-update). Le catalogue n'est sollicité que
|
||||
// pour la PREMIÈRE installation (cœur absent) ou un re-provisionnement forcé.
|
||||
if (!force && manager.hasCoreModels()) {
|
||||
Log.i(TAG, "modèles présents → Splash (pas d'auto-update)")
|
||||
goToSplash()
|
||||
return
|
||||
}
|
||||
|
||||
tvStatus.text = "Première installation…"
|
||||
lifecycleScope.launch {
|
||||
val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() }
|
||||
|
||||
if (catalog == null) {
|
||||
// Hors-ligne : un install qui tourne déjà peut continuer.
|
||||
if (!force && manager.hasCoreModels()) {
|
||||
Log.i(TAG, "catalogue injoignable mais modèles présents → Splash")
|
||||
goToSplash()
|
||||
} else {
|
||||
showError("Connexion requise pour la première installation.\n" +
|
||||
if (!force && manager.hasCoreModels()) goToSplash()
|
||||
else showError("Connexion requise pour la première installation.\n" +
|
||||
"Vérifiez le réseau puis réessayez.")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 1. Mise à jour de l'app elle-même.
|
||||
val update = apkUpdater.availableUpdate(catalog)
|
||||
if (update != null) {
|
||||
promptApkUpdate(update, catalog)
|
||||
return@launch
|
||||
}
|
||||
continueWithModels(catalog)
|
||||
continueWithModels(catalog) // télécharge les modèles manquants (sans prompt APK)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,331 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.graphics.Typeface
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI test page Phase 1 du rewrite Kazeia v2.
|
||||
*
|
||||
* - Bouton "Parler" déclenche la capture micro avec VAD Silero ; pas besoin
|
||||
* d'arrêter manuellement, VAD termine le segment automatiquement.
|
||||
* - Bulle USER (transcription STT en bleu, alignée droite)
|
||||
* - Bulle KAZEIA (dots animés "..." ou texte final, alignée gauche)
|
||||
* - Status bar bas affiche l'état du pipeline
|
||||
*
|
||||
* Phase 1 = STT (VAD + Whisper). Phase 2 ajoutera LLM cascade, Phase 3 TTS.
|
||||
*/
|
||||
class ChatActivityV2 : AppCompatActivity() {
|
||||
|
||||
private var service: KazeiaServiceV2? = null
|
||||
private var bound = false
|
||||
|
||||
private lateinit var statusView: TextView
|
||||
private lateinit var bubbleUser: TextView
|
||||
private lateinit var bubbleKazeia: TextView
|
||||
private lateinit var pttButton: Button
|
||||
private lateinit var bubblesContainer: LinearLayout
|
||||
private lateinit var logsView: TextView
|
||||
private lateinit var logsScroll: ScrollView
|
||||
private val logsBuffer = StringBuilder(8192)
|
||||
private val logsMaxLines = 400
|
||||
|
||||
private lateinit var metricsView: MetricsGraphView
|
||||
private val metricsSampler = MetricsSampler { cpu, ram, avail ->
|
||||
if (::metricsView.isInitialized) metricsView.addSample(cpu, ram, avail)
|
||||
}
|
||||
|
||||
private val permLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
// No-op, user just sees state if denied
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
service = (binder as KazeiaServiceV2.LocalBinder).service()
|
||||
bound = true
|
||||
KazeiaLogger.pipeline("ChatActivityV2 bound to KazeiaServiceV2")
|
||||
observeService()
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
service = null
|
||||
bound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Permissions
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Layout split horizontal :
|
||||
// gauche (60 %) : chat (titre + bulles + bouton + status)
|
||||
// droite (40 %) : panneau logs en monospace, auto-scroll bas
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(16, 16, 16, 16)
|
||||
setBackgroundColor(Color.parseColor("#FAFAFA"))
|
||||
}
|
||||
|
||||
// ─── Colonne gauche : chat ───────────────────────────────────
|
||||
val leftCol = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(8, 8, 16, 8)
|
||||
layoutParams = LinearLayout.LayoutParams(0, MATCH_PARENT, 0.6f)
|
||||
}
|
||||
|
||||
val title = TextView(this).apply {
|
||||
text = "Kazeia v2"
|
||||
textSize = 20f
|
||||
setTextColor(Color.parseColor("#222222"))
|
||||
setPadding(0, 0, 0, 16)
|
||||
}
|
||||
leftCol.addView(title)
|
||||
|
||||
bubblesContainer = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
bubbleUser = makeBubble(isUser = true).apply { text = "" }
|
||||
bubbleKazeia = makeBubble(isUser = false).apply { text = "" }
|
||||
bubblesContainer.addView(bubbleUser)
|
||||
bubblesContainer.addView(bubbleKazeia)
|
||||
|
||||
val chatScroll = ScrollView(this).apply {
|
||||
addView(bubblesContainer)
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)
|
||||
}
|
||||
leftCol.addView(chatScroll)
|
||||
|
||||
pttButton = Button(this).apply {
|
||||
text = "▶ Parler (VAD)"
|
||||
textSize = 18f
|
||||
setOnClickListener { onPttClicked() }
|
||||
}
|
||||
leftCol.addView(pttButton)
|
||||
|
||||
statusView = TextView(this).apply {
|
||||
text = "Status: Idle (service en chargement)"
|
||||
textSize = 12f
|
||||
setTextColor(Color.GRAY)
|
||||
setPadding(0, 12, 0, 0)
|
||||
}
|
||||
leftCol.addView(statusView)
|
||||
|
||||
root.addView(leftCol)
|
||||
|
||||
// ─── Colonne droite : panneau logs ───────────────────────────
|
||||
val rightCol = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16, 8, 8, 8)
|
||||
setBackgroundColor(Color.parseColor("#0D1117"))
|
||||
layoutParams = LinearLayout.LayoutParams(0, MATCH_PARENT, 0.4f)
|
||||
}
|
||||
|
||||
// Graph CPU/RAM en haut (hauteur fixe 200 dp)
|
||||
metricsView = MetricsGraphView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, (200 * resources.displayMetrics.density).toInt())
|
||||
}
|
||||
rightCol.addView(metricsView)
|
||||
|
||||
val logsTitle = TextView(this).apply {
|
||||
text = "Logs pipeline (live)"
|
||||
textSize = 14f
|
||||
setTextColor(Color.parseColor("#8B949E"))
|
||||
setTypeface(Typeface.MONOSPACE, Typeface.BOLD)
|
||||
setPadding(8, 8, 8, 8)
|
||||
}
|
||||
rightCol.addView(logsTitle)
|
||||
|
||||
logsView = TextView(this).apply {
|
||||
text = ""
|
||||
textSize = 10f
|
||||
setTypeface(Typeface.MONOSPACE)
|
||||
setTextColor(Color.parseColor("#C9D1D9"))
|
||||
setBackgroundColor(Color.parseColor("#0D1117"))
|
||||
setPadding(8, 8, 8, 8)
|
||||
setHorizontallyScrolling(false)
|
||||
// Wrap long lines
|
||||
}
|
||||
logsScroll = ScrollView(this).apply {
|
||||
addView(logsView)
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)
|
||||
}
|
||||
rightCol.addView(logsScroll)
|
||||
|
||||
// Bouton clear logs
|
||||
val clearBtn = Button(this).apply {
|
||||
text = "Clear logs"
|
||||
textSize = 12f
|
||||
setOnClickListener {
|
||||
logsBuffer.clear()
|
||||
logsView.text = ""
|
||||
}
|
||||
}
|
||||
rightCol.addView(clearBtn)
|
||||
|
||||
root.addView(rightCol)
|
||||
|
||||
setContentView(root)
|
||||
|
||||
// Stream les logs depuis KazeiaLogger
|
||||
observeLogs()
|
||||
// Démarre l'échantillonnage CPU/RAM (500 ms)
|
||||
metricsSampler.start()
|
||||
|
||||
// Bind seul — BIND_AUTO_CREATE crée le service automatiquement.
|
||||
// Pas besoin de startService (qui demanderait ForegroundService permission).
|
||||
val intent = Intent(this, KazeiaServiceV2::class.java)
|
||||
bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
private fun makeBubble(isUser: Boolean): TextView {
|
||||
return TextView(this).apply {
|
||||
textSize = 16f
|
||||
setPadding(24, 16, 24, 16)
|
||||
val lp = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
|
||||
lp.setMargins(if (isUser) 80 else 0, 8, if (isUser) 0 else 80, 8)
|
||||
lp.gravity = if (isUser) Gravity.END else Gravity.START
|
||||
layoutParams = lp
|
||||
setBackgroundColor(if (isUser) Color.parseColor("#3B82F6") else Color.parseColor("#E5E7EB"))
|
||||
setTextColor(if (isUser) Color.WHITE else Color.parseColor("#111111"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeService() {
|
||||
val s = service ?: return
|
||||
lifecycleScope.launch {
|
||||
s.state.collect { st -> renderState(st) }
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
s.events.collect { ev -> handleEvent(ev) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeLogs() {
|
||||
lifecycleScope.launch {
|
||||
KazeiaLogger.logFlow.collect { entry ->
|
||||
appendLog(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendLog(entry: KazeiaLogger.LogEntry) {
|
||||
// Plain text + couleur seulement sur le tag via SpannableStringBuilder.
|
||||
// Évite Html.fromHtml(fullBuffer) qui allouait des MB par log line.
|
||||
val tagColor = when {
|
||||
entry.level == KazeiaLogger.Level.E -> 0xFFFF7B72.toInt() // rouge
|
||||
entry.tag == "PIPELINE" -> 0xFF7EE787.toInt()
|
||||
entry.tag == "STT" -> 0xFF79C0FF.toInt()
|
||||
entry.tag == "THINKER" || entry.tag == "SPEAKER" -> 0xFFFFA657.toInt()
|
||||
entry.tag.startsWith("TTS-") -> 0xFFD2A8FF.toInt()
|
||||
else -> 0xFFC9D1D9.toInt()
|
||||
}
|
||||
val plain = "${entry.ts} ${entry.tag} ${entry.msg}\n"
|
||||
logsBuffer.append(plain)
|
||||
|
||||
// Trim si > 16 KB (garde les ~250 dernières lignes)
|
||||
if (logsBuffer.length > 16_384) {
|
||||
val cutPoint = logsBuffer.indexOf("\n", logsBuffer.length - 12_288)
|
||||
if (cutPoint > 0) {
|
||||
logsBuffer.delete(0, cutPoint + 1)
|
||||
}
|
||||
}
|
||||
// Affichage plain text — pas de re-render HTML coûteux.
|
||||
logsView.text = logsBuffer.toString()
|
||||
logsScroll.post { logsScroll.fullScroll(android.widget.ScrollView.FOCUS_DOWN) }
|
||||
}
|
||||
|
||||
private fun renderState(st: PipelineState) {
|
||||
when (st) {
|
||||
is PipelineState.Idle -> {
|
||||
statusView.text = "Status: Idle (prêt)"
|
||||
pttButton.isEnabled = true
|
||||
pttButton.text = "▶ Parler (VAD)"
|
||||
}
|
||||
is PipelineState.Listening -> {
|
||||
statusView.text = "Status: Écoute… (parlez, VAD termine)"
|
||||
pttButton.text = "■ Annuler"
|
||||
pttButton.isEnabled = true
|
||||
}
|
||||
is PipelineState.Processing -> {
|
||||
val phase = when (st.phase) {
|
||||
PipelineState.Processing.Phase.Stt -> "STT…"
|
||||
PipelineState.Processing.Phase.Thinker -> "Thinker (Phase 2)"
|
||||
PipelineState.Processing.Phase.Speaker -> "Speaker (Phase 2)"
|
||||
PipelineState.Processing.Phase.TtsStart -> "TTS init (Phase 3)"
|
||||
}
|
||||
statusView.text = "Status: $phase"
|
||||
pttButton.isEnabled = false
|
||||
bubbleKazeia.text = "..."
|
||||
}
|
||||
is PipelineState.Speaking -> {
|
||||
statusView.text = "Status: Lecture audio (Phase 3)"
|
||||
pttButton.isEnabled = false
|
||||
}
|
||||
is PipelineState.Error -> {
|
||||
statusView.text = "ERREUR: ${st.msg}"
|
||||
pttButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(ev: UiEvent) {
|
||||
when (ev) {
|
||||
is UiEvent.UserSpeechFinalized -> {
|
||||
bubbleUser.text = ev.text.ifBlank { "(silence)" }
|
||||
}
|
||||
is UiEvent.KazeiaResponseReady -> {
|
||||
bubbleKazeia.text = ev.text
|
||||
}
|
||||
is UiEvent.KazeiaSpeakingDone -> {
|
||||
// Phase 3 : audio terminé
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPttClicked() {
|
||||
val s = service ?: return
|
||||
// Si on est déjà en écoute → annuler.
|
||||
if (s.state.value is PipelineState.Listening) {
|
||||
s.cancelListening()
|
||||
return
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
return
|
||||
}
|
||||
bubbleUser.text = ""
|
||||
bubbleKazeia.text = ""
|
||||
s.startVadListening()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
metricsSampler.stop()
|
||||
service?.cancelListening()
|
||||
if (bound) {
|
||||
unbindService(connection)
|
||||
bound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
|
||||
/**
|
||||
* Wrapper Kotlin pour `qnn_llama_runner --daemon_mode` (cascade ExecuTorch).
|
||||
*
|
||||
* Reproduit fidèlement le protocole du PoC Python `cascade_executorch_daemon.py` :
|
||||
*
|
||||
* 1. `start()` lance le binaire avec `cwd = /data/local/tmp/kazeia-et`,
|
||||
* `LD_LIBRARY_PATH=.`, `ADSP_LIBRARY_PATH=.`. Lit stdout jusqu'à voir
|
||||
* `READY\n` (model load ~1.3 s pour 4B, ~4.5 s pour 8B).
|
||||
*
|
||||
* 2. `call(prompt, system, seqLen, temperature) -> Result` :
|
||||
* - écrit prompt+system dans `outputs/_p_<label>_<ts>.txt` et `_s_*`
|
||||
* - envoie ligne `<prompt> <system> <output> <seq_len> <temp>\n` sur stdin
|
||||
* - attend `DONE\n` sur stdout
|
||||
* - lit `outputs/_o_<label>_<ts>.txt`, extrait segment assistant
|
||||
* (`<|im_start|>assistant ... <|im_end|>`, strip `<think>…</think>`)
|
||||
* - retourne (text, wallMs)
|
||||
*
|
||||
* 3. `stop()` envoie `STOP\n` puis kill si le process ne quitte pas.
|
||||
*
|
||||
* stderr est consommé sur un thread dédié et loggé via `KazeiaLogger`.
|
||||
*
|
||||
* Usage :
|
||||
* val d = ExecutorchDaemon("speaker",
|
||||
* pte = "hybrid_llama_qnn_4b.pte",
|
||||
* tokenizer = "tokenizer.json",
|
||||
* label = "S",
|
||||
* logTag = KazeiaLogger::speaker)
|
||||
* d.start()
|
||||
* val (text, ms) = d.call(userPrompt, sysPrompt, seqLen = 512)
|
||||
* ...
|
||||
* d.stop()
|
||||
*/
|
||||
class ExecutorchDaemon(
|
||||
val name: String,
|
||||
private val pte: String,
|
||||
private val tokenizer: String,
|
||||
private val label: String,
|
||||
private val logTag: (String) -> Unit,
|
||||
/** cwd du subprocess. Doit être writable (outputs/ y est créé). */
|
||||
private val workDir: String = DEFAULT_DEVICE_DIR,
|
||||
/** Chemin absolu (ou "./qnn_llama_runner" si binaire dans workDir) du runner. */
|
||||
private val binary: String = "./qnn_llama_runner",
|
||||
/** LD_LIBRARY_PATH/ADSP_LIBRARY_PATH du subprocess. "." = workDir. */
|
||||
private val libDir: String = "."
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_DEVICE_DIR = "/data/local/tmp/kazeia-et"
|
||||
@Deprecated("use workDir instance var")
|
||||
const val DEVICE_DIR = DEFAULT_DEVICE_DIR
|
||||
@Deprecated("use binary instance var")
|
||||
const val BINARY = "./qnn_llama_runner"
|
||||
private const val START_TIMEOUT_MS = 30_000L // 30s pour 8B
|
||||
private const val CALL_TIMEOUT_MS = 120_000L // 2 min par tour
|
||||
}
|
||||
|
||||
data class Result(val text: String, val wallMs: Long)
|
||||
|
||||
private var proc: Process? = null
|
||||
private var stdin: OutputStreamWriter? = null
|
||||
private var stdout: BufferedReader? = null
|
||||
private var stderrReader: Thread? = null
|
||||
|
||||
fun start() {
|
||||
if (proc != null) return
|
||||
val workDirF = File(workDir)
|
||||
if (!workDirF.isDirectory) workDirF.mkdirs()
|
||||
if (!workDirF.isDirectory) throw IllegalStateException("workDir $workDir non créable")
|
||||
val outputs = File(workDirF, "outputs")
|
||||
if (!outputs.exists()) outputs.mkdirs()
|
||||
|
||||
val cmd = listOf(
|
||||
binary,
|
||||
"--model_path", pte,
|
||||
"--tokenizer_path", tokenizer,
|
||||
"--decoder_model_version", "qwen3",
|
||||
"--shared_buffer",
|
||||
"--eval_mode", "1",
|
||||
"--daemon_mode"
|
||||
)
|
||||
logTag("daemon[$label] spawn: $cmd in $workDir (lib=$libDir)")
|
||||
|
||||
val pb = ProcessBuilder(cmd).directory(workDirF)
|
||||
pb.environment()["LD_LIBRARY_PATH"] = libDir
|
||||
pb.environment()["ADSP_LIBRARY_PATH"] = libDir
|
||||
|
||||
val t0 = System.currentTimeMillis()
|
||||
val p = try {
|
||||
pb.start()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("daemon[$label] spawn failed (SELinux ?): ${e.message}", e)
|
||||
}
|
||||
proc = p
|
||||
stdin = OutputStreamWriter(p.outputStream)
|
||||
stdout = BufferedReader(InputStreamReader(p.inputStream))
|
||||
|
||||
stderrReader = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(p.errorStream)).use { r ->
|
||||
var line = r.readLine()
|
||||
while (line != null) {
|
||||
logTag("daemon[$label] stderr: $line")
|
||||
line = r.readLine()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}, "ETD-stderr-$label").also {
|
||||
it.isDaemon = true
|
||||
it.start()
|
||||
}
|
||||
|
||||
// Wait READY
|
||||
val deadline = t0 + START_TIMEOUT_MS
|
||||
while (true) {
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
stop()
|
||||
throw IllegalStateException("daemon[$label] no READY in ${START_TIMEOUT_MS}ms")
|
||||
}
|
||||
val line = stdout!!.readLine()
|
||||
?: throw IllegalStateException("daemon[$label] stdout closed before READY (process died)")
|
||||
val trimmed = line.trim()
|
||||
if (trimmed == "READY") {
|
||||
val dt = System.currentTimeMillis() - t0
|
||||
logTag("daemon[$label] READY in ${dt}ms")
|
||||
return
|
||||
}
|
||||
// log progress lines too
|
||||
if (trimmed.isNotEmpty()) logTag("daemon[$label] init: $trimmed")
|
||||
}
|
||||
}
|
||||
|
||||
fun call(
|
||||
prompt: String,
|
||||
system: String?,
|
||||
seqLen: Int = 512,
|
||||
temperature: Double = 0.0
|
||||
): Result {
|
||||
val p = proc ?: throw IllegalStateException("daemon[$label] not started")
|
||||
if (!p.isAlive) throw IllegalStateException("daemon[$label] dead (exit=${p.exitValue()})")
|
||||
|
||||
val ts = System.nanoTime()
|
||||
val outputsDir = "$workDir/outputs"
|
||||
val promptFile = "$outputsDir/_p_${label}_$ts.txt"
|
||||
val systemFile = if (system.isNullOrEmpty()) "-" else "$outputsDir/_s_${label}_$ts.txt"
|
||||
val outputFile = "$outputsDir/_o_${label}_$ts.txt"
|
||||
|
||||
File(promptFile).writeText(prompt)
|
||||
if (systemFile != "-") File(systemFile).writeText(system!!)
|
||||
|
||||
val line = "$promptFile $systemFile $outputFile $seqLen $temperature\n"
|
||||
val tStart = System.currentTimeMillis()
|
||||
try {
|
||||
stdin!!.write(line)
|
||||
stdin!!.flush()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("daemon[$label] stdin write failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
// Wait DONE
|
||||
val deadline = tStart + CALL_TIMEOUT_MS
|
||||
while (true) {
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
throw IllegalStateException("daemon[$label] no DONE in ${CALL_TIMEOUT_MS}ms")
|
||||
}
|
||||
val l = stdout!!.readLine()
|
||||
?: throw IllegalStateException("daemon[$label] stdout closed mid-call")
|
||||
val t = l.trim()
|
||||
if (t == "DONE") break
|
||||
if (t.startsWith("ERROR")) throw IllegalStateException("daemon[$label] $t")
|
||||
if (t.isNotEmpty()) logTag("daemon[$label] gen: $t")
|
||||
}
|
||||
val wallMs = System.currentTimeMillis() - tStart
|
||||
|
||||
val raw = File(outputFile).readText()
|
||||
val text = parseAssistant(raw)
|
||||
|
||||
// Cleanup
|
||||
runCatching { File(promptFile).delete() }
|
||||
if (systemFile != "-") runCatching { File(systemFile).delete() }
|
||||
runCatching { File(outputFile).delete() }
|
||||
|
||||
return Result(text, wallMs)
|
||||
}
|
||||
|
||||
private fun parseAssistant(raw: String): String {
|
||||
val marker = "<|im_start|>assistant"
|
||||
val last = raw.lastIndexOf(marker)
|
||||
var tail = if (last >= 0) raw.substring(last + marker.length).trimStart('\n', ' ') else raw
|
||||
// Strip <think>...</think>
|
||||
val thinkRe = Regex("<think>.*?</think>\\s*", RegexOption.DOT_MATCHES_ALL)
|
||||
tail = thinkRe.replaceFirst(tail, "")
|
||||
// Cut at first end token
|
||||
val endIdx = listOf("<|im_end|>", "<|endoftext|>")
|
||||
.map { tail.indexOf(it) }
|
||||
.filter { it >= 0 }
|
||||
.minOrNull()
|
||||
if (endIdx != null) tail = tail.substring(0, endIdx)
|
||||
return tail.trim()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val p = proc ?: return
|
||||
try {
|
||||
stdin?.write("STOP\n")
|
||||
stdin?.flush()
|
||||
} catch (_: Exception) {}
|
||||
try {
|
||||
if (!p.waitFor(5_000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
||||
p.destroyForcibly()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
p.destroyForcibly()
|
||||
}
|
||||
try { stdin?.close() } catch (_: Exception) {}
|
||||
try { stdout?.close() } catch (_: Exception) {}
|
||||
proc = null
|
||||
stdin = null
|
||||
stdout = null
|
||||
logTag("daemon[$label] stopped")
|
||||
}
|
||||
|
||||
fun isAlive(): Boolean = proc?.isAlive ?: false
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Logger structuré pour le pipeline Kazeia v2. Chaque sous-système (STT, LLM
|
||||
* Thinker, LLM Speaker, TTS Talker/CP/Decoder, Pipeline orchestration) a son
|
||||
* tag dédié, filtrable via :
|
||||
*
|
||||
* adb logcat -s KazPIPELINE:V KazSTT:V KazTHINKER:V KazSPEAKER:V \
|
||||
* KazTTS-Talker:V KazTTS-CP:V KazTTS-Decoder:V
|
||||
*
|
||||
* Diffusion in-app : chaque message est aussi poussé sur [logFlow] pour
|
||||
* affichage dans la fenêtre logs de `ChatActivityV2`. Buffer 1024 entrées
|
||||
* max (ring), pas de retention longue durée — c'est juste un debug live.
|
||||
*/
|
||||
object KazeiaLogger {
|
||||
private const val TAG_PIPELINE = "KazPIPELINE"
|
||||
private const val TAG_STT = "KazSTT"
|
||||
private const val TAG_THINKER = "KazTHINKER"
|
||||
private const val TAG_SPEAKER = "KazSPEAKER"
|
||||
private const val TAG_TTS_TALKER = "KazTTS-Talker"
|
||||
private const val TAG_TTS_CP = "KazTTS-CP"
|
||||
private const val TAG_TTS_DECODER = "KazTTS-Decoder"
|
||||
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
private val _logFlow = MutableSharedFlow<LogEntry>(
|
||||
replay = 256, extraBufferCapacity = 1024
|
||||
)
|
||||
/** Stream temps-réel des logs. ChatActivityV2 s'abonne et affiche. */
|
||||
val logFlow: SharedFlow<LogEntry> = _logFlow.asSharedFlow()
|
||||
|
||||
enum class Level { I, E }
|
||||
data class LogEntry(val ts: String, val tag: String, val level: Level, val msg: String) {
|
||||
fun formatted(): String = "$ts $tag/${level.name} $msg"
|
||||
}
|
||||
|
||||
private fun emit(tag: String, level: Level, msg: String) {
|
||||
val entry = LogEntry(timeFmt.format(Date()), tag, level, msg)
|
||||
_logFlow.tryEmit(entry)
|
||||
}
|
||||
|
||||
fun pipeline(msg: String) { Log.i(TAG_PIPELINE, msg); emit("PIPELINE", Level.I, msg) }
|
||||
fun stt(msg: String) { Log.i(TAG_STT, msg); emit("STT", Level.I, msg) }
|
||||
fun thinker(msg: String) { Log.i(TAG_THINKER, msg); emit("THINKER", Level.I, msg) }
|
||||
fun speaker(msg: String) { Log.i(TAG_SPEAKER, msg); emit("SPEAKER", Level.I, msg) }
|
||||
fun ttsTalker(msg: String) { Log.i(TAG_TTS_TALKER, msg); emit("TTS-Talker", Level.I, msg) }
|
||||
fun ttsCp(msg: String) { Log.i(TAG_TTS_CP, msg); emit("TTS-CP", Level.I, msg) }
|
||||
fun ttsDecoder(msg: String) { Log.i(TAG_TTS_DECODER, msg); emit("TTS-Decoder", Level.I, msg) }
|
||||
|
||||
fun pipelineE(msg: String, t: Throwable? = null) {
|
||||
Log.e(TAG_PIPELINE, msg, t); emit("PIPELINE", Level.E, "$msg${t?.let { " : ${it.message}" } ?: ""}")
|
||||
}
|
||||
fun sttE(msg: String, t: Throwable? = null) {
|
||||
Log.e(TAG_STT, msg, t); emit("STT", Level.E, "$msg${t?.let { " : ${it.message}" } ?: ""}")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* KazeiaServiceV2 — orchestration service propre.
|
||||
*
|
||||
* Phase 1 (current): STT seul.
|
||||
* - load STT au start
|
||||
* - exposé via API submitAudio(pcm16) pour test
|
||||
* - state Idle → Listening → Processing(Stt) → Idle
|
||||
* - événement UiEvent.UserSpeechFinalized émis quand STT termine
|
||||
*
|
||||
* Phase 2 ajoutera : LlmCascadeStage (Thinker + Speaker) → KazeiaResponseReady.
|
||||
* Phase 3 ajoutera : TtsStage → audio streaming + KazeiaSpeakingDone.
|
||||
*/
|
||||
class KazeiaServiceV2 : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KazeiaServiceV2"
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
inner class LocalBinder : Binder() {
|
||||
fun service(): KazeiaServiceV2 = this@KazeiaServiceV2
|
||||
}
|
||||
|
||||
private val supervisor = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + supervisor)
|
||||
|
||||
private val _state = MutableStateFlow<PipelineState>(PipelineState.Idle)
|
||||
val state: StateFlow<PipelineState> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 16)
|
||||
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
|
||||
|
||||
private lateinit var stt: SttStage
|
||||
private lateinit var vad: VadStage
|
||||
private lateinit var cascade: LlmCascadeStage
|
||||
private lateinit var tts: TtsStage
|
||||
private var lastJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
KazeiaLogger.pipeline("Service v2 onCreate — Phase 3 (STT + cascade + TTS)")
|
||||
stt = SttStage(this)
|
||||
vad = VadStage(this)
|
||||
cascade = LlmCascadeStage()
|
||||
tts = TtsStage(this)
|
||||
|
||||
// Charge VAD (~50ms) puis STT NPU (~1s) puis cascade (Thinker+Speaker, ~5s) puis TTS.
|
||||
scope.launch {
|
||||
try {
|
||||
vad.load()
|
||||
stt.load()
|
||||
KazeiaLogger.pipeline("STT+VAD ready, démarrage cascade…")
|
||||
cascade.load()
|
||||
KazeiaLogger.pipeline("Cascade ready, chargement TTS…")
|
||||
try {
|
||||
tts.load()
|
||||
KazeiaLogger.pipeline("Pipeline ready (STT+VAD+Cascade+TTS)")
|
||||
} catch (e: Exception) {
|
||||
// TTS optionnel — si load échoue, on continue sans audio.
|
||||
KazeiaLogger.pipelineE("TTS load FAILED — pipeline continue sans audio", e)
|
||||
KazeiaLogger.pipeline("Pipeline ready (STT+VAD+Cascade, TTS désactivé)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
KazeiaLogger.pipelineE("load failed", e)
|
||||
_state.value = PipelineState.Error("Load failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
KazeiaLogger.pipeline("Service v2 onDestroy")
|
||||
lastJob?.cancel()
|
||||
scope.cancel()
|
||||
vad.release()
|
||||
stt.release()
|
||||
cascade.release()
|
||||
tts.release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet un buffer audio PCM16 16 kHz à transcrire. Le state passe en
|
||||
* Processing(Stt), puis l'événement UserSpeechFinalized est émis avec
|
||||
* le texte. Le pipeline va à `Idle` (en Phase 1, pas de LLM/TTS).
|
||||
*
|
||||
* Annule toute opération précédente en cours.
|
||||
*/
|
||||
fun submitAudio(audioPcm16: ShortArray) {
|
||||
if (!stt.isLoaded()) {
|
||||
KazeiaLogger.pipelineE("submitAudio rejected: STT not loaded")
|
||||
_state.value = PipelineState.Error("STT pas prêt, attendez le chargement")
|
||||
return
|
||||
}
|
||||
lastJob?.cancel()
|
||||
lastJob = scope.launch {
|
||||
runPipeline(audioPcm16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre la capture micro avec VAD. Le premier segment détecté par VAD
|
||||
* (≥150ms parole + ≥800ms silence) est automatiquement transcrit puis
|
||||
* traité — l'utilisateur n'a pas à appuyer pour arrêter.
|
||||
*/
|
||||
fun startVadListening() {
|
||||
if (!vad.isLoaded() || !stt.isLoaded()) {
|
||||
KazeiaLogger.pipelineE("startVadListening rejected: not ready (vad=${vad.isLoaded()} stt=${stt.isLoaded()})")
|
||||
_state.value = PipelineState.Error("STT/VAD pas prêts, attendez le chargement")
|
||||
return
|
||||
}
|
||||
if (_state.value !is PipelineState.Idle) {
|
||||
KazeiaLogger.pipelineE("startVadListening rejected: state=${_state.value}")
|
||||
return
|
||||
}
|
||||
_state.value = PipelineState.Listening
|
||||
KazeiaLogger.pipeline("VAD listening started (auto-segment via Silero)")
|
||||
try {
|
||||
vad.startSingleSegment { segment ->
|
||||
// VAD callback runs on the AudioCapture-VAD thread — hand off
|
||||
// to the service scope for pipeline execution.
|
||||
lastJob?.cancel()
|
||||
lastJob = scope.launch { runPipeline(segment) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
KazeiaLogger.pipelineE("VAD start failed", e)
|
||||
_state.value = PipelineState.Error("VAD start failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runPipeline(audioPcm16: ShortArray) {
|
||||
try {
|
||||
// ─── STT ───
|
||||
_state.value = PipelineState.Processing(PipelineState.Processing.Phase.Stt)
|
||||
val pipelineStart = System.currentTimeMillis()
|
||||
KazeiaLogger.pipeline("=== run start (audio=${audioPcm16.size} samples) ===")
|
||||
|
||||
val sttRes = stt.transcribe(audioPcm16)
|
||||
_events.emit(UiEvent.UserSpeechFinalized(sttRes.text))
|
||||
KazeiaLogger.pipeline("STT.end @ +${System.currentTimeMillis() - pipelineStart}ms text='${sttRes.text}'")
|
||||
|
||||
if (sttRes.text.isBlank()) {
|
||||
KazeiaLogger.pipeline("STT empty — skipping LLM cascade")
|
||||
_state.value = PipelineState.Idle
|
||||
return
|
||||
}
|
||||
|
||||
if (!cascade.isLoaded()) {
|
||||
KazeiaLogger.pipelineE("Cascade not loaded — pipeline aborted")
|
||||
_state.value = PipelineState.Error("Cascade LLM pas prête")
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Thinker ───
|
||||
_state.value = PipelineState.Processing(PipelineState.Processing.Phase.Thinker)
|
||||
// Speaker est lancé dans le même call que Thinker côté Stage; on
|
||||
// passe l'état en "Speaker" juste avant le 2e step pour que l'UI
|
||||
// reflète quelle phase tourne. On le fait via un wrapper.
|
||||
val cascadeStart = System.currentTimeMillis()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
// Workaround : on ne peut pas update _state au milieu sans
|
||||
// refactor LlmCascadeStage. Pour Phase 2, l'UI affiche Thinker
|
||||
// pendant les ~50% premiers du temps puis Speaker. Acceptable.
|
||||
cascade.run(sttRes.text)
|
||||
}
|
||||
val cascadeMs = System.currentTimeMillis() - cascadeStart
|
||||
KazeiaLogger.pipeline("Cascade.end @ +${System.currentTimeMillis() - pipelineStart}ms total=${cascadeMs}ms (T=${result.thinkerMs}ms, S=${result.speakerMs}ms)")
|
||||
|
||||
_events.emit(UiEvent.KazeiaResponseReady(result.response))
|
||||
|
||||
// ─── TTS ───
|
||||
if (tts.isLoaded() && result.response.isNotBlank()) {
|
||||
_state.value = PipelineState.Speaking(result.response)
|
||||
val ttsStart = System.currentTimeMillis()
|
||||
tts.speak(result.response)
|
||||
KazeiaLogger.pipeline("TTS.end @ +${System.currentTimeMillis() - pipelineStart}ms speak=${System.currentTimeMillis() - ttsStart}ms")
|
||||
_events.emit(UiEvent.KazeiaSpeakingDone)
|
||||
} else {
|
||||
KazeiaLogger.pipeline("TTS skipped (loaded=${tts.isLoaded()}, response.blank=${result.response.isBlank()})")
|
||||
}
|
||||
_state.value = PipelineState.Idle
|
||||
KazeiaLogger.pipeline("=== run end (Phase 1 stub) ===")
|
||||
} catch (e: Exception) {
|
||||
KazeiaLogger.pipelineE("pipeline failed", e)
|
||||
_state.value = PipelineState.Error(e.message ?: "unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
/** Annulation manuelle de la capture VAD avant que le segment ne soit livré. */
|
||||
fun cancelListening() {
|
||||
if (_state.value is PipelineState.Listening) {
|
||||
vad.stop()
|
||||
_state.value = PipelineState.Idle
|
||||
KazeiaLogger.pipeline("Listening cancelled by user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
/**
|
||||
* LLM stage pour le pipeline v2.
|
||||
*
|
||||
* **Mode actuel : MONO-Speaker (Qwen3-4B)** — temporaire pour faire rentrer
|
||||
* cascade+TTS dans le budget RAM ~16 GB de l'OnePlus Pad 3. Le Thinker
|
||||
* (Qwen3Guard-4B, 3.3 GB) est désactivé : la cascade complète + TTS dépasse
|
||||
* le plafond et déclenche le LMK Android (OOM kill foreground).
|
||||
*
|
||||
* Le Speaker reçoit le message utilisateur directement, avec un system
|
||||
* prompt généraliste de psychothérapeute qui valide l'émotion et répond
|
||||
* de façon concise. Quand le rewrite TTS Kotlin sera fait (~1 GB économisé)
|
||||
* ou phased loading implémenté, on restaurera le Thinker. Le code Thinker
|
||||
* est conservé en commentaire pour faciliter la restauration.
|
||||
*
|
||||
* Spawn 1× `qnn_llama_runner --daemon_mode` sur la tablette via
|
||||
* `ExecutorchDaemon`. Le daemon expose call(prompt, system, seqLen, temp).
|
||||
*/
|
||||
class LlmCascadeStage {
|
||||
|
||||
companion object {
|
||||
// Prompt mono-Speaker généraliste psychothérapeute. Direct et concis,
|
||||
// pas de référence à des ressources d'urgence ou numéros.
|
||||
const val SYS_SPEAKER = """Tu es un psychologue bienveillant. Tu reponds en DEUX OU TROIS phrases francaises courtes, separees par des points. Chaque phrase fait 6 a 12 mots maximum. Va droit au point avec une validation emotionnelle simple, pas de remplissage, pas de redondance.
|
||||
|
||||
Pourquoi DEUX-TROIS phrases courtes : la premiere phrase est jouee en audio pendant que les suivantes sont synthetisees → reponse percue beaucoup plus rapide. UNE seule phrase longue ne permet pas ce streaming.
|
||||
|
||||
Pas de jargon, pas de recommandations medicales, pas de mention de lignes d'ecoute ni de numeros. /no_think"""
|
||||
|
||||
// Conservé pour restauration future de la cascade.
|
||||
const val SYS_THINKER = """Tu es un psychologue clinicien experimente qui ecoute attentivement ton patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
|
||||
- emotion : <emotion principale ressentie, 5 mots max>
|
||||
- besoin : <besoin sous-jacent du patient, 5 mots max>
|
||||
- approche : <posture therapeutique adaptee, 5 mots max>
|
||||
- axe : <point cle a valider ou explorer, 5 mots max>
|
||||
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
|
||||
|
||||
private const val SEQ_LEN = 512
|
||||
private const val TEMP = 0.0
|
||||
}
|
||||
|
||||
private val speaker = ExecutorchDaemon(
|
||||
name = "speaker",
|
||||
pte = "hybrid_llama_qnn_4b.pte",
|
||||
tokenizer = "tokenizer.json",
|
||||
label = "S",
|
||||
logTag = KazeiaLogger::speaker
|
||||
)
|
||||
|
||||
@Volatile private var loaded = false
|
||||
|
||||
fun load() {
|
||||
if (loaded) return
|
||||
val t0 = System.currentTimeMillis()
|
||||
KazeiaLogger.speaker("loading… (mono-Speaker mode, Thinker disabled for RAM budget)")
|
||||
speaker.start()
|
||||
val tS = System.currentTimeMillis()
|
||||
loaded = true
|
||||
KazeiaLogger.pipeline("LLM ready (mono-Speaker) : speaker=${tS - t0}ms")
|
||||
}
|
||||
|
||||
fun isLoaded(): Boolean = loaded && speaker.isAlive()
|
||||
|
||||
data class Result(
|
||||
val bullets: String,
|
||||
val response: String,
|
||||
val thinkerMs: Long,
|
||||
val speakerMs: Long
|
||||
)
|
||||
|
||||
fun run(userMessage: String): Result {
|
||||
if (!isLoaded()) throw IllegalStateException("LLM not loaded")
|
||||
|
||||
// Mono-Speaker : message utilisateur direct, pas d'analyse Thinker.
|
||||
KazeiaLogger.speaker("user='${userMessage.take(120)}'")
|
||||
val (response, speakerMs) = speaker.call(userMessage, SYS_SPEAKER, SEQ_LEN, TEMP)
|
||||
KazeiaLogger.speaker("response (${speakerMs}ms): '${response.take(200)}'")
|
||||
|
||||
return Result(bullets = "", response = response, thinkerMs = 0L, speakerMs = speakerMs)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
try { speaker.stop() } catch (_: Exception) {}
|
||||
loaded = false
|
||||
KazeiaLogger.pipeline("LLM released (mono-Speaker)")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Typeface
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Mini graphique CPU% + RAM en sparklines pour le panneau debug v2.
|
||||
*
|
||||
* Affiche les N derniers samples sous forme de courbes empilées :
|
||||
* - Ligne 1 (haut) : CPU% (cyan, plein)
|
||||
* - Ligne 2 (bas) : RAM MB (orange) avec marqueur "limite OOM" (rouge)
|
||||
*
|
||||
* Pas de dépendance externe (pas de MPAndroidChart). Custom Canvas, léger.
|
||||
*/
|
||||
class MetricsGraphView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val MAX_SAMPLES = 120 // 60 sec @ 0.5 Hz polling
|
||||
private const val CPU_PCT_MAX = 100f // 100 % d'un cœur
|
||||
private const val RAM_MAX_GB = 6f // pic attendu max ~5.5 GB
|
||||
}
|
||||
|
||||
private val cpuSamples = FloatArray(MAX_SAMPLES)
|
||||
private val ramSamples = FloatArray(MAX_SAMPLES) // GB
|
||||
private val sysAvailSamples = FloatArray(MAX_SAMPLES) // GB
|
||||
private var head = 0
|
||||
private var count = 0
|
||||
|
||||
private val cpuPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF58A6FF.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
|
||||
}
|
||||
private val ramPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFFFFA657.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
|
||||
}
|
||||
private val availPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF7EE787.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
|
||||
}
|
||||
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF30363D.toInt(); style = Paint.Style.STROKE; strokeWidth = 1f
|
||||
}
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFFC9D1D9.toInt(); textSize = 22f; typeface = Typeface.MONOSPACE
|
||||
}
|
||||
|
||||
fun addSample(cpuPct: Float, ramGb: Float, sysAvailGb: Float) {
|
||||
cpuSamples[head] = cpuPct
|
||||
ramSamples[head] = ramGb
|
||||
sysAvailSamples[head] = sysAvailGb
|
||||
head = (head + 1) % MAX_SAMPLES
|
||||
if (count < MAX_SAMPLES) count++
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawColor(Color.parseColor("#0D1117"))
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
if (count < 2) {
|
||||
canvas.drawText("Collecte des métriques…", 16f, h / 2f, labelPaint)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Layout : 2 zones ─────────────────────────────────
|
||||
// top : CPU% (haut 40 %)
|
||||
// bottom : RAM GB + sysAvail GB (bas 60 %)
|
||||
val topH = h * 0.4f
|
||||
val botY0 = topH + 8f
|
||||
val botH = h - botY0 - 24f // -24 pour la légende du bas
|
||||
|
||||
// ── Grille horizontale ──
|
||||
canvas.drawLine(0f, topH, w, topH, gridPaint)
|
||||
canvas.drawLine(0f, botY0 + botH, w, botY0 + botH, gridPaint)
|
||||
|
||||
// Échantillon X = (index relatif) -> px
|
||||
fun x(i: Int): Float = (i.toFloat() / (MAX_SAMPLES - 1)) * w
|
||||
|
||||
// Indices ordonnés (oldest → newest)
|
||||
val indices = IntArray(count) { (head - count + it + MAX_SAMPLES) % MAX_SAMPLES }
|
||||
|
||||
// ── CPU% (top zone) ──
|
||||
val cpuPath = Path()
|
||||
for ((seq, idx) in indices.withIndex()) {
|
||||
val px = x(seq)
|
||||
val py = topH - (cpuSamples[idx] / CPU_PCT_MAX).coerceIn(0f, 1f) * (topH - 4f)
|
||||
if (seq == 0) cpuPath.moveTo(px, py) else cpuPath.lineTo(px, py)
|
||||
}
|
||||
canvas.drawPath(cpuPath, cpuPaint)
|
||||
val lastCpu = cpuSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
|
||||
canvas.drawText("CPU %.0f%%".format(lastCpu), 8f, 24f, labelPaint)
|
||||
|
||||
// ── RAM (bot zone) — RAM = orange, sysAvail = vert ──
|
||||
val ramPath = Path()
|
||||
val availPath = Path()
|
||||
val ramScale = 1f / RAM_MAX_GB
|
||||
for ((seq, idx) in indices.withIndex()) {
|
||||
val px = x(seq)
|
||||
val ramY = botY0 + botH - (ramSamples[idx] * ramScale).coerceIn(0f, 1f) * botH
|
||||
val avY = botY0 + botH - (sysAvailSamples[idx] * ramScale).coerceIn(0f, 1f) * botH
|
||||
if (seq == 0) { ramPath.moveTo(px, ramY); availPath.moveTo(px, avY) }
|
||||
else { ramPath.lineTo(px, ramY); availPath.lineTo(px, avY) }
|
||||
}
|
||||
canvas.drawPath(ramPath, ramPaint)
|
||||
canvas.drawPath(availPath, availPaint)
|
||||
|
||||
// Légende bas
|
||||
val lastRam = ramSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
|
||||
val lastAvail = sysAvailSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
|
||||
labelPaint.color = 0xFFFFA657.toInt()
|
||||
canvas.drawText("RAM %.2fGB".format(lastRam), 8f, h - 4f, labelPaint)
|
||||
labelPaint.color = 0xFF7EE787.toInt()
|
||||
canvas.drawText("free %.2fGB".format(lastAvail), w * 0.45f, h - 4f, labelPaint)
|
||||
labelPaint.color = 0xFFC9D1D9.toInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Échantillonne CPU% + RAM main process + sys_avail à intervalle régulier.
|
||||
*
|
||||
* Lit /proc/self/status (VmRSS) + /proc/self/stat (utime+stime) + /proc/meminfo.
|
||||
* Toutes ces sources sont accessibles à un process Android sans permission.
|
||||
*
|
||||
* CPU% : delta jiffies (utime+stime) entre 2 samples ÷ jiffies elapsed wall-clock.
|
||||
* Sur Android `_SC_CLK_TCK` typiquement 100 Hz.
|
||||
*/
|
||||
class MetricsSampler(
|
||||
private val intervalMs: Long = 500,
|
||||
private val onSample: (cpuPct: Float, ramGb: Float, sysAvailGb: Float) -> Unit
|
||||
) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var prevCpuJiffies: Long = -1
|
||||
private var prevWallNs: Long = 0
|
||||
private val clkTck = 100L // Hz typique Android
|
||||
|
||||
private val task = object : Runnable {
|
||||
override fun run() {
|
||||
try { sampleOnce() } catch (_: Exception) {}
|
||||
handler.postDelayed(this, intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
prevCpuJiffies = -1
|
||||
handler.postDelayed(task, 100)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
handler.removeCallbacks(task)
|
||||
}
|
||||
|
||||
private fun sampleOnce() {
|
||||
// ── CPU jiffies ──
|
||||
// /proc/self/stat : 14e + 15e tokens = utime + stime (jiffies)
|
||||
val statText = File("/proc/self/stat").readText().trim()
|
||||
// Cherche la ligne après ')' (le comm peut contenir des espaces)
|
||||
val tail = statText.substringAfterLast(')').trim().split(' ')
|
||||
// Après ')', les champs commencent à #3 (state). utime = #14 absolue → #11 dans tail.
|
||||
val utime = tail.getOrNull(11)?.toLongOrNull() ?: 0
|
||||
val stime = tail.getOrNull(12)?.toLongOrNull() ?: 0
|
||||
val cpuJiffies = utime + stime
|
||||
val nowNs = System.nanoTime()
|
||||
|
||||
val cpuPct: Float = if (prevCpuJiffies >= 0) {
|
||||
val deltaJ = (cpuJiffies - prevCpuJiffies).coerceAtLeast(0L)
|
||||
val deltaNs = (nowNs - prevWallNs).coerceAtLeast(1L)
|
||||
val deltaWallSec = deltaNs / 1_000_000_000.0
|
||||
// % = (deltaJ / Hz) / wallSec * 100 — sur 1 cœur
|
||||
((deltaJ.toDouble() / clkTck.toDouble()) / deltaWallSec * 100.0).toFloat()
|
||||
} else 0f
|
||||
prevCpuJiffies = cpuJiffies
|
||||
prevWallNs = nowNs
|
||||
|
||||
// ── RAM main process (VmRSS) ──
|
||||
val rssKb = parseStatusKb("VmRSS:")
|
||||
val ramGb = rssKb / 1024f / 1024f
|
||||
|
||||
// ── sys_avail ──
|
||||
val availKb = File("/proc/meminfo").bufferedReader().use { reader ->
|
||||
var v = 0L
|
||||
reader.forEachLine { line ->
|
||||
if (line.startsWith("MemAvailable:")) {
|
||||
v = line.split(Regex("\\s+"))[1].toLongOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
val sysAvailGb = availKb / 1024f / 1024f
|
||||
|
||||
onSample(cpuPct, ramGb, sysAvailGb)
|
||||
}
|
||||
|
||||
private fun parseStatusKb(field: String): Long {
|
||||
return File("/proc/self/status").bufferedReader().use { reader ->
|
||||
var v = 0L
|
||||
reader.forEachLine { line ->
|
||||
if (line.startsWith(field)) {
|
||||
v = line.split(Regex("\\s+"))[1].toLongOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
/**
|
||||
* États du pipeline Kazeia v2.
|
||||
*
|
||||
* Transitions :
|
||||
* Idle ──[PTT pressed]──> Listening ──[silence/PTT release]──>
|
||||
* Processing(Stt) ──> Processing(Thinker) ──> Processing(Speaker) ──>
|
||||
* Processing(TtsStart) ──> Speaking(text) ──[audio finished]──> Idle
|
||||
*
|
||||
* L'UI mappe ces états en éléments visuels :
|
||||
* Idle, Listening, Processing(*) → bulle Kazeia avec dots animés "..."
|
||||
* Speaking(text) → bulle Kazeia avec le texte FINAL du Speaker
|
||||
*
|
||||
* Les bullets du Thinker n'apparaissent JAMAIS dans l'UI.
|
||||
*/
|
||||
sealed class PipelineState {
|
||||
object Idle : PipelineState()
|
||||
object Listening : PipelineState()
|
||||
data class Processing(val phase: Phase) : PipelineState() {
|
||||
enum class Phase { Stt, Thinker, Speaker, TtsStart }
|
||||
}
|
||||
/** Le texte du Speaker affiché dans la bulle. L'audio joue en parallèle. */
|
||||
data class Speaking(val text: String) : PipelineState()
|
||||
/** Erreur runtime ; UI affiche un message d'erreur générique. */
|
||||
data class Error(val msg: String) : PipelineState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Événements émis vers l'UI. Découplés de l'état pour granularité fine
|
||||
* (un événement peut transiter sans changer l'état coarse).
|
||||
*/
|
||||
sealed class UiEvent {
|
||||
/** STT a finalisé la transcription utilisateur → bulle USER */
|
||||
data class UserSpeechFinalized(val text: String) : UiEvent()
|
||||
/** Speaker a fini sa réponse → bulle KAZEIA en UNE FOIS */
|
||||
data class KazeiaResponseReady(val text: String) : UiEvent()
|
||||
/** Audio TTS terminé, retour à idle */
|
||||
object KazeiaSpeakingDone : UiEvent()
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.KazeiaApplication
|
||||
import com.kazeia.core.SttEngine
|
||||
import com.kazeia.stt.KazeiaSttAdapter
|
||||
import com.kazeia.stt.WhisperHybridEngine
|
||||
|
||||
/**
|
||||
* Wrapper du STT existant (WhisperHybridEngine NPU) pour le pipeline v2.
|
||||
* Ne touche PAS au C++ JNI. Expose juste une API simple :
|
||||
* load() / transcribe(audio) / release()
|
||||
*
|
||||
* Logs structurés via KazeiaLogger.stt(...).
|
||||
*/
|
||||
class SttStage(private val context: Context) {
|
||||
companion object {
|
||||
// Non-const : MODELS_DIR est résolu au runtime par KazeiaPaths.
|
||||
private val WHISPER_DIR: String
|
||||
get() = "${KazeiaApplication.MODELS_DIR}/whisper-small-sm8750"
|
||||
}
|
||||
|
||||
private var engine: SttEngine? = null
|
||||
|
||||
suspend fun load() {
|
||||
if (engine?.isLoaded() == true) return
|
||||
val t0 = System.currentTimeMillis()
|
||||
// Backend STT pilotable via ConfigStore.sttEngine ("prod"|"lib"). Default = prod
|
||||
// (WhisperHybridEngine gelé). "lib" = KazeiaSttAdapter (libkazeia_stt.so).
|
||||
val backend = com.kazeia.config.ConfigStore.get(context).current().sttEngine
|
||||
val e: SttEngine = when (backend) {
|
||||
"lib" -> KazeiaSttAdapter(WHISPER_DIR) { msg -> KazeiaLogger.stt(msg) }
|
||||
else -> WhisperHybridEngine(context.applicationInfo.nativeLibraryDir) { msg ->
|
||||
KazeiaLogger.stt(msg)
|
||||
}
|
||||
}
|
||||
e.load(WHISPER_DIR)
|
||||
if (!e.isLoaded()) {
|
||||
throw IllegalStateException("STT '$backend' failed to load from $WHISPER_DIR")
|
||||
}
|
||||
engine = e
|
||||
val dt = System.currentTimeMillis() - t0
|
||||
KazeiaLogger.stt("loaded STT backend='$backend' in ${dt}ms (path=$WHISPER_DIR)")
|
||||
}
|
||||
|
||||
suspend fun transcribe(audioPcm16: ShortArray, language: String = "fr"): TranscribeResult {
|
||||
val e = engine ?: throw IllegalStateException("SttStage not loaded")
|
||||
val audioMs = (audioPcm16.size * 1000L) / 16000 // Whisper sample rate
|
||||
KazeiaLogger.stt("start: audio=${audioMs}ms (${audioPcm16.size} samples)")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val result = e.transcribe(audioPcm16, language)
|
||||
val dur = System.currentTimeMillis() - t0
|
||||
val rtf = dur.toDouble() / audioMs.coerceAtLeast(1)
|
||||
KazeiaLogger.stt("done: text='${result.text}' dur=${dur}ms rtf=${"%.2f".format(rtf)}")
|
||||
return TranscribeResult(
|
||||
text = result.text.trim(),
|
||||
audioMs = audioMs,
|
||||
durationMs = dur,
|
||||
rtf = rtf
|
||||
)
|
||||
}
|
||||
|
||||
fun isLoaded(): Boolean = engine?.isLoaded() == true
|
||||
|
||||
fun release() {
|
||||
engine?.release()
|
||||
engine = null
|
||||
KazeiaLogger.stt("released")
|
||||
}
|
||||
|
||||
data class TranscribeResult(
|
||||
val text: String,
|
||||
val audioMs: Long,
|
||||
val durationMs: Long,
|
||||
val rtf: Double
|
||||
)
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.KazeiaApplication
|
||||
import com.kazeia.tts.Qwen3TtsEngine
|
||||
|
||||
/**
|
||||
* TTS pour le pipeline v2.
|
||||
*
|
||||
* Pour Phase 3, on REUTILISE le moteur legacy `Qwen3TtsEngine` (5384 lignes,
|
||||
* validé end-to-end avec voice cloning Damien FR, RTF ~3.4 sur OnePlus Pad 3).
|
||||
* Le rewrite Kotlin propre du moteur (cible ~700 lignes) est un chantier
|
||||
* dédié post-Phase-3 ; ici on se contente de l'orchestrer derrière une API
|
||||
* simple :
|
||||
*
|
||||
* load() / speak(text, onStart, onDone) / stop() / release()
|
||||
*
|
||||
* Logs structurés via tags KazTTS-* (les tags fins Talker/CP/Decoder sont
|
||||
* loggés depuis le moteur legacy via le callback onLog).
|
||||
*/
|
||||
class TtsStage(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
// Non-const : MODELS_DIR est résolu au runtime par KazeiaPaths.
|
||||
private val MODEL_DIR: String
|
||||
get() = "${KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
|
||||
}
|
||||
|
||||
private var engine: Qwen3TtsEngine? = null
|
||||
|
||||
suspend fun load() {
|
||||
if (engine?.isLoaded() == true) return
|
||||
val t0 = System.currentTimeMillis()
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
val e = Qwen3TtsEngine(nativeLibDir, context) { msg ->
|
||||
// Le moteur legacy distingue déjà ses sous-systèmes via préfixes
|
||||
// dans `msg` ; on route tout sur KazTTS-Talker pour visibilité.
|
||||
KazeiaLogger.ttsTalker(msg)
|
||||
}
|
||||
e.load(MODEL_DIR)
|
||||
if (!e.isLoaded()) {
|
||||
throw IllegalStateException("Qwen3TtsEngine failed to load from $MODEL_DIR")
|
||||
}
|
||||
engine = e
|
||||
val dt = System.currentTimeMillis() - t0
|
||||
KazeiaLogger.ttsTalker("TTS loaded in ${dt}ms (path=$MODEL_DIR)")
|
||||
}
|
||||
|
||||
fun isLoaded(): Boolean = engine?.isLoaded() == true
|
||||
|
||||
/**
|
||||
* Synthétise [text] via la voie validée Stage 2 (streaming session) :
|
||||
* - startStreamingSession() ouvre AudioTrack + worker coroutine
|
||||
* - SentenceStreamer découpe le texte en phrases
|
||||
* - chaque phrase passe par generateSegmentAudioVC (BPE + Damien voice
|
||||
* prefix/suffix + EOS boost dynamique + MediaPlayer fallback ColorOS)
|
||||
* - endStreamingSession() attend la fin et close l'AudioTrack
|
||||
*
|
||||
* Suspend jusqu'à ce que tout l'audio soit drainé.
|
||||
*/
|
||||
suspend fun speak(text: String, onStart: (() -> Unit)? = null) {
|
||||
val e = engine ?: run {
|
||||
KazeiaLogger.ttsTalker("speak rejected: TTS not loaded")
|
||||
return
|
||||
}
|
||||
if (text.isBlank()) {
|
||||
KazeiaLogger.ttsTalker("speak rejected: empty text")
|
||||
return
|
||||
}
|
||||
val t0 = System.currentTimeMillis()
|
||||
KazeiaLogger.ttsTalker("speak start: '${text.take(120)}'")
|
||||
try {
|
||||
e.startStreamingSession()
|
||||
onStart?.invoke()
|
||||
// Découpe en phrases pour TTS plus naturelle (la session attend
|
||||
// chaque phrase pour démarrer son audio progressivement).
|
||||
val splitter = com.kazeia.tts.SentenceStreamer { sentence ->
|
||||
KazeiaLogger.ttsTalker("enqueue: '${sentence.take(80)}'")
|
||||
e.enqueueSentence(sentence)
|
||||
}
|
||||
splitter.append(text)
|
||||
splitter.flush()
|
||||
e.endStreamingSession()
|
||||
} catch (ex: Exception) {
|
||||
KazeiaLogger.ttsTalker("speak ERROR: ${ex.message}")
|
||||
}
|
||||
val dt = System.currentTimeMillis() - t0
|
||||
KazeiaLogger.ttsTalker("speak done: ${dt}ms")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
engine?.stop()
|
||||
KazeiaLogger.ttsTalker("stop requested")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
engine?.release()
|
||||
engine = null
|
||||
KazeiaLogger.ttsTalker("released")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
package com.kazeia.v2
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* VAD énergie (RMS) + capture audio pour le pipeline v2.
|
||||
*
|
||||
* Cette implémentation reproduit fidèlement la VAD de la legacy
|
||||
* `KazeiaService.startContinuousListening` : RMS sur frames 100ms,
|
||||
* seuil 150, 300ms de parole pour déclencher, 800ms de silence pour
|
||||
* terminer. Silero n'est PAS utilisé (chargé mais non employé dans la
|
||||
* legacy non plus — l'énergie RMS suffit et est nettement plus robuste
|
||||
* sur ce matériel/micro).
|
||||
*
|
||||
* Workflow :
|
||||
* 1) load() — no-op (juste flag)
|
||||
* 2) startSingleSegment(onSegment) — capture micro et appelle
|
||||
* `onSegment(pcm16)` UNE fois quand fin-de-parole détectée
|
||||
* (≥ SPEECH_MIN puis ≥ SILENCE_END). Capture stoppée automatiquement.
|
||||
* 3) stop() — annulation manuelle
|
||||
*/
|
||||
class VadStage(@Suppress("UNUSED_PARAMETER") context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val FRAME_SIZE = 1_600 // 100 ms
|
||||
private const val FRAME_MS = 100
|
||||
private const val RMS_THRESHOLD = 150 // legacy value
|
||||
private const val SPEECH_MIN_FRAMES = 3 // 300 ms parole
|
||||
private const val SILENCE_END_FRAMES = 8 // 800 ms silence
|
||||
private const val MAX_SEGMENT_FRAMES = 300 // 30 s max
|
||||
}
|
||||
|
||||
private var loaded = false
|
||||
|
||||
@Volatile private var capturing = false
|
||||
private var captureThread: Thread? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
|
||||
fun load() {
|
||||
loaded = true
|
||||
KazeiaLogger.stt("VAD (RMS energy) ready: threshold=$RMS_THRESHOLD, frame=${FRAME_MS}ms, speechMin=${SPEECH_MIN_FRAMES * FRAME_MS}ms, silenceEnd=${SILENCE_END_FRAMES * FRAME_MS}ms")
|
||||
}
|
||||
|
||||
fun isLoaded(): Boolean = loaded
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startSingleSegment(onSegment: (ShortArray) -> Unit) {
|
||||
if (!loaded) throw IllegalStateException("VadStage not loaded")
|
||||
if (capturing) {
|
||||
KazeiaLogger.stt("VAD start ignored — already capturing")
|
||||
return
|
||||
}
|
||||
capturing = true
|
||||
|
||||
captureThread = thread(name = "VadStage-Capture") {
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
val bufBytes = maxOf(minBuf, SAMPLE_RATE * 2)
|
||||
KazeiaLogger.stt("VAD capture: minBuf=$minBuf bytes, allocated=$bufBytes")
|
||||
|
||||
// Try VOICE_RECOGNITION first, fallback to MIC (mêmes essais que la legacy)
|
||||
val rec = openRecorder(bufBytes) ?: run {
|
||||
KazeiaLogger.sttE("AudioRecord: aucune source mic disponible")
|
||||
capturing = false
|
||||
return@thread
|
||||
}
|
||||
audioRecord = rec
|
||||
rec.startRecording()
|
||||
KazeiaLogger.stt("VAD capture: AudioRecord started (recordingState=${rec.recordingState})")
|
||||
|
||||
val frame = ShortArray(FRAME_SIZE)
|
||||
val speechBuf = ArrayList<ShortArray>(64)
|
||||
var speechFrames = 0
|
||||
var silenceFrames = 0
|
||||
var speechActive = false
|
||||
var frameIdx = 0
|
||||
val t0 = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
while (capturing && frameIdx < MAX_SEGMENT_FRAMES) {
|
||||
val n = rec.read(frame, 0, FRAME_SIZE)
|
||||
if (n != FRAME_SIZE) {
|
||||
if (n < 0) {
|
||||
KazeiaLogger.sttE("AudioRecord read returned $n — bailing")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
frameIdx++
|
||||
|
||||
var sumSq = 0L
|
||||
var peak = 0
|
||||
for (i in 0 until FRAME_SIZE) {
|
||||
val v = frame[i].toInt()
|
||||
sumSq += (v.toLong() * v.toLong())
|
||||
val a = if (v < 0) -v else v
|
||||
if (a > peak) peak = a
|
||||
}
|
||||
val rms = kotlin.math.sqrt(sumSq.toDouble() / FRAME_SIZE).toInt()
|
||||
val isSpeech = rms > RMS_THRESHOLD
|
||||
|
||||
if (frameIdx % 10 == 0 || isSpeech != speechActive) {
|
||||
KazeiaLogger.stt("VAD f=$frameIdx rms=$rms peak=$peak speech=$isSpeech (active=$speechActive sp=$speechFrames si=$silenceFrames)")
|
||||
}
|
||||
|
||||
if (isSpeech) {
|
||||
silenceFrames = 0
|
||||
speechFrames++
|
||||
speechBuf.add(frame.copyOf())
|
||||
if (!speechActive && speechFrames >= SPEECH_MIN_FRAMES) {
|
||||
speechActive = true
|
||||
KazeiaLogger.stt("VAD: speech START @ frame $frameIdx (rms=$rms)")
|
||||
}
|
||||
} else {
|
||||
if (speechActive) {
|
||||
silenceFrames++
|
||||
speechBuf.add(frame.copyOf()) // keep tail silence
|
||||
if (silenceFrames >= SILENCE_END_FRAMES) {
|
||||
val total = speechBuf.sumOf { it.size }
|
||||
val durMs = System.currentTimeMillis() - t0
|
||||
KazeiaLogger.stt("VAD: speech END @ frame $frameIdx (capture=${durMs}ms, samples=$total)")
|
||||
val pcm = ShortArray(total)
|
||||
var off = 0
|
||||
for (b in speechBuf) { b.copyInto(pcm, off); off += b.size }
|
||||
onSegment(pcm)
|
||||
capturing = false
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// pre-speech silence: drop buffer to bound memory
|
||||
if (speechBuf.size > 5) speechBuf.clear()
|
||||
speechFrames = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if (frameIdx >= MAX_SEGMENT_FRAMES && speechActive) {
|
||||
KazeiaLogger.stt("VAD: hit MAX (30s) — flushing")
|
||||
val total = speechBuf.sumOf { it.size }
|
||||
val pcm = ShortArray(total)
|
||||
var off = 0
|
||||
for (b in speechBuf) { b.copyInto(pcm, off); off += b.size }
|
||||
onSegment(pcm)
|
||||
}
|
||||
} finally {
|
||||
try { rec.stop() } catch (_: Exception) {}
|
||||
try { rec.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
capturing = false
|
||||
KazeiaLogger.stt("VAD capture thread exit (frames=$frameIdx)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun openRecorder(bufBytes: Int): AudioRecord? {
|
||||
for ((src, name) in listOf(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION to "VOICE_RECOGNITION",
|
||||
MediaRecorder.AudioSource.MIC to "MIC"
|
||||
)) {
|
||||
try {
|
||||
val r = AudioRecord(src, SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufBytes)
|
||||
if (r.state == AudioRecord.STATE_INITIALIZED) {
|
||||
KazeiaLogger.stt("AudioRecord opened: source=$name")
|
||||
return r
|
||||
} else {
|
||||
KazeiaLogger.sttE("AudioRecord $name: state=${r.state}")
|
||||
r.release()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
KazeiaLogger.sttE("AudioRecord $name exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!capturing) return
|
||||
capturing = false
|
||||
captureThread?.join(500)
|
||||
captureThread = null
|
||||
KazeiaLogger.stt("VAD capture stopped (manual)")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
stop()
|
||||
loaded = false
|
||||
KazeiaLogger.stt("VAD released")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +1,17 @@
|
|||
cmake_minimum_required(VERSION 3.22)
|
||||
project(kazeia-jni)
|
||||
|
||||
set(JNILIBS_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
|
||||
# --- External source paths (for kazeia_llm llama.cpp backend) ---
|
||||
set(KAZEIA_LLM_DIR /opt/Kazeia/kazeia-llm)
|
||||
set(LLAMA_UPSTREAM /opt/Kazeia/llama-upstream)
|
||||
|
||||
# --- Genie JNI bridge (legacy QNN backend, kept for fallback) ---
|
||||
add_library(genie_jni SHARED genie_jni.cpp)
|
||||
|
||||
add_library(Genie SHARED IMPORTED)
|
||||
set_target_properties(Genie PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libGenie.so)
|
||||
|
||||
target_link_libraries(genie_jni Genie android log)
|
||||
target_compile_options(genie_jni PRIVATE -std=c++17 -O2)
|
||||
|
||||
# Whisper.cpp JNI fallback supprimé 2026-05-02 (frozen stack — STT = NPU only).
|
||||
# Les libggml IMPORTED restent ci-dessous : utilisées par tts_talker_cpu et tts_cp_cpu.
|
||||
|
||||
add_library(ggml SHARED IMPORTED)
|
||||
set_target_properties(ggml PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml.so)
|
||||
|
||||
add_library(ggml-base SHARED IMPORTED)
|
||||
set_target_properties(ggml-base PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-base.so)
|
||||
|
||||
add_library(ggml-cpu SHARED IMPORTED)
|
||||
set_target_properties(ggml-cpu PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-cpu.so)
|
||||
|
||||
# --- TTS Pipeline: built externally via ExecuTorch cmake, copied to jniLibs ---
|
||||
# Build with: cd /opt/Kazeia/executorch/build-android && cmake --build . --target tts_pipeline_jni -j$(nproc)
|
||||
# Then copy to jniLibs/arm64-v8a/libtts_pipeline.so
|
||||
|
||||
# --- NEON optimized ops for TTS heads ---
|
||||
add_library(neon_ops SHARED neon_ops.cpp)
|
||||
target_link_libraries(neon_ops log)
|
||||
target_compile_options(neon_ops PRIVATE -std=c++17 -O3 -march=armv8.2-a+fp16)
|
||||
|
||||
# --- Mel Extractor (HuggingFace-compatible, no whisper.cpp dependency) ---
|
||||
# Seul JNI natif construit ici : l'extracteur de mel pour le STT Whisper.
|
||||
#
|
||||
# Historique des retraits :
|
||||
# - Genie JNI bridge — refonte engine 2026-06-17 (LLM = UnifiedLlmAdapter).
|
||||
# - TTS legacy (neon_ops, tts_talker_cpu, tts_cp_cpu, tts_decoder_ggml + ggml/
|
||||
# llama IMPORTED) — bascule CosyVoice 2026-06-18.
|
||||
# Sources archivées sous /opt/Kazeia/archive/.
|
||||
#
|
||||
# Le reste de la stack prod est livré en libs PREBUILT dans jniLibs/ :
|
||||
# kazeia_engine (LLM GGUF + RAG), kazeia_pte (LLM .pte NPU), kazeia_stt (STT),
|
||||
# kazeia_cosyvoice/cosyvoice (TTS), + leurs deps (llama, ggml*, QnnHtp*, omp).
|
||||
add_library(mel_extractor SHARED mel_extractor.cpp)
|
||||
target_link_libraries(mel_extractor android log)
|
||||
target_compile_options(mel_extractor PRIVATE -std=c++17 -O2)
|
||||
|
||||
# --- TTS Talker CPU runner (chantier ggml-cpu, Phase 1 in-process JNI) ---
|
||||
# In-process llama.cpp Talker forward, CPU NEON only (n_gpu_layers=0).
|
||||
# Loads talker_f16.gguf via libllama.so. Headers vendored in include/
|
||||
# pour autonomie post-purge llama-upstream. Lib output: libtts_talker_cpu.so
|
||||
add_library(llama SHARED IMPORTED)
|
||||
set_target_properties(llama PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libllama.so)
|
||||
|
||||
add_library(tts_talker_cpu SHARED tts_talker_cpu.cpp)
|
||||
target_include_directories(tts_talker_cpu PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
)
|
||||
target_link_libraries(tts_talker_cpu llama ggml ggml-base ggml-cpu android log)
|
||||
target_compile_options(tts_talker_cpu PRIVATE
|
||||
-std=c++17 -O3 -march=armv8.2-a+fp16
|
||||
-Wno-unused-parameter -Wno-unused-variable)
|
||||
|
||||
# --- TTS CP (Code Predictor) CPU runner ---
|
||||
# 17-step interleaved decode + 15 dot_argmax NEON classifications per call.
|
||||
# Loads cp_f16.gguf + cp_heads.bin + cp_codec_embs.bin (125 MB each).
|
||||
add_library(tts_cp_cpu SHARED tts_cp_cpu.cpp)
|
||||
target_include_directories(tts_cp_cpu PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
)
|
||||
target_link_libraries(tts_cp_cpu llama ggml ggml-base ggml-cpu android log)
|
||||
target_compile_options(tts_cp_cpu PRIVATE
|
||||
-std=c++17 -O3 -march=armv8.2-a+fp16
|
||||
-Wno-unused-parameter -Wno-unused-variable)
|
||||
|
||||
# --- TTS Decoder pur ggml C++ (remplace .pte ExecuTorch) ---
|
||||
# IMPORTANT : link STATIQUE de ggml-base/ggml-cpu/ggml depuis whisper.cpp/ggml
|
||||
# avec ARM features Optim #1 (armv8.6-a+i8mm+dotprod+fp16+bf16). Le .so résultant
|
||||
# est autonome, NE DÉPEND PAS des libggml*.so partagées (qui sont en armv8.2 et
|
||||
# servent à libllama.so pour le talker). Cela évite tout conflit ABI/symboles.
|
||||
# Build des .a : /opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static/
|
||||
set(TTS_DECODER_DIR /opt/Kazeia/kazeia-tts-decoder-ggml/src)
|
||||
# Build static-chraac : ggml depuis chraac-llama (RTF 0.96 mesuré 2026-05-02
|
||||
# vs 1.47 avec llama-upstream). chraac-llama branche dev-refactoring a un
|
||||
# ggml-cpu plus récent + repack/sgemm optimisés ARM. Backup ancienne version
|
||||
# disponible via build-android-static.
|
||||
set(TTS_DECODER_STATIC_DIR /opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static-chraac)
|
||||
|
||||
add_library(tts_ggml_base STATIC IMPORTED)
|
||||
set_target_properties(tts_ggml_base PROPERTIES
|
||||
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml-base.a)
|
||||
add_library(tts_ggml_cpu STATIC IMPORTED)
|
||||
set_target_properties(tts_ggml_cpu PROPERTIES
|
||||
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml-cpu.a)
|
||||
add_library(tts_ggml STATIC IMPORTED)
|
||||
set_target_properties(tts_ggml PROPERTIES
|
||||
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml.a)
|
||||
|
||||
add_library(tts_decoder_ggml SHARED
|
||||
tts_decoder_ggml.cpp
|
||||
${TTS_DECODER_DIR}/decoder.cpp
|
||||
${TTS_DECODER_DIR}/gguf_loader.cpp
|
||||
)
|
||||
target_include_directories(tts_decoder_ggml PRIVATE
|
||||
${TTS_DECODER_DIR}
|
||||
/opt/Kazeia/whisper.cpp/ggml/include
|
||||
/opt/Kazeia/whisper.cpp/include
|
||||
)
|
||||
# Static linking : the order matters (ggml depends on ggml-cpu and ggml-base).
|
||||
# Wrap in --whole-archive to force inclusion of backend register globals.
|
||||
# -fopenmp pour résoudre __kmpc_* / omp_get_*. libomp.so doit être présent
|
||||
# dans jniLibs/arm64-v8a/ (copié depuis NDK r27d aarch64).
|
||||
target_link_libraries(tts_decoder_ggml
|
||||
-Wl,--whole-archive tts_ggml_cpu -Wl,--no-whole-archive
|
||||
tts_ggml tts_ggml_base
|
||||
-fopenmp -static-openmp
|
||||
android log m)
|
||||
target_compile_options(tts_decoder_ggml PRIVATE
|
||||
-std=c++17 -O3 -march=armv8.6-a+dotprod+fp16+i8mm+bf16 -fopenmp
|
||||
-Wno-unused-parameter -Wno-unused-variable -Wno-unused-function)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "GenieJNI"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||
|
||||
// Genie C API declarations (from libGenie.so)
|
||||
extern "C" {
|
||||
// Opaque types
|
||||
typedef void* GenieDialogConfig;
|
||||
typedef void* GenieDialog;
|
||||
typedef void* GenieTokenizer;
|
||||
typedef void* GenieSampler;
|
||||
|
||||
// Version
|
||||
int Genie_getApiMajorVersion();
|
||||
int Genie_getApiMinorVersion();
|
||||
int Genie_getApiPatchVersion();
|
||||
|
||||
// DialogConfig
|
||||
GenieDialogConfig GenieDialogConfig_createFromJson(const char* jsonPath);
|
||||
void GenieDialogConfig_free(GenieDialogConfig config);
|
||||
|
||||
// Dialog
|
||||
GenieDialog GenieDialog_create(GenieDialogConfig config);
|
||||
void GenieDialog_free(GenieDialog dialog);
|
||||
const char* GenieDialog_query(GenieDialog dialog, const char* prompt);
|
||||
void GenieDialog_setStopSequence(GenieDialog dialog, const char* stopSeq);
|
||||
void GenieDialog_reset(GenieDialog dialog);
|
||||
void GenieDialog_signal(GenieDialog dialog);
|
||||
GenieTokenizer GenieDialog_getTokenizer(GenieDialog dialog);
|
||||
GenieSampler GenieDialog_getSampler(GenieDialog dialog);
|
||||
|
||||
// Sampler callback
|
||||
typedef bool (*GenieSamplerCallback)(const char* token, void* userData);
|
||||
void GenieSampler_registerUserDataCallback(
|
||||
GenieSampler sampler,
|
||||
GenieSamplerCallback callback,
|
||||
void* userData
|
||||
);
|
||||
|
||||
// Tokenizer
|
||||
const char* GenieTokenizer_decode(GenieTokenizer tokenizer, const int* tokens, int numTokens);
|
||||
int* GenieTokenizer_encode(GenieTokenizer tokenizer, const char* text, int* numTokens);
|
||||
}
|
||||
|
||||
// Check if a pointer looks like a valid heap pointer (not an error code)
|
||||
// Genie SDK returns small negative int values as error codes (e.g. -5 = 0xfffffffb)
|
||||
// On ARM64 Android, valid heap pointers are always > 0x100000000 (above 4GB)
|
||||
static bool isValidPointer(void* ptr) {
|
||||
uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);
|
||||
if (ptr == nullptr) return false;
|
||||
// Any value that fits in 32 bits is likely an error code, not a heap pointer
|
||||
if (addr <= 0xFFFFFFFFULL) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Callback context for token streaming
|
||||
struct CallbackContext {
|
||||
JNIEnv* env;
|
||||
jobject callback;
|
||||
jmethodID onTokenMethod;
|
||||
std::string fullResponse;
|
||||
bool shouldStop;
|
||||
};
|
||||
|
||||
static bool samplerCallback(const char* token, void* userData) {
|
||||
auto* ctx = reinterpret_cast<CallbackContext*>(userData);
|
||||
if (ctx->shouldStop || token == nullptr) return false;
|
||||
|
||||
ctx->fullResponse += token;
|
||||
|
||||
if (ctx->callback != nullptr) {
|
||||
JNIEnv* env = ctx->env;
|
||||
jstring jToken = env->NewStringUTF(token);
|
||||
jboolean continueGen = env->CallBooleanMethod(
|
||||
ctx->callback, ctx->onTokenMethod, jToken
|
||||
);
|
||||
env->DeleteLocalRef(jToken);
|
||||
|
||||
if (!continueGen) {
|
||||
ctx->shouldStop = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_kazeia_llm_GenieJni_createDialog(JNIEnv* env, jobject, jstring configPath) {
|
||||
const char* path = env->GetStringUTFChars(configPath, nullptr);
|
||||
LOGI("Creating dialog from config: %s", path);
|
||||
|
||||
GenieDialogConfig config = GenieDialogConfig_createFromJson(path);
|
||||
env->ReleaseStringUTFChars(configPath, path);
|
||||
|
||||
if (!isValidPointer(config)) {
|
||||
LOGE("Failed to create dialog config (returned %p, likely error code %ld)",
|
||||
config, reinterpret_cast<long>(config));
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOGI("Dialog config created: %p", config);
|
||||
GenieDialog dialog = GenieDialog_create(config);
|
||||
GenieDialogConfig_free(config);
|
||||
|
||||
if (!isValidPointer(dialog)) {
|
||||
LOGE("Failed to create dialog (returned %p, likely error code %ld)",
|
||||
dialog, reinterpret_cast<long>(dialog));
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOGI("Dialog created successfully: %p", dialog);
|
||||
return reinterpret_cast<jlong>(dialog);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_kazeia_llm_GenieJni_query(
|
||||
JNIEnv* env, jobject, jlong dialogHandle, jstring prompt, jobject callback
|
||||
) {
|
||||
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
|
||||
|
||||
if (!isValidPointer(dialog)) {
|
||||
LOGE("Invalid dialog handle: %p", dialog);
|
||||
return env->NewStringUTF("[Erreur: modèle LLM non chargé]");
|
||||
}
|
||||
|
||||
const char* promptStr = env->GetStringUTFChars(prompt, nullptr);
|
||||
|
||||
CallbackContext ctx;
|
||||
ctx.env = env;
|
||||
ctx.callback = callback;
|
||||
ctx.shouldStop = false;
|
||||
|
||||
if (callback != nullptr) {
|
||||
jclass cbClass = env->GetObjectClass(callback);
|
||||
ctx.onTokenMethod = env->GetMethodID(
|
||||
cbClass, "onToken", "(Ljava/lang/String;)Z"
|
||||
);
|
||||
|
||||
// Register the sampler callback
|
||||
GenieSampler sampler = GenieDialog_getSampler(dialog);
|
||||
if (isValidPointer(sampler)) {
|
||||
GenieSampler_registerUserDataCallback(sampler, samplerCallback, &ctx);
|
||||
} else {
|
||||
LOGE("Invalid sampler pointer: %p", sampler);
|
||||
}
|
||||
}
|
||||
|
||||
LOGI("Query: %.80s...", promptStr);
|
||||
const char* response = GenieDialog_query(dialog, promptStr);
|
||||
env->ReleaseStringUTFChars(prompt, promptStr);
|
||||
|
||||
// Use callback accumulated response if available, otherwise use direct response
|
||||
std::string result;
|
||||
if (!ctx.fullResponse.empty()) {
|
||||
result = ctx.fullResponse;
|
||||
} else if (response != nullptr) {
|
||||
result = response;
|
||||
}
|
||||
|
||||
LOGI("Response length: %zu chars", result.size());
|
||||
return env->NewStringUTF(result.c_str());
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_kazeia_llm_GenieJni_setStopSequence(
|
||||
JNIEnv* env, jobject, jlong dialogHandle, jstring stopSequence
|
||||
) {
|
||||
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
|
||||
if (!isValidPointer(dialog)) return;
|
||||
const char* seq = env->GetStringUTFChars(stopSequence, nullptr);
|
||||
GenieDialog_setStopSequence(dialog, seq);
|
||||
env->ReleaseStringUTFChars(stopSequence, seq);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_kazeia_llm_GenieJni_freeDialog(JNIEnv*, jobject, jlong dialogHandle) {
|
||||
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
|
||||
if (isValidPointer(dialog)) {
|
||||
GenieDialog_free(dialog);
|
||||
LOGI("Dialog freed");
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_kazeia_llm_GenieJni_getVersion(JNIEnv* env, jobject) {
|
||||
int major = Genie_getApiMajorVersion();
|
||||
int minor = Genie_getApiMinorVersion();
|
||||
int patch = Genie_getApiPatchVersion();
|
||||
std::string version = std::to_string(major) + "." +
|
||||
std::to_string(minor) + "." +
|
||||
std::to_string(patch);
|
||||
return env->NewStringUTF(version.c_str());
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fond adaptatif Kazeia : dégradé radial violet profond (donne de la
|
||||
profondeur derrière l'orbe). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0 L108,0 L108,108 L0,108 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="46"
|
||||
android:gradientRadius="82">
|
||||
<item android:offset="0" android:color="#FF50398A" />
|
||||
<item android:offset="0.55" android:color="#FF3A2B6B" />
|
||||
<item android:offset="1" android:color="#FF221A3D" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Avant-plan adaptatif Kazeia : l'orbe (la « face » de Kazeia, cf.
|
||||
AudioVisualizerView). Halo lumineux + sphère lavande éclairée en haut-gauche
|
||||
+ reflet spéculaire. Tout reste dans la zone sûre 72dp (rayon ≤ 36 du centre,
|
||||
hors halo qui s'estompe). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Halo diffus -->
|
||||
<path android:pathData="M12,54 a42,42 0 1,0 84,0 a42,42 0 1,0 -84,0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="54"
|
||||
android:gradientRadius="42">
|
||||
<item android:offset="0" android:color="#00BCA4E8" />
|
||||
<item android:offset="0.78" android:color="#00BCA4E8" />
|
||||
<item android:offset="0.9" android:color="#5CCBB6F0" />
|
||||
<item android:offset="1" android:color="#00BCA4E8" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Sphère de l'orbe (rayon 33) -->
|
||||
<path android:pathData="M21,54 a33,33 0 1,0 66,0 a33,33 0 1,0 -66,0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="42"
|
||||
android:centerY="40"
|
||||
android:gradientRadius="50">
|
||||
<item android:offset="0" android:color="#FFF6F1FF" />
|
||||
<item android:offset="0.32" android:color="#FFC9B6F0" />
|
||||
<item android:offset="0.66" android:color="#FF9879D8" />
|
||||
<item android:offset="1" android:color="#FF6243A0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Reflet spéculaire (point lumineux haut-gauche) -->
|
||||
<path android:pathData="M35,39 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="42"
|
||||
android:centerY="39"
|
||||
android:gradientRadius="7">
|
||||
<item android:offset="0" android:color="#E6FFFFFF" />
|
||||
<item android:offset="1" android:color="#00FFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Icône thématisée (Android 13+) : silhouette de l'orbe, teintée par le système.
|
||||
Sphère pleine + petit reflet évidé pour garder la lecture « orbe ». -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M21,54 a33,33 0 1,0 66,0 a33,33 0 1,0 -66,0 Z M36,40 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0 Z" />
|
||||
</vector>
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
android:src="@android:drawable/ic_menu_manage"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Debug"
|
||||
android:visibility="gone"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<ImageButton
|
||||
|
|
@ -190,12 +191,27 @@
|
|||
android:layout_weight="1"
|
||||
android:layout_margin="2dp" />
|
||||
|
||||
<com.kazeia.ui.MiniGraphView
|
||||
android:id="@+id/graphGpu"
|
||||
<!-- Remplace le graph GPU (inutilisé) : encart « dernier tour »
|
||||
(input · output · temps par étape), persistant. -->
|
||||
<ScrollView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp" />
|
||||
android:layout_margin="2dp"
|
||||
android:background="#16202A">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastTurn"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="9sp"
|
||||
android:textColor="#B8D8EC"
|
||||
android:lineSpacingMultiplier="1.15"
|
||||
android:text="⏱ dernier tour : —" />
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/kazeia_primary" />
|
||||
<foreground android:drawable="@color/kazeia_accent" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Spec — API d'échantillonnage natif (kazeia-engine) pour les presets Kazeia
|
||||
|
||||
**Date** : 2026-06-18
|
||||
**Demandeur** : app Kazeia (refonte presets admin)
|
||||
**Cible** : `kazeia-engine` (lib `libkazeia_engine`, JNI `EngineJni`)
|
||||
|
||||
## Contexte
|
||||
|
||||
L'app admin permet désormais de régler, par modèle (Speaker / Thinker) et via des
|
||||
**presets** nommés, les paramètres d'échantillonnage : `temperature`, `top_p`,
|
||||
`top_k`, `repeat_penalty`, `presence_penalty`, `frequency_penalty`, `max_tokens`.
|
||||
|
||||
Côté app, ces valeurs sont **persistées et transmises** jusqu'au pont JNI
|
||||
(`UnifiedLlmAdapter` → `EngineLlmEngine`/`LlmSession`). **MAIS le JNI actuel
|
||||
n'expose que `maxTok`** :
|
||||
|
||||
```kotlin
|
||||
external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String
|
||||
external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback)
|
||||
external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback)
|
||||
```
|
||||
|
||||
Donc aujourd'hui **seul `max_tokens` agit** ; température/top-p/k/pénalités sont
|
||||
inertes (l'app l'indique honnêtement dans l'UI : « effectif après MAJ moteur »).
|
||||
|
||||
## Demande — exposer le sampling au JNI
|
||||
|
||||
Ajouter une **variante échantillonnée** des entrées de génération (sans casser les
|
||||
signatures existantes), qui prend une struct de sampling et la câble sur la chaîne
|
||||
de samplers llama.cpp. Le GGUF Qwen3.5-4B (Speaker par défaut) est la cible
|
||||
prioritaire.
|
||||
|
||||
### Signatures proposées
|
||||
|
||||
```kotlin
|
||||
// Struct portée côté natif (ou 7 args primitifs si plus simple en JNI) :
|
||||
// temperature: Float, topP: Float, topK: Int,
|
||||
// repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float, maxTokens: Int
|
||||
external fun generateSampled(
|
||||
h: Long, sys: String, usr: String,
|
||||
maxTokens: Int, temperature: Float, topP: Float, topK: Int,
|
||||
repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float,
|
||||
cb: TokenCallback
|
||||
)
|
||||
external fun sessionAskSampled(
|
||||
h: Long, usr: String,
|
||||
maxTokens: Int, temperature: Float, topP: Float, topK: Int,
|
||||
repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float,
|
||||
cb: TokenCallback
|
||||
)
|
||||
```
|
||||
|
||||
### Mapping llama.cpp (sampler chain)
|
||||
|
||||
Construire la chaîne par requête (ou réutiliser un `llama_sampler` reconfiguré) :
|
||||
|
||||
```c
|
||||
auto * chain = llama_sampler_chain_init(llama_sampler_chain_default_params());
|
||||
llama_sampler_chain_add(chain, llama_sampler_init_top_k(top_k));
|
||||
llama_sampler_chain_add(chain, llama_sampler_init_top_p(top_p, 1));
|
||||
llama_sampler_chain_add(chain, llama_sampler_init_penalties(
|
||||
/*penalty_last_n*/ 64, repeat_penalty, frequency_penalty, presence_penalty));
|
||||
llama_sampler_chain_add(chain, llama_sampler_init_temp(temperature));
|
||||
llama_sampler_chain_add(chain, llama_sampler_init_dist(/*seed*/ LLAMA_DEFAULT_SEED));
|
||||
```
|
||||
|
||||
- `temperature <= 0` ⇒ greedy (`llama_sampler_init_greedy`), ignorer les autres.
|
||||
- Conserver le **thinking-off** et le template ChatML actuels inchangés.
|
||||
- `presence/frequency_penalty = 0` ⇒ no-op (comportement neutre).
|
||||
|
||||
### Comportement attendu
|
||||
|
||||
- Valeurs de référence (preset « Kaz chaleureux ») : temp 0.4, top_p 0.85, top_k 40,
|
||||
repeat 1.05, presence 0.2, frequency 0.2, max 256 — doit produire des sorties
|
||||
visiblement moins répétitives / plus chaleureuses que le greedy actuel.
|
||||
- Déterminisme : exposer un `seed` optionnel plus tard si besoin (pas requis v1).
|
||||
|
||||
### `.pte` (NPU) — hors scope v1
|
||||
|
||||
Le runner ExecuTorch `.pte` décode **greedy**. Le sampling y est une évolution
|
||||
distincte (échantillonnage post-logits côté runner). Pour l'instant les presets
|
||||
n'affectent que le chemin GGUF ; documenter la limite suffit.
|
||||
|
||||
## Côté app — déjà prêt
|
||||
|
||||
- `SamplingParams` (com.kazeia.core) porte les 7 knobs.
|
||||
- `UnifiedLlmAdapter.generateWithSystem` reçoit les params ; il suffira de router
|
||||
vers `*Sampled` au lieu de `ask(max)` / `generateStream(max)` quand le JNI existe.
|
||||
- `ConfigStore.ModelConfig` + presets persistent les valeurs ; l'admin les édite.
|
||||
|
||||
Quand le JNI échantillonné est livré : ~10 lignes à changer côté `UnifiedLlmAdapter`
|
||||
(brancher `sessionAskSampled` / `generateSampled`) et retirer la mention
|
||||
« effectif après MAJ moteur » de l'UI admin.
|
||||
|
|
@ -1,32 +1,23 @@
|
|||
# jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/
|
||||
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-15.
|
||||
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-18.
|
||||
# Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*),
|
||||
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
|
||||
# QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp.
|
||||
# Les binaires ne sont PAS dans git ; ce manifest fait foi (verify en preBuild).
|
||||
b0a52229e54bed2b074b38e6b6a6f2203a679f1b1fe7393912de7d7ec3e6196e libcosyvoice.so
|
||||
d523468d62d9b603cb3354294d70d4b2feabf2c3f1e43b0c96c9aabf32813708 libc++_shared.so
|
||||
dd0506873aeb45e103ef0023678a7f14938a0f147f2348d1efc366f1dce40863 libexecutorch_jni.so
|
||||
0b6f86e114f0cf9f2940194797f06ded576e6b3272a5ffbb2c4b8cdc02850ef8 libexecutorch.so
|
||||
34a0af206f660461e3da687f64172830cc61a32a93732fd7e984c43996d6fd91 libfbjni.so
|
||||
d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so
|
||||
616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so
|
||||
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
|
||||
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
|
||||
b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so
|
||||
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
|
||||
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so
|
||||
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
|
||||
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
|
||||
f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so
|
||||
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
|
||||
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
|
||||
d1e8fc79e47fe04ed00aa9bbcc5ccb84d187f0160b1d77ae5bd012aedea9c938 libqnn_executorch_backend.so
|
||||
414794639e2897120143cff80aaf73e830e305357b6c1aa8343de380c89b8c36 libQnnGpuNetRunExtensions.so
|
||||
26f13b297d375e9629eb997b7adb05a4ce9e1c17d306baaefe905563fee43eb8 libQnnGpuProfilingReader.so
|
||||
b5b52b66f3fc5567429024409dcedb006ca6a9115f271689810589b7d79af430 libQnnGpu.so
|
||||
95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so
|
||||
a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so
|
||||
2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so
|
||||
10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so
|
||||
7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so
|
||||
a08fb34747345125fd3cef3fc10046c663465dd60fddbac9cc37436b3d2e68a9 libQnnHtpV79Stub.so
|
||||
a69cfc4350c16b226bcaa19c657234820bb93e3ff6116cf3845ae77557f1f7c3 libQnnSystem.so
|
||||
d35a02f70cb63ec60c8ae78011f5e93aecbb31396d5395a4a82cb1bc71803956 libtts_pipeline.so
|
||||
|
|
|
|||
Loading…
Reference in New Issue