新模型+说话人区分
This commit is contained in:
parent
80d3f59ecf
commit
7e7206a8d4
|
|
@ -12,6 +12,7 @@ import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
|
import android.os.SystemClock
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.method.ScrollingMovementMethod
|
import android.text.method.ScrollingMovementMethod
|
||||||
|
|
@ -99,6 +100,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
private var colorSeq = 0
|
private var colorSeq = 0
|
||||||
private var lastConfirmedLabel: String? = null // partial 行沿用的最近确认说话人
|
private var lastConfirmedLabel: String? = null // partial 行沿用的最近确认说话人
|
||||||
private var lastSettledSid = -1L // 主线程:最近定格的句子槽位
|
private var lastSettledSid = -1L // 主线程:最近定格的句子槽位
|
||||||
|
// 同人停顿超时封行:距上一句定格 > 该时长再开口,同一人也另起新行
|
||||||
|
// (会议记录里算新一轮发言)。仅作用于"最底行接句"判断。
|
||||||
|
private val lineIdleTimeoutMs = 60_000L
|
||||||
|
private var lastSentenceSettledAt = 0L
|
||||||
private lateinit var subtitleRecycler: RecyclerView
|
private lateinit var subtitleRecycler: RecyclerView
|
||||||
private lateinit var subtitleAdapter: SubtitleAdapter
|
private lateinit var subtitleAdapter: SubtitleAdapter
|
||||||
|
|
||||||
|
|
@ -142,6 +147,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
private val speakerThreshold = 0.5f
|
private val speakerThreshold = 0.5f
|
||||||
private var speakersEnabled = false
|
private var speakersEnabled = false
|
||||||
private var lastEmbedding: FloatArray? = null
|
private var lastEmbedding: FloatArray? = null
|
||||||
|
// 同一句话在 fast(轻量)空间的镜像嵌入,命名时双写进 fastRegistry
|
||||||
|
private var lastEmbeddingFast: FloatArray? = null
|
||||||
private lateinit var nameSpeakerButton: Button
|
private lateinit var nameSpeakerButton: Button
|
||||||
private lateinit var clearSpeakersButton: Button
|
private lateinit var clearSpeakersButton: Button
|
||||||
|
|
||||||
|
|
@ -150,11 +157,26 @@ class MainActivity : AppCompatActivity() {
|
||||||
// compares it (cosine) with the window reference taken at the start of
|
// compares it (cosine) with the window reference taken at the start of
|
||||||
// the current utterance. A big similarity drop == voice changed mid
|
// the current utterance. A big similarity drop == voice changed mid
|
||||||
// utterance == force an endpoint cut at the switch point.
|
// utterance == force an endpoint cut at the switch point.
|
||||||
private class SpeakerSwitch(val label: String?, val cutSamples: Int)
|
private class SpeakerSwitch(
|
||||||
|
val label: String?,
|
||||||
|
val cutSamples: Int,
|
||||||
|
val ownerSid: Long, // 判定属于哪句话;消费时句已结算则作废
|
||||||
|
)
|
||||||
|
|
||||||
private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() }
|
private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() }
|
||||||
private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) }
|
private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) }
|
||||||
private val speakerSwitchDrop = 0.35f // cos(window, reference) below this => switch
|
private val speakerSwitchDrop = 0.30f // cos(window, reference) below this => switch
|
||||||
|
// 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4;
|
||||||
|
// endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。
|
||||||
|
private val fastSearchThreshold = 0.4f
|
||||||
|
// 最速档:1.2s 周期 + 单票(switchStrikesNeeded=1)。误断的代价只是
|
||||||
|
// 多分行+流重置,不丢音频(快照平铺保证时间轴连续)。
|
||||||
|
private val switchStrikesNeeded = 1
|
||||||
|
private val minWindowRms = 0.004f // 静音主导窗不做判定(embedding 不可靠)
|
||||||
|
private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句
|
||||||
|
@Volatile private var rollingLowHits = 0
|
||||||
|
// C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过
|
||||||
|
@Volatile private var rollingSeq = 0L
|
||||||
@Volatile private var pendingSwitch: SpeakerSwitch? = null
|
@Volatile private var pendingSwitch: SpeakerSwitch? = null
|
||||||
@Volatile private var windowRefEmb: FloatArray? = null
|
@Volatile private var windowRefEmb: FloatArray? = null
|
||||||
@Volatile private var activeLabelForWindow: String? = null
|
@Volatile private var activeLabelForWindow: String? = null
|
||||||
|
|
@ -240,6 +262,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
clearSpeakersButton.setOnClickListener {
|
clearSpeakersButton.setOnClickListener {
|
||||||
SpeakerRecognition.resetSpeakers()
|
SpeakerRecognition.resetSpeakers()
|
||||||
lastEmbedding = null
|
lastEmbedding = null
|
||||||
|
lastEmbeddingFast = null
|
||||||
lastLabel = null
|
lastLabel = null
|
||||||
lastConfirmedLabel = null
|
lastConfirmedLabel = null
|
||||||
activeLabelForWindow = null
|
activeLabelForWindow = null
|
||||||
|
|
@ -260,8 +283,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
subtitleHandler.post {
|
subtitleHandler.post {
|
||||||
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
|
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
|
||||||
}
|
}
|
||||||
|
// D:声纹检测线程优先级高于定稿线程(nano),换行判定不被定稿挤慢
|
||||||
speakerCheckHandler.post {
|
speakerCheckHandler.post {
|
||||||
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
|
Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading ~1.6 GB of ONNX weights takes tens of seconds. Do it OFF the
|
// Loading ~1.6 GB of ONNX weights takes tens of seconds. Do it OFF the
|
||||||
|
|
@ -276,6 +300,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
initOfflineRecognizer()
|
initOfflineRecognizer()
|
||||||
setStatus("正在加载声纹模型…")
|
setStatus("正在加载声纹模型…")
|
||||||
initSpeakerIdentification()
|
initSpeakerIdentification()
|
||||||
|
if (speakersEnabled) {
|
||||||
|
initFastSpeakerModel(
|
||||||
|
application.assets.list("").orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
modelsReady = true
|
modelsReady = true
|
||||||
runOnUiThread { recordButton.isEnabled = true }
|
runOnUiThread { recordButton.isEnabled = true }
|
||||||
val speakerNote = if (speakersEnabled) "说话人功能已启用"
|
val speakerNote = if (speakersEnabled) "说话人功能已启用"
|
||||||
|
|
@ -349,6 +378,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
resetBuffer()
|
resetBuffer()
|
||||||
pendingSwitch = null
|
pendingSwitch = null
|
||||||
windowRefEmb = null
|
windowRefEmb = null
|
||||||
|
rollingLowHits = 0
|
||||||
checkSerial++
|
checkSerial++
|
||||||
activeLabelForWindow = null
|
activeLabelForWindow = null
|
||||||
lastConfirmedLabel = null
|
lastConfirmedLabel = null
|
||||||
|
|
@ -389,8 +419,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
var sentenceId = -1L
|
var sentenceId = -1L
|
||||||
|
|
||||||
// rolling speaker-check state (recording-thread only)
|
// rolling speaker-check state (recording-thread only)
|
||||||
val window = RollingWindow(sampleRateInHz * 2) // last 2 s, 0.5 s overlap
|
// v8.1 定点回滚:窗口 2s / 周期 1.2s(v8 的 1.2s/0.7s 单票导致误断
|
||||||
val checkStep = (sampleRateInHz * 1.5).toInt() // re-check every 1.5 s
|
// 刷屏灰"未识别");单票保留,配合误断防护使用
|
||||||
|
val window = RollingWindow(sampleRateInHz * 2)
|
||||||
|
val checkStep = (sampleRateInHz * 1.2f).toInt()
|
||||||
var newSinceCheck = 0
|
var newSinceCheck = 0
|
||||||
|
|
||||||
while (isRecording) {
|
while (isRecording) {
|
||||||
|
|
@ -400,31 +432,71 @@ class MainActivity : AppCompatActivity() {
|
||||||
pushChunk(samples)
|
pushChunk(samples)
|
||||||
window.push(samples)
|
window.push(samples)
|
||||||
|
|
||||||
if (speakersEnabled && sentenceId != -1L) {
|
if (enableRollingSwitch && speakersEnabled && sentenceId != -1L) {
|
||||||
newSinceCheck += ret
|
newSinceCheck += ret
|
||||||
if (newSinceCheck >= checkStep) {
|
if (newSinceCheck >= checkStep) {
|
||||||
newSinceCheck = 0
|
newSinceCheck = 0
|
||||||
val win = window.snapshot()
|
val win = window.snapshot()
|
||||||
val cutAt = bufferTotal
|
val cutAt = bufferTotal
|
||||||
val seq = checkSerial
|
val seq = checkSerial
|
||||||
|
val owner = sentenceId
|
||||||
|
if (rmsOf(win) >= minWindowRms) {
|
||||||
|
val rseq = ++rollingSeq
|
||||||
speakerCheckHandler.post {
|
speakerCheckHandler.post {
|
||||||
|
// C:被更新的检测投递超过 → 直接作废(防积压)
|
||||||
|
if (rseq != rollingSeq) return@post
|
||||||
if (seq != checkSerial || pendingSwitch != null) return@post
|
if (seq != checkSerial || pendingSwitch != null) return@post
|
||||||
val emb = SpeakerRecognition.computeEmbedding(
|
// E:滚动检测走轻量模型(base),推理快 ~4 倍;
|
||||||
|
// 匹配也只在 fast 空间的镜像 registry 里查
|
||||||
|
val emb = SpeakerRecognition.computeEmbeddingFast(
|
||||||
win, sampleRateInHz
|
win, sampleRateInHz
|
||||||
) ?: return@post
|
) ?: return@post
|
||||||
|
if (rseq != rollingSeq) return@post
|
||||||
|
// 计算耗时内句子可能已结算:回写前二次核销
|
||||||
|
if (seq != checkSerial) return@post
|
||||||
val ref = windowRefEmb
|
val ref = windowRefEmb
|
||||||
if (ref == null) {
|
if (ref == null) {
|
||||||
// first window of this utterance becomes the
|
// first window of this utterance becomes the
|
||||||
// voice reference
|
// voice reference
|
||||||
windowRefEmb = emb
|
windowRefEmb = emb
|
||||||
} else if (SpeakerRecognition.similarity(emb, ref)
|
rollingLowHits = 0
|
||||||
< speakerSwitchDrop
|
} else {
|
||||||
|
val sim = SpeakerRecognition.similarity(emb, ref)
|
||||||
|
if (sim >= speakerSwitchDrop) {
|
||||||
|
// 同人缓漂:参考嵌入 EMA 慢速更新
|
||||||
|
windowRefEmb = blendEmb(ref, emb)
|
||||||
|
rollingLowHits = 0
|
||||||
|
} else {
|
||||||
|
rollingLowHits += 1
|
||||||
|
if (rollingLowHits >= switchStrikesNeeded &&
|
||||||
|
pendingSwitch == null
|
||||||
) {
|
) {
|
||||||
val newLabel = SpeakerRecognition.registry.search(
|
rollingLowHits = 0
|
||||||
emb, speakerThreshold
|
val newLabel =
|
||||||
|
rollingSpeakerSearch(emb)
|
||||||
|
val active = activeLabelForWindow
|
||||||
|
when {
|
||||||
|
// ③ 误断防护:查出来就是当前人 →
|
||||||
|
// 相似度毛刺,不断行,参考回拉
|
||||||
|
newLabel != null &&
|
||||||
|
active != null &&
|
||||||
|
newLabel == active -> {
|
||||||
|
windowRefEmb =
|
||||||
|
blendEmb(ref, emb)
|
||||||
|
}
|
||||||
|
// ③ 当前行还没归属、而窗口恰好匹配到
|
||||||
|
// 某个已知人 → 直接冠名,不必断行
|
||||||
|
newLabel != null && active == null -> {
|
||||||
|
uiHandler.post {
|
||||||
|
adoptActiveLabel(newLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> pendingSwitch = SpeakerSwitch(
|
||||||
|
newLabel, cutAt, owner
|
||||||
)
|
)
|
||||||
if (pendingSwitch == null) {
|
}
|
||||||
pendingSwitch = SpeakerSwitch(newLabel, cutAt)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -438,10 +510,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// ---- 声纹判定换人:强制 endpoint(需求一.4a/b/c) ----
|
// ---- 声纹判定换人:强制 endpoint(需求一.4a/b/c) ----
|
||||||
val ps = pendingSwitch
|
val ps = pendingSwitch
|
||||||
if (ps != null) {
|
if (ps != null && ps.ownerSid == sentenceId) {
|
||||||
pendingSwitch = null
|
pendingSwitch = null
|
||||||
checkSerial++ // drop in-flight window jobs
|
checkSerial++ // drop in-flight window jobs
|
||||||
val oldLabel = activeLabelForWindow // 被切断句子的归属人
|
val oldLabel = activeLabelForWindow // 被切断句子的归属人
|
||||||
|
Log.i(TAG, "speaker-switch FIRED newLabel=" +
|
||||||
|
(ps.label?.let { displayName(it) } ?: "未识别") +
|
||||||
|
" owner=sid${ps.ownerSid} cut@" + ps.cutSamples +
|
||||||
|
" (active=" + (oldLabel?.let { displayName(it) }
|
||||||
|
?: "null") + ")")
|
||||||
val size = samplesBuffer.size
|
val size = samplesBuffer.size
|
||||||
if (size == 0) {
|
if (size == 0) {
|
||||||
uiHandler.post { onSpeakerSwitched(ps.label) }
|
uiHandler.post { onSpeakerSwitched(ps.label) }
|
||||||
|
|
@ -472,6 +549,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
val sid = sentenceId
|
val sid = sentenceId
|
||||||
sentenceId = -1L
|
sentenceId = -1L
|
||||||
windowRefEmb = null
|
windowRefEmb = null
|
||||||
|
rollingLowHits = 0
|
||||||
newSinceCheck = 0
|
newSinceCheck = 0
|
||||||
|
|
||||||
if (sid != -1L) {
|
if (sid != -1L) {
|
||||||
|
|
@ -497,6 +575,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
dropPrefixChunks(k)
|
dropPrefixChunks(k)
|
||||||
// 切换点之后的音频留在缓冲区,归入新说话人(需求一.4c)
|
// 切换点之后的音频留在缓冲区,归入新说话人(需求一.4c)
|
||||||
}
|
}
|
||||||
|
} else if (ps != null) {
|
||||||
|
// 判定属于已结算的旧句子:纯过期,直接丢弃 ——
|
||||||
|
// 不 reset、不丢音频(v5 误断第一遍的根因修复)
|
||||||
|
pendingSwitch = null
|
||||||
|
Log.i(TAG, "SPEAKER-SWITCH ignored: stale owner=${ps.ownerSid} current=$sentenceId")
|
||||||
}
|
}
|
||||||
|
|
||||||
val isEndpoint = onlineRecognizer.isEndpoint(stream)
|
val isEndpoint = onlineRecognizer.isEndpoint(stream)
|
||||||
|
|
@ -510,6 +593,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (sentenceId == -1L) {
|
if (sentenceId == -1L) {
|
||||||
sentenceId = lineIdSeq.incrementAndGet()
|
sentenceId = lineIdSeq.incrementAndGet()
|
||||||
windowRefEmb = null
|
windowRefEmb = null
|
||||||
|
rollingLowHits = 0
|
||||||
}
|
}
|
||||||
val sid = sentenceId
|
val sid = sentenceId
|
||||||
uiHandler.post { onPartial(sid, text) }
|
uiHandler.post { onPartial(sid, text) }
|
||||||
|
|
@ -522,13 +606,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
val sid = sentenceId
|
val sid = sentenceId
|
||||||
sentenceId = -1L
|
sentenceId = -1L
|
||||||
windowRefEmb = null
|
windowRefEmb = null
|
||||||
|
rollingLowHits = 0
|
||||||
newSinceCheck = 0
|
newSinceCheck = 0
|
||||||
checkSerial++
|
checkSerial++
|
||||||
|
|
||||||
val snap = flattenSamples() // fresh copy, 绝不共享引用
|
val snap = flattenSamples() // fresh copy, 绝不共享引用
|
||||||
val dur = bufferTotal / sampleRateInHz.toFloat()
|
val dur = bufferTotal / sampleRateInHz.toFloat()
|
||||||
val prompt = rawAll // 未过滤底稿作提示(需求二:不丢词)
|
val prompt = rawAll // 未过滤底稿作提示(需求二:不丢词)
|
||||||
keepTailChunks(5) // 0.5 s 尾作下一片段上下文
|
// 时间轴严格平铺:本段全部音频已交给 snap,缓冲区清零,
|
||||||
|
// 下一段从该断点开始(不再回含 0.5s 重叠)
|
||||||
|
resetBuffer()
|
||||||
|
|
||||||
// 整句声纹判定移出录音线程(停摆根因):后台算完
|
// 整句声纹判定移出录音线程(停摆根因):后台算完
|
||||||
// 回主线程 confirmSpeaker 修正归属
|
// 回主线程 confirmSpeaker 修正归属
|
||||||
|
|
@ -536,14 +623,30 @@ class MainActivity : AppCompatActivity() {
|
||||||
val emb = SpeakerRecognition.computeEmbedding(
|
val emb = SpeakerRecognition.computeEmbedding(
|
||||||
snap, sampleRateInHz
|
snap, sampleRateInHz
|
||||||
)
|
)
|
||||||
|
// 镜像嵌入:② 用句尾 2s 切片(与滚动检测同视角),
|
||||||
|
// 整句 embedding 会拉低窗口匹配度,导致换行时查不到人
|
||||||
|
val fastEmb = if (emb != null &&
|
||||||
|
SpeakerRecognition.fastReady()
|
||||||
|
) {
|
||||||
|
val tailSlice = if (snap.size > 32000)
|
||||||
|
snap.copyOfRange(
|
||||||
|
snap.size - 32000, snap.size
|
||||||
|
) else snap
|
||||||
|
SpeakerRecognition.computeEmbeddingFast(
|
||||||
|
tailSlice, sampleRateInHz
|
||||||
|
)
|
||||||
|
} else null
|
||||||
val label = if (emb == null) null else
|
val label = if (emb == null) null else
|
||||||
SpeakerRecognition.registry.search(
|
SpeakerRecognition.registry.search(
|
||||||
emb, speakerThreshold
|
emb, speakerThreshold
|
||||||
)
|
)
|
||||||
?: SpeakerRecognition.nextAutoLabel().also {
|
?: SpeakerRecognition.nextAutoLabel().also {
|
||||||
SpeakerRecognition.registry.addAuto(it, emb)
|
SpeakerRecognition.registry.addAuto(it, emb)
|
||||||
|
if (fastEmb != null)
|
||||||
|
SpeakerRecognition.fastRegistry
|
||||||
|
.addAuto(it, fastEmb)
|
||||||
}
|
}
|
||||||
uiHandler.post { confirmSpeaker(sid, label, emb) }
|
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
|
||||||
}
|
}
|
||||||
// 定格 + 送检立即走主线程队列,不等声纹
|
// 定格 + 送检立即走主线程队列,不等声纹
|
||||||
uiHandler.post {
|
uiHandler.post {
|
||||||
|
|
@ -625,6 +728,30 @@ class MainActivity : AppCompatActivity() {
|
||||||
activeSpeakerModel = null
|
activeSpeakerModel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E:轻量声纹模型(eres2net base)专供滚动换人检测,推理快 ~4 倍。
|
||||||
|
// 找不到该文件或准确模型本来就是 base 时跳过,滚动检测自动回退到准确模型。
|
||||||
|
private fun initFastSpeakerModel(topAssets: Array<out String>) {
|
||||||
|
val fastName = speakerModels[1]
|
||||||
|
if (activeSpeakerModel == fastName) return // 准确模型已是 base
|
||||||
|
try {
|
||||||
|
SpeakerRecognition.initFastExtractor(
|
||||||
|
null, File(externalModelDir, fastName).absolutePath
|
||||||
|
)
|
||||||
|
Log.i(TAG, "fast speaker model ready (external): $fastName")
|
||||||
|
return
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.i(TAG, "fast model '$fastName' not in external dir")
|
||||||
|
}
|
||||||
|
if (topAssets.contains(fastName)) {
|
||||||
|
try {
|
||||||
|
SpeakerRecognition.initFastExtractor(application.assets, fastName)
|
||||||
|
Log.i(TAG, "fast speaker model ready (assets): $fastName")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "fast model '$fastName' failed: $t")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onNameSpeakerClick() {
|
private fun onNameSpeakerClick() {
|
||||||
val emb = lastEmbedding
|
val emb = lastEmbedding
|
||||||
if (emb == null) {
|
if (emb == null) {
|
||||||
|
|
@ -639,6 +766,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
val name = input.text.toString().trim()
|
val name = input.text.toString().trim()
|
||||||
if (name.isNotEmpty()) {
|
if (name.isNotEmpty()) {
|
||||||
SpeakerRecognition.registry.addNamed(name, emb)
|
SpeakerRecognition.registry.addNamed(name, emb)
|
||||||
|
lastEmbeddingFast?.let {
|
||||||
|
SpeakerRecognition.fastRegistry.addNamed(name, it)
|
||||||
|
}
|
||||||
relabelSpeakerLines(name)
|
relabelSpeakerLines(name)
|
||||||
Toast.makeText(this, "之后「$name」说话时会显示真名", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "之后「$name」说话时会显示真名", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
@ -748,9 +878,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs on subtitleHandler (serial): never touches UI state, only decodes
|
// Runs on subtitleHandler (serial): never touches UI state, only decodes
|
||||||
// a snapshot of one completed utterance. Trims trailing audio that is
|
// a snapshot of one completed utterance. Trims a graded amount of trailing
|
||||||
// usually the silence/gap that ended the segment; short snapshots keep
|
// audio (usually the gap/silence that ended the segment); segments are
|
||||||
// their tail (it doubles as 前段上下文补偿, 需求二.2).
|
// strictly contiguous (v6: no 0.5 s overlap between clips).
|
||||||
//
|
//
|
||||||
// 需求二(prompt):第一遍草稿经 setConfig 注入 funasrNano.hotwords ——
|
// 需求二(prompt):第一遍草稿经 setConfig 注入 funasrNano.hotwords ——
|
||||||
// native 会把它拼进模型训练时学过的"上下文信息/热词列表"提示模板。
|
// native 会把它拼进模型训练时学过的"上下文信息/热词列表"提示模板。
|
||||||
|
|
@ -869,13 +999,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
bufferTotal = 0
|
bufferTotal = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun keepTailChunks(n: Int) {
|
|
||||||
while (samplesBuffer.size > n) {
|
|
||||||
bufferTotal -= samplesBuffer.removeAt(0).size
|
|
||||||
chunkRms.removeAt(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dropPrefixChunks(n: Int) {
|
private fun dropPrefixChunks(n: Int) {
|
||||||
repeat(minOf(n, samplesBuffer.size)) {
|
repeat(minOf(n, samplesBuffer.size)) {
|
||||||
bufferTotal -= samplesBuffer.removeAt(0).size
|
bufferTotal -= samplesBuffer.removeAt(0).size
|
||||||
|
|
@ -890,6 +1013,13 @@ class MainActivity : AppCompatActivity() {
|
||||||
return Math.sqrt(acc / s.size).toFloat()
|
return Math.sqrt(acc / s.size).toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 句内参考 embedding 的 EMA 混合(7:3),让基准缓慢跟随说话人的
|
||||||
|
// 音量/音色自然漂移,避免"越说越像别人"的连锁误判
|
||||||
|
private fun blendEmb(a: FloatArray, b: FloatArray): FloatArray {
|
||||||
|
val n = minOf(a.size, b.size)
|
||||||
|
return FloatArray(n) { a[it] * 0.7f + b[it] * 0.3f }
|
||||||
|
}
|
||||||
|
|
||||||
private fun flattenSamples(): FloatArray = flattenPrefix(samplesBuffer.size)
|
private fun flattenSamples(): FloatArray = flattenPrefix(samplesBuffer.size)
|
||||||
|
|
||||||
// fresh copy of the first k chunks — never hand the shared buffer to threads
|
// fresh copy of the first k chunks — never hand the shared buffer to threads
|
||||||
|
|
@ -1034,6 +1164,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
labelColorIdx.clear()
|
labelColorIdx.clear()
|
||||||
colorSeq = 0
|
colorSeq = 0
|
||||||
lastLabel = null
|
lastLabel = null
|
||||||
|
lastSentenceSettledAt = SystemClock.elapsedRealtime()
|
||||||
if (n > 0) subtitleAdapter.notifyItemRangeRemoved(0, n)
|
if (n > 0) subtitleAdapter.notifyItemRangeRemoved(0, n)
|
||||||
textView.visibility = View.GONE
|
textView.visibility = View.GONE
|
||||||
subtitleRecycler.visibility = View.VISIBLE
|
subtitleRecycler.visibility = View.VISIBLE
|
||||||
|
|
@ -1053,6 +1184,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
labelColorIdx.getOrPut(label) { colorSeq++ % speakerPalette.size }
|
labelColorIdx.getOrPut(label) { colorSeq++ % speakerPalette.size }
|
||||||
|
|
||||||
private fun appendNewLine(label: String?): SubtitleLine {
|
private fun appendNewLine(label: String?): SubtitleLine {
|
||||||
|
// 新行出现即封掉之前的开放行,并把其中"未定格的进行中句子"整体迁移
|
||||||
|
// 过来 —— 硬保证 in-progress 只可能发生在最底行(修复:声纹判定搬家后,
|
||||||
|
// 旧行里还在长字的句子被孤立在上方的问题)
|
||||||
|
val old = subtitleLines.lastOrNull()
|
||||||
|
val migrating = if (old != null && old.open)
|
||||||
|
old.sentences.filter { !it.finalized } else emptyList()
|
||||||
|
old?.open = false
|
||||||
|
if (migrating.isNotEmpty()) {
|
||||||
|
old?.sentences?.removeAll(migrating.toSet())
|
||||||
|
}
|
||||||
val line = SubtitleLine(
|
val line = SubtitleLine(
|
||||||
id = lineIdSeq.incrementAndGet(),
|
id = lineIdSeq.incrementAndGet(),
|
||||||
label = label,
|
label = label,
|
||||||
|
|
@ -1060,6 +1201,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
)
|
)
|
||||||
subtitleLines.add(line)
|
subtitleLines.add(line)
|
||||||
subtitleAdapter.notifyItemInserted(subtitleLines.size - 1)
|
subtitleAdapter.notifyItemInserted(subtitleLines.size - 1)
|
||||||
|
// 进行中句子接在新行尾部,继续实时更新
|
||||||
|
line.sentences.addAll(migrating)
|
||||||
|
for (s in migrating) s.line = line
|
||||||
|
if (migrating.isNotEmpty()) old?.let { notifyLine(it) }
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1069,8 +1214,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
private fun onPartial(sid: Long, partial: String) {
|
private fun onPartial(sid: Long, partial: String) {
|
||||||
val existing = sentenceById[sid]
|
val existing = sentenceById[sid]
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
val active = subtitleLines.lastOrNull()?.takeIf { it.open }
|
// 只接"最底部的开放行";同人停顿超时则另起新行(用户确认的规则)
|
||||||
?: appendNewLine(lastConfirmedLabel)
|
val last = subtitleLines.lastOrNull()
|
||||||
|
val fresh = SystemClock.elapsedRealtime() -
|
||||||
|
lastSentenceSettledAt < lineIdleTimeoutMs
|
||||||
|
val active = if (last != null && last.open && fresh) last
|
||||||
|
else appendNewLine(lastConfirmedLabel)
|
||||||
val sentence = Sentence(sid, partial, finalized = false)
|
val sentence = Sentence(sid, partial, finalized = false)
|
||||||
sentence.line = active
|
sentence.line = active
|
||||||
active.sentences.add(sentence)
|
active.sentences.add(sentence)
|
||||||
|
|
@ -1089,6 +1238,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
// v5 起声纹判定已移出录音线程)。
|
// v5 起声纹判定已移出录音线程)。
|
||||||
private fun finalizeSentence(sid: Long, willRefine: Boolean) {
|
private fun finalizeSentence(sid: Long, willRefine: Boolean) {
|
||||||
lastSettledSid = sid
|
lastSettledSid = sid
|
||||||
|
lastSentenceSettledAt = SystemClock.elapsedRealtime()
|
||||||
val sentence = sentenceById[sid] ?: return
|
val sentence = sentenceById[sid] ?: return
|
||||||
if (sentence.finalized) return
|
if (sentence.finalized) return
|
||||||
sentence.finalized = true
|
sentence.finalized = true
|
||||||
|
|
@ -1114,36 +1264,70 @@ class MainActivity : AppCompatActivity() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 声纹线程回传的整句权威判定(需求一.2/一.4):
|
// 声纹线程回传的整句权威判定:
|
||||||
// * 与所在行标签不符 → 该句整体迁入新说话人行(原行不含此句);
|
// * 无判定或已同人 → 不动;
|
||||||
// * embedding 回写供"命名说话人";
|
// * 占位行首次判定 → 就地冠名;若满足"保守并回"条件(本行为底行、
|
||||||
// * 仅当该槽仍是最近 settle 的句子才更新全局活跃归属(防旧判定覆盖更新的换人状态)。
|
// 上一行正是判定同人)→ 整行并回上一行,撤销误切的那次换行;
|
||||||
private fun confirmSpeaker(sid: Long, label: String?, emb: FloatArray?) {
|
// * 行已归属他人 → 句子迁出;若误切行因此清空、且它是底行、上一行
|
||||||
|
// 正是判定同人 → 同样并回上方,不再另起新行;
|
||||||
|
// * 其余情况维持原行为(并回底部同人行,没有则在最下方新建)。
|
||||||
|
// * embedding 回写供"命名说话人";仅当该槽是最近 settle 的句子才更新全局归属。
|
||||||
|
private fun confirmSpeaker(
|
||||||
|
sid: Long, label: String?, emb: FloatArray?, fastEmb: FloatArray?,
|
||||||
|
) {
|
||||||
Log.i(TAG, "SLOT sid=$sid VERDICT speaker=" +
|
Log.i(TAG, "SLOT sid=$sid VERDICT speaker=" +
|
||||||
(label?.let { displayName(it) } ?: "未匹配/无判定"))
|
(label?.let { displayName(it) } ?: "未匹配/无判定"))
|
||||||
if (emb != null) {
|
if (emb != null) {
|
||||||
lastEmbedding = emb
|
lastEmbedding = emb
|
||||||
|
lastEmbeddingFast = fastEmb
|
||||||
nameSpeakerButton.isEnabled = true
|
nameSpeakerButton.isEnabled = true
|
||||||
}
|
}
|
||||||
val sentence = findSentence(sid) ?: return
|
val sentence = findSentence(sid) ?: return
|
||||||
val line = sentence.line ?: return
|
var line = sentence.line ?: return
|
||||||
if (label != null && label != line.label) {
|
if (label != null && label != line.label) {
|
||||||
line.sentences.remove(sentence)
|
if (line.label == null) {
|
||||||
line.open = false
|
// 占位行首次判定:整行就地冠名
|
||||||
if (line.sentences.isEmpty()) {
|
line.label = label
|
||||||
val pos = subtitleLines.indexOf(line)
|
line.colorIdx = colorIdxFor(label)
|
||||||
if (pos >= 0) {
|
if (!mergeLineIntoPrevIfSame(line, label)) {
|
||||||
subtitleLines.removeAt(pos)
|
|
||||||
subtitleAdapter.notifyItemRemoved(pos)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
notifyLine(line)
|
notifyLine(line)
|
||||||
}
|
}
|
||||||
val nl = appendNewLine(label)
|
} else {
|
||||||
nl.sentences.add(sentence)
|
line.sentences.remove(sentence)
|
||||||
sentence.line = nl
|
val pos = subtitleLines.indexOf(line)
|
||||||
notifyLine(nl)
|
val wasLast = pos == subtitleLines.size - 1
|
||||||
|
val prevSame = if (pos > 0)
|
||||||
|
subtitleLines[pos - 1].takeIf { it.label == label } else null
|
||||||
|
val lineEmpty = line.sentences.isEmpty()
|
||||||
|
if (lineEmpty && pos >= 0) {
|
||||||
|
subtitleLines.removeAt(pos)
|
||||||
|
subtitleAdapter.notifyItemRemoved(pos)
|
||||||
|
} else if (pos >= 0) {
|
||||||
|
notifyLine(line)
|
||||||
|
}
|
||||||
|
if (lineEmpty && wasLast && prevSame != null) {
|
||||||
|
// 保守并回:误切行清空后撤销该行,句子回到上方同人行
|
||||||
|
Log.i(TAG, "verdict merge-back sid=$sid -> line above")
|
||||||
|
prevSame.sentences.add(sentence)
|
||||||
|
sentence.line = prevSame
|
||||||
|
line = prevSame
|
||||||
|
notifyLine(prevSame)
|
||||||
autoScroll()
|
autoScroll()
|
||||||
|
} else {
|
||||||
|
// 只并回"最底部且同人"的行;否则在最下方新建
|
||||||
|
val target = subtitleLines.lastOrNull()
|
||||||
|
?.takeIf { it.open && it.label == label }
|
||||||
|
?: appendNewLine(label)
|
||||||
|
// 定格句插到该行"进行中句子"之前,保持行内时间序
|
||||||
|
val ins = target.sentences.indexOfFirst { !it.finalized }
|
||||||
|
if (ins >= 0) target.sentences.add(ins, sentence)
|
||||||
|
else target.sentences.add(sentence)
|
||||||
|
sentence.line = target
|
||||||
|
line = target
|
||||||
|
notifyLine(target)
|
||||||
|
autoScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (label != null && sid == lastSettledSid) {
|
if (label != null && sid == lastSettledSid) {
|
||||||
lastConfirmedLabel = label
|
lastConfirmedLabel = label
|
||||||
|
|
@ -1152,6 +1336,30 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保守并回(用户确认的规则,2026-09-10):误切行仍是底行、且它的上一条
|
||||||
|
// 恰好就是判定出的同一个人 → 整行并回上一行后删除。in-progress 句子随行
|
||||||
|
// 迁移,合并后 prev 成为底行,"进行中只存在于最底行"不变量保持成立。
|
||||||
|
// 只撤销"被判定翻转的误切行";60s 超时产生的相邻同人行不在此列
|
||||||
|
// (超时行建档时就带着标签,永远走不到本分支)。
|
||||||
|
private fun mergeLineIntoPrevIfSame(line: SubtitleLine, verdict: String): Boolean {
|
||||||
|
val pos = subtitleLines.indexOf(line)
|
||||||
|
if (pos <= 0 || pos != subtitleLines.size - 1) return false
|
||||||
|
val prev = subtitleLines[pos - 1]
|
||||||
|
if (prev.label != verdict) return false
|
||||||
|
Log.i(TAG, "verdict merge-back: whole line@$pos -> prev(same speaker)")
|
||||||
|
val snapshot = line.sentences.toList()
|
||||||
|
for (s in snapshot) {
|
||||||
|
s.line = prev
|
||||||
|
prev.sentences.add(s)
|
||||||
|
}
|
||||||
|
line.sentences.clear()
|
||||||
|
subtitleLines.removeAt(pos)
|
||||||
|
subtitleAdapter.notifyItemRemoved(pos)
|
||||||
|
notifyLine(prev)
|
||||||
|
autoScroll()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// second-pass refine, 需求二加固:
|
// second-pass refine, 需求二加固:
|
||||||
// * 底稿铁律:refine 只能覆盖对应句;空/异常/超时(看门狗)/折叠误杀/
|
// * 底稿铁律:refine 只能覆盖对应句;空/异常/超时(看门狗)/折叠误杀/
|
||||||
// 明显缩短 → 一律保留第一遍文字,该句标记 sentence.refined=false。
|
// 明显缩短 → 一律保留第一遍文字,该句标记 sentence.refined=false。
|
||||||
|
|
@ -1174,15 +1382,27 @@ class MainActivity : AppCompatActivity() {
|
||||||
result = "REJECT_EXCEPTION"
|
result = "REJECT_EXCEPTION"
|
||||||
note = "底稿保留"
|
note = "底稿保留"
|
||||||
} else {
|
} else {
|
||||||
val collapsed = collapseRepetitions(refined)
|
// 长度锚定(v8.4):底稿是"实时音频能装下多少字"的自然上界。
|
||||||
val candidate =
|
// 精修 ≤ 底稿×1.5+8 → 无膨胀迹象,原文直接用(真说出口的重叠词
|
||||||
if (collapsed.isEmpty() || collapsed.length * 2 < refined.length) {
|
// 如"谢谢谢谢"原样保留,不再误折叠);
|
||||||
note = "折叠误杀回退原文;"
|
// 超出 → 必是 LLM 复读,折叠;折叠后仍超界或为空 → 整条拒绝留底稿。
|
||||||
refined
|
// (修复:旧规则按"折叠量>50%判误杀回退原文",恰好给真复读开了后门)
|
||||||
|
val ceiling = first.length * 3 / 2 + 8
|
||||||
|
var candidate = ""
|
||||||
|
if (refined.length <= ceiling) {
|
||||||
|
candidate = refined
|
||||||
} else {
|
} else {
|
||||||
collapsed
|
note = "精修超长(${refined.length}>ceil $ceiling)疑似复读;"
|
||||||
|
val collapsed = collapseRepetitions(refined)
|
||||||
|
if (collapsed.isNotEmpty() && collapsed.length <= ceiling) {
|
||||||
|
candidate = collapsed
|
||||||
|
note += "折叠后采用"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
|
candidate.isEmpty() -> {
|
||||||
|
result = "REJECT_TOO_LONG"; note += "底稿保留"
|
||||||
|
}
|
||||||
candidate.isBlank() -> {
|
candidate.isBlank() -> {
|
||||||
result = "REJECT_EMPTY"; note = "底稿保留"
|
result = "REJECT_EMPTY"; note = "底稿保留"
|
||||||
}
|
}
|
||||||
|
|
@ -1213,6 +1433,24 @@ class MainActivity : AppCompatActivity() {
|
||||||
activeLabelForWindow = label
|
activeLabelForWindow = label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示);
|
||||||
|
// 没有 base 文件时滚动检测本身回退在准确模型空间 → 直查准确 registry。
|
||||||
|
// 修复:base 缺失时镜像库恒空、换行标签全 null、误断防护失效的问题。
|
||||||
|
private fun rollingSpeakerSearch(emb: FloatArray): String? =
|
||||||
|
if (SpeakerRecognition.fastReady())
|
||||||
|
SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold)
|
||||||
|
else
|
||||||
|
SpeakerRecognition.registry.search(emb, speakerThreshold)
|
||||||
|
|
||||||
|
// ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"):
|
||||||
|
// 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行
|
||||||
|
private fun adoptActiveLabel(label: String) {
|
||||||
|
if (lastConfirmedLabel == null) {
|
||||||
|
lastConfirmedLabel = label
|
||||||
|
activeLabelForWindow = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun notifyLine(line: SubtitleLine) {
|
private fun notifyLine(line: SubtitleLine) {
|
||||||
trimOldLines()
|
trimOldLines()
|
||||||
val pos = subtitleLines.indexOf(line)
|
val pos = subtitleLines.indexOf(line)
|
||||||
|
|
|
||||||
|
|
@ -826,9 +826,10 @@ fun getOfflineModelConfig(type: Int, modelPrefix: String = ""): OfflineModelConf
|
||||||
tokenizer = "$modelDir/Qwen3-0.6B",
|
tokenizer = "$modelDir/Qwen3-0.6B",
|
||||||
),
|
),
|
||||||
tokens = "",
|
tokens = "",
|
||||||
// 2 threads: leave cores for the streaming pass + eres2net so
|
// 定稿提速:LLM 解码 4 线程(≈快一倍)。线程预算:nemotron 2 +
|
||||||
// nano decoding can't starve the first pass (v5 停摆加固)
|
// nano 4 + 声纹 v2 2 + 滚动 base 1;若机身发烫/第一遍变卡,
|
||||||
numThreads = 2,
|
// 退回 2 并改上"积压保险"方案
|
||||||
|
numThreads = 4,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,9 @@ fun getModelConfig(type: Int, modelPrefix: String = ""): OnlineModelConfig? {
|
||||||
joiner = "$modelDir/joiner.int8.onnx",
|
joiner = "$modelDir/joiner.int8.onnx",
|
||||||
),
|
),
|
||||||
tokens = "$modelDir/tokens.txt",
|
tokens = "$modelDir/tokens.txt",
|
||||||
|
// 0.6B 流式模型单线程解码实时余量太薄,定稿/声纹一跑就掉帧;
|
||||||
|
// 2 线程保障第一遍实时性(纯资源参数,不影响精度)
|
||||||
|
numThreads = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,31 +159,45 @@ class SpeakerRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Singleton holder shared by the UI thread and the recording thread.
|
// Singleton holder shared by the UI thread, recording thread and the rolling
|
||||||
|
// speaker-check thread.
|
||||||
|
//
|
||||||
|
// 双模型方案(E):准确模型(eres2netv2)负责 endpoint 权威判定与注册;
|
||||||
|
// 轻量模型(eres2net base,可选)只负责滚动换人检测(推理快 ~4 倍)。
|
||||||
|
// ⚠ 两个模型的 embedding 不互通:各自配一份 registry,注册时双写镜像。
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
object SpeakerRecognition {
|
object SpeakerRecognition {
|
||||||
private var _extractor: SpeakerEmbeddingExtractor? = null
|
@Volatile private var _extractor: SpeakerEmbeddingExtractor? = null
|
||||||
|
@Volatile private var _fastExtractor: SpeakerEmbeddingExtractor? = null
|
||||||
val extractor: SpeakerEmbeddingExtractor
|
val extractor: SpeakerEmbeddingExtractor
|
||||||
get() = _extractor!!
|
get() = _extractor!!
|
||||||
|
|
||||||
|
/** accurate-space registry (endpoint verdict / naming source of truth) */
|
||||||
val registry = SpeakerRegistry()
|
val registry = SpeakerRegistry()
|
||||||
|
/** fast-space mirror (rolling-check search only; may stay empty) */
|
||||||
|
val fastRegistry = SpeakerRegistry()
|
||||||
|
|
||||||
var autoCounter: Int = 0
|
var autoCounter: Int = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// autoCounter is touched from the recording thread (identifySpeaker) while
|
private val accLock = Any()
|
||||||
// resetSpeakers runs on the UI thread — keep them serialized.
|
private val fastLock = Any()
|
||||||
|
|
||||||
|
// autoCounter is touched from worker threads while resetSpeakers runs on
|
||||||
|
// the UI thread — keep them serialized.
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun nextAutoLabel(): String = "S${autoCounter++}"
|
fun nextAutoLabel(): String = "S${autoCounter++}"
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun resetSpeakers() {
|
fun resetSpeakers() {
|
||||||
registry.clear()
|
registry.clear()
|
||||||
|
fastRegistry.clear()
|
||||||
autoCounter = 0
|
autoCounter = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// assetManager = null -> `model` is treated as an absolute file system path
|
// assetManager = null -> `model` is treated as an absolute file system path
|
||||||
fun initExtractor(assetManager: AssetManager?, model: String) {
|
fun initExtractor(assetManager: AssetManager?, model: String) {
|
||||||
synchronized(this) {
|
synchronized(accLock) {
|
||||||
if (_extractor != null) return
|
if (_extractor != null) return
|
||||||
Log.i(TAG, "Initializing speaker embedding extractor with $model")
|
Log.i(TAG, "Initializing speaker embedding extractor with $model")
|
||||||
_extractor = SpeakerEmbeddingExtractor(
|
_extractor = SpeakerEmbeddingExtractor(
|
||||||
|
|
@ -198,13 +212,45 @@ object SpeakerRecognition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute one embedding from a complete utterance (float mono @ 16 kHz).
|
// Optional lightweight model for the rolling speaker-change detector.
|
||||||
// Returns null when the utterance is too short for the model to consume.
|
fun initFastExtractor(assetManager: AssetManager?, model: String) {
|
||||||
// Serialized: the native extractor holds one session shared by the
|
synchronized(fastLock) {
|
||||||
// recording thread and the rolling-window checker thread.
|
if (_fastExtractor != null) return
|
||||||
@Synchronized
|
Log.i(TAG, "Initializing FAST speaker embedding extractor with $model")
|
||||||
fun computeEmbedding(samples: FloatArray, sampleRate: Int): FloatArray? {
|
_fastExtractor = SpeakerEmbeddingExtractor(
|
||||||
|
assetManager = assetManager,
|
||||||
|
config = SpeakerEmbeddingExtractorConfig(
|
||||||
|
model = model,
|
||||||
|
numThreads = 1,
|
||||||
|
debug = false,
|
||||||
|
provider = "cpu",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fastReady(): Boolean = _fastExtractor != null
|
||||||
|
|
||||||
|
// Compute one embedding with the accurate extractor (endpoint verdicts).
|
||||||
|
fun computeEmbedding(samples: FloatArray, sampleRate: Int): FloatArray? =
|
||||||
|
synchronized(accLock) {
|
||||||
val ext = _extractor ?: return null
|
val ext = _extractor ?: return null
|
||||||
|
runCompute(ext, samples, sampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling-check embedding: fast space when available, else accurate space
|
||||||
|
// (in the latter case both registries see the same space anyway).
|
||||||
|
fun computeEmbeddingFast(samples: FloatArray, sampleRate: Int): FloatArray? {
|
||||||
|
val fast = _fastExtractor
|
||||||
|
return if (fast == null) computeEmbedding(samples, sampleRate)
|
||||||
|
else synchronized(fastLock) { runCompute(fast, samples, sampleRate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCompute(
|
||||||
|
ext: SpeakerEmbeddingExtractor,
|
||||||
|
samples: FloatArray,
|
||||||
|
sampleRate: Int,
|
||||||
|
): FloatArray? {
|
||||||
if (samples.isEmpty()) return null
|
if (samples.isEmpty()) return null
|
||||||
val stream = ext.createStream()
|
val stream = ext.createStream()
|
||||||
stream.acceptWaveform(samples = samples, sampleRate = sampleRate)
|
stream.acceptWaveform(samples = samples, sampleRate = sampleRate)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue