本文档着重介绍 Soundly 项目内 DSP 流程的代码架构逻辑实现细节,周边配套逻辑不做说明
一、为什么要有 DSP 工具?
录音场景下无法确保周边环境绝对安静,导致生成的录音文件会有杂声、噪音等无关主题内容的声音,影响用户收听最终产物的体验。常见问题真实场景有博客录音、视频会议、VLog等,这些场景一般情况下都会伴随周边杂声等。
DSP 工具可以针对有杂声、噪音的音频文件,进行音频解析、噪声识别、降噪、声音优化、再生成优化后的音频产物这几个流程,完成整个优化过程。
二、框架、流程、步骤
flowchart TD
A[开始] --> B[选择音频文件 WAV / MP4 / MP3]
B --> C[解析音频文件生成原始PCM数据]
C --> D[分析音频特征]
D --> E[确定DSP策略]
E --> F[根据DSP策略配置音频处理流水线参数]
F --> G[根据流水线参数重新生成新的PCM数据]
G --> H[根据新的PCM数据生成WAV音频文件]
H --> I[音频处理结束]
三、核心步骤、算法逻辑
1、选择音频文件
此部分不涉及核心逻辑,按常规获取本地文件方法处理即可
2、解析音频文件
此部分会涉及到 MediaExtractor 对媒体文件的信息解释,该组件是 Android 系统自带的多媒体 这个步骤会把媒体文件的相关信息解析出来,如 mime、码率、声道数量 等 处理完音频文件后,会把音频数据转化为 PCM 原始数据,方便后续进一步处理
fun decode(uri: Uri): PcmData {
val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
val trackIndex = selectAudioTrack(extractor)
require(trackIndex >= 0) {"No audio track found"}
extractor.selectTrack(trackIndex)
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME)!!
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
Timber.i("mime=$mime, sampleRate=$sampleRate, channelCount=$channelCount")
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
codec.start()
val pcmBuffer = ArrayList<Float>(1024 * 1024)
val bufferInfo = MediaCodec.BufferInfo()
var isEos = false
while (true) {
// --------- 输入 ----------
if (!isEos) {
val inputIndex = codec.dequeueInputBuffer(10_000)
if (inputIndex >= 0) {
val inputBuffer = codec.getInputBuffer(inputIndex)!!
val size = extractor.readSampleData(inputBuffer, 0)
if (size < 0) {
codec.queueInputBuffer(
inputIndex,
0,
0,
0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
isEos = true
} else {
codec.queueInputBuffer(
inputIndex,
0,
size,
extractor.sampleTime,
0
)
extractor.advance()
}
}
}
// -------- 输出 ----------
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000)
when {
outputIndex >= 0 -> {
val outputBuffer = codec.getOutputBuffer(outputIndex)!!
val pcmChunk = decodeToFloatPcm(
buffer = outputBuffer,
size = bufferInfo.size,
channelCount = channelCount
)
pcmBuffer.addAll(pcmChunk.toList())
codec.releaseOutputBuffer(outputIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
break
}
}
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
// k可读取新 format (例如 sampleRate 变化)
}
}
}
codec.stop()
codec.release()
extractor.release()
// 重新计算 channel
val finalPcm: FloatArray
val finalChannel: Int
if (channelCount == 2) {
finalPcm = stereoToMono(pcmBuffer.toFloatArray())
finalChannel = 1
} else {
finalPcm = pcmBuffer.toFloatArray()
finalChannel = 1
}
return PcmData(
pcm = finalPcm,
sampleRate = sampleRate,
channels = finalChannel
)
}
3、分析音频特征
这个步骤会根据原始 PCM 数据,按照以下条件得出音频的特征信息
- RMS(整体响度)
- Zero Crossing Rate(嘈杂 / 高频程度)
- Spectral Flatness(声音类型近似值)
- Speech Confidence(人声可信度)
data class AudioFeatures(
val rms: Float, // 整体响度
val zeroCrossingRate: Float, // 嘈杂程度
val spectralFlatness: Float, // 0.0 ~ 0.3 有结构(人声),0.5 ~ 1.0 (白噪声/风声)
val speechConfidence: Float, // 0 ~ 1 人声可信度
val isValid: Boolean, // 是否可用
)
override fun analyze(
pcm: FloatArray,
sampleRate: Int
): AudioFeatures {
// ========= 基础合法性校验 =========
if (pcm.isEmpty() || sampleRate <= 0) {
return invalid()
}
// -------- 1. RMS(整体响度)--------
var energySum = 0f
for (v in pcm) {
energySum += v * v
}
val rms = sqrt(energySum / pcm.size)
// -------- 2. Zero Crossing Rate(嘈杂 / 高频程度)--------
var zeroCrossings = 0
for (i in 1 until pcm.size) {
if (pcm[i - 1] * pcm[i] < 0) {
zeroCrossings++
}
}
val zeroCrossingRate =
zeroCrossings.toFloat() / pcm.size.toFloat()
// -------- 3. Spectral Flatness(工程近似版)--------
// 用“瞬时变化能量 / 总能量”来近似
var diffEnergy = 0f
for (i in 1 until pcm.size) {
val diff = pcm[i] - pcm[i - 1]
diffEnergy += diff * diff
}
val spectralFlatness =
(diffEnergy / (energySum + 1e-9f))
.coerceIn(0f, 1f)
// -------- 4. Speech Confidence(人声可信度)--------
val speechConfidence = estimateSpeechConfidence(
rms = rms,
zcr = zeroCrossingRate,
flatness = spectralFlatness
)
val valid = rms.isFinite() &&
spectralFlatness.isFinite() &&
zeroCrossingRate.isFinite() &&
speechConfidence.isFinite()
return AudioFeatures(
rms = rms,
spectralFlatness = spectralFlatness,
zeroCrossingRate = zeroCrossingRate,
speechConfidence = speechConfidence,
isValid = valid
)
}
4、确定 DSP 策略
当前有多种固定的 DSP 策略可以提前选择,分别是
- 原始音频(完全不处理)
- 默认增强(产品默认)
- 人声稳态增强
- 清晰优先
- 自然顺滑
- 播客/访谈场景
- 会议/电话场景
- 音乐/嘈杂场景
- AI增强
1 - 8 选项是已经成型的固定参数配置选项
第 9 项 AI增强 为根据当前音频特征云端分析后下发适配参数(待扩展内容)
5、根据 DSP 策略配置音频处理流水线参数
DSP 策略本质上是针对流水线上4个处理节点下发的配置参数,4个处理节点分别为
- 降噪节点
- 均衡器节点
- 压缩节点
- 最大允许振幅节点
当前逻辑架构是流水线形式,不一定4个节点都要进入处理,可以跳过当中任意1个或多个节点,跳过的节点会保持原音频的数据特征。
对应节点参数安全区间
NoiseReduction.strength 0.05 ~ 0.30 过高会导致水声 / 能量掏空
NoiseReduction.noiseFloor 0.008 ~ 0.025 低于下限关键频噪被带进人声
Eq.lowGain 0.90 ~ 1.05 低频过高→浑厚 过低→薄
Eq.midGain 1.00 ~ 1.15 核心人声频段,过高刺耳
Eq.highGain 0.95 ~ 1.10 高频太高→刺耳;太低→沉闷
Compressor.thresholdDb 0.60 ~ 0.75 阈值越小越容易压顶
Compressor.ratio 1.0 ~ 1.4 太大压缩推进→炸音
Limiter.limit 0.985 ~ 1.000 过低 → 人声闷;过高 → 不保护
6、DSP 参数确定后,进入流水线进行音频数据再生产,最终成品为处理后的 PCM 数据
- 降噪节点 NoiseReductionNode
override fun process(input: FloatArray, sampleRate: Int): FloatArray {
val p = preset ?: return input
val strength = p.strength ?: 0.2f
val noiseFloor = p.noiseFloor ?: 0.015f
val output = FloatArray(input.size)
for (i in input.indices) {
val s = input[i]
output[i] =
if (abs(s) < noiseFloor) {
s * (1f - strength)
} else {
s
}
}
return output
}
- 均衡器节点 EqNode
override fun process(input: FloatArray, sampleRate: Int): FloatArray {
val p = preset ?: return input
val low = p.lowGain ?: 1.0f
val mid = p.midGain ?: 1.05f
val high = p.highGain ?: 1.0f
val gain = (low + mid + high) / 3f
return FloatArray(input.size) {
(input[it] * gain).coerceIn(-1f, 1f)
}
}
- 压缩节点 CompressorNode
override fun process(input: FloatArray, sampleRate: Int): FloatArray {
val p = preset ?: return input
val threshold = p.threshold ?: 0.7f
val ratio = p.ratio ?: 1.8f
val out = FloatArray(input.size)
for (i in input.indices) {
val s = input[i]
val abs = kotlin.math.abs(s)
out[i] =
if (abs > threshold) {
val excess = abs - threshold
(threshold + excess / ratio) * kotlin.math.sign(s)
} else s
}
return out
}
- 最大允许振幅节点
override fun process(input: FloatArray, sampleRate: Int): FloatArray {
val limit = preset?.limit ?: 0.95f
return FloatArray(input.size) {
input[it].coerceIn(-limit, limit)
}
}
7、将处理后的 PCM 数据,压缩为 wav 文件
此处为常规文件格式转换逻辑,无特殊加工
fun write(
pcm: FloatArray,
sampleRate: Int,
channels:Int,
fileName: String
): File {
Timber.i("Writing WAV sampleRate=$sampleRate, pcmSize=${pcm.size}")
val durationSec = pcm.size.toFloat() / sampleRate
Timber.i("PCM duration ~ ${durationSec}s")
val pcm16 = floatToPcm16(pcm)
val dataSize = pcm16.size * 2
val bitsPerSample = 16
val blockAlgin = channels * (bitsPerSample / 8)
val byteRate = sampleRate * blockAlgin
val file = File(outputDir, "$fileName.wav")
FileOutputStream(file).use { out ->
// RIFF header
writeString(out, "RIFF")
writeInt(out, 36 + dataSize)
writeString(out, "WAVE")
// fmt chunk
writeString(out, "fmt ")
writeInt(out, 16)
writeShort(out, 1) // PCM
writeShort(out, channels.toShort()) // Mono
writeInt(out, sampleRate)
writeInt(out, byteRate)
writeShort(out, blockAlgin.toShort())
writeShort(out, bitsPerSample.toShort())
// data chunk
writeString(out, "data")
writeInt(out, dataSize)
// PCM data
for (s in pcm16) {
writeShort(out, s)
}
}
return file
}
经过7个步骤,即可将导入的原始音频进行优化加工,得到优化后的音频文件。可对处理后的文件进行播放、转发、再生产等