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
|
<application
|
||||||
android:label="Kazeia Admin"
|
android:label="Kazeia Admin"
|
||||||
android:icon="@mipmap/ic_launcher_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:theme="@style/Theme.KazeiaAdmin"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:allowBackup="false">
|
android:allowBackup="false">
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ private fun MainShell() {
|
||||||
composable(AdminDestination.Prompts.route) {
|
composable(AdminDestination.Prompts.route) {
|
||||||
PromptsScreen()
|
PromptsScreen()
|
||||||
}
|
}
|
||||||
|
composable(AdminDestination.Parameters.route) {
|
||||||
|
com.kazeia.admin.ui.parameters.ParametersScreen()
|
||||||
|
}
|
||||||
composable(AdminDestination.Rag.route) {
|
composable(AdminDestination.Rag.route) {
|
||||||
com.kazeia.admin.ui.rag.RagScreen()
|
com.kazeia.admin.ui.rag.RagScreen()
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +105,9 @@ private fun MainShell() {
|
||||||
composable(AdminDestination.Telemetry.route) {
|
composable(AdminDestination.Telemetry.route) {
|
||||||
TelemetryScreen()
|
TelemetryScreen()
|
||||||
}
|
}
|
||||||
|
composable(AdminDestination.Updates.route) {
|
||||||
|
com.kazeia.admin.ui.updates.UpdatesScreen()
|
||||||
|
}
|
||||||
composable(AdminDestination.System.route) {
|
composable(AdminDestination.System.route) {
|
||||||
SystemScreen()
|
SystemScreen()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@ class ConfigRepository(context: Context) {
|
||||||
fun updateTts(enabled: Boolean): Boolean =
|
fun updateTts(enabled: Boolean): Boolean =
|
||||||
client.updateConfig(ttsEnabled = enabled)
|
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 =
|
fun updateSpeakerModel(modelId: String): Boolean =
|
||||||
client.updateConfig(speakerModelId = modelId)
|
client.updateConfig(speakerModelId = modelId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package com.kazeia.admin.data.source
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client du ContentProvider Kazeia pour les URI `/config` et `/models`.
|
* 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")
|
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(
|
data class Config(
|
||||||
val cascadeEnabled: Boolean,
|
val cascadeEnabled: Boolean,
|
||||||
val ttsEnabled: Boolean,
|
val ttsEnabled: Boolean,
|
||||||
val speakerModelId: String,
|
val speakerModelId: String,
|
||||||
val speakerSystemPrompt: String,
|
val speakerSystemPrompt: String,
|
||||||
val speakerTemperature: Float,
|
val speakerSampling: Sampling,
|
||||||
|
val speakerPresetName: String,
|
||||||
val thinkerModelId: String,
|
val thinkerModelId: String,
|
||||||
val thinkerSystemPrompt: String,
|
val thinkerSystemPrompt: String,
|
||||||
val thinkerTemperature: Float,
|
val thinkerSampling: Sampling,
|
||||||
|
val thinkerPresetName: String,
|
||||||
|
val presets: List<Preset> = emptyList(),
|
||||||
val ragEnabled: Boolean = false,
|
val ragEnabled: Boolean = false,
|
||||||
val ragThreshold: Float = 0.82f,
|
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(
|
data class ModelInfo(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|
@ -43,6 +68,23 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
||||||
val notes: String
|
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 {
|
fun queryConfig(): Config? = runCatching {
|
||||||
resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
|
resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
|
||||||
if (!c.moveToFirst()) return@use null
|
if (!c.moveToFirst()) return@use null
|
||||||
|
|
@ -51,17 +93,58 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
||||||
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
|
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
|
||||||
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
|
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
|
||||||
speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")),
|
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")),
|
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
|
||||||
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
|
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
|
||||||
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")),
|
thinkerSampling = c.sampling("thinker_"),
|
||||||
ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false },
|
thinkerPresetName = c.strOr("thinker_preset_name", ""),
|
||||||
ragThreshold = c.getColumnIndex("rag_threshold").let { if (it >= 0) c.getFloat(it) else 0.82f },
|
presets = parsePresets(c.strOr("presets_json", "[]")),
|
||||||
ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 }
|
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()
|
}.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 {
|
fun queryModels(): List<ModelInfo> = runCatching {
|
||||||
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
|
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
|
||||||
val out = mutableListOf<ModelInfo>()
|
val out = mutableListOf<ModelInfo>()
|
||||||
|
|
@ -89,29 +172,49 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
|
||||||
ttsEnabled: Boolean? = null,
|
ttsEnabled: Boolean? = null,
|
||||||
speakerModelId: String? = null,
|
speakerModelId: String? = null,
|
||||||
speakerSystemPrompt: String? = null,
|
speakerSystemPrompt: String? = null,
|
||||||
speakerTemperature: Float? = null,
|
|
||||||
thinkerModelId: String? = null,
|
thinkerModelId: String? = null,
|
||||||
thinkerSystemPrompt: String? = null,
|
thinkerSystemPrompt: String? = null,
|
||||||
thinkerTemperature: Float? = null,
|
|
||||||
ragEnabled: Boolean? = null,
|
ragEnabled: Boolean? = null,
|
||||||
ragThreshold: Float? = null,
|
ragThreshold: Float? = null,
|
||||||
ragTopK: Int? = null
|
ragTopK: Int? = null,
|
||||||
|
debugEnabled: Boolean? = null
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val v = ContentValues()
|
val v = ContentValues()
|
||||||
cascadeEnabled?.let { v.put("cascade_enabled", it) }
|
cascadeEnabled?.let { v.put("cascade_enabled", it) }
|
||||||
ttsEnabled?.let { v.put("tts_enabled", it) }
|
ttsEnabled?.let { v.put("tts_enabled", it) }
|
||||||
speakerModelId?.let { v.put("speaker_model_id", it) }
|
speakerModelId?.let { v.put("speaker_model_id", it) }
|
||||||
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
|
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
|
||||||
speakerTemperature?.let { v.put("speaker_temperature", it) }
|
|
||||||
thinkerModelId?.let { v.put("thinker_model_id", it) }
|
thinkerModelId?.let { v.put("thinker_model_id", it) }
|
||||||
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
|
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
|
||||||
thinkerTemperature?.let { v.put("thinker_temperature", it) }
|
|
||||||
ragEnabled?.let { v.put("rag_enabled", it) }
|
ragEnabled?.let { v.put("rag_enabled", it) }
|
||||||
ragThreshold?.let { v.put("rag_threshold", it) }
|
ragThreshold?.let { v.put("rag_threshold", it) }
|
||||||
ragTopK?.let { v.put("rag_top_k", 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
|
if (v.size() == 0) return true
|
||||||
return runCatching {
|
return runCatching { resolver.update(CONFIG_URI, v, null, null) > 0 }.getOrDefault(false)
|
||||||
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.GraphicEq
|
||||||
import androidx.compose.material.icons.outlined.History
|
import androidx.compose.material.icons.outlined.History
|
||||||
import androidx.compose.material.icons.outlined.People
|
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.RecordVoiceOver
|
||||||
|
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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),
|
Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver),
|
||||||
Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People),
|
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),
|
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),
|
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
|
||||||
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
|
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
|
||||||
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
|
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);
|
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
|
||||||
|
|
||||||
companion object {
|
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(
|
ModelSection(
|
||||||
title = "Speaker",
|
title = "Speaker",
|
||||||
subtitle = "LLM qui répond au patient",
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModelSection(
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import com.kazeia.admin.data.source.KazeiaProfilesClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -87,6 +95,21 @@ fun RecordVoiceScreen(
|
||||||
}
|
}
|
||||||
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
|
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
|
||||||
|
|
||||||
|
// Exclusivité (Phase 1) : portée de la voix choisie au moment d'enregistrer.
|
||||||
|
val profilesClient = remember { KazeiaProfilesClient(context.contentResolver) }
|
||||||
|
var profiles by remember { mutableStateOf<List<KazeiaProfilesClient.ProfileRow>>(emptyList()) }
|
||||||
|
var scopeChoice by remember { mutableStateOf("exclusive") } // exclusive|global|pending
|
||||||
|
var ownerProfileId by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val list = withContext(Dispatchers.IO) { profilesClient.queryAll() }
|
||||||
|
val active = withContext(Dispatchers.IO) { profilesClient.activeProfileId() }
|
||||||
|
profiles = list
|
||||||
|
// Défaut = patient actif (cas courant : enregistrer la voix d'un proche
|
||||||
|
// POUR le patient actif de cette tablette). Aucun profil → bascule pending.
|
||||||
|
ownerProfileId = active ?: list.firstOrNull()?.id
|
||||||
|
if (list.isEmpty()) scopeChoice = "pending"
|
||||||
|
}
|
||||||
|
|
||||||
val permLauncher = rememberLauncherForActivityResult(
|
val permLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> permissionGranted = granted }
|
) { granted -> permissionGranted = granted }
|
||||||
|
|
@ -139,6 +162,99 @@ fun RecordVoiceScreen(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 1.5 Exclusivité : à qui cette voix est destinée (capture record-time,
|
||||||
|
// écrite dans le manifeste → Kazeia-central applique la politique).
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rec_scope_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
val opts = listOf(
|
||||||
|
"exclusive" to stringResource(R.string.rec_scope_exclusive),
|
||||||
|
"global" to stringResource(R.string.rec_scope_global),
|
||||||
|
"pending" to stringResource(R.string.rec_scope_pending)
|
||||||
|
)
|
||||||
|
opts.forEach { (value, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = scopeChoice == value,
|
||||||
|
enabled = state != AudioRecorder.State.RECORDING,
|
||||||
|
onClick = { scopeChoice = value }
|
||||||
|
)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = scopeChoice == value,
|
||||||
|
onClick = { scopeChoice = value },
|
||||||
|
enabled = state != AudioRecorder.State.RECORDING
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scopeChoice == "exclusive") {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
if (profiles.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rec_scope_no_patients),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val owner = profiles.firstOrNull { it.id == ownerProfileId }
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = {
|
||||||
|
if (state != AudioRecorder.State.RECORDING) expanded = it
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = owner?.displayName ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.rec_scope_patient_label)) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
profiles.forEach { p ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(p.displayName) },
|
||||||
|
onClick = { ownerProfileId = p.id; expanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (scopeChoice == "pending") {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rec_scope_pending_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Phrase de référence (vaut consentement enregistré)
|
// 2. Phrase de référence (vaut consentement enregistré)
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
|
|
@ -240,18 +356,23 @@ fun RecordVoiceScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Bouton save : visible quand audio capturé ET qualité OK
|
// 5. Bouton save : visible quand audio capturé ET qualité OK
|
||||||
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true
|
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true &&
|
||||||
|
(scopeChoice != "exclusive" || ownerProfileId != null)
|
||||||
if (state == AudioRecorder.State.FINISHED) {
|
if (state == AudioRecorder.State.FINISHED) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val samples = recorder.samples()
|
val samples = recorder.samples()
|
||||||
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
|
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
|
||||||
|
val ownerName = profiles.firstOrNull { it.id == ownerProfileId }?.displayName
|
||||||
val saved = runCatching {
|
val saved = runCatching {
|
||||||
storage.save(
|
storage.save(
|
||||||
displayName = voiceName.trim(),
|
displayName = voiceName.trim(),
|
||||||
samples = samples,
|
samples = samples,
|
||||||
durationSeconds = durSec,
|
durationSeconds = durSec,
|
||||||
referenceText = referenceText
|
referenceText = referenceText,
|
||||||
|
scope = scopeChoice,
|
||||||
|
ownerProfileId = if (scopeChoice == "exclusive") ownerProfileId else null,
|
||||||
|
ownerName = if (scopeChoice == "exclusive") ownerName else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,15 @@ class VoiceStorage(private val context: Context) {
|
||||||
displayName: String,
|
displayName: String,
|
||||||
samples: ShortArray,
|
samples: ShortArray,
|
||||||
durationSeconds: Float,
|
durationSeconds: Float,
|
||||||
referenceText: String
|
referenceText: String,
|
||||||
|
// Exclusivité (Phase 1, capture record-time). scope ∈ {exclusive, global,
|
||||||
|
// pending}. Lu par Kazeia-central à l'ingestion → localisation de
|
||||||
|
// déploiement + assignation Profile.voiceId (cf. VOICE_DEPLOYMENT_SPEC §4).
|
||||||
|
// exclusive → ownerProfileId requis ; pending → propriétaire à lier plus
|
||||||
|
// tard (ni déployée ni assignable tant que non résolu) ; global → tous.
|
||||||
|
scope: String = "pending",
|
||||||
|
ownerProfileId: String? = null,
|
||||||
|
ownerName: String? = null
|
||||||
): RecordedVoice {
|
): RecordedVoice {
|
||||||
val ts = TS_FMT.format(System.currentTimeMillis())
|
val ts = TS_FMT.format(System.currentTimeMillis())
|
||||||
val id = "${slug(displayName)}_$ts"
|
val id = "${slug(displayName)}_$ts"
|
||||||
|
|
@ -63,6 +71,9 @@ class VoiceStorage(private val context: Context) {
|
||||||
put("created_at", System.currentTimeMillis())
|
put("created_at", System.currentTimeMillis())
|
||||||
put("reference_text", referenceText)
|
put("reference_text", referenceText)
|
||||||
put("source_device", "tablet-admin-recording")
|
put("source_device", "tablet-admin-recording")
|
||||||
|
put("scope", scope)
|
||||||
|
ownerProfileId?.let { put("owner_profile_id", it) }
|
||||||
|
ownerName?.let { put("owner_name", it) }
|
||||||
}
|
}
|
||||||
manifest.writeText(json.toString(2))
|
manifest.writeText(json.toString(2))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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
|
<path
|
||||||
android:fillColor="#BCA4E8"
|
android:strokeColor="#FF9C8FC9"
|
||||||
android:pathData="M54 28 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0" />
|
android:strokeWidth="4.5"
|
||||||
<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:fillColor="#00000000"
|
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>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<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>
|
</adaptive-icon>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<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>
|
</adaptive-icon>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
<string name="nav_voices">Voix</string>
|
<string name="nav_voices">Voix</string>
|
||||||
<string name="nav_profiles">Profils patients</string>
|
<string name="nav_profiles">Profils patients</string>
|
||||||
<string name="nav_prompts">Prompts</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_rag">Base documentaire</string>
|
||||||
<string name="nav_history">Historique</string>
|
<string name="nav_history">Historique</string>
|
||||||
<string name="nav_telemetry">Telemetry</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_label">Nom de la voix</string>
|
||||||
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
|
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
|
||||||
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
|
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
|
||||||
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux.</string>
|
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Aujourd\'hui, le ciel est bleu et le vent souffle doucement. J\'aime écouter le chant joyeux des oiseaux, près d\'un feu paisible. Quel bonheur tranquille ! Et vous, qu\'est-ce qui vous rend vraiment heureux ?</string>
|
||||||
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
|
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
|
||||||
|
<string name="rec_scope_title">À qui est destinée cette voix ?</string>
|
||||||
|
<string name="rec_scope_exclusive">Exclusive à un patient</string>
|
||||||
|
<string name="rec_scope_global">Non-exclusive (tous les patients)</string>
|
||||||
|
<string name="rec_scope_pending">Lier à un patient plus tard</string>
|
||||||
|
<string name="rec_scope_patient_label">Patient propriétaire</string>
|
||||||
|
<string name="rec_scope_no_patients">Aucun patient chargé sur cette tablette — choisissez « Lier à un patient plus tard ».</string>
|
||||||
|
<string name="rec_scope_pending_hint">La voix sera enregistrée mais ni déployée ni utilisable tant qu\'un patient ne lui sera pas assigné.</string>
|
||||||
<string name="rec_start">Commencer l\'enregistrement</string>
|
<string name="rec_start">Commencer l\'enregistrement</string>
|
||||||
<string name="rec_stop">Arrêter</string>
|
<string name="rec_stop">Arrêter</string>
|
||||||
<string name="rec_replay">Réécouter</string>
|
<string name="rec_replay">Réécouter</string>
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ android {
|
||||||
applicationId = "com.kazeia"
|
applicationId = "com.kazeia"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 11
|
versionCode = 15
|
||||||
versionName = "0.1.10"
|
versionName = "0.2.3"
|
||||||
|
|
||||||
// WebDAV de distribution — injecté depuis local.properties.
|
// WebDAV de distribution — injecté depuis local.properties.
|
||||||
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
|
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
|
||||||
|
|
@ -106,6 +106,21 @@ android {
|
||||||
jniLibs.srcDirs("src/main/jniLibs")
|
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
|
// 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-litert-delegate:2.44.0")
|
||||||
implementation("com.qualcomm.qti:qnn-runtime:2.44.0")
|
implementation("com.qualcomm.qti:qnn-runtime:2.44.0")
|
||||||
|
|
||||||
// ExecuTorch JNI dependencies (for TTS CP on NPU)
|
// (ExecuTorch jar + fbjni + soloader retirés 2026-06-18 : seul le TTS legacy
|
||||||
implementation("com.facebook.fbjni:fbjni:0.7.0")
|
// Qwen3 les utilisait, supprimé avec la bascule CosyVoice. Le LLM .pte passe
|
||||||
implementation("com.facebook.soloader:nativeloader:0.10.5")
|
// par libkazeia_pte autonome, pas par org.pytorch.executorch.)
|
||||||
implementation(files("libs/executorch.jar"))
|
|
||||||
|
// 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
|
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
android:extractNativeLibs="true"
|
android:extractNativeLibs="true"
|
||||||
android:label="Kazeia"
|
android:label="Kazeia"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:theme="@style/Theme.Kazeia">
|
android:theme="@style/Theme.Kazeia">
|
||||||
|
|
||||||
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
|
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
|
||||||
|
|
@ -74,24 +75,6 @@
|
||||||
android:value="AI inference service for emotional support chatbot" />
|
android:value="AI inference service for emotional support chatbot" />
|
||||||
</service>
|
</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
|
<!-- WorkManager : déclare le type FGS dataSync sur son service foreground
|
||||||
(requis API 34+ pour le worker de téléchargement modèles). -->
|
(requis API 34+ pour le worker de téléchargement modèles). -->
|
||||||
<service
|
<service
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,25 @@ class KazeiaApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
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
|
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
|
||||||
// requires its own initialization sequence. Loading libs out of order causes
|
// requires its own initialization sequence. Loading libs out of order causes
|
||||||
// native crashes. Unity handles lib loading internally in onCreate().
|
// 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.
|
// Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app.
|
||||||
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
|
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
|
||||||
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
|
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
|
||||||
|
|
@ -51,13 +53,6 @@ class KazeiaApplication : Application() {
|
||||||
trigLlm.delete()
|
trigLlm.delete()
|
||||||
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
|
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() {
|
private fun createNotificationChannel() {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,32 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
data class ModelConfig(
|
data class ModelConfig(
|
||||||
val modelId: String,
|
val modelId: String,
|
||||||
val systemPrompt: 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(
|
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
|
/** Synthèse vocale active. Si false : pas de TTS, réponse affichée
|
||||||
* progressivement (token par token). Pilotable depuis l'app admin. */
|
* progressivement (token par token). Pilotable depuis l'app admin. */
|
||||||
val ttsEnabled: Boolean = true,
|
val ttsEnabled: Boolean = true,
|
||||||
/** Backend STT : "prod" = WhisperHybridEngine gelé, "lib" = libkazeia_stt
|
/** Backend STT : VESTIGIAL depuis le retrait du backend "lib" (2026-06-18,
|
||||||
* (Kazeia-Engine unifié, A/B identique sur set 6 audios mais à valider plus large).
|
* non validé). KazeiaService charge toujours WhisperHybridEngine (NPU, gelé).
|
||||||
* Pilotable depuis l'app admin / provider. Defaults safe = "prod". */
|
* Champ conservé pour compat config/télémétrie. */
|
||||||
val sttEngine: String = "prod",
|
val sttEngine: String = "prod",
|
||||||
/** Backend LLM : "lib" = EngineLlmAdapter (libkazeia_engine + GGUF Qwen3.5,
|
/** Backend LLM : VESTIGIAL depuis la refonte 2026-06-17. Le dispatch ne passe
|
||||||
* q35-lmq4 par défaut) — défaut depuis 2026-06-15 : on n'utilise PLUS le
|
* plus par cette valeur : KazeiaService charge tout LLM via UnifiedLlmAdapter
|
||||||
* .pte avec kazeia-engine (directive). "prod" = ExecuTorchLlmEngine (.pte) —
|
* qui auto-détecte le format par MAGIC-BYTE (GGUF→CPU i8mm, .pte→NPU V79). Le
|
||||||
* conservé en repli d'urgence uniquement ; sur cette plateforme le runner QNN
|
* modèle effectif est choisi par `speaker.modelId` (ModelRegistry). Champ
|
||||||
* du .pte échoue au load (« Failed to load llm runner [1] »), donc à éviter.
|
* conservé pour compat config/télémétrie. */
|
||||||
* "lib" valide device : load q35-lmq4.gguf 2.6s + génération FR cohérente. */
|
|
||||||
val llmEngine: String = "lib",
|
val llmEngine: String = "lib",
|
||||||
/** Backend TTS : "lib" = libkazeia_tts unifié (SEUL compatible avec le
|
/** Backend TTS : "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues,
|
||||||
* libllama.so fork ql embarqué depuis 2026-06-02), "prod" = Qwen3TtsEngine
|
* CPU-only) = SEUL moteur depuis la bascule 2026-06-18 (legacy Qwen3-TTS/lib
|
||||||
* legacy — CRASHE (SIGSEGV nativeInit, dérive ABI llama_context_params) tant
|
* retirés). Champ conservé pour compat config ; KazeiaService ne charge que
|
||||||
* que les .so legacy ne sont pas recompilés contre les headers fork ql.
|
* CosyVoice. */
|
||||||
* "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues, CPU-only) —
|
val ttsEngine: String = "cosyvoice",
|
||||||
* 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",
|
|
||||||
/** RAG (récupération de contexte injecté au prompt). Default OFF : tant que
|
/** 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é,
|
* 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. */
|
* 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. */
|
* pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */
|
||||||
val ragThreshold: Float = 0.82f,
|
val ragThreshold: Float = 0.82f,
|
||||||
/** Nombre max de chunks récupérés/injectés par tour. */
|
/** 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 {
|
companion object {
|
||||||
|
|
@ -107,22 +133,34 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
return store
|
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(
|
fun defaultConfig(): RuntimeConfig = RuntimeConfig(
|
||||||
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
|
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
|
||||||
speaker = ModelConfig(
|
speaker = ModelConfig(
|
||||||
modelId = ModelRegistry.defaultSpeaker().id,
|
modelId = ModelRegistry.defaultSpeaker().id,
|
||||||
systemPrompt = DEFAULT_SPEAKER_PROMPT,
|
systemPrompt = DEFAULT_SPEAKER_PROMPT,
|
||||||
temperature = 0.7f
|
temperature = 0.7f,
|
||||||
|
maxTokens = 450 // réponse Speaker longue (valeur prod historique)
|
||||||
),
|
),
|
||||||
thinker = ModelConfig(
|
thinker = ModelConfig(
|
||||||
modelId = ModelRegistry.defaultThinker().id,
|
modelId = ModelRegistry.defaultThinker().id,
|
||||||
systemPrompt = DEFAULT_THINKER_PROMPT,
|
systemPrompt = DEFAULT_THINKER_PROMPT,
|
||||||
temperature = 0.0f
|
temperature = 0.0f,
|
||||||
|
maxTokens = 128 // bloc clinique court
|
||||||
),
|
),
|
||||||
ttsEnabled = true,
|
ttsEnabled = true,
|
||||||
sttEngine = "prod",
|
sttEngine = "prod",
|
||||||
llmEngine = "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_enabled", cfg.ragEnabled)
|
||||||
root.put("rag_threshold", cfg.ragThreshold.toDouble())
|
root.put("rag_threshold", cfg.ragThreshold.toDouble())
|
||||||
root.put("rag_top_k", cfg.ragTopK)
|
root.put("rag_top_k", cfg.ragTopK)
|
||||||
root.put("speaker", JSONObject().apply {
|
root.put("debug_enabled", cfg.debugEnabled)
|
||||||
put("model_id", cfg.speaker.modelId)
|
root.put("speaker", modelToJson(cfg.speaker))
|
||||||
put("system_prompt", cfg.speaker.systemPrompt)
|
root.put("thinker", modelToJson(cfg.thinker))
|
||||||
put("temperature", cfg.speaker.temperature)
|
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)
|
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 {
|
private fun fromJson(text: String): RuntimeConfig {
|
||||||
val root = JSONObject(text)
|
val root = JSONObject(text)
|
||||||
val defaults = defaultConfig()
|
val defaults = defaultConfig()
|
||||||
|
|
@ -198,20 +269,25 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
||||||
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
|
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
|
||||||
ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
|
ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
|
||||||
speaker = root.optJSONObject("speaker")?.let { js ->
|
debugEnabled = root.optBoolean("debug_enabled", defaults.debugEnabled),
|
||||||
ModelConfig(
|
speaker = root.optJSONObject("speaker")?.let { modelFromJson(it, defaults.speaker) } ?: defaults.speaker,
|
||||||
modelId = js.optString("model_id", defaults.speaker.modelId),
|
thinker = root.optJSONObject("thinker")?.let { modelFromJson(it, defaults.thinker) } ?: defaults.thinker,
|
||||||
systemPrompt = js.optString("system_prompt", defaults.speaker.systemPrompt),
|
presets = root.optJSONArray("presets")?.let { arr ->
|
||||||
temperature = js.optDouble("temperature", defaults.speaker.temperature.toDouble()).toFloat()
|
(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 ->
|
}.filter { it.name.isNotBlank() }
|
||||||
ModelConfig(
|
} ?: defaults.presets
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
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(
|
ModelInfo(
|
||||||
id = "qwen3.5-4b",
|
id = "qwen3.5-4b",
|
||||||
displayName = "Qwen3.5-4B (GGUF) ★",
|
displayName = "Qwen3.5-4B (GGUF) ★",
|
||||||
|
|
@ -83,43 +67,58 @@ object ModelRegistry {
|
||||||
role = Role.SPEAKER,
|
role = Role.SPEAKER,
|
||||||
sizeMb = 2380,
|
sizeMb = 2380,
|
||||||
maxSeqLen = 4096,
|
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,
|
backend = Backend.GGUF,
|
||||||
ggufFile = "q35-lmq4.gguf",
|
ggufFile = "q35-lmq4.gguf",
|
||||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||||
),
|
),
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id = "qwen2.5-7b-instruct",
|
id = "qwen3-4b",
|
||||||
displayName = "Qwen2.5-7B Instruct (GGUF)",
|
displayName = "Qwen3-4B (.pte NPU)",
|
||||||
pteFile = "",
|
pteFile = "qwen3_4b_seq1024/hybrid_llama_qnn.pte",
|
||||||
tokenizerFile = "",
|
tokenizerFile = "qwen3_4b_seq1024/tokenizer.json",
|
||||||
role = Role.SPEAKER,
|
role = Role.SPEAKER,
|
||||||
sizeMb = 4683,
|
sizeMb = 3100,
|
||||||
maxSeqLen = 4096,
|
maxSeqLen = 1024,
|
||||||
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.",
|
notes = "Dense Qwen3-4B sur NPU. Prefill 313 tok/s, RAM ÷2, sans root. Decode 15.7.",
|
||||||
backend = Backend.GGUF,
|
backend = Backend.PTE,
|
||||||
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
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
|
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(
|
ModelInfo(
|
||||||
id = "guard4b",
|
id = "guard4b",
|
||||||
displayName = "Qwen3Guard 4B",
|
displayName = "Qwen3Guard 4B (.pte NPU)",
|
||||||
pteFile = "hybrid_llama_qnn_guard4b.pte",
|
pteFile = "guard4b/hybrid_llama_qnn.pte",
|
||||||
tokenizerFile = "tokenizer_guard.json",
|
tokenizerFile = "guard4b/tokenizer.json",
|
||||||
role = Role.THINKER,
|
role = Role.THINKER,
|
||||||
sizeMb = 3000,
|
sizeMb = 3100,
|
||||||
maxSeqLen = 512,
|
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 }
|
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
|
||||||
|
|
||||||
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte
|
// Défaut Speaker = Qwen3.5-4B GGUF (hybride, décision refonte 2026-06-17).
|
||||||
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque.
|
// Repli : tout autre Speaker présent sur disque.
|
||||||
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
|
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
|
||||||
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
|
?: 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() }
|
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ data class SamplingParams(
|
||||||
val temperature: Float = 0.7f,
|
val temperature: Float = 0.7f,
|
||||||
val topP: Float = 0.85f,
|
val topP: Float = 0.85f,
|
||||||
val topK: Int = 40,
|
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(
|
data class GenerationResult(
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
||||||
private var inputKind: String = ""
|
private var inputKind: String = ""
|
||||||
private var inputPayload: 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) {
|
fun begin(kind: String, payload: String) {
|
||||||
t0 = System.currentTimeMillis()
|
t0 = System.currentTimeMillis()
|
||||||
inputKind = kind
|
inputKind = kind
|
||||||
|
|
@ -94,29 +103,67 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
||||||
.append('\n')
|
.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 sttEnd = firstT("STT.end")
|
||||||
|
val thinkerStart = firstT("THINKER.start")
|
||||||
|
val thinkerEnd = firstT("THINKER.end")
|
||||||
val llmStart = firstT("LLM.start")
|
val llmStart = firstT("LLM.start")
|
||||||
val llmFirstTok = firstT("LLM.first_token")
|
val llmFirstTok = firstT("LLM.first_token")
|
||||||
val llmEnd = firstT("LLM.end")
|
val llmEnd = firstT("LLM.end")
|
||||||
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
|
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
|
||||||
val lastAudio = lastT("TTS.seg.play_end")
|
val lastAudio = lastT("TTS.seg.play_end")
|
||||||
|
|
||||||
sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----")
|
val sttDur = sttEnd?.let { it - sttStart }
|
||||||
if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd")
|
val thinkerDur = if (thinkerStart != null && thinkerEnd != null) thinkerEnd - thinkerStart else null
|
||||||
if (llmStart != null && llmFirstTok != null)
|
val speakerTtft = if (llmStart != null && llmFirstTok != null) llmFirstTok - llmStart else null
|
||||||
sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}")
|
val speakerGen = if (llmStart != null && llmEnd != null) llmEnd - llmStart else null
|
||||||
if (llmStart != null && llmEnd != null)
|
// TTS « synthèse 1ʳᵉ phrase » = Speaker fini → 1er son audible.
|
||||||
sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}")
|
val ttsSynth = if (llmEnd != null && firstAudio != null) firstAudio - llmEnd else null
|
||||||
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long")
|
val playback = if (firstAudio != null && lastAudio != null) lastAudio - firstAudio else null
|
||||||
if (firstAudio != null && lastAudio != null)
|
|
||||||
sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}")
|
sb.appendLine("[PIPELINE] ---- KPIs (durées par étape, ms) ----")
|
||||||
sb.appendLine("[PIPELINE] Total wall time : $total")
|
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
|
// Single log call so it shows up contiguously even if the logger
|
||||||
// interleaves other threads.
|
// interleaves other threads.
|
||||||
Log.i(tag, sb.toString().trimEnd())
|
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
|
// Push a structured snapshot to TelemetryHolder so the admin app
|
||||||
// can pull it via ContentProvider. Best-effort — if anything goes
|
// can pull it via ContentProvider. Best-effort — if anything goes
|
||||||
// wrong here it must not affect the pipeline.
|
// wrong here it must not affect the pipeline.
|
||||||
|
|
@ -129,15 +176,14 @@ class PipelineMetrics(val tag: String = "Pipeline") {
|
||||||
inputKind = inputKind,
|
inputKind = inputKind,
|
||||||
status = status,
|
status = status,
|
||||||
totalMs = total,
|
totalMs = total,
|
||||||
sttDurationMs = sttEnd,
|
sttDurationMs = sttDur,
|
||||||
llmTtftMs = if (llmStart != null && llmFirstTok != null)
|
thinkerMs = thinkerDur,
|
||||||
llmFirstTok - llmStart else null,
|
llmTtftMs = speakerTtft,
|
||||||
llmGenerationMs = if (llmStart != null && llmEnd != null)
|
llmGenerationMs = speakerGen,
|
||||||
llmEnd - llmStart else null,
|
|
||||||
llmTokens = tokensMatch,
|
llmTokens = tokensMatch,
|
||||||
|
ttsSynthMs = ttsSynth,
|
||||||
timeToFirstAudioMs = firstAudio,
|
timeToFirstAudioMs = firstAudio,
|
||||||
audioPlaybackMs = if (firstAudio != null && lastAudio != null)
|
audioPlaybackMs = playback
|
||||||
lastAudio - firstAudio else null
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,17 @@ interface TtsEngine {
|
||||||
text: String,
|
text: String,
|
||||||
language: String = "fr",
|
language: String = "fr",
|
||||||
onStart: (() -> Unit)? = null,
|
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 stop()
|
||||||
fun release()
|
fun release()
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,12 @@ class ProvisioningManager(context: Context) {
|
||||||
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
|
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
|
||||||
*/
|
*/
|
||||||
fun hasCoreModels(): Boolean {
|
fun hasCoreModels(): Boolean {
|
||||||
val llm = File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b.pte").exists() ||
|
// Cœur post-refonte 2026-06-17 : Speaker GGUF (q35-lmq4) + Whisper STT.
|
||||||
File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b_seq1024.pte").exists()
|
// (Le LLM .pte est devenu une alternative admin ; le TTS est provisionné
|
||||||
val talker = File(KazeiaPaths.modelsDir, "talker_f32.gguf").exists() ||
|
// à part — cf #286 productisation CosyVoice.)
|
||||||
File(KazeiaPaths.modelsDir, "talker_f16.gguf").exists()
|
val llm = File(KazeiaPaths.modelsDir, "q35-lmq4.gguf").exists()
|
||||||
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
|
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. */
|
/** 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.
|
// 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
|
package com.kazeia.llm
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.kazeia.KazeiaApplication
|
import com.kazeia.config.ModelRegistry
|
||||||
|
import com.kazeia.core.SamplingParams
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
object BenchLlmHarness {
|
object BenchLlmHarness {
|
||||||
private const val TAG = "BenchLlm"
|
private const val TAG = "BenchLlm"
|
||||||
|
|
@ -16,30 +21,61 @@ object BenchLlmHarness {
|
||||||
"Je n'arrive plus à parler à ma femme"
|
"Je n'arrive plus à parler à ma femme"
|
||||||
)
|
)
|
||||||
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
|
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 t0 = System.currentTimeMillis()
|
||||||
val eng = try {
|
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) {
|
} 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)")
|
Log.i(TAG, " load OK (${System.currentTimeMillis() - t0}ms)")
|
||||||
// warmup
|
|
||||||
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {}
|
// warmup (1 tour court, hors mesure)
|
||||||
|
try { eng.generate("Bonjour", SamplingParams(maxNewTokens = 16)) { true } } catch (_: Throwable) {}
|
||||||
|
|
||||||
for ((i, p) in PROMPTS.withIndex()) {
|
for ((i, p) in PROMPTS.withIndex()) {
|
||||||
val ts = System.currentTimeMillis()
|
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 ms = System.currentTimeMillis() - ts
|
||||||
val nWords = out.trim().split(Regex("\\s+")).size
|
Log.i(TAG, " [${i + 1}/${PROMPTS.size}] '$p' → ${ms}ms (${"%.1f".format(r.tokensPerSecond)} tok/s)")
|
||||||
val tps = if (ms > 0) nWords * 1000f / ms else 0f
|
Log.i(TAG, " « ${r.text} »")
|
||||||
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
|
|
||||||
Log.i(TAG, " '${out.trim()}'")
|
|
||||||
}
|
}
|
||||||
eng.release()
|
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 sessionReset(h: Long) // repart du checkpoint système
|
||||||
external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens]
|
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).
|
// (Bindings TTS-Talker Qwen3-TTS retirés 2026-06-18 : TTS = CosyVoice, lib dédiée.)
|
||||||
// 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
|
|
||||||
|
|
||||||
// -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur.
|
// -- 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).
|
// 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
|
val ttsEngine = tts ?: return
|
||||||
_pipelineState.value = PipelineState.Speaking
|
_pipelineState.value = PipelineState.Speaking
|
||||||
try {
|
try {
|
||||||
val qwen = ttsEngine as? com.kazeia.tts.Qwen3TtsEngine
|
// CosyVoice : lecture batch (synthèse N+1 pendant lecture N EN INTERNE).
|
||||||
if (qwen != null) {
|
// onSegment propage le retour par-phrase (texte + features visuelles)
|
||||||
qwen.onSegmentPlaying = onSegmentPlaying
|
// pour le reveal mot-par-mot et l'orbe — perdu lors de la bascule
|
||||||
qwen.startStreamingSession()
|
// CosyVoice quand la branche streaming Qwen3 a été retirée (bug #2/#3).
|
||||||
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 {
|
|
||||||
ttsEngine.synthesizeAndPlay(text, context.language,
|
ttsEngine.synthesizeAndPlay(text, context.language,
|
||||||
onComplete = { _pipelineState.value = PipelineState.Idle }
|
onComplete = { _pipelineState.value = PipelineState.Idle },
|
||||||
)
|
onSegment = onSegmentPlaying)
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("TTS error: ${e.message}")
|
log("TTS error: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ import com.kazeia.conversation.ConversationManager
|
||||||
import com.kazeia.conversation.PromptBuilder
|
import com.kazeia.conversation.PromptBuilder
|
||||||
import com.kazeia.conversation.StoppingCriteria
|
import com.kazeia.conversation.StoppingCriteria
|
||||||
import com.kazeia.core.*
|
import com.kazeia.core.*
|
||||||
import com.kazeia.llm.ExecuTorchLlmEngine
|
|
||||||
import com.kazeia.llm.GenieLlmEngine
|
|
||||||
import com.kazeia.profiles.ConversationDao
|
import com.kazeia.profiles.ConversationDao
|
||||||
import com.kazeia.profiles.ConversationDb
|
import com.kazeia.profiles.ConversationDb
|
||||||
import com.kazeia.profiles.ProfileStore
|
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"""
|
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var llm: LlmEngine
|
private lateinit var llm: LlmEngine // Speaker (UnifiedLlmAdapter : GGUF défaut ou .pte)
|
||||||
// Cascade Option E v2 : 2 ExecuTorchLlmEngine cohabitants in-process.
|
// Cascade (off par défaut) : 2ᵉ moteur Thinker via UnifiedLlmAdapter,
|
||||||
// - llm = Speaker Qwen3-4B (.pte ~3.3 GB ION, ~4.4 GB total)
|
// lazy au 1er tour (défaut Guard-4B .pte). nThreads réduit pour le Speaker.
|
||||||
// - thinkerEngine = Guard-0.6B (.pte ~700 MB ION) lazy-loaded au 1er PTT
|
private var thinkerEngine: com.kazeia.llm.UnifiedLlmAdapter? = null
|
||||||
// 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 val thinkerLock = Object()
|
private val thinkerLock = Object()
|
||||||
|
|
||||||
// Config runtime (cascade, prompts, modèles) pilotée par l'admin app via
|
// 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")
|
log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadLlmAfterTurn() = llmMutex.withLock {
|
// reloadAfterTurn supprimé avec la refonte LLM 2026-06-17 : l'ancien hack
|
||||||
val ll = llm as? com.kazeia.llm.ExecuTorchLlmEngine ?: return@withLock
|
// ExecuTorchLlmEngine (reset cur_pos) n'a plus lieu d'être. UnifiedLlmAdapter
|
||||||
if (!ll.isLoaded()) return@withLock
|
// gère l'état par tour (session reset GGUF ; .pte sans état persistant).
|
||||||
val t0 = System.currentTimeMillis()
|
private suspend fun reloadLlmAfterTurn() { /* no-op */ }
|
||||||
log("[LLM] reload pour reset cur_pos (état propre prochain tour)…")
|
|
||||||
ll.reloadAfterTurn()
|
|
||||||
log("[LLM] reloaded in ${System.currentTimeMillis() - t0}ms")
|
|
||||||
}
|
|
||||||
private lateinit var tts: TtsEngine
|
private lateinit var tts: TtsEngine
|
||||||
|
|
||||||
private lateinit var audioPlayback: AudioPlaybackManager
|
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().
|
// Per-interaction timing tracer. One instance, reset at every begin().
|
||||||
// All stage timings for a single user turn (STT+LLM+TTS+playback) end
|
// All stage timings for a single user turn (STT+LLM+TTS+playback) end
|
||||||
// up in a single grep-friendly log section tagged "[PIPELINE]".
|
// 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
|
private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor
|
||||||
|
|
||||||
// New modular pipeline
|
// 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())
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||||
val logs: StateFlow<List<String>> = _logs
|
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)
|
// AI workload tracking: which component is active (for monitoring)
|
||||||
data class AiWorkload(
|
data class AiWorkload(
|
||||||
val sttActive: Boolean = false,
|
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
|
val loadingState: StateFlow<LoadingState> = _loadingState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cascade E v2 : crée la 2ᵉ ExecuTorchLlmEngine (Thinker = Guard-0.6B) dans
|
* Cascade : crée le 2ᵉ moteur (Thinker, défaut Guard-4B .pte NPU) via
|
||||||
* le MÊME process app. Lazy au 1er PTT (~1-2 s de load 0.6B). Le
|
* UnifiedLlmAdapter dans le même process, lazy au 1er tour. Off par défaut
|
||||||
* QnnBackendUnifiedRegistry singleton réutilise le QnnBackendBundle du
|
* (cascadeEnabled=false). nThreads réduit pour laisser des cœurs au Speaker.
|
||||||
* Speaker → un seul handle fastrpc DSP, 2 contextes/graphes coexistent.
|
|
||||||
*/
|
*/
|
||||||
private fun ensureThinkerEngine() {
|
private fun ensureThinkerEngine() {
|
||||||
synchronized(thinkerLock) {
|
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)…")
|
log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…")
|
||||||
val tStart = System.currentTimeMillis()
|
val tStart = System.currentTimeMillis()
|
||||||
val memBefore = readMemAvailableMb()
|
val memBefore = readMemAvailableMb()
|
||||||
val e = com.kazeia.llm.ExecuTorchLlmEngine(
|
// Thinker via UnifiedLlmAdapter : Guard-4B .pte (NPU) ou tout GGUF du registre.
|
||||||
context = this@KazeiaService,
|
val isPte = info.backend == com.kazeia.config.ModelRegistry.Backend.PTE
|
||||||
onLog = { msg -> log("[THINKER] $msg") },
|
val e = com.kazeia.llm.UnifiedLlmAdapter(
|
||||||
modelPath = info.ptePath(),
|
modelPath = if (isPte) info.ptePath() else info.ggufPath(),
|
||||||
tokenizerPath = info.tokenizerPath(),
|
|
||||||
systemPrompt = cfg.thinker.systemPrompt,
|
systemPrompt = cfg.thinker.systemPrompt,
|
||||||
temperature = cfg.thinker.temperature,
|
ctx = if (isPte) info.maxSeqLen else 4096,
|
||||||
displayName = "${info.displayName} Thinker"
|
nThreads = 4, // cascade : laisser des cœurs au Speaker
|
||||||
|
tokenizerPath = if (isPte) info.tokenizerPath() else null,
|
||||||
|
onLog = { msg -> log("[THINKER] $msg") }
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
runBlocking { e.load("", com.kazeia.core.LlmConfig()) }
|
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 onBind(intent: Intent?): IBinder = binder
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
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 ->
|
intent?.getStringExtra("tts_text")?.let { text ->
|
||||||
val savePath = intent.getStringExtra("save_wav")
|
val savePath = intent.getStringExtra("save_wav")
|
||||||
if (savePath != null) {
|
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)
|
// (admin app → ContentProvider.update → ConfigStore.save → listener)
|
||||||
val store = com.kazeia.config.ConfigStore.get(this)
|
val store = com.kazeia.config.ConfigStore.get(this)
|
||||||
runtimeConfig = store.current()
|
runtimeConfig = store.current()
|
||||||
|
_debugMode.value = runtimeConfig.debugEnabled // gate UI debug (bouton logs/métriques)
|
||||||
store.addListener { new -> handleConfigChange(new) }
|
store.addListener { new -> handleConfigChange(new) }
|
||||||
registerConfigReloadReceiver()
|
registerConfigReloadReceiver()
|
||||||
registerRagReloadReceiver()
|
registerRagReloadReceiver()
|
||||||
|
|
@ -755,37 +479,20 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
|
|
||||||
val nativeLibDir = applicationInfo.nativeLibraryDir
|
val nativeLibDir = applicationInfo.nativeLibraryDir
|
||||||
|
|
||||||
// Dispatch backend TTS via ttsEngine. "prod" = Qwen3TtsEngine legacy
|
// TTS = CosyVoice (seul moteur depuis la bascule 2026-06-18 ; les
|
||||||
// (incompat ABI avec libllama fork-ql -> SIGSEGV sur install fraîche,
|
// moteurs legacy Qwen3-TTS/lib ont été retirés). Clonage vocal 9
|
||||||
// cf feedback_tts_engine_default_lib). "lib" = KazeiaTtsEngine
|
// langues, CPU-only, RTF ~1.0.
|
||||||
// (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" -> {
|
|
||||||
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
|
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
|
||||||
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
|
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
|
||||||
cv.load()
|
cv.load()
|
||||||
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
|
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
|
||||||
tts = cv
|
tts = cv
|
||||||
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
|
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
|
||||||
}
|
// Pré-chauffe le cache voix EN TÂCHE DE FOND (chevauche le reste du
|
||||||
"lib" -> {
|
// chargement + la 1ʳᵉ phrase de l'usager) → le 1er vrai tour part
|
||||||
_loadingState.value = LoadingState(15, "TTS engine…")
|
// chaud (~3 s) au lieu de ~10 s (remplissage de cache à froid).
|
||||||
val libTts = com.kazeia.tts.KazeiaTtsEngine(this@KazeiaService) { msg -> log("[TTS] $msg") }
|
serviceScope.launch(Dispatchers.IO) {
|
||||||
libTts.load()
|
runCatching { cv.warmup() }
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_loadingState.value = LoadingState(20, "TTS OK")
|
_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
|
// pendant TTS (caché derrière le playback) et release pendant
|
||||||
// les phases LLM/TTS où Whisper ne sert pas.
|
// les phases LLM/TTS où Whisper ne sert pas.
|
||||||
_loadingState.value = LoadingState(25, "STT Whisper (lazy)…")
|
_loadingState.value = LoadingState(25, "STT Whisper (lazy)…")
|
||||||
// Backend STT pilotable via ConfigStore.sttEngine. Default "prod" =
|
// STT = WhisperHybridEngine (NPU, gelé) — SEUL backend depuis le retrait
|
||||||
// WhisperHybridEngine gelé. "lib" = KazeiaSttAdapter (libkazeia_stt.so).
|
// du backend "lib" (KazeiaSttAdapter/libkazeia_stt) non validé, 2026-06-18.
|
||||||
val sttBackend = runtimeConfig.sttEngine
|
// ConfigStore.sttEngine est désormais vestigial (compat config).
|
||||||
val npuStt: com.kazeia.core.SttEngine = when (sttBackend) {
|
val npuStt: com.kazeia.core.SttEngine = WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
|
||||||
"lib" -> com.kazeia.stt.KazeiaSttAdapter(
|
|
||||||
"$modelsDir/whisper-small-sm8750"
|
|
||||||
) { msg -> log("[STT] $msg") }
|
|
||||||
else -> WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
|
|
||||||
}
|
|
||||||
sttModelPath = "$modelsDir/whisper-small-sm8750"
|
sttModelPath = "$modelsDir/whisper-small-sm8750"
|
||||||
stt = npuStt
|
stt = npuStt
|
||||||
log("Whisper créé backend='$sttBackend' (lazy load au 1er PTT)")
|
log("Whisper créé (lazy load au 1er PTT)")
|
||||||
addMessage(ChatMessage(
|
addMessage(ChatMessage(
|
||||||
role = ChatMessage.Role.SYSTEM,
|
role = ChatMessage.Role.SYSTEM,
|
||||||
text = "STT: Whisper-Small NPU"
|
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
|
// LLM = ExecuTorch QNN. Modèle choisi dans la config runtime
|
||||||
// (admin app peut le changer à chaud). Fallback sur le
|
// (admin app peut le changer à chaud). Fallback sur le
|
||||||
// Speaker par défaut du registre si l'id config est inconnu.
|
// Speaker par défaut du registre si l'id config est inconnu.
|
||||||
// LLM = Kazeia-Engine GGUF (llama.cpp upstream + Hexagon), Speaker
|
// LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md) :
|
||||||
// Qwen3.5-9B prefill NPU / decode CPU. Plus de .pte (-30% RAM).
|
// UnifiedLlmAdapter détecte le format par magic-byte et charge le bon
|
||||||
// Backend LLM pilotable via ConfigStore.llmEngine. Default "prod" =
|
// moteur — GGUF (Qwen3.5-4B hybride, défaut) sur CPU i8mm avec cache-
|
||||||
// ExecuTorch .pte (Qwen3 dense). "lib" = EngineLlmAdapter (libkazeia_engine,
|
// préfixe, ou .pte (denses Qwen3-4B/8B, Qwen2.5-7B) sur NPU V79 (sans
|
||||||
// GGUF Qwen3.5). Fallback gracieux sur prod si la lib échoue au load
|
// root). Le flag ConfigStore.llmEngine n'est plus déterminant (auto-détecté).
|
||||||
// (libllama fork ql encore absente du jniLibs tant que swap coordonné non fait).
|
|
||||||
val llmBackend = runtimeConfig.llmEngine
|
|
||||||
_loadingState.value = LoadingState(50, "LLM backend=$llmBackend…")
|
|
||||||
val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker()
|
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 {
|
_loadingState.value = LoadingState(50, "LLM ${sp.id}…")
|
||||||
// Si le Speaker sélectionné est un GGUF du registre (ex. Qwen2.5-7B),
|
val isPte = sp.backend == com.kazeia.config.ModelRegistry.Backend.PTE
|
||||||
// on le charge ; sinon fallback sur le GGUF moteur par défaut (q35-lmq4).
|
val mPath = if (isPte) sp.ptePath() else sp.ggufPath()
|
||||||
val gguf = if (sp.backend == com.kazeia.config.ModelRegistry.Backend.GGUF && sp.fileExists())
|
val tkPath = if (isPte) sp.tokenizerPath() else null
|
||||||
sp.ggufPath()
|
val ctxLen = if (isPte) sp.maxSeqLen else 4096
|
||||||
else
|
log("[LLM] speaker=${sp.id} backend=${sp.backend} path=$mPath ctx=$ctxLen")
|
||||||
"${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
|
llm = com.kazeia.llm.UnifiedLlmAdapter(
|
||||||
val plain = sp.chatTemplate == com.kazeia.config.ModelRegistry.ChatTemplate.PLAIN_CHATML
|
mPath, runtimeConfig.speaker.systemPrompt,
|
||||||
log("[LLM] lib GGUF=$gguf (speaker=${sp.id}, plainChatml=$plain)")
|
ctx = ctxLen, nThreads = 6, tokenizerPath = tkPath,
|
||||||
com.kazeia.llm.EngineLlmAdapter(gguf, runtimeConfig.speaker.systemPrompt,
|
onLog = { msg -> log(msg) }
|
||||||
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)
|
|
||||||
try {
|
try {
|
||||||
llm.load("", com.kazeia.core.LlmConfig())
|
llm.load("", com.kazeia.core.LlmConfig())
|
||||||
log("LLM créé backend='${if (llmLib != null) "lib" else "prod"}'")
|
log("LLM créé : ${sp.id} (${sp.backend})")
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
log("LLM load failed (${e.message}) — fallback echo")
|
log("LLM load failed (${e.message}) — mode écho jusqu'au prochain essai")
|
||||||
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) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
|
// 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
|
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
|
||||||
if (nowId != lastActiveProfileId) {
|
if (nowId != lastActiveProfileId) {
|
||||||
lastActiveProfileId = nowId
|
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")
|
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é. */
|
* ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */
|
||||||
fun setVoiceId(voiceId: String) {
|
fun setVoiceId(voiceId: String) {
|
||||||
currentVoiceName = voiceId
|
currentVoiceName = voiceId
|
||||||
val wavPath = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceId.wav"
|
|
||||||
when (val e = tts) {
|
when (val e = tts) {
|
||||||
is com.kazeia.tts.CosyVoiceTtsEngine -> { e.setVoice(voiceId); log("Voix (cosyvoice): $voiceId") }
|
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")
|
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) =
|
fun setVoice(voicePathOrId: String) =
|
||||||
setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.'))
|
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() {
|
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")
|
log("TTS interrupted by user")
|
||||||
tts.stop()
|
tts.stop()
|
||||||
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
|
_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
|
@Volatile private var pttRecording = false
|
||||||
|
|
||||||
fun toggleListening() {
|
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) {
|
if (_pipelineState.value is PipelineState.Speaking) {
|
||||||
log("Interrupting TTS")
|
log("Interrupting TTS")
|
||||||
tts.stop()
|
tts.stop()
|
||||||
|
|
@ -1284,6 +988,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
var wasMuted = false
|
var wasMuted = false
|
||||||
var lastBusyMs = 0L
|
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) {
|
while (_isListening.value) {
|
||||||
val read = recorder.read(frame, 0, frameSize)
|
val read = recorder.read(frame, 0, frameSize)
|
||||||
if (read != frameSize) continue
|
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.Transcribed
|
||||||
|| st is PipelineState.TokenGenerated
|
|| st is PipelineState.TokenGenerated
|
||||||
val nowMs = System.currentTimeMillis()
|
val nowMs = System.currentTimeMillis()
|
||||||
// Tail buffer : 800 ms après la fin du Speaking pour
|
// Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
|
||||||
// absorber la reverb / queue MediaPlayer qui peut continuer
|
// joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
|
||||||
// à émettre du son alors que pipelineState est passé Idle.
|
// é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) {
|
if (machineBusy) {
|
||||||
lastBusyMs = nowMs
|
lastBusyMs = nowMs
|
||||||
}
|
}
|
||||||
val inTailBuffer = (nowMs - lastBusyMs) < 800
|
val inTailBuffer = (nowMs - lastBusyMs) < 300
|
||||||
|
|
||||||
if (machineBusy || inTailBuffer) {
|
if (machineBusy || inTailBuffer) {
|
||||||
if (isSpeechActive) {
|
if (isSpeechActive) {
|
||||||
|
|
@ -1354,6 +1068,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
|
|
||||||
if (isSpeech && !inWarmup) {
|
if (isSpeech && !inWarmup) {
|
||||||
silenceFrameCount = 0
|
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++
|
speechFrameCount++
|
||||||
speechBuffer.add(frame.copyOf())
|
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
|
// No speech, reset
|
||||||
speechBuffer.clear()
|
speechBuffer.clear()
|
||||||
speechFrameCount = 0
|
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" -> {
|
"CLEAR_CHAT" -> {
|
||||||
_messages.value = emptyList()
|
_messages.value = emptyList()
|
||||||
// Vide aussi la mémoire conversationnelle du LLM (session engine).
|
// 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(
|
addMessage(ChatMessage(
|
||||||
role = ChatMessage.Role.SYSTEM,
|
role = ChatMessage.Role.SYSTEM,
|
||||||
text = "Conversation effacée."
|
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)
|
metrics.begin("text", text)
|
||||||
}
|
}
|
||||||
serviceScope.launch {
|
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
|
// Check voice commands before processing
|
||||||
if (handleVoiceCommand(text)) return@launch
|
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) {
|
private suspend fun processLlmResponse(patientMessage: String) {
|
||||||
_pipelineState.value = PipelineState.Thinking
|
_pipelineState.value = PipelineState.Thinking
|
||||||
|
lastTurnInput = patientMessage // encart « dernier tour »
|
||||||
|
lastTurnOutput = ""
|
||||||
conversationManager.onNewTurn()
|
conversationManager.onNewTurn()
|
||||||
|
|
||||||
// Historique : tour patient persisté avant toute génération (no-op
|
// 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(
|
val result = tE.generateWithSystem(
|
||||||
prompt = "Message du patient : $patientMessage",
|
prompt = "Message du patient : $patientMessage",
|
||||||
systemPromptOverride = cfg.thinker.systemPrompt,
|
systemPromptOverride = cfg.thinker.systemPrompt,
|
||||||
params = com.kazeia.core.SamplingParams(
|
params = samplingFrom(cfg.thinker),
|
||||||
maxNewTokens = 128, temperature = cfg.thinker.temperature
|
|
||||||
),
|
|
||||||
onToken = null,
|
onToken = null,
|
||||||
tag = "THINKER"
|
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.
|
// À 4 chars/tok ≈ 200 chars max user message safe avec cascade.
|
||||||
// Si message plus long → on tombe en mono Speaker (pas de bullets).
|
// Si message plus long → on tombe en mono Speaker (pas de bullets).
|
||||||
val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) {
|
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" +
|
"\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" +
|
||||||
cascadeBullets
|
cascadeBullets
|
||||||
} else {
|
} 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}") }
|
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate,
|
// TTS = CosyVoice (CPU) : PAS de streaming LLM→TTS. LLM decode et synthèse
|
||||||
// so the first complete sentence emitted by the LLM (on terminal
|
// CosyVoice sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher
|
||||||
// punctuation) is enqueued for synthesis while the LLM is still
|
// étrangle le LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM
|
||||||
// decoding the rest of the response. Saves ~LLM-wall-time of TTFA
|
// finir vite (session ~1,1s) PUIS pipeline.speakText (batch) qui streame déjà
|
||||||
// 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à
|
|
||||||
// phrase-par-phrase EN INTERNE (synthèse N+1 pendant lecture N, sans contention).
|
// 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
|
// Bulle vide + signal Thinking sur le visualizer : la couleur de l'orbe
|
||||||
// pulse pendant que le LLM réfléchit, plus de "..." textuels parasites.
|
// 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",
|
metrics.mark("PIPELINE.first_audio",
|
||||||
"seg=$segIdx dur=${durationMs}ms")
|
"seg=$segIdx dur=${durationMs}ms")
|
||||||
_pipelineState.value = PipelineState.Speaking
|
_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",
|
metrics.mark("TTS.seg.play_start",
|
||||||
"seg=$segIdx sentence=\"${sentence.take(40)}\" dur=${durationMs}ms")
|
"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.
|
// as soon as the user finishes speaking, covering the LLM's TTFT.
|
||||||
addMessage(bubble)
|
addMessage(bubble)
|
||||||
val llmStreamStart = System.currentTimeMillis()
|
val llmStreamStart = System.currentTimeMillis()
|
||||||
val sentenceCounter = java.util.concurrent.atomic.AtomicInteger(0)
|
// TTS coupé (llmOnly) → pas de session ; la bulle se remplit token par token.
|
||||||
// TTS coupé (llmOnly) → pas de session de synthèse ouverte ; la bulle
|
// CosyVoice (CPU) : pas de streaming LLM→TTS (contention CPU). Le LLM décode
|
||||||
// se remplit token par token dans onToken (voir plus bas).
|
// vite, puis synthèse batch via pipeline.speakText (qui streame déjà
|
||||||
// Streaming LLM→TTS réservé à Qwen3 (NPU/Hexagon : pas de contention CPU avec
|
// phrase-par-phrase EN INTERNE). ttsStreamer reste donc null.
|
||||||
// le LLM). CosyVoice (CPU) passe par le chemin batch (voir note plus haut).
|
val ttsStreamer: com.kazeia.tts.SentenceStreamer? = null
|
||||||
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
|
|
||||||
|
|
||||||
// In LLM_ONLY_MODE, kill the typing dots as soon as the first token
|
// In LLM_ONLY_MODE, kill the typing dots as soon as the first token
|
||||||
// arrives so the bubble starts revealing text immediately. Outside
|
// 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())
|
!stoppingCriteria.shouldStop(responseBuilder.toString())
|
||||||
}
|
}
|
||||||
val sParams = SamplingParams(
|
// Sampling Speaker depuis la config (preset admin). maxTokens honoré par
|
||||||
maxNewTokens = 450,
|
// le moteur ; les autres knobs prendront effet avec l'API sampling natif.
|
||||||
temperature = conversationManager.currentTemperature()
|
val sParams = samplingFrom(cfg.speaker)
|
||||||
)
|
// generateWithSystem propage le prompt système Speaker par tour (bullets
|
||||||
// Si bullets cascade dispo → injection dans system prompt via
|
// cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF
|
||||||
// generateWithSystem (cast vers ExecuTorchLlmEngine). Sinon
|
// (mono-tour si override) comme pour .pte.
|
||||||
// generateWithSystem aussi pour propager le prompt système Speaker
|
|
||||||
// de la config runtime (hot-reload depuis admin app).
|
|
||||||
val llmRef = llm
|
val llmRef = llm
|
||||||
val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt
|
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(
|
llmRef.generateWithSystem(
|
||||||
prompt = prompt,
|
prompt = prompt,
|
||||||
systemPromptOverride = speakerPromptToUse,
|
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)
|
_aiWorkload.value = _aiWorkload.value.copy(llmActive = false)
|
||||||
val responseText = result.text.trim()
|
val responseText = result.text.trim()
|
||||||
|
lastTurnOutput = responseText // encart « dernier tour »
|
||||||
log("LLM prompt: '$prompt'")
|
log("LLM prompt: '$prompt'")
|
||||||
log("LLM raw response: '${result.text}'")
|
log("LLM raw response: '${result.text}'")
|
||||||
log("LLM clean response: '$responseText'")
|
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.
|
// onToken ; on finalise avec la réponse nettoyée.
|
||||||
updateMessageText(bubble.id, responseText)
|
updateMessageText(bubble.id, responseText)
|
||||||
metrics.end("llm_only")
|
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 {
|
} else {
|
||||||
// Legacy batch path for non-Qwen TTS engines.
|
// CosyVoice : synthèse batch (speakText streame en interne).
|
||||||
_pipelineState.value = PipelineState.Speaking
|
_pipelineState.value = PipelineState.Speaking
|
||||||
pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram ->
|
pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram ->
|
||||||
onSegPlay(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
|
totalMs = result.timeMs
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Empty response — close any open streaming session cleanly
|
// Empty response — drop the placeholder bubble.
|
||||||
// and drop the placeholder bubble.
|
|
||||||
try { qwen?.endStreamingSession() } catch (_: Exception) {}
|
|
||||||
_messages.value = _messages.value.filter { it.id != bubble.id }
|
_messages.value = _messages.value.filter { it.id != bubble.id }
|
||||||
metrics.end("empty_response")
|
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}")
|
log("ERROR: LLM generation error: ${e.message}")
|
||||||
metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"")
|
metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"")
|
||||||
metrics.end("error")
|
metrics.end("error")
|
||||||
try { qwen?.endStreamingSession() } catch (_: Exception) {}
|
|
||||||
_pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM")
|
_pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM")
|
||||||
addMessage(ChatMessage(
|
addMessage(ChatMessage(
|
||||||
role = ChatMessage.Role.SYSTEM,
|
role = ChatMessage.Role.SYSTEM,
|
||||||
|
|
@ -2052,14 +1715,17 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
))
|
))
|
||||||
} finally {
|
} finally {
|
||||||
firstSegmentSeen.set(true)
|
firstSegmentSeen.set(true)
|
||||||
|
audiblePlayback = false // fin de tour : ré-verrouille pour le tour suivant
|
||||||
try { typingJob.cancel() } catch (_: Exception) {}
|
try { typingJob.cancel() } catch (_: Exception) {}
|
||||||
_pipelineState.value = if (_isListening.value)
|
_pipelineState.value = if (_isListening.value)
|
||||||
PipelineState.Listening else PipelineState.Idle
|
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
|
_visualizerSignal.value = VisualizerSignal.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun addMessage(message: ChatMessage) {
|
private fun addMessage(message: ChatMessage) {
|
||||||
_messages.value = _messages.value + message
|
_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.
|
* libération immédiate.
|
||||||
* - Thinker model_id changé en cascade ON → release, sera lazy-loadé.
|
* - 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) {
|
private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) {
|
||||||
val old = runtimeConfig
|
val old = runtimeConfig
|
||||||
runtimeConfig = new
|
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}, " +
|
log("[CONFIG] applied : cascade ${old.cascadeEnabled}→${new.cascadeEnabled}, " +
|
||||||
"speaker ${old.speaker.modelId}→${new.speaker.modelId}, " +
|
"speaker ${old.speaker.modelId}→${new.speaker.modelId}, " +
|
||||||
"thinker ${old.thinker.modelId}→${new.thinker.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.Cursor
|
||||||
import android.database.MatrixCursor
|
import android.database.MatrixCursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.Debug
|
import android.os.Debug
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -42,6 +43,10 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
const val PATH_RAG = "rag"
|
const val PATH_RAG = "rag"
|
||||||
const val PATH_RAG_STATUS = "rag_status"
|
const val PATH_RAG_STATUS = "rag_status"
|
||||||
const val PATH_RAG_QUERY = "rag_query"
|
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_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
|
||||||
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
|
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
|
||||||
const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG"
|
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_ITEM = 16
|
||||||
private const val CODE_RAG_STATUS = 17
|
private const val CODE_RAG_STATUS = 17
|
||||||
private const val CODE_RAG_QUERY = 18
|
private const val CODE_RAG_QUERY = 18
|
||||||
|
private const val CODE_UPDATES = 19
|
||||||
|
|
||||||
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
|
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
|
||||||
// sur tablette dev, stockage externe app sinon). Non-const.
|
// 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"
|
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
|
||||||
private const val PREFIX_SUFFIX = "_voice_prefix.bin"
|
private const val PREFIX_SUFFIX = "_voice_prefix.bin"
|
||||||
private const val SUFFIX_SUFFIX = "_voice_suffix.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 {
|
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/*", CODE_RAG_ITEM)
|
||||||
addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS)
|
addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS)
|
||||||
addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY)
|
addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY)
|
||||||
|
addURI(AUTHORITY, PATH_UPDATES, CODE_UPDATES)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(): Boolean = true
|
override fun onCreate(): Boolean = true
|
||||||
|
|
@ -124,9 +136,49 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
|
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
|
||||||
CODE_RAG_STATUS -> ragStatusCursor()
|
CODE_RAG_STATUS -> ragStatusCursor()
|
||||||
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
|
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
|
||||||
|
CODE_UPDATES -> updatesCursor()
|
||||||
else -> null
|
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 {
|
private fun ragStatusCursor(): Cursor {
|
||||||
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending")
|
val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending")
|
||||||
|
|
@ -256,23 +308,51 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
val cols = arrayOf(
|
val cols = arrayOf(
|
||||||
"cascade_enabled", "tts_enabled",
|
"cascade_enabled", "tts_enabled",
|
||||||
"stt_engine", "llm_engine", "tts_engine", "rag_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",
|
"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 cursor = MatrixCursor(cols)
|
||||||
|
val s = cfg.speaker; val t = cfg.thinker
|
||||||
cursor.addRow(arrayOf(
|
cursor.addRow(arrayOf(
|
||||||
if (cfg.cascadeEnabled) 1 else 0,
|
if (cfg.cascadeEnabled) 1 else 0,
|
||||||
if (cfg.ttsEnabled) 1 else 0,
|
if (cfg.ttsEnabled) 1 else 0,
|
||||||
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
||||||
if (cfg.ragEnabled) 1 else 0,
|
if (cfg.ragEnabled) 1 else 0,
|
||||||
cfg.ragThreshold, cfg.ragTopK,
|
cfg.ragThreshold, cfg.ragTopK,
|
||||||
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
if (cfg.debugEnabled) 1 else 0,
|
||||||
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
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
|
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 {
|
private fun modelsCursor(): Cursor {
|
||||||
val cols = arrayOf(
|
val cols = arrayOf(
|
||||||
"id", "display_name", "pte_path", "tokenizer_path",
|
"id", "display_name", "pte_path", "tokenizer_path",
|
||||||
|
|
@ -361,20 +441,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
values.getAsBoolean("rag_enabled") else cur.ragEnabled,
|
values.getAsBoolean("rag_enabled") else cur.ragEnabled,
|
||||||
ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold,
|
ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold,
|
||||||
ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK,
|
ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK,
|
||||||
speaker = com.kazeia.config.ConfigStore.ModelConfig(
|
debugEnabled = if (values.containsKey("debug_enabled"))
|
||||||
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
values.getAsBoolean("debug_enabled") else cur.debugEnabled,
|
||||||
systemPrompt = values.getAsString("speaker_system_prompt")
|
speaker = mergeModel(values, "speaker_", cur.speaker),
|
||||||
?: cur.speaker.systemPrompt,
|
thinker = mergeModel(values, "thinker_", cur.thinker),
|
||||||
temperature = values.getAsFloat("speaker_temperature")
|
presets = values.getAsString("presets_json")?.let { presetsFromJson(it) } ?: cur.presets
|
||||||
?: 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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
store.save(newCfg)
|
store.save(newCfg)
|
||||||
ctx.contentResolver.notifyChange(uri, null)
|
ctx.contentResolver.notifyChange(uri, null)
|
||||||
|
|
@ -382,6 +453,42 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
return 1
|
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 {
|
private fun upsertProfile(values: ContentValues): Int {
|
||||||
val ctx = context ?: return 0
|
val ctx = context ?: return 0
|
||||||
val store = com.kazeia.profiles.ProfileStore.get(ctx)
|
val store = com.kazeia.profiles.ProfileStore.get(ctx)
|
||||||
|
|
@ -424,26 +531,32 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
private fun voicesCursor(): Cursor {
|
private fun voicesCursor(): Cursor {
|
||||||
val cols = arrayOf(
|
val cols = arrayOf(
|
||||||
"id", "name", "state",
|
"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"
|
"wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at"
|
||||||
)
|
)
|
||||||
val cursor = MatrixCursor(cols)
|
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 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())
|
val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray())
|
||||||
.associateBy { it.nameWithoutExtension.lowercase() }
|
.associateBy { it.nameWithoutExtension.lowercase() }
|
||||||
val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray())
|
val cvpsIds = (cvpsDir.listFiles { f -> f.name.endsWith(CVPS_SUFFIX) } ?: emptyArray())
|
||||||
.associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() }
|
.associateBy { it.name.removeSuffix(CVPS_SUFFIX).lowercase() }
|
||||||
val ids = (wavIds.keys + prefixIds.keys).sorted()
|
val ids = (wavIds.keys + cvpsIds.keys).sorted()
|
||||||
|
|
||||||
for (id in ids) {
|
for (id in ids) {
|
||||||
val wav = wavIds[id]
|
val wav = wavIds[id]
|
||||||
|
val cvps = cvpsIds[id]
|
||||||
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
|
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
|
||||||
val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_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 {
|
val state = when {
|
||||||
prefix.exists() && suffix.exists() -> "ready"
|
cvps != null -> "ready"
|
||||||
wav != null -> "recorded"
|
wav != null -> "recorded"
|
||||||
else -> "error"
|
else -> "error"
|
||||||
}
|
}
|
||||||
|
|
@ -455,10 +568,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
if (wav != null) 1 else 0,
|
if (wav != null) 1 else 0,
|
||||||
if (prefix.exists()) 1 else 0,
|
if (prefix.exists()) 1 else 0,
|
||||||
if (suffix.exists()) 1 else 0,
|
if (suffix.exists()) 1 else 0,
|
||||||
|
if (cvps != null) 1 else 0,
|
||||||
wav?.length() ?: 0L,
|
wav?.length() ?: 0L,
|
||||||
durSec,
|
durSec,
|
||||||
wav?.absolutePath ?: "",
|
wav?.absolutePath ?: "",
|
||||||
wav?.lastModified() ?: prefix.lastModified()
|
wav?.lastModified() ?: cvps?.lastModified() ?: 0L
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return cursor
|
return cursor
|
||||||
|
|
@ -617,15 +731,15 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
private fun turnsCursor(): Cursor {
|
private fun turnsCursor(): Cursor {
|
||||||
val cols = arrayOf(
|
val cols = arrayOf(
|
||||||
"timestamp", "input_kind", "status", "total_ms",
|
"timestamp", "input_kind", "status", "total_ms",
|
||||||
"stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
|
"stt_ms", "thinker_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
|
||||||
"time_to_first_audio_ms", "audio_playback_ms"
|
"tts_ms", "time_to_first_audio_ms", "audio_playback_ms"
|
||||||
)
|
)
|
||||||
val cursor = MatrixCursor(cols)
|
val cursor = MatrixCursor(cols)
|
||||||
for (t in TelemetryHolder.recentTurns()) {
|
for (t in TelemetryHolder.recentTurns()) {
|
||||||
cursor.addRow(arrayOf(
|
cursor.addRow(arrayOf(
|
||||||
t.timestamp, t.inputKind, t.status, t.totalMs,
|
t.timestamp, t.inputKind, t.status, t.totalMs,
|
||||||
t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
|
t.sttDurationMs, t.thinkerMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
|
||||||
t.timeToFirstAudioMs, t.audioPlaybackMs
|
t.ttsSynthMs, t.timeToFirstAudioMs, t.audioPlaybackMs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return cursor
|
return cursor
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,11 @@ object TelemetryHolder {
|
||||||
val status: String, // "ok" | "llm_only" | "empty_response" | "error"
|
val status: String, // "ok" | "llm_only" | "empty_response" | "error"
|
||||||
val totalMs: Long,
|
val totalMs: Long,
|
||||||
val sttDurationMs: Long?,
|
val sttDurationMs: Long?,
|
||||||
val llmTtftMs: Long?,
|
val thinkerMs: Long? = null, // cascade Thinker (analyse silencieuse), si actif
|
||||||
val llmGenerationMs: Long?,
|
val llmTtftMs: Long?, // Speaker : temps jusqu'au 1er token
|
||||||
|
val llmGenerationMs: Long?, // Speaker : génération complète
|
||||||
val llmTokens: Int?,
|
val llmTokens: Int?,
|
||||||
|
val ttsSynthMs: Long? = null, // TTS : Speaker fini → 1er son (synthèse 1ʳᵉ phrase)
|
||||||
val timeToFirstAudioMs: Long?,
|
val timeToFirstAudioMs: Long?,
|
||||||
val audioPlaybackMs: 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.TtsEngine
|
||||||
import com.kazeia.core.TtsResult
|
import com.kazeia.core.TtsResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
@ -58,6 +59,10 @@ class CosyVoiceTtsEngine(
|
||||||
private val voices = HashMap<String, Long>()
|
private val voices = HashMap<String, Long>()
|
||||||
@Volatile private var currentVoiceId: String = defaultVoiceId
|
@Volatile private var currentVoiceId: String = defaultVoiceId
|
||||||
@Volatile private var audioTrack: AudioTrack? = null
|
@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 base(): String = com.kazeia.KazeiaApplication.MODELS_DIR
|
||||||
private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
|
private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
|
||||||
|
|
@ -104,8 +109,18 @@ class CosyVoiceTtsEngine(
|
||||||
|
|
||||||
private fun voiceHandleOrThrow(): Long {
|
private fun voiceHandleOrThrow(): Long {
|
||||||
require(handle != 0L) { "CosyVoice: not loaded" }
|
require(handle != 0L) { "CosyVoice: not loaded" }
|
||||||
val v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId)
|
var v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId)
|
||||||
require(v != 0L) { "CosyVoice: aucune voix chargée ('$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
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,31 +129,45 @@ class CosyVoiceTtsEngine(
|
||||||
val v = voiceHandleOrThrow()
|
val v = voiceHandleOrThrow()
|
||||||
val out = ArrayList<Float>()
|
val out = ArrayList<Float>()
|
||||||
for (s in sentences(text)) {
|
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) }
|
pcm.forEach { out.add(it) }
|
||||||
}
|
}
|
||||||
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
|
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
|
||||||
TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR)
|
TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun synthesizeAndPlay(
|
/**
|
||||||
text: String,
|
* Pré-chauffe le cache K/V du prompt voix : la 1ʳᵉ synthèse d'une voix remplit
|
||||||
language: String,
|
* ce cache (~70 % du coût flow), d'où un 1er énoncé lent (RTF ~1.9, mesuré
|
||||||
onStart: (() -> Unit)?,
|
* 10→8→5 s à mesure que le cache chauffe), puis RTF ~1. En synthétisant une
|
||||||
onComplete: (() -> Unit)?
|
* 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
|
||||||
val v = voiceHandleOrThrow()
|
* vraie synthèse). Best-effort : un échec ne casse rien.
|
||||||
val segs = sentences(text)
|
*/
|
||||||
if (segs.isEmpty()) { onComplete?.invoke(); return }
|
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
|
/** Construit un AudioTrack 24 kHz mono float MODE_STREAM. Renvoie null (et
|
||||||
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le
|
* libère) si le HAL le rend NON initialisé, pour permettre un retry au lieu
|
||||||
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour
|
* d'un play() qui lèverait. */
|
||||||
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts).
|
private fun buildTrackOrNull(): AudioTrack? {
|
||||||
val minBuf = AudioTrack.getMinBufferSize(
|
val minBuf = AudioTrack.getMinBufferSize(
|
||||||
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
|
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
|
||||||
).coerceAtLeast(SR * 4) // ~2 s de marge mono float
|
).coerceAtLeast(SR * 4) // ~2 s de marge mono float
|
||||||
val track = AudioTrack.Builder()
|
val t = try {
|
||||||
|
AudioTrack.Builder()
|
||||||
.setAudioAttributes(AudioAttributes.Builder()
|
.setAudioAttributes(AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
|
@ -151,6 +180,43 @@ class CosyVoiceTtsEngine(
|
||||||
.setBufferSizeInBytes(minBuf)
|
.setBufferSizeInBytes(minBuf)
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build()
|
.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
|
audioTrack = track
|
||||||
|
|
||||||
var totalFrames = 0
|
var totalFrames = 0
|
||||||
|
|
@ -159,14 +225,35 @@ class CosyVoiceTtsEngine(
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
for ((i, s) in segs.withIndex()) {
|
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 (pcm == null || pcm.isEmpty()) continue
|
||||||
|
if (audioTrack !== track) { log("TTS interrompu — pas de lecture"); break }
|
||||||
if (!started) {
|
if (!started) {
|
||||||
started = true
|
started = true
|
||||||
onStart?.invoke()
|
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})")
|
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.
|
// write bloquant (MODE_STREAM) : régule le débit sur la lecture.
|
||||||
var off = 0
|
var off = 0
|
||||||
while (off < pcm.size) {
|
while (off < pcm.size) {
|
||||||
|
|
@ -183,17 +270,16 @@ class CosyVoiceTtsEngine(
|
||||||
|
|
||||||
if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return }
|
if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return }
|
||||||
|
|
||||||
// Attend le drain réel des échantillons écrits avant de rendre la main.
|
// Drain DÉTERMINISTE : à ce point tout le PCM est écrit, mais les writes
|
||||||
suspendCancellableCoroutine<Unit> { cont ->
|
// bloquants laissent ~1 buffer (≈1 s) ENCORE non joué. On lit la position
|
||||||
cont.invokeOnCancellation { stopInternal(track) }
|
// de lecture (sans Looper — onMarkerReached ne se déclenche pas sur
|
||||||
track.setNotificationMarkerPosition(totalFrames)
|
// Dispatchers.Default) pour savoir combien il reste, et on attend EXACTEMENT
|
||||||
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
|
// cette durée avant de libérer. PAS de stop()/flush avant la fin (ça
|
||||||
override fun onMarkerReached(t: AudioTrack?) { if (cont.isActive) cont.resume(Unit) }
|
// jetterait le buffer → phrase coupée). Au plus près de la dernière syllabe,
|
||||||
override fun onPeriodicNotification(t: AudioTrack?) {}
|
// sans queue morte ni troncature.
|
||||||
})
|
val played = try { track.playbackHeadPosition } catch (_: Exception) { totalFrames }
|
||||||
// MODE_STREAM : stop() laisse drainer ce qui est déjà écrit puis atteint le marker.
|
val remainMs = ((totalFrames - played).coerceAtLeast(0)) * 1000L / SR
|
||||||
try { track.stop() } catch (_: Exception) {}
|
if (remainMs > 0) kotlinx.coroutines.delay(remainMs + 150L) // laisse jouer le reste
|
||||||
}
|
|
||||||
stopInternal(track)
|
stopInternal(track)
|
||||||
onComplete?.invoke()
|
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 {
|
) : View(context, attrs, defStyleAttr), Choreographer.FrameCallback {
|
||||||
|
|
||||||
companion object {
|
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
|
private const val SPECTRUM_BANDS = 12
|
||||||
/** Listening-mode outline deformation modes (even = smooth blobs). */
|
/** Listening-mode outline deformation modes (even = smooth blobs). */
|
||||||
private const val BLOB_MODES = 8
|
private const val BLOB_MODES = 8
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,6 @@ class ChatActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun setupResourceMonitoring() {
|
private fun setupResourceMonitoring() {
|
||||||
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
|
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
|
||||||
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
|
|
||||||
val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu)
|
val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu)
|
||||||
val graphRam = findViewById<MiniGraphView>(R.id.graphRam)
|
val graphRam = findViewById<MiniGraphView>(R.id.graphRam)
|
||||||
|
|
||||||
|
|
@ -189,7 +188,6 @@ class ChatActivity : AppCompatActivity() {
|
||||||
val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat()
|
val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat()
|
||||||
|
|
||||||
graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50"))
|
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"))
|
graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800"))
|
||||||
// Affiche la conso PSS de Kazeia uniquement (~2 GB idle, ~5.5 GB pic).
|
// 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
|
// Pas la RAM système globale (qui inclut ~3 GB d'OS Android dont on
|
||||||
|
|
@ -201,7 +199,6 @@ class ChatActivity : AppCompatActivity() {
|
||||||
while (true) {
|
while (true) {
|
||||||
val snap = resourceMonitor!!.snapshot()
|
val snap = resourceMonitor!!.snapshot()
|
||||||
graphCpu?.addValue(snap.cpuPercent)
|
graphCpu?.addValue(snap.cpuPercent)
|
||||||
graphGpu?.addValue(if (snap.gpuPercent >= 0) snap.gpuPercent else 0f)
|
|
||||||
|
|
||||||
// AI workload: show which AI component is active
|
// AI workload: show which AI component is active
|
||||||
val workload = kazeiaService?.aiWorkload?.value
|
val workload = kazeiaService?.aiWorkload?.value
|
||||||
|
|
@ -319,8 +316,19 @@ class ChatActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
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 ->
|
service.debugMode.collect { debug ->
|
||||||
setDebugPanelVisible(debug)
|
applyDebugGate(debug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
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 var logFile: java.io.FileWriter? = null
|
||||||
|
|
||||||
private fun appendLog(msg: String) {
|
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. */
|
/** Point d'entrée : récupère le catalogue puis décide de la suite. */
|
||||||
private fun begin() {
|
private fun begin() {
|
||||||
resetUi()
|
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 {
|
lifecycleScope.launch {
|
||||||
val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() }
|
val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() }
|
||||||
|
|
||||||
if (catalog == null) {
|
if (catalog == null) {
|
||||||
// Hors-ligne : un install qui tourne déjà peut continuer.
|
if (!force && manager.hasCoreModels()) goToSplash()
|
||||||
if (!force && manager.hasCoreModels()) {
|
else showError("Connexion requise pour la première installation.\n" +
|
||||||
Log.i(TAG, "catalogue injoignable mais modèles présents → Splash")
|
|
||||||
goToSplash()
|
|
||||||
} else {
|
|
||||||
showError("Connexion requise pour la première installation.\n" +
|
|
||||||
"Vérifiez le réseau puis réessayez.")
|
"Vérifiez le réseau puis réessayez.")
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
continueWithModels(catalog) // télécharge les modèles manquants (sans prompt APK)
|
||||||
// 1. Mise à jour de l'app elle-même.
|
|
||||||
val update = apkUpdater.availableUpdate(catalog)
|
|
||||||
if (update != null) {
|
|
||||||
promptApkUpdate(update, catalog)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
continueWithModels(catalog)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
cmake_minimum_required(VERSION 3.22)
|
||||||
project(kazeia-jni)
|
project(kazeia-jni)
|
||||||
|
|
||||||
set(JNILIBS_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
# Seul JNI natif construit ici : l'extracteur de mel pour le STT Whisper.
|
||||||
|
#
|
||||||
# --- External source paths (for kazeia_llm llama.cpp backend) ---
|
# Historique des retraits :
|
||||||
set(KAZEIA_LLM_DIR /opt/Kazeia/kazeia-llm)
|
# - Genie JNI bridge — refonte engine 2026-06-17 (LLM = UnifiedLlmAdapter).
|
||||||
set(LLAMA_UPSTREAM /opt/Kazeia/llama-upstream)
|
# - TTS legacy (neon_ops, tts_talker_cpu, tts_cp_cpu, tts_decoder_ggml + ggml/
|
||||||
|
# llama IMPORTED) — bascule CosyVoice 2026-06-18.
|
||||||
# --- Genie JNI bridge (legacy QNN backend, kept for fallback) ---
|
# Sources archivées sous /opt/Kazeia/archive/.
|
||||||
add_library(genie_jni SHARED genie_jni.cpp)
|
#
|
||||||
|
# Le reste de la stack prod est livré en libs PREBUILT dans jniLibs/ :
|
||||||
add_library(Genie SHARED IMPORTED)
|
# kazeia_engine (LLM GGUF + RAG), kazeia_pte (LLM .pte NPU), kazeia_stt (STT),
|
||||||
set_target_properties(Genie PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libGenie.so)
|
# kazeia_cosyvoice/cosyvoice (TTS), + leurs deps (llama, ggml*, QnnHtp*, omp).
|
||||||
|
|
||||||
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) ---
|
|
||||||
add_library(mel_extractor SHARED mel_extractor.cpp)
|
add_library(mel_extractor SHARED mel_extractor.cpp)
|
||||||
target_link_libraries(mel_extractor android log)
|
target_link_libraries(mel_extractor android log)
|
||||||
target_compile_options(mel_extractor PRIVATE -std=c++17 -O2)
|
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:src="@android:drawable/ic_menu_manage"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:contentDescription="Debug"
|
android:contentDescription="Debug"
|
||||||
|
android:visibility="gone"
|
||||||
android:tint="@android:color/white" />
|
android:tint="@android:color/white" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
|
|
@ -190,12 +191,27 @@
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_margin="2dp" />
|
android:layout_margin="2dp" />
|
||||||
|
|
||||||
<com.kazeia.ui.MiniGraphView
|
<!-- Remplace le graph GPU (inutilisé) : encart « dernier tour »
|
||||||
android:id="@+id/graphGpu"
|
(input · output · temps par étape), persistant. -->
|
||||||
|
<ScrollView
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
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>
|
</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/
|
# 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_*),
|
# Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*),
|
||||||
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
|
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
|
||||||
# QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp.
|
# 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).
|
# Les binaires ne sont PAS dans git ; ce manifest fait foi (verify en preBuild).
|
||||||
b0a52229e54bed2b074b38e6b6a6f2203a679f1b1fe7393912de7d7ec3e6196e libcosyvoice.so
|
b0a52229e54bed2b074b38e6b6a6f2203a679f1b1fe7393912de7d7ec3e6196e libcosyvoice.so
|
||||||
d523468d62d9b603cb3354294d70d4b2feabf2c3f1e43b0c96c9aabf32813708 libc++_shared.so
|
d523468d62d9b603cb3354294d70d4b2feabf2c3f1e43b0c96c9aabf32813708 libc++_shared.so
|
||||||
dd0506873aeb45e103ef0023678a7f14938a0f147f2348d1efc366f1dce40863 libexecutorch_jni.so
|
|
||||||
0b6f86e114f0cf9f2940194797f06ded576e6b3272a5ffbb2c4b8cdc02850ef8 libexecutorch.so
|
|
||||||
34a0af206f660461e3da687f64172830cc61a32a93732fd7e984c43996d6fd91 libfbjni.so
|
|
||||||
d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so
|
|
||||||
616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so
|
616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so
|
||||||
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
|
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
|
||||||
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
|
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
|
||||||
b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so
|
b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so
|
||||||
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
|
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
|
||||||
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so
|
f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so
|
||||||
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
|
|
||||||
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
|
|
||||||
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
|
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
|
||||||
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
|
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
|
||||||
d1e8fc79e47fe04ed00aa9bbcc5ccb84d187f0160b1d77ae5bd012aedea9c938 libqnn_executorch_backend.so
|
95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so
|
||||||
414794639e2897120143cff80aaf73e830e305357b6c1aa8343de380c89b8c36 libQnnGpuNetRunExtensions.so
|
a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so
|
||||||
26f13b297d375e9629eb997b7adb05a4ce9e1c17d306baaefe905563fee43eb8 libQnnGpuProfilingReader.so
|
|
||||||
b5b52b66f3fc5567429024409dcedb006ca6a9115f271689810589b7d79af430 libQnnGpu.so
|
|
||||||
2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so
|
2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so
|
||||||
10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so
|
10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so
|
||||||
7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so
|
7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so
|
||||||
a08fb34747345125fd3cef3fc10046c663465dd60fddbac9cc37436b3d2e68a9 libQnnHtpV79Stub.so
|
a08fb34747345125fd3cef3fc10046c663465dd60fddbac9cc37436b3d2e68a9 libQnnHtpV79Stub.so
|
||||||
a69cfc4350c16b226bcaa19c657234820bb93e3ff6116cf3845ae77557f1f7c3 libQnnSystem.so
|
a69cfc4350c16b226bcaa19c657234820bb93e3ff6116cf3845ae77557f1f7c3 libQnnSystem.so
|
||||||
d35a02f70cb63ec60c8ae78011f5e93aecbb31396d5395a4a82cb1bc71803956 libtts_pipeline.so
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue