Kazeia-engine/dist/jni/SttEngine.kt

133 lines
4.3 KiB
Kotlin

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
}
}