AudioPrecheck.kt

package com.shengzhiai.yugu.audio

import com.shengzhiai.yugu.LocalWarning
import com.shengzhiai.yugu.errors.ErrorTable
import java.util.Locale
import kotlin.math.log10
import kotlin.math.sqrt

/** Running peak and RMS over 16-bit little-endian PCM, fed chunk by chunk. */
internal class PcmStats {
    var samples: Long = 0
        private set
    var peak: Int = 0
        private set
    private var sumSquares = 0.0
    private var carry = -1 // low byte waiting for its high byte when a chunk has odd length

    fun add(b: ByteArray, off: Int = 0, len: Int = b.size) {
        var i = off
        val end = off + len
        if (carry >= 0 && i < end) {
            sample((carry or (b[i].toInt() shl 8)).toShort().toInt())
            carry = -1
            i++
        }
        while (i + 1 < end) {
            sample(((b[i].toInt() and 0xFF) or (b[i + 1].toInt() shl 8)).toShort().toInt())
            i += 2
        }
        if (i < end) carry = b[i].toInt() and 0xFF
    }

    private fun sample(v: Int) {
        val a = if (v < 0) -v else v
        if (a > peak) peak = a
        sumSquares += v.toDouble() * v
        samples++
    }

    val rms: Double get() = if (samples == 0L) 0.0 else sqrt(sumSquares / samples)

    /** RMS in dBFS relative to 32768, -inf for silence. */
    val dbfs: Double get() = if (rms <= 0.0) Double.NEGATIVE_INFINITY else 20.0 * log10(rms / 32768.0)
}

/**
 * Local audio precheck (DESIGN 2.9). Thresholds equal the platform silence guard, so audio the
 * SDK rejects as silent is audio the platform would have scored 0.
 */
internal object AudioPrecheck {
    /** Server limit for one REST upload. */
    const val MAX_UPLOAD_BYTES = 50L * 1024 * 1024

    /** Limit for the audio of one streaming round. */
    const val MAX_STREAM_BYTES = 10L * 1024 * 1024
    const val MIN_DURATION_MS = 1_000L
    const val MAX_DURATION_MS = 300_000L
    const val SILENT_PEAK = 200
    const val SILENT_RMS = 30.0
    const val LOW_VOLUME_DBFS = -45.0
    const val MIN_SAMPLE_RATE = 16_000

    const val TOO_SHORT = 90101
    const val TOO_LONG = 90102
    const val SILENT = 90103
    const val LOW_VOLUME = 90104
    const val FORMAT = 90105

    private fun fmt1(v: Double): String = String.format(Locale.ROOT, "%.1f", v)

    /** 90104 never blocks an upload, the other findings do in REJECT mode. */
    fun rejectable(code: Int): Boolean = code != LOW_VOLUME

    private fun warning(code: Int, detail: String): LocalWarning =
        LocalWarning(code, (ErrorTable.LOCAL[code]?.message ?: "audio $code") + ":" + detail)

    /** Checks a whole upload. [rawPcm] marks headerless PCM of the given format. */
    fun checkUpload(bytes: ByteArray, rawPcm: Boolean, sampleRate: Int, channels: Int): List<LocalWarning> {
        val out = ArrayList<LocalWarning>()
        if (bytes.size > MAX_UPLOAD_BYTES) out.add(warning(TOO_LONG, "大小 ${bytes.size} 字节,整段上传上限 50 MB"))
        if (rawPcm) {
            val stats = PcmStats().apply { add(bytes) }
            out.addAll(checkPcm(stats, bytes.size.toLong(), sampleRate, channels, sizeChecked = true))
            return out
        }
        if (!WavFormat.isWav(bytes)) return out // other containers: size check only
        val info = WavFormat.parse(bytes)
        if (info == null) {
            out.add(warning(FORMAT, "WAV 头无法解析"))
            return out
        }
        if (!info.isPcm16 || info.sampleRate < MIN_SAMPLE_RATE || info.channels <= 0) {
            out.add(warning(FORMAT, "格式 ${info.formatTag},${info.bitsPerSample} 位,${info.sampleRate} Hz"))
            return out
        }
        val stats = PcmStats().apply { add(bytes, info.dataOffset, info.dataLength) }
        out.addAll(checkPcm(stats, info.dataLength.toLong(), info.sampleRate, info.channels, sizeChecked = true))
        return out
    }

    /** Checks accumulated 16-bit PCM, used for uploads and for a stream at end(). */
    fun checkPcm(stats: PcmStats, pcmBytes: Long, sampleRate: Int, channels: Int, sizeChecked: Boolean = false): List<LocalWarning> {
        val out = ArrayList<LocalWarning>()
        if (sampleRate < MIN_SAMPLE_RATE) {
            out.add(warning(FORMAT, "采样率 $sampleRate Hz"))
            return out
        }
        val bytesPerSecond = sampleRate.toLong() * channels * 2
        val durationMs = if (bytesPerSecond == 0L) 0 else pcmBytes * 1000 / bytesPerSecond
        if (!sizeChecked && pcmBytes > MAX_STREAM_BYTES) out.add(warning(TOO_LONG, "大小 $pcmBytes 字节,实时评测一轮上限 10 MB"))
        if (durationMs > MAX_DURATION_MS && out.none { it.code == TOO_LONG }) {
            out.add(warning(TOO_LONG, "时长 ${durationMs / 1000.0} 秒,上限 300 秒"))
        }
        if (durationMs < MIN_DURATION_MS) out.add(warning(TOO_SHORT, "时长 ${durationMs / 1000.0} 秒"))
        if (stats.samples > 0 || pcmBytes == 0L) {
            if (stats.peak < SILENT_PEAK && stats.rms < SILENT_RMS) {
                out.add(warning(SILENT, "峰值 ${stats.peak},均方根 ${fmt1(stats.rms)}"))
            } else if (stats.dbfs < LOW_VOLUME_DBFS) {
                out.add(warning(LOW_VOLUME, "均方根电平 ${fmt1(stats.dbfs)} dBFS,低于 -45 dBFS"))
            }
        }
        return out
    }
}