File size: 8,784 Bytes
bd48fd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// Copyright (c) 2026, CIIL Anuvani-LID. Apache-2.0.
//
// A starting point for an Android app: one class over the Kotlin API of the patched sherpa-onnx (LidPipeline for
// whole recordings, AudioTagging for one stretch of speech). Copy it into your app and change the package. It is
// compiled against android.jar and run in ART on an emulator by tests/android (docs/03-android.md).
package com.example.anuvanilid

import android.os.Build
import android.system.Os
import com.k2fsa.sherpa.onnx.AudioTagging
import com.k2fsa.sherpa.onnx.AudioTaggingConfig
import com.k2fsa.sherpa.onnx.AudioTaggingModelConfig
import com.k2fsa.sherpa.onnx.LidPipeline
import com.k2fsa.sherpa.onnx.LidPipelineConfig
import org.json.JSONObject
import java.io.File

/** The model file and execution provider to run (docs/01-formats-and-devices.md). */
enum class Accelerator {
    /** anuvani_lid.int8.onnx on the CPU: every phone; the default. */
    CPU_INT8,

    /** anuvani_lid.fp32.onnx on the CPU: the reference numbers, 2.7 times the file size of int8. */
    CPU_FP32,

    /** The 15 s accelerator format on the Qualcomm Hexagon NPU (Snapdragon with Hexagon v69 or newer; needs the
     *  QNN plugin in the app: docs/03-android.md). */
    QNN,

    /** The 15 s NNAPI encoder through NNAPI (Android 8.1 to 14; deprecated from Android 15). */
    NNAPI,
}

data class LanguageScore(val language: String, val probability: Double)

data class Segment(val startS: Double, val endS: Double, val language: String, val probability: Double)

/** The result for a recording: the anuvani-lid/1 JSON of the CIIL Anuvani-LID server, parsed. */
data class LidResult(
    /** The recording's language (null when there is no speech). */
    val language: String?,
    /** exp(duration-weighted mean log-probability) of that language. */
    val confidence: Double,
    val topK: List<LanguageScore>,
    /** The language with the most speech time. */
    val dominantByTime: String?,
    val timeShare: Map<String, Double>,
    /** One entry per classified piece of speech, in order. */
    val segments: List<Segment>,
    val speechSeconds: Double,
    val json: String,
)

class LanguageIdentifier(
    /** Directory holding the model files (stored as files, not compressed assets: docs/03-android.md). */
    private val modelDir: File,
    /** A writable directory of the app (e.g. context.filesDir): provider config files, the NPU-compiled graph. */
    private val workDir: File,
    val accelerator: Accelerator = Accelerator.CPU_INT8,
    private val threads: Int = 4,
    /** For QNN: the app's nativeLibraryDir (context.applicationInfo.nativeLibraryDir), where the plugin's
     *  libraries are; the NPU loads its libraries from there. */
    private val nativeLibraryDir: String? = null,
) : AutoCloseable {

    private val modelConfig: AudioTaggingModelConfig by lazy {
        val (file, provider) = when (accelerator) {
            Accelerator.CPU_INT8 -> "anuvani_lid.int8.onnx" to "cpu"
            Accelerator.CPU_FP32 -> "anuvani_lid.fp32.onnx" to "cpu"
            Accelerator.NNAPI -> "anuvani_lid.npu-15s-nnapi.encoder.onnx" to providerConfig("nnapi", "NNAPI.fp16=1\n")
            Accelerator.QNN -> "anuvani_lid.npu-15s.encoder.onnx" to providerConfig("qnn", qnnConfig())
        }
        AudioTaggingModelConfig(lid = File(modelDir, file).path, numThreads = threads, provider = provider)
    }

    // created on first use: an app that only identifies recordings, or only classifies segments, loads one model
    private val pipelineDelegate = lazy {
        LidPipeline(
            config = LidPipelineConfig(
                model = modelConfig,
                vadModel = File(modelDir, "silero_vad_6.2.1.onnx").path,
            ),
        )
    }
    private val taggerDelegate = lazy { AudioTagging(config = AudioTaggingConfig(model = modelConfig, topK = 23)) }

    /** The language(s) of a recording of any length: Silero VAD, speech pieces, one decision per piece, the
     *  duration-weighted decision; the procedure and results of the CIIL Anuvani-LID server. samples: mono in
     *  [-1, 1] at sampleRate (resampled to 16 kHz if different). Blocking; call it off the main thread. */
    fun identify(samples: FloatArray, sampleRate: Int): LidResult =
        parse(pipelineDelegate.value.process(samples, sampleRate))

    /** Probabilities of the languages for one stretch of speech (e.g. a VAD segment), largest first. Audio shorter
     *  than 0.1 s gives an empty list. Blocking. */
    fun classify(samples: FloatArray, sampleRate: Int, topK: Int = 23): List<LanguageScore> {
        val tagger = taggerDelegate.value
        val stream = tagger.createStream()
        stream.acceptWaveform(samples, sampleRate)
        val events = tagger.compute(stream, topK)
        stream.release()
        return events.map { LanguageScore(it.name, it.prob.toDouble()) }
    }

    override fun close() {
        if (pipelineDelegate.isInitialized()) pipelineDelegate.value.release()
        if (taggerDelegate.isInitialized()) taggerDelegate.value.release()
    }

    private fun providerConfig(name: String, text: String): String {
        val f = File(workDir, "anuvani-lid-$name.cfg")
        f.writeText(text)
        return "$name:${f.path}"
    }

    private fun qnnConfig(): String {
        nativeLibraryDir?.let {
            // the NPU side of QNN (FastRPC) finds the libQnnHtpV*Skel.so libraries through ADSP_LIBRARY_PATH
            Os.setenv("ADSP_LIBRARY_PATH", "$it;/system/lib/rfsa/adsp;/system/vendor/lib/rfsa/adsp;/dsp", true)
        }
        val cache = File(workDir, "qnn").apply { mkdirs() }
        val sb = StringBuilder()
        sb.append("QNN.library=libonnxruntime_providers_qnn.so\n")
        sb.append("QNN.backend_type=htp\n")
        sb.append("QNN.htp_performance_mode=burst\n")
        sb.append("QNN.cache_dir=${cache.path}\n")
        qnnSocModel()?.let {
            sb.append("QNN.soc_model=$it\n")
            sb.append("QNN.enable_htp_fp16_precision=1\n")
        }
        return sb.toString()
    }

    companion object {
        /** Qualcomm chips the QNN plugin knows, with their soc_model IDs and Hexagon generations (onnxruntime-qnn,
         *  onnxruntime/core/providers/qnn/soc_utility/soc_table.h). */
        val QNN_CHIPS: Map<String, Pair<Int, Int>> = mapOf(
            "SM8350" to (30 to 68), "SM8325" to (34 to 68), "SM8450" to (36 to 69), "SM8475" to (42 to 69),
            "SM8550" to (43 to 73), "SM8650" to (57 to 75), "SM8635" to (68 to 73), "SM8750" to (69 to 79),
            "SM7675" to (70 to 73), "SM8850" to (87 to 81),
        )

        /** The chip name Android reports (Android 12+), e.g. "SM8650"; "" before Android 12. */
        fun socModel(): String = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL ?: "" else ""

        /** soc_model of this phone's chip for the QNN plugin, or null if the table does not list it. */
        fun qnnSocModel(): Int? = QNN_CHIPS[socModel().uppercase()]?.first

        /** True on Snapdragon chips whose NPU builds the accelerator format in fp16: Hexagon v69 (Snapdragon 8 Gen 1)
         *  or newer. On Qualcomm's emulated NPU, v68 (Snapdragon 888) cannot compile it; if QNN is chosen there
         *  anyway, the model runs on the CPU (docs/01). */
        fun qnnFloatCapable(): Boolean = (QNN_CHIPS[socModel().uppercase()]?.second ?: 0) >= 69

        /** Parse the anuvani-lid/1 JSON of LidPipeline.process(). */
        fun parse(json: String): LidResult {
            val j = JSONObject(json)
            val overall = j.getJSONObject("overall")
            val topK = overall.getJSONArray("top_k").let { a ->
                (0 until a.length()).map { LanguageScore(a.getJSONObject(it).getString("label"), a.getJSONObject(it).getDouble("prob")) }
            }
            val share = j.getJSONObject("language_time_share").let { o -> o.keys().asSequence().associateWith { o.getDouble(it) } }
            val segments = j.getJSONArray("segments").let { a ->
                (0 until a.length()).map {
                    val s = a.getJSONObject(it)
                    Segment(s.getDouble("start_s"), s.getDouble("end_s"), s.getString("label"), s.getDouble("prob"))
                }
            }
            return LidResult(
                language = if (overall.isNull("label")) null else overall.getString("label"),
                confidence = overall.getDouble("confidence"),
                topK = topK,
                dominantByTime = if (j.isNull("dominant_by_time")) null else j.getString("dominant_by_time"),
                timeShare = share,
                segments = segments,
                speechSeconds = j.getJSONObject("stats").getDouble("speech_seconds"),
                json = json,
            )
        }
    }
}