吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3707|回复: 49
收起左侧

[其他原创] 音频采样率转换的研究与Rust代码实现

  [复制链接]
DEATHTOUCH 发表于 2024-8-30 17:57
本帖最后由 DEATHTOUCH 于 2024-10-15 15:06 编辑

采样率转换

前言

两年前,我简单研究了 Direct SoundWASAPI 的使用方法,发现了 WASAPI 的 IAudioClient 接口的 GetMixFormat 可以获取系统内部混音器结构。但是在调用 Initialize 时如果设置的 wav 格式不符合系统内部混音器结构会导致调用失败。然而,在其 flags 参数添加 AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM 就可以解决这个问题。后来发现还有一个 AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY 说是可以获得更好的采样率转换质量。

因此我开始好奇采样率转换是如何进行的,并在当时进行了一些研究,阅读了一些代码,又照猫画虎对着文献写了些代码,能够跑起来但设计上存在问题,且质量非常一般。由于了解到存在知识盲区,因此就暂时搁置,直到最近我又重新研究了这个话题。

简介

采样率转换(Sample Rate Conversion)通常也称作重采样(Resample),是对已有的数字信号采样进行处理的一种手段,在音频、图像处理等领域比较常见。本文将要讨论的内容,主要是在音频处理领域的常见采样率转换方法。

相信对于很多人来说,采样率转换似乎并不常见,但实际上你的手机、电脑每天都在运行相关的代码。在音频领域,只要手机或电脑在播放声音,那么系统极有可能会进行采样率转换的操作;在图像领域,对图片或视频的缩放就会对像素进行处理。

从音频角度来说,声卡驱动通常会提供多种不同的输出设置,如 44100Hz 16bit、48000Hz 24bit 等。但是系统通常只允许用户选择一种选项,可能是声卡不支持多采样率,也可能是系统不支持。因此当播放的音频文件采样率与系统设置的采样率不同时就涉及到采样率转换了。

在音乐行业,常见的 CD 音频标准是 44100Hz 16bit;而在影视行业,通常采用 48000Hz 的压缩格式(如 AAC)。比如使用音乐软件听歌,大部分情况下都是 44100Hz 的音频;而视频网站的视频内置的音频几乎都是 48000Hz 的。这种情况下,用户可以手动切换声卡设置,或者使用更专业的播放器,采用独占音频(一般是 WASAPI 或者是 ASIO)的方式使得声卡按照音频文件的采样率播放,从而达到无损的效果。而对于大部分普通板载声卡的驱动,可能只提供了一种采样率标准且甚至无法切换,因此必须使用采样率转换。

由于大部分声卡提供的输出采样率标准通常是 44100Hz 和 48000Hz,本文主要也着重于这二者之间的转换,但也会研究 96000Hz 的下采样问题(Hi-Res 通常要求至少 96k/24bit)。注意使用的算法其实是通用的,但是针对不同的采样率需要调整特定的参数才能达到最好的效果。

曾经的安卓系统就因为其内置的采样率转换而为人诟病,彼时的骁龙芯片更是让听歌体验变得极度糟糕。但是对于听歌来说,在音源已经达到 CD 标准的时候,好的播放和收听设备的影响显然是明显大于音源的。而且对于所谓的 Hi-Res,"母带级" 等音质,实际上更高的采样率对于听歌的来说毫无意义(也就是在音乐发行环节,参考此文:https://people.xiph.org/~xiphmont/demo/neil-young.html),但对于音乐的制作环节还是有价值的。

前置知识

首先要明确的是,本文所需的知识主要来自数字信号处理(Digital Signal Processing)领域,建议掌握一定的基础,以便于了解本文内容。推荐书籍有奥本海姆的《信号与系统》和《离散时间信号处理》,当然其他的书籍也是可以的。这些书籍的内容不必全都了解(我自己就没看太多),根据需要即可。下面列出重点内容:

  1. 傅里叶变换、时域和频域的关系
  2. 采样定理及相关内容
  3. 滤波器,特别是 FIR 滤波器

其中 1 点应该算是广为人知的了。2 和 3 点则是对于后面部分的重点,也是本文最重要的部分。

当然还需要一定代码能力,本文不涉及 MATLAB 相关内容,而是使用 Rust 语言。当然不会很复杂的,因为我自己也是属于 Rust 初学者,所以有很多地方都不了解,写这个也是练练手。而且总体代码量不大,也可以轻松的用 C/C++ 等其他语言实现。此外,还需要一定的 Python 基础,因为分析时需要使用。

采样率转换的方法

如果你看过上面说的基本书籍,通常会给出采样率转换相关的内容。一般来说,对于采样率从高到低的过程,称为减采样(或者下采样downsampling);反之则称为增采样(或者上采样upsampling)。而具体的实施需要低通滤波器的配合,这样的过程则称为抽取decimation)和内插interpolation)。

上述过程都是针对整数倍数转换的,也就是说只能从 44100Hz 到 22050Hz,或者反过来。而要实现非整数倍的转换,则可以级联内插器和抽取器。比如从 44100Hz 到 48000Hz,可以先找出最大公因数 300,从而得出需要内插系数 L=160,抽取系数 M=147。

为了克服巨大的系数,就出现了多采样率信号处理的方法。比如对于前面的 L=160,可以分解成 L1=40 和 L2=4;而 M=147 可以分解为 M1=49 和 M2=3。当然具体的分解方法有详细的论证和严格的数学证明,这里只是举个例子。这样的好处是可以减少计算量,因为对于带限信号,44100Hz 采样率下的最大有效频率是 22050Hz,在 L=160 的情况下,滤波器为了能抑制镜像频率需要较高的阶数,需要很大的计算量。而先以 L1=40 进行内插可以允许一定的镜像频率,因为在下一级 L2=4 时内插时可以使用相对较高阶数的滤波器来去除这个镜像频率。这样下来总体需要的计算量是远远小于不分解的情况。同理在抽取的时候,第一级抽取可以允许一定的混叠,因为第二级可以进行过滤。

当然以上都是书中提到的方法,在实际的使用中并不一定是最合适的。比如按照上面的描述,我们在实现 44100Hz 和 48000Hz 采样率互相转换的时候,需要复杂的多级系统,实现起来也是相当的麻烦。

比如我们有时候只是为了实现简单,快速的转换,完全可以使用基本的线性插值加 IIR 滤波器。而对于较高要求的情况,则可以使用精度更插值算法,如 sinc 函数插值。文献 https://ccrma.stanford.edu/~jos/resample/resample.html 给出了一种基于 sinc 函数的带限插值的算法,网站 https://src.infinitewave.ca/ 提供了各种常见软件的各项技术指标。

正如文献所说,插值算法有拉格朗日插值、三次样条插值、贝塞尔样条插值等,但是这些算法对于图像的优秀效果并不适用于音频。对于音频来说可听性是最重要的,因此文献提出了一种公共领域的算法,用来实现带限信号的插值。该算法主要使用 sinc 函数插值,并针对软件和硬件设备进行了优化,有较高的性能且实现简单,使用灵活。本文也针对这种算法进行了研究,并分析了具体的细节。

如何判断转换的好坏

对于采样率转换来说,通常有很多的指标,通常是检测噪音的引入程度。细分一下包括对无关频率的影响、混叠和镜像频率的产生等。前面提供的网站给出了详细的技术指标,本文主要测试 1kHz 固定音调的正弦音频,以及频率在 $(0, F_S/2)Hz$ 的以二次函数变化的正弦音频(其中 $Fs$ 表示采样率)。前者通常称为 beep,后者则称为 sweep

为了能够准确地反映转换的质量,需要使用特定的工具来显示音频文件的频谱Spectrum)以及 Spectrogram(可译为语谱图,也通常叫做时频图或者频谱图)。反正名字挺乱的不同的人可能说的不一样,建议以英文为主。其中 Spectrum 通常是对于某一时间段(比如 1024 个样本)或者全部样本的傅里叶变换的结果,对于 beep 音频来说很合适;而 Spectrogram 则是关于时间、频率和功率的图,功率用颜色表示,通过短时傅里叶变换实现,非常适合 sweep 音频。

除此之外,对于 sinc 插值方法,还需要使用冲激函数测试其滤波器的性能。冲激函数 $\delta(t)$ 在数学上的表述是当 $t=0$ 时,$\delta(t)=\infty$;当 $t \ne 0$ 时,$\delta(t)=0$;且 $\int_{t=-\infty}^{\infty}\delta(t)dt=1$ 。不过实际上,我们只需要让 $x[0]=1$ ,让其他值都为 0 就行了,在需要的时候进行一定的缩放。

生成测试文件

由于需要生成一个固定频率的正弦波和频率逐渐变化的正弦波,所以仅使用简单的 sin 函数显然无法满足要求。我们需要一个振荡器Oscillator)来生成波形,这并不难实现。考虑正弦函数的特性,我们知道其相位在 $(0, 2\pi)$ 之间,幅度在 $(0, 1)$ 之间,频率则取决于周期 $T$。其生成公式为 $x[n]=Asin(\omega n+\varphi)$,其中 $A$ 表示振幅,$\omega=2\pi / T$$\varphi$ 表示初始相位,$n$ 是表示样本的整数。

由于数字音频的特点,实际的相位、频率和采样率相关。已知采样率 $F_S$,要求频率 $F_A$,显然周期 $T=F_S/F_A$,带入可得 $\omega=2\pi F_A / F_S$ 。考虑到 sin 函数的周期性,我们可以在相位超过 $2\pi$ 时减去 $2\pi$,一般我们使用 $\tau$ 来表示 $2\pi$

因此代码可以这样来写:

use std::f64::consts::TAU; // 等于 2 * PI

struct Osc {
    phase: f64, // 相位
    omega: f64, // 角频率
    freq: f64,  // 目标频率
    sample_rate: f64, // 采样率
}

impl Osc {
    fn init(sample_rate: f64) -> Self {
        Self {
            phase: 0.0,
            omega: 0.0,
            freq: 0.0,
            sample_rate,
        }
    }

    fn set_freq(&mut self, freq: f64) {
        self.freq = freq;
        self.omega = TAU * freq / self.sample_rate;
    }

    fn next(&mut self) -> f64 {
        let sample = self.phase.sin(); // 计算样本
        self.phase += self.omega;
        while self.phase >= TAU { // 用 while 是为了防止设定过高的频率
            self.phase -= TAU;
        }
        sample
    }
}

生成采样之后,还需要写入文件才行,这里我们使用了 Rust 库 hound,这个库提供了完整的 wav 文件的读写操作,支持整数和 32 位浮点数。为了减少量化带来的损失,同时方便操作,我们使用 32 位浮点数来保存文件。

下面是生成 beep 和 sweep 文件的代码:

use hound::{WavSpec, WavWriter};

fn gen_beep(sample_rate: u32) {
    let filename = format!("beep_{}k.wav", sample_rate / 1000);
    let spec = WavSpec {
        channels: 1,
        sample_rate,
        bits_per_sample: 32,
        sample_format: hound::SampleFormat::Float,
    };
    let mut writer = WavWriter::create(filename, spec).unwrap();
    let mut osc = Osc::init(sample_rate as f64);
    osc.set_freq(1000.0);
    let sample_count = sample_rate * 5;
    for _ in 0..sample_count {
        let sample = osc.next() * 0.99;
        writer.write_sample(sample as f32).unwrap();
    }
    writer.finalize().unwrap();
}

fn gen_sweep(sample_rate: u32) {
    let filename = format!("sweep_{}k.wav", sample_rate / 1000);
    let spec = WavSpec {
        channels: 1,
        sample_rate,
        bits_per_sample: 32,
        sample_format: hound::SampleFormat::Float,
    };
    let mut writer = WavWriter::create(filename, spec).unwrap();
    let mut osc = Osc::init(sample_rate as f64);
    let sample_count = sample_rate * 5;
    let nyquist_freq = sample_rate as f64 / 2.0;
    for i in 0..sample_count {
        osc.set_freq(nyquist_freq * (i as f64 / sample_count as f64).powi(2));
        let sample = osc.next() * 0.99;
        writer.write_sample(sample as f32).unwrap();
    }
    writer.finalize().unwrap();
}

gen_beep 能生成指定采样率下一段长 5 秒的 1kHz 的正弦单声道音频,听起来就像嘟~的声音,为了避免将来转换时可能出现的振幅过大的问题,最后还将振幅乘了 0.99 以减少失真的可能性,这样实际最大音量约是 -0.1dBFS,因为通常我们假定 ±1 是一般系统能处理的最大值。gen_sweep 则是能生成指定采样率下同样规格的文件,区别是正弦的频率是不断变化的,从 0Hz 到奈奎斯特频率,振幅也同样进行了限制。

检测音频并绘制图像

由于生成的音频文件仅通过播放是几乎无法听出具体的转换结果是否符合预期,因此使用专业的工具进行分析是很重要的。一般来说这个时候应该请出 MATLAB 了,不过相比这个大家伙,我觉得还是 Python 更加灵活。我们使用 numpyscipymatplotlib.pyplot 来进行分析和绘制。

对于前面提到的 Spectrum,我们只需要对文件进行 FFT 就可以得到了;而 Spectrogram 则是需要使用 STFT 进行分析。除此之外还专门在 Spectrum 的基础上进行了一定的修改,以展示滤波器的冲激响应。具体代码如下:

import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile
from scipy.signal import ShortTimeFFT
from scipy.signal.windows import kaiser

def _show_spectrum(fs, data, name, impulse: None | str = None):
    passband = impulse == 'passband'
    N = len(data)
    half_N = N // 2
    fft_data = abs(np.fft.fft(data))
    fft_data = fft_data / half_N if impulse is None else fft_data / max(fft_data)
    fft_dBFS = 20 * np.log10(fft_data)
    freqs = np.fft.fftfreq(N, 1/fs)
    plt.figure(figsize=(6, 4))
    xticks = np.arange(0, fs // 2 + 1, 2000)
    xticklabels = [f'{int(tick/1000)}' for tick in xticks]
    ymin, ymax, ystep = (-3, 1, 0.5) if passband else (-200, 10, 20)
    ax = plt.gca()
    ax.set(xlabel='Frequency in kHz', ylabel='Magnitude in dBFS',
           xlim=(0, fs//2), ylim=(ymin, ymax),
           xticks=xticks, yticks=np.arange(ymin, ymax, ystep),
           xticklabels=xticklabels, facecolor='black')
    ax.plot(freqs[:half_N], fft_dBFS[:half_N], color='white')
    ax.grid()
    prefix = 'Passband of ' if passband else 'Spectrum of '
    plt.title(prefix + name)
    plt.show()

# 显示wav文件频谱
def show_wav_spectrum(filename):
    fs, data = wavfile.read(filename)
    _show_spectrum(fs, data, filename)

# 显示wav冲激响应频谱
def show_wav_impulse(filename, passband=False):
    fs, data = wavfile.read(filename)
    _show_spectrum(fs, data, filename, impulse='passband' if passband else '')

# 显示原始f64数据冲激响应频谱
def show_raw_impulse(filename, fs, passband=False):
    data = np.fromfile(filename, np.float64)
    _show_spectrum(fs, data, filename, impulse='passband' if passband else '')

# 显示wav文件spectrogram
def show_wav_spectrogram(filename):
    fs, data = wavfile.read(filename)
    N = len(data)
    window_size = 2048
    hop = window_size // 2
    win = kaiser(window_size, 20)
    SFT = ShortTimeFFT(win, hop, fs, scale_to='magnitude')
    Sx = SFT.stft(data)
    fig = plt.figure(figsize=(6, 4))
    ax = plt.gca()
    yticks = np.arange(0, fs // 2 + 1, 2000)
    yticklabels = [f'{int(tick/1000)}' for tick in yticks]
    ax.set(xlabel='Time in seconds', ylabel='Frequency in kHz',
        yticks=yticks, yticklabels=yticklabels)
    im = ax.imshow(20*np.log10(abs(Sx)), origin='lower', aspect='auto',
                     extent=SFT.extent(N), cmap='inferno', # 我觉得不错的配色
                     vmin=-200, vmax=1, # 最大最小值
                     interpolation='sinc') # sinc插值比较准确
    fig.colorbar(im, label="Magnitude in dBFS", ticks=np.arange(-200,1,20))
    plt.title(f'Spectrogram of {filename}')
    plt.show()

这里有很多细节,包括对数据进行的各种处理,图像的坐标轴的处理,图像颜色的选择等。而且有关短时傅里叶变换的 ShortTimeFFT 在网上几乎没有资料,只能查官方文档。为了得到一张效果较为完美的图像,我也是查阅大量资料,前前后后多次修改代码。

看一下 44100Hz 采样率的 beep 文件的 Spectrum:

simple_src_beep_44k.png

以及 48000Hz 采样率的:

simple_src_beep_48k.png

能看出来因为 1kHz 和采样率的倍数关系,噪音的引入还是有所区别的。但是二者的噪音也都是在 -160dBFS 以下,属于几乎不可听的状态。

当然对于 44100Hz 采样率 sweep 的 Spectrogram 也放在这里供参考,48000Hz 的就不放了,因为图都是差不多的:

simple_src_sweep_44k.png

这些图是不是看上去都挺不错的,而经过采样率转换之后,它们还能保持这样的状态吗?接着往后看。

代码框架设计

实际上这一块是后来搞的(而且修改了很多次),因为一开始根本没考虑这些,可以去代码仓库看看提交历史。不过确实狠狠学了不少 Rust 知识,只能说真复杂啊,和编译器斗智斗勇的。

首先是核心 Trait:

pub trait Convert {
    fn next_sample<I>(&mut self, iter: &mut I) -> Option<f64>
    where
        I: Iterator<Item = f64>,
        Self: Sized;

    fn process<I>(&mut self, iter: I) -> ConvertIter<'_, I, Self>
    where
        I: Iterator<Item = f64>,
        Self: Sized,
    {
        ConvertIter::new(iter, self)
    }
}

所有的和插值相关的代码都应该实现这个 Trait,这样为后续的包装提供了好处。注意 next_sampleprocess 都是泛型函数,不兼容动态分发,为了让其支持动态分发,我发现约束 Self 为 Sized 就可以了,因为这样使用 Box<dyn Convert> 编译器可以确定要分配内存的大小。

process 包装了 next_sample ,其接受一个迭代器参数,然后返回一个 ConvertIter 结构,该结构实现了一个迭代器,可以获得转换后的输出。在设计上,当输入迭代器出现 None 时,再次输入有效数据还能接着输出剩余的内容,这样就可以避免数据无法通过一个连续的迭代器提供的情况。

ConvertIter 结构和迭代器实现如下:

pub struct ConvertIter<'a, I, C> {
    iter: I,
    cvtr: &'a mut C,
}

impl<'a, I, C> ConvertIter<'a, I, C> {
    #[inline]
    pub fn new(iter: I, cvtr: &'a mut C) -> Self {
        Self { iter, cvtr }
    }
}

impl<I, C> Iterator for ConvertIter<'_, I, C>
where
    I: Iterator<Item = f64>,
    C: Convert,
{
    type Item = f64;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.cvtr.next_sample(&mut self.iter)
    }
}

看上去好像还整上生命周期了,好像很麻烦的样子,其实这是 Rust 的迭代器的基本用法。实际上内部就是简单调用了 next_sample 这个函数而已。因此我们接下来的关键就是如何为不同的插值方法都实现这个函数。而且看起来这些复杂的包装代码,其实不会造成任何损失,因为这些都是静态的,也就是编译时确定的,这就是所谓的零成本抽象。
因此对于任意一个实现了 Convert Trait 的转换器,都可以使用类似这样的代码实现转换:

let samples = vec![1.0, 2.0];
let mut cvtr = get_converter();
for s in cvtr.process(samples.into_iter()) {
    println!("sample = {s}");
}

线性插值

首先出场的是线性插值,这应该是最容易想到的方法了,也是最容易实现的方法。

这个很好理解,比如从采样率 5 降低到 4,比率是 0.8,步长就变成了 1.25。那么对于时长为一秒的数据 $x[0]=1, x[1]=3, x[2]=5, x[3]=7, x[4]=9$ ,我们需要 $y[0]=x(0), y[1]=x(1.25), y[2]=x(2.5), y[3]=x(3.75)$ ,但是 $x[n]$ 的索引不能是小数,所以就用线性插值公式计算。比如 $y[1]=x(1.25)=x[1]+0.25 \times (x[2]-x[1])=3+0.25 \times (5-3)=3.5$ ,同样的可以算出每一个 $y[m]$ 出来。

线性插值转换器的结构定义如下 :

enum State {
    First,   // 第一次状态
    Normal,  // 正常状态
    Suspend, // 挂起状态,也就是输入存在None
}

pub struct Converter {
    step: f64,  // 插值位置步长
    pos: f64,   // 当前插值位置,0到1
    last_in: [f64; 2], // 插值点前后的两个样本
    state: State, // 当前状态
}

线性插值的具体插值代码如下:

fn next_sample<I>(&mut self, iter: &mut I) -> Option<f64>
where
    I: Iterator<Item = f64>,
{
    loop {
        match self.state {
            State::First => {
                if let Some(s) = iter.next() {
                    self.last_in[1] = s;
                    self.pos = 1.0;
                    self.state = State::Normal;
                } else {
                    return None;
                }
            }
            State::Normal => {
                while self.pos >= 1.0 {
                    self.pos -= 1.0;
                    self.last_in[0] = self.last_in[1];
                    if let Some(s) = iter.next() {
                        self.last_in[1] = s;
                    } else {
                        self.state = State::Suspend;
                        return None;
                    }
                }
                let interp = self.last_in[0] + (self.last_in[1] - self.last_in[0]) * self.pos;
                self.pos += self.step;
                return Some(interp);
            }
            State::Suspend => {
                if let Some(s) = iter.next() {
                    self.last_in[1] = s;
                    self.state = State::Normal;
                } else {
                    return None;
                }
            }
        }
    }
}

说一下设计思路。首先不考虑其他状态,只考虑正常状态,忽略掉相等的情况,转换比率有两种情况——大于 1 和小于 1,但是不论对于哪一种,我们在每一次插值的时候都需要确保插值位置在 0 到 1 之间。对于比率大于 1 的情况,此时步长小于 1,那么同样两个样本可能会插值多次;而当比率小于 1,也就是步长大于 1 的时候,就存在跳过部分样本的情况。因此我们只需要构造这样一个结构,即每次迭代都更新位置,如果位置超出了,就读入一定的样本。

所以这一段代码就很好解释了:

while self.pos >= 1.0 {
    self.pos -= 1.0; // 移动1个样本位置
    self.last_in[0] = self.last_in[1]; // 移进
    if let Some(s) = iter.next() { // 读入新数据
        self.last_in[1] = s;
    } else {
        self.state = State::Suspend; // 切换到挂起状态
        return None; // 并返回空
    }
}
// 执行插值
let interp = self.last_in[0] + (self.last_in[1] - self.last_in[0]) * self.pos;
self.pos += self.step; // 下一个插值位置
return Some(interp);

然后考虑第一次输入,需要专门关照一下,因为线性插值无论如何都是需要获取原始输入的第一个样本作为起始的。

if let Some(s) = iter.next() { // 尝试读取
    self.last_in[1] = s;
    self.pos = 1.0;
    self.state = State::Normal; // 切换到正常状态
} else { // 读取失败返回None,此时依然是First状态
    return None;
}

这里值得注意的是,在读取到第一个样本的时候,将位置设置成了 1.0,这样可以确保在接下来的代码中可以移进这个样本,而又不会导致计算出现问题。

从挂起状态恢复也是类似的,只有读取到了数据才会切换到正常状态:

if let Some(s) = iter.next() {
    self.last_in[1] = s;
    self.state = State::Normal;
} else {
    return None;
}

不过显而易见的,只根据前后两个样本进行插值计算,显然有较大的误差,尤其是因为声音信号的波形通常都是是由一系列不同频率的周期信号组成,而两个样本不足以表达这些信号的周期。但是线性插值并非一无是处,因为其速度快,而且对于低频信号的影响并没有那么大,所以对于一些性能较差的设备可能还是存在一定的价值。同时,对于线性信号(三角波、锯齿波、方波等)来说,也是有不错的效果。

转换质量分析

这是使用线性插值算法将 44.1k 的 beep 文件转换到 48k 的结果:

simple_src_beep_44k_48k_l.png

对比前面的图,是不是差距还挺大的?其实仔细看可以发现,最大的噪音在 -60dB 以下,剩下的基本都在 -80dB 以下,其余多次混叠的噪音则都在 -100dB 以下,实际上造成的影响并没有那么大。另一张 48k 下采样到 44.1k 的图就不放了,基本上是差不多的。说实话,这样的噪音我反正是听不出来的,想听到得多大音量,怕不是耳朵都要聋了。

而 sweep 就有意思了,因为 sweep 有两个特点,一个是频率不断变化,另一个是频率高点达到奈奎斯特频率,这会导致出现强烈的频率镜像现象和频率混叠现象。

simple_src_sweep_44k_48k_l.png

对照原图,可以看到在低频的时候噪音的强度还不突出,而到了高频部分噪音明显变多。看起来是不是很乱?好像每次到了边界就会反弹,而且是不断的反弹,直到信号衰减到检测不到。这其实是由多次的混叠和镜像共同造成的。这回一下就能听出来了,尤其是当声音来到高频的时候,那些低频的混叠就会非常明显。

这就是线性插值的局限性了,因为对于 44100 和 48000,我们之前已经讨论过二者的比例关系是 147:160。从频域的角度来说,线性插值就像是直接先上采样 160 倍,再下采样 147 倍,而上采样的过程就出现了镜像,而且频率高达原先的 160 倍,之后再下采样,又会产生混叠,所以就乱七八糟了。这就是非整数倍转换的一个很大的问题。

为了说明这种情况,这里给出 48000Hz 使用线性插值到 96000Hz 的图像和相反的图像。

simple_src_sweep_48k_96k_l.png

simple_src_sweep_96k_48k_l.png

总之可以看到,每次到达边界的时候,就会出现一次反弹。具体反弹的次数取决于转换的倍数,倍数越大就会越多。而反弹的位置则取决于是上采样还是下采样。很明显,上采样的时候频率是完全对称的,所以这种现象被叫做镜像。而下采样的时候,高于奈奎斯特频率的信号又会重新出现,这种现象叫做混叠,至于为什么叫混叠,这需要从频域的角度来看了,就不细说了。

但是值得注意的是,对于上采样的镜像来说,通常是听不到的,所以实际上问题不大,而下采样时的混叠会对声音造成明显的可听的影响,这是采样率转换的重点。为了降低干扰,一般情况线性插值还会加入一个 IIR 滤波器,比如经典的巴特沃斯Butterworth)滤波器。不过 IIR 滤波器会导致非线性相位,在有的时候并不是最好的选择。

由于线性插值并不是本文的核心内容,所以就没有深入分析,只是简单做一个说明,因为其远不如接下来马上要探讨的 sinc 函数插值方法。一般来说,线性插值仅适用于需要快速处理,对质量要求不高的低端嵌入式设备场景。

带限 sinc 函数插值

sinc 函数,定义为 $sinc(x)=\frac{sin(\pi x)}{\pi x}$ ,其中当输入为 0 的时候,结果是 1 。这个函数为什么长这样,这主要还是傅里叶变换的功劳,一般教材都会有推导过程,了解即可。sinc 函数的一大好处就是它是理想的低通滤波器。怎么理解呢,就是在频域上竖着切掉一定频率的信号,只保留低频的信号,对其进行傅里叶逆变换,得到的图像就是 sinc 函数图像。但是为什么说是理想的滤波器呢,因为 sinc 函数只有在正负无穷的时候才会达到 0,显然是过于理想了。

sinc 插值是完美的信号重建方法。因为采样定理告诉我们,对于带限信号,使用低通滤波器就可以重建连续时间的函数(带限信号在频域上是无限的,所以需要低通,这也就是为什么会有混叠和镜像)。

实际上正如之前提到的,整数倍采样率转换实际上只需要低通滤波器就行了。回看上面的两张展示镜像和混叠的图,如果有一个低通滤波器,事情就可以得到解决。而这个低通滤波器,和 sinc 插值就像是提前安排好了一样,为什么不直接使用 sinc 插值的同时滤波呢?

sinc 插值的方法其实很简单,使用这个公式可以实现: $\hat{x}(t)=\sum_{n=-\infty}^{\infty}x(nT_s)h_s(t-nT_s)$ ,其中,$h_s(t)=sinc(F_st)$ 。仔细看可以发现这个公式其实是对整个时域信号做了一个卷积操作,我们知道时域的卷积对应了频域的乘积,所以相当于进行了滤波。但是这是理想的情况,实际上我们不可能有无穷多的点,而且也不可能用那么多的点只是为了计算一个值。

所以我们需要近似,最简单的方法就是把 sinc 函数截断,比如只取前后各 32 个点。又因为截断必然会引起误差(尤其是选择的点数较少的时候),所以还需要加窗。这样下来就是一个 64 阶的 FIR 低通滤波器,只不过这个滤波器的系数表不能提前计算(但是可以近似计算)。

其实和线性插值几乎一样,我们只需要先找到要插值的位置,然后找到这个地方前后一共 M 个点(M 表示阶数,N 表示滤波器长度,M=N-1,也有地方直接用 N 表示阶数,用 L 表示长度),再一顿卷积就可以了。伪代码就像这样:

double sum = 0.0;
for (int k = -M; k <= M; k++) {
    sum += x[k] * sinc(pos-k) * kaiser(pos-k, M, beta);
}
return sum;

其中 pos 表示插值的位置,这和线性插值是一样的。需要注意的是,Kaiser 窗提供了一个 beta 参数,可以控制对旁瓣的抑制。但是 beta 不是越大越好,这是和阶数有关系的,一般来说,阶数高了再使用更大的 beta,具体的组合需要实测才能得出。当然实际使用的时候,还需要加一个 cutoff 参数,用来控制滤波器的截止频率。

由于 Kaiser 窗函数计算复杂,如果像这样每一个点就要调用 M 次,显然性能是不够的。考虑到插值的位置是一个 0 到 1 的小数,我们完全可以量化这个插值系数,比如提前将其划分为 Q 份,并根据实际的系数对量化后的系数再进行一次线性插值(没想到吧,线性插值表示我还在呢)。这样我们只需要保存 N*Q 个点的滤波器系数表。通常来说这个 Q 值的选取取决于精度和存储空间,比如对于嵌入式设备来说,查找表大小受 Flash 大小限制。实际上,由于 sinc 函数和 Kaiser 窗函数都有偶对称的特性,所以只需要保留其一半的系数就可以了,这样又能节省一半的空间。

这里先给出这些数学函数的代码:

use std::f64::consts::PI;

fn sinc_c(x: f64, cutoff: f64) -> f64 {
    if x != 0.0 {
        (PI * x * cutoff).sin() / (PI * x)
    } else {
        cutoff
    }
}

fn bessel_i0(x: f64) -> f64 {
    let mut y = 1.0;
    let mut t = 1.0;
    for k in 1..32 {
        t *= (x / (2.0 * k as f64)).powi(2);
        y += t;
        if t < 1e-10 {
            break;
        }
    }
    y
}

fn kaiser(x: f64, order: u32, beta: f64) -> f64 {
    let half = order as f64 * 0.5;
    if (x < -half) || (x > half) {
        return 0.0;
    }
    bessel_i0(beta * (1.0 - (x / half).powi(2)).sqrt()) / bessel_i0(beta)
}

看到这里,你会发现 sinc 去哪了?怎么是 sinc_c 了?实际上直接使用 sinc 函数是一个理想的情况,而对于 sinc 低通滤波器来说,其系数计算的公式是这样的:$h[n]=\frac{sin(\omega_c(n-M/2))}{\pi(n-M/2)}\cdot \hat w[n]$ ,其中 $\omega_c\in(0,\pi)$ ,且 $\hat w[n]$ 是窗函数。因此我们使用了上面的 sinc_c ,采用了归一化的截止频率。而零阶修正贝塞尔函数针对迭代情况,展开了公式里的阶乘,从而方便计算。Kaiser 窗也是进行了一定的修改,从而使得函数和 sinc_c 一样是偶对称的。

为了生成一个系数表,我们需要以下几个参数:

  • ratio:采样率转换比率。目标采样率除以原始采样率,除了用于步长的计算,还用于滤波器理想截止频率的计算。比如 44100Hz 转换到 48000Hz 时,为了抑制镜像频率,需要从 22050Hz 频率处进行过滤,由于原始文件采样率是 44100Hz,所以理想截止频率相对于奈奎斯特频率的比率就是 1;而从 48000Hz 到 44100Hz 的时候,为了抑制混叠,理想截止频率也是 22050Hz,其相对于奈奎斯特频率的比率是 22050/24000,而这恰好等于了 ratio。

  • order:滤波器阶数。阶数越大滤波过渡带越窄,滤波效果也越好,但也会增加计算量。一般来说,阶数为偶数的滤波器就是 Ⅰ 型 FIR 滤波器,奇数阶数的是 Ⅱ 型 FIR 滤波器,对于低通滤波器来说二者效果一致。滤波器延迟的样本数计算方法是 M / 2 * ratio 。

  • quan:量化数量。数量越多精度越高,但也占用更多存储空间。一般来说,量化数量不能过低,否则会影响插值的准确性,从而产生较多的噪音。具体取值和样本的精度有关,在前面提到的文献中有所论证。

  • kaiser_beta:Kaiser 窗函数 beta 值。越大对旁瓣的衰减更多,但在相同滤波器阶数下对过渡带的要求也会更高,因此需要和滤波器的阶数配合使用,不能盲目选择更大的值。一般可以通过经验公式计算目标衰减下的 beta 值。

  • cutoff:滤波器截止频率系数。实际的截止频率通常低于理想的情况,用来抑制镜像和混叠,具体取值和阶数有关。阶数越高,过渡带越窄,则可以越接近理想的截止频率。理想情况下,上采样时取 1,下采样时取转换比率。由于 FIR 滤波器的特性,截止频率是通带和阻带频率的中心点,也就是衰减 6dB 的点。

因为这些参数排列组合可以无限多,所以我们需要一定的理论来预设一些合理的参数,具体方法这里暂且略过,等到后面需要的时候再说。先来说一说如何利用前面这 5 个参数来构造一个插值滤波器系数。

let half = order as f64 * 0.5;
let h_len = (quan as f64 * half).ceil() as usize;
let mut filter = Vec::with_capacity(h_len);
for i in 0..h_len {
    let pos = i as f64 / quan as f64;
    let coef = sinc_c(pos, cutoff) * kaiser(pos, order, kaiser_beta);
    filter.push(coef);
}

是不是很简单,就像之前说的那样。不过这里并没有出现 ratio 参数,实际上滤波器的 cutoff 计算是需要的,后面很快就会说明。下面说说如何利用滤波器系数进行插值。

由于需要保存插值滤波器表和 (M+1) 个点作为缓存,我们使用 VecDeque 作为缓冲区保存之前输入的样本。同时由于不要考虑第一次输入,因此只需要两个状态即可。其他的代码结构基本上是一样的,就不多赘述了。

enum State {
    Normal,
    Suspend,
}

pub struct Converter {
    step: f64,
    pos: f64,
    half_order: f64,
    quan: f64,
    filter: Arc<Vec<f64>>, // 为了处理多线程情况
    buf: VecDeque<f64>, // 缓存输入数据
    state: State,
}

fn interpolate(&self) -> f64 {
    let coef = self.pos;
    let mut interp = 0.0;
    let pos_max = self.filter.len() - 1;
    // 这里就是最耗时的代码了
    for (i, s) in self.buf.iter().enumerate() {
        let index = i as f64 - self.half_order;
        // 计算在插值滤波器系数表的位置
        let pos = (coef - index).abs() * self.quan;
        let pos_n = pos as usize;
        // 如果位置超出最大范围就不计算了,因为此时系数是0
        if pos_n < pos_max {
            let h1 = self.filter[pos_n];
            let h2 = self.filter[pos_n + 1];
            let h = h1 + (h2 - h1) * pos.fract();
            interp += s * h;
        }
    }
    interp
}

fn next_sample<I>(&mut self, iter: &mut I) -> Option<f64>
where
    I: Iterator<Item = f64>,
{
    loop {
        match self.state {
            State::Normal => {
                while self.pos >= 1.0 {
                    self.pos -= 1.0;
                    if let Some(s) = iter.next() {
                        self.buf.pop_front();
                        self.buf.push_back(s);
                    } else {
                        self.state = State::Suspend;
                        return None;
                    }
                }
                self.pos += self.step;
                let interp = self.interpolate();
                return Some(interp);
            }
            State::Suspend => {
                if let Some(s) = iter.next() {
                    self.buf.pop_front();
                    self.buf.push_back(s);
                    self.state = State::Normal;
                } else {
                    return None;
                }
            }
        }
    }
}```

完整代码会在稍后给出,因为还包含了有关初始化参数计算相关的代码。

### 初始化参数的计算

在教材上有使用窗函数法设计 FIR 滤波器的内容,我们可以按照教材的方法计算我们需要的参数。首先需要确定阻带衰减水平 A,然后据此可以计算 Kaiser 窗的 β 值。

```rust
fn calc_kaiser_beta(atten: f64) -> f64 {
    if atten > 50.0 {
        0.1102 * (atten - 8.7)
    } else if atten >= 21.0 {
        0.5842 * (atten - 21.0).powf(0.4) + 0.07886 * (atten - 21.0)
    } else {
        0.0
    }
}

通常来说我们选择的 A 都是大于 50 的,所以基本上就是按照 $\beta=0.1102\times(A-8.7)$ 计算的。

然后根据公式 $M=\frac{A-8}{2.285\varDelta\omega}$,可以根据阶数 M 或者过渡带宽度 $\varDelta\omega$ 来计算。这里有一个重要的细节,就是对于采样率转换的情况,过渡带的宽度是需要根据上采用还是下采样进行缩放的,就像之前提到的截止频率 $\omega_c$ 在下采样需要乘转换系数一样。比如从 44100 到 96000 的上采样,我们以 20000Hz 作为通带,22050Hz 作为阻带,那么截止频率为 21025Hz,对应到角频率就是通带 $\omega_p\approx0.907\pi$,阻带 $\omega_s=\pi$,过渡带宽度 $\varDelta\omega\approx0.093\pi$,截止频率 $\omega_c\approx0.9535\pi$ 。但是如果其他条件不变,变成了下采样,此时最大频率来到了 48000Hz,所以通带 $\omega_p\approx0.4167\pi$,阻带 $\omega_s=0.459375\pi$,过渡带宽度 $\varDelta\omega\approx0.0427\pi$,截止频率 $\omega_c\approx0.438\pi$。此时发现过渡带宽度为原来的 $\frac{44100}{96000}$,刚好就是转换比率 R 的倒数。因此上述公式修改为 $M=\frac{A-8}{2.285\varDelta\omega\cdot\min(R,1)}$ 即可计算我们需要的结果了。代码中不要忘了 $\pi$ 值,这样得到的就是归一化的结果了。

fn calc_trans_width(ratio: f64, atten: f64, order: u32) -> f64 {
    (atten - 8.0) / (2.285 * order as f64 * PI * ratio.min(1.0))
}

fn calc_order(ratio: f64, atten: f64, trans_width: f64) -> u32 {
    f64::ceil((atten - 8.0) / (2.285 * trans_width * PI * ratio.min(1.0))) as u32
}

之后就可以通过输入其中一个参数来计算另一个参数了。利用这些方法,我们可以根据阻带衰减、阶数或过渡带宽度来计算所需的全部参数了。

// 如果输入的是过渡带宽度
let order = calc_order(ratio, atten, trans_width);
// 如果输入的是阶数
let trans_width = calc_trans_width(ratio, atten, order);
// 之后就是一样的计算了
let kaiser_beta = calc_kaiser_beta(atten);
let cutoff = ratio.min(1.0) * (1.0 - 0.5 * trans_width);

完整代码

use std::collections::VecDeque;
use std::f64::consts::PI;
use std::sync::Arc;

use super::Convert;

#[inline]
fn sinc_c(x: f64, cutoff: f64) -> f64 {
    if x != 0.0 {
        (PI * x * cutoff).sin() / (PI * x)
    } else {
        cutoff
    }
}

#[inline]
fn bessel_i0(x: f64) -> f64 {
    let mut y = 1.0;
    let mut t = 1.0;
    for k in 1..32 {
        t *= (x / (2.0 * k as f64)).powi(2);
        y += t;
        if t < 1e-10 {
            break;
        }
    }
    y
}

#[inline]
#[allow(dead_code)]
fn kaiser(x: f64, order: u32, beta: f64) -> f64 {
    let half = order as f64 * 0.5;
    if (x < -half) || (x > half) {
        return 0.0;
    }
    bessel_i0(beta * (1.0 - (x / half).powi(2)).sqrt()) / bessel_i0(beta)
}

#[inline]
fn generate_filter_table(quan: u32, order: u32, beta: f64, cutoff: f64) -> Vec<f64> {
    let len = order * quan / 2;
    let i0_beta = bessel_i0(beta);
    let half_order = order as f64 * 0.5;
    let mut filter = Vec::with_capacity(len as usize + 1);
    for i in 0..len {
        let pos = i as f64 / quan as f64;
        let i0_1 = bessel_i0(beta * (1.0 - (pos / half_order).powi(2)).sqrt());
        let coef = sinc_c(pos, cutoff) * (i0_1 / i0_beta);
        filter.push(coef);
    }
    filter.push(0.0);
    filter
}

#[inline]
fn calc_kaiser_beta(atten: f64) -> f64 {
    if atten > 50.0 {
        0.1102 * (atten - 8.7)
    } else if atten >= 21.0 {
        0.5842 * (atten - 21.0).powf(0.4) + 0.07886 * (atten - 21.0)
    } else {
        0.0
    }
}

#[inline]
fn calc_trans_width(ratio: f64, atten: f64, order: u32) -> f64 {
    (atten - 8.0) / (2.285 * order as f64 * PI * ratio.min(1.0))
}

#[inline]
fn calc_order(ratio: f64, atten: f64, trans_width: f64) -> u32 {
    f64::ceil((atten - 8.0) / (2.285 * trans_width * PI * ratio.min(1.0))) as u32
}

enum State {
    Normal,
    Suspend,
}

pub struct Converter {
    step: f64,
    pos: f64,
    half_order: f64,
    quan: f64,
    filter: Arc<Vec<f64>>,
    buf: VecDeque<f64>,
    state: State,
}

impl Converter {
    #[inline]
    pub fn new(step: f64, order: u32, quan: u32, filter: Arc<Vec<f64>>) -> Self {
        let taps = (order + 1) as usize;
        let mut buf = VecDeque::with_capacity(taps);
        buf.extend(std::iter::repeat(0.0).take(taps));
        Self {
            step,
            pos: 0.0,
            half_order: 0.5 * order as f64,
            quan: quan as f64,
            filter,
            buf,
            state: State::Normal,
        }
    }

    #[inline]
    fn interpolate(&self) -> f64 {
        let coef = self.pos;
        let mut interp = 0.0;
        let pos_max = self.filter.len() - 1;
        for (i, s) in self.buf.iter().enumerate() {
            let index = i as f64 - self.half_order;
            let pos = (coef - index).abs() * self.quan;
            let pos_n = pos as usize;
            if pos_n < pos_max {
                let h1 = self.filter[pos_n];
                let h2 = self.filter[pos_n + 1];
                let h = h1 + (h2 - h1) * (pos - pos_n as f64);
                interp += s * h;
            }
        }
        interp
    }
}

impl Convert for Converter {
    #[inline]
    fn next_sample<I>(&mut self, iter: &mut I) -> Option<f64>
    where
        I: Iterator<Item = f64>,
    {
        loop {
            match self.state {
                State::Normal => {
                    while self.pos >= 1.0 {
                        self.pos -= 1.0;
                        if let Some(s) = iter.next() {
                            self.buf.pop_front();
                            self.buf.push_back(s);
                        } else {
                            self.state = State::Suspend;
                            return None;
                        }
                    }
                    self.pos += self.step;
                    let interp = self.interpolate();
                    return Some(interp);
                }
                State::Suspend => {
                    if let Some(s) = iter.next() {
                        self.buf.pop_front();
                        self.buf.push_back(s);
                        self.state = State::Normal;
                    } else {
                        return None;
                    }
                }
            }
        }
    }
}

转换质量分析

这里给出 A=100,Q=128,M=128 参数时 44k 到 48k 的具体情况:

simple_src_beep_44k_48k_s_a100_1.png

simple_src_sweep_44k_48k_s_a100_1.png

simple_src_impulse_44k_48k_s_a100_1.png

simple_src_impulse_44k_48k_s_a100_1_pass.png

可以看到基本满足要求,通带也刚好卡到了 20kHz 左右。如果以同样的参数,作用于 48k 到 44k 的下采样会怎么样呢?

simple_src_impulse_48k_44k_s_a100_1.png

simple_src_impulse_48k_44k_s_a100_1_pass.png

其他两张图差不太多就不放了,关键就是这张通带图,可以看到通带已经不足 20kHz 了,唯一的解决办法就是加大阶数。但是加大阶数会增加计算量吗?我们接着讨论这个问题。

还是一样的配置,唯独把过渡带宽度设置为 $\frac{2050}{22050}$,这样就可以确保通带一定是满足 20kHz 的要求的。具体结果就不放了,因为衰减还是 100dB,所以效果其实和之前差不多,唯一的区别就是通带的频率都保证一样了。此时对于 44k 到 48k,M=138,而对于 48k 到 44k,M=151,由于计算量取决于输出点的数量乘阶数,所以对于上采样,每秒的计算量是 $Calc=48000\times138=6624000$,而下采样时计算量是 $Calc=44100\times151=6659100$,可以看到计算量并没有增加多少,不过滤波器系数的存储空间增加是必然的。

那么量化数量 Q 应该怎么选取呢?前面我们看到当 A=100 时,Q=128 看上去没什么问题,那么将 A 提高到 120,是否会遇到问题呢?按照之前的参数设置,观察 44k 到 48k 情况下的阻带衰减情况。

simple_src_impulse_44k_48k_s_a120_1.png

此时 M=168,看起来依然能达到要求,但是如果将 A 设为 110,却发现效果并不好:

simple_src_impulse_44k_48k_s_a110_1.png

这也是一个很奇怪的问题,实际上 Q=256 才能满足 A=110,而按照这样子应该 Q=512 才能满足 A=120 才对。而实际上按照 Q=128 得出的 Spectrogram 图如下:

simple_src_sweep_44k_48k_s_a120_1.png

仔细对比图上的颜色可以发现有 -110dB 左右的噪音。使用 A=120,Q=512 的结果如下:

simple_src_impulse_44k_48k_s_a120_2.png

simple_src_sweep_44k_48k_s_a120_2.png

这样才符合 120dB 的衰减。怀疑产生这个情况的原因是对于冲激响应测试,A=120 和 Q=128 在数值上可能存在巧合,因此产生了一个极具迷惑性的结果。所以我们需要多种方法共同测试才能得出更加准确的结果。

那么继续提高 A 到 140 呢?为了减少图片数量,就不贴图了,实际上需要 Q=2048 才能满足 A=140 的要求了。

为了给 24bit 音频留出足够的余量,我们通常会使用更高的衰减来满足至少 144dB 的动态要求,因此选择 A=160,看看需要多大的 Q 才能满足。然而发现即使是 Q 来到了 4096 甚至更高,依然无法满足这个要求。

怀疑可能是 wav 文件用的 f32 存在精度瓶颈,因为 f32 实际有效精度为 24 位,因此将 f64 的计算结果转换到 f32 的过程中损失了一定的精度。尽管 scipy 支持 f64 的 wav 文件,但是由于 hound 库不支持,因此尝试直接将原始的 f64 数据写入到文件来分析结果。

由此发现直到 Q=8192 才达到瓶颈,不过此时对存储空间的占用显然是很夸张了,因为滤波器每一阶就需要 8k 个双精度浮点数。此时选择 Q=4096 配合 A=160 也不失为一种选择,只不过实际只能达到 155dB 左右的衰减。如果为了进一步降低 Q,可以选择 Q=2048 和 A=150,此时也能勉强达到要求。

以 A=150,Q=2048,测试一下这么高的标准下的 96k 到 44k 和 48k 的下采样转换。

首先是 96k 到 44k,此时为了 20kHz 的通带,需要高达 464 阶的滤波器才能满足。

simple_src_sweep_96k_44k_s_a150_1.png

当然实际上使用这个标准还是过于极端了,我们用 A=120,Q=512 来试试看 96k 的下采样,此时仍然需要 366 阶滤波器。

simple_src_sweep_96k_44k_s_a120_2.png

看起来其实效果并不差了,而且实际上基本不可能听出来。至于 96k 到 48k 的图,我就不放了,结果是差不多的,因为过渡带选的比较宽(4000Hz),所以就不需要很高的阶数了。

下面的表格总结了一下不同衰减下 96k 下采样需要的滤波器阶数情况,顺便也计算一下 192k 的情况:

96->44.1 96->48 192->44.1 192->48
A=120 M=366 M=188 M=731 M=375
A=150 M=464 M=238 M=927 M=475

不过上述都是之前的一些比较简单的实验,实际上按照文献给出的论证,衰减(A)和量化(Q)的关系大致是 $A=12+12log_2Q$$Q=2^{A/12-1}$,不过实测会存在一些偏差,这并不奇怪。

因此我给出几个不错的参数搭配选择:

  1. A=96,Q=128:这个参数恰好达到了 16bit 音频的理论 96dB 动态上限(实际不止),且阶数低,存储占用低,适合快速的转换。
  2. A=108,Q=256 或 A=120,Q=512:这两组基本上对于有质量需求的 16bit 音频来说是完美符合的了,同时兼顾计算量和存储空间。
  3. A=144,Q=2048 或 A=156、Q=4096:极致的标准,可以满足 24bit 音频的转换,达到了所谓 Hi-Res 的标准,需要极大的计算量和存储空间。

当然略微偏大的 A 也不会造成问题,只会使计算出的阶数稍微高一点点。

性能测试

从前面的表格可以看到计算压力应该不小,于是我们尝试进行一个性能测试。由于 Rust 官方的 benchmark 尚且不够完善,我们选择 Criterion.rs 进行性能测试。不过由于网速问题,第一次运行似乎会下载 plotters 这个东西,看起来和卡住了一样,我是等了很久才能正常跑。

由于需要测试的项目繁多,我挑了几个有代表性的,比如 44k 和 48k 的互转,以及 96k 的下采样。内容主要是测试初始化滤波器表所需要的时间,以及生成一秒钟数据需要的时间消耗。为了提高测试压力,我选用了 A=150,Q=2048 下的情况。考虑到误差范围,选择多次测试且不保留任何小数,并且取稳定和较好的结果。使用的 Rust 工具链都通过 Rustup 更新到测试平台默认设置的最新版(目前版本号 1.80.1)。

主要测试代码如下:

use criterion::{black_box, criterion_group, criterion_main, Criterion};
use simple_src::{sinc, Convert};

fn src_bench_init(c: &mut Criterion) {
    c.bench_function("src 44k to 48k a150 init", |b| {
        b.iter(|| {
            let manager = sinc::Manager::new(48000.0 / 44100.0, 150.0, 2048, 2050.0 / 22050.0);
            black_box(manager);
        })
    });
    //其他测试
}

fn src_bench_process_1s(c: &mut Criterion) {
    let manager = sinc::Manager::new(48000.0 / 44100.0, 150.0, 2048, 2050.0 / 22050.0);
    c.bench_function("src 44k to 48k a150 1s", |b| {
        b.iter(|| {
            let iter = (0..).map(|x| x as f64).into_iter();
            for s in manager.converter().process(iter).take(48000) {
                black_box(s);
            }
        })
    });
    //其他测试
}

criterion_group!(benches, src_bench_init, src_bench_process_1s);
criterion_main!(benches);

由于测试是单声道的情况,如果是立体声则计算量至少翻倍,需要的时间也翻倍。同时为了确保性能,数据都是在插电情况下得出,离电情况下性能通常会打个七八折,这些需要注意。

表格给出了各种测试条件下的滤波器阶数、初始计算量(也反映存储占用)和每秒计算量,方便与测试结果进行比较:

44.1->48 48->44.1 96->44.1 96->48
滤波器阶数 213 232 464 238
初始计算量 218112 237568 475136 243712
每秒计算量 10.224M 10.2312M 20.4624M 11.424M

测试结果如下:

平台 44.1->48 (init/1s) 48->44.1 (init/1s) 96->44.1 (init/1s) 96->48 (init/1s)
平台 1 23ms/40ms 25ms/40ms 50ms/88ms 26ms/35ms
平台 2 28ms/56ms 31ms/56ms 61ms/137ms 31ms/53ms
平台 3 17ms/138ms 18ms/139ms 36ms/367ms 18ms/150ms
平台 4 23ms/86ms 25ms/87ms 50ms/280ms 25ms/80ms
平台 5 23ms/79ms 25ms/81ms 50ms/240ms 26ms/76ms
平台 6 13ms/105ms 14ms/105ms 28ms/215ms 14ms/114ms
平台 7 19ms/77ms 21ms/77ms 41ms/152ms 21ms/79ms
平台 8 18ms/150ms 20ms/150ms 40ms/298ms 20ms/172ms

关于测试平台的详细说明(收集了这么多平台的信息也是不容易啊):

  1. 平台 1:高通骁龙 8cx Gen 3 @ 2.69 GHz,Windows on ARM
  2. 平台 2:高通骁龙 865,红米 K30 Pro,Termux Ubuntu
  3. 平台 3:AMD Ryzen 5 4600H,Windows
  4. 平台 4:AMD Ryzen 5 4600H,WSL 2 Ubuntu
  5. 平台 5:AMD Ryzen 7 4800H,WSL 2 Ubuntu
  6. 平台 6:Intel Core Ultra 7 155H,Windows
  7. 平台 7:GitHub Actions,ubuntu-latest
  8. 平台 8:GitHub Actions,windows-latest

以下是一些补充说明:

  • 平台 1 在 Windows 和 WSL 2 Ubuntu 性能几乎一样,故没有分别列出。
  • 平台 3 和平台 4 是我自己的电脑,可以发现 Windows 和 Linux 明显差异。
  • 平台 5 是找朋友搞的,由于 CPU 差距不大,可以和平台 4 进行对照。
  • 平台 6 是另一个朋友搞的,算是较新的 Intel 平台,但是只搞了 Windows 的。
  • 平台 7 和平台 8 是 GitHub Actions,可以看出 Windows 和 Linux 的显著差距。

测试发现使用 x86 的平台在计算插值时性能居然不如 arm64 的平台,电脑的性能甚至不如手机。而且 x86 下 Windows 和 Linux 平台也存在不少差异。目前排查发现除去查表造成的缓存问题,主要问题是在插值时使用的 fract 函数。在 arm64 这个函数耗时极短,而在 x86 这个函数就非常浪费,特别是在 Windows 平台,耗时比 Linux 平台多一倍。以后有机会再仔细分析这个函数的性能问题。

补充:最新版本代码性能有一定的提升,且不再使用这个 benchmark 方式了,上述测试仅供参考。

目前使用 divan 进行测试,选用 A=120 和 A=144 这两种情况,以及多种常见采样率之间的互相转换(通带均为 20kHz),一个典型的输出结果如下:

bench1             fastest       │ slowest       │ median        │ mean          │ samples │ iters
├─ init_a120                     │               │               │               │         │
│  ├─ 44k to 48k   1.684 ms      │ 3.035 ms      │ 1.698 ms      │ 1.76 ms       │ 100     │ 100
│  ├─ 44k to 96k   1.684 ms      │ 2.613 ms      │ 1.704 ms      │ 1.745 ms      │ 100     │ 100
│  ├─ 48k to 44k   1.834 ms      │ 2.157 ms      │ 1.849 ms      │ 1.868 ms      │ 100     │ 100
│  ├─ 48k to 96k   942.7 µs      │ 1.355 ms      │ 948.7 µs      │ 962.3 µs      │ 100     │ 100
│  ├─ 96k to 44k   3.67 ms       │ 5.186 ms      │ 3.705 ms      │ 3.758 ms      │ 100     │ 100
│  ╰─ 96k to 48k   1.884 ms      │ 2.448 ms      │ 1.897 ms      │ 1.927 ms      │ 100     │ 100
├─ init_a144                     │               │               │               │         │
│  ├─ 44k to 48k   9.163 ms      │ 10.47 ms      │ 9.279 ms      │ 9.37 ms       │ 100     │ 100
│  ├─ 44k to 96k   9.173 ms      │ 10.32 ms      │ 9.279 ms      │ 9.377 ms      │ 100     │ 100
│  ├─ 48k to 44k   9.979 ms      │ 11.23 ms      │ 10.13 ms      │ 10.19 ms      │ 100     │ 100
│  ├─ 48k to 96k   4.985 ms      │ 6.397 ms      │ 5.035 ms      │ 5.111 ms      │ 100     │ 100
│  ├─ 96k to 44k   19.95 ms      │ 21.42 ms      │ 20.2 ms       │ 20.29 ms      │ 100     │ 100
│  ╰─ 96k to 48k   10.23 ms      │ 11.85 ms      │ 10.37 ms      │ 10.46 ms      │ 100     │ 100
├─ proc_a120_10ms                │               │               │               │         │
│  ├─ 44k to 48k   405.5 µs      │ 1.054 ms      │ 409.3 µs      │ 423.4 µs      │ 1000    │ 1000
│  ├─ 44k to 96k   815.5 µs      │ 1.399 ms      │ 827.2 µs      │ 854.3 µs      │ 1000    │ 1000
│  ├─ 48k to 44k   405.2 µs      │ 1.131 ms      │ 409.2 µs      │ 429.7 µs      │ 1000    │ 1000
│  ├─ 48k to 96k   461.1 µs      │ 1.076 ms      │ 474 µs        │ 495.9 µs      │ 1000    │ 1000
│  ├─ 96k to 44k   831 µs        │ 1.794 ms      │ 848.5 µs      │ 877.3 µs      │ 1000    │ 1000
│  ╰─ 96k to 48k   450.7 µs      │ 1.227 ms      │ 454.6 µs      │ 475 µs        │ 1000    │ 1000
╰─ proc_a144_10ms                │               │               │               │         │
   ├─ 44k to 48k   594.4 µs      │ 1.599 ms      │ 617.9 µs      │ 659.8 µs      │ 1000    │ 1000
   ├─ 44k to 96k   1.182 ms      │ 5.041 ms      │ 1.224 ms      │ 1.32 ms       │ 1000    │ 1000
   ├─ 48k to 44k   583.2 µs      │ 4.393 ms      │ 604.6 µs      │ 657.1 µs      │ 1000    │ 1000
   ├─ 48k to 96k   616.2 µs      │ 1.249 ms      │ 638.1 µs      │ 679.2 µs      │ 1000    │ 1000
   ├─ 96k to 44k   1.209 ms      │ 5.265 ms      │ 1.4 ms        │ 1.592 ms      │ 1000    │ 1000
   ╰─ 96k to 48k   592 µs        │ 1.472 ms      │ 606.1 µs      │ 625.8 µs      │ 1000    │ 1000

结语

有关采样率转换的研究,我全部写在这里了。从知识的学习,到代码的编写,看了不少书,也重写了几次代码,学会了很多东西,又花了很长时间写完本文,时间跨度从巴黎奥运会开始前到现在黑神话都发售了好几天。这也算是我今年的年度大作了,记得去年也是研究了很久的 IEEE-754 浮点数标准和牛顿迭代法,并完成了软浮点的代码实现。实现这样的作品极其耗费时间和精力,但学习的过程和学到的知识却是非常重要的,这也是我完成这些内容的原因。

本文虽然到这结束了,但是知识的学习远没有结束。有关采样率转换还有很多我不知道的内容,正等待进一步的探索。因此本文肯定存在不少疏漏之处,还希望高手能够指出。

本文全部代码在此 GitHub 仓库可以找到:https://github.com/DrPeaboss/simple_src ,如果无法访问,也可以在此 Gitee 仓库找到:https://gitee.com/peazomboss/simple_src 。目前已经上传第一个版本到 crates.io,链接:https://crates.io/crates/simple_src ,使用命令 cargo add simple_src 即可直接添加到项目。

当然,如果需要成熟的采样率转换方案,那么 r8brain、libsamplerate、libsoxr 都是不错的选择。或者,通过 GitHub,一起完善这个简单的采样率转换器吧!

参考

  1. 奥本海姆《信号与系统》《离散时间信号处理》
  2. https://ccrma.stanford.edu/~jos/resample/resample.html 重采样算法
  3. https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.html SciPy 短时傅里叶变换文档
  4. https://matplotlib.org/stable/api/pyplot_summary.html# matplotlib.pyplot 库的 API 参考
  5. https://src.infinitewave.ca/ 各项采样率转换测试指标和各种软件数据
  6. https://kaisery.github.io/trpl-zh-cn/ Rust Book 中文翻译版
  7. https://doc.rust-lang.org/cargo/index.html 官方 Cargo 教程
  8. https://people.xiph.org/~xiphmont/demo/neil-young.html 为什么 192k/24bit 毫无意义,对所谓“无损”音乐有兴趣推荐阅读
  9. 一些喜欢瞎扯淡不怎么好用的生成式 AI 工具(没用多少)

致谢

感谢我的朋友不厌其烦配合测试,给我提供宝贵的数据。

免费评分

参与人数 39吾爱币 +45 热心值 +35 收起 理由
xiao_yang + 1 + 1 我很赞同!
dudupangle + 1 + 1 热心回复!
冰魂罗多 + 1 + 1 我很赞同!
monkey_cici + 1 + 1 我很赞同!
qiaoyong + 1 + 1 热心回复!
一夜梦惊人 + 2 + 1 鼓励转贴优秀软件安全工具和文档!
脱俗小子 + 1 + 1 我很赞同!
GISerliang + 1 + 1 谢谢@Thanks!
txy5532 + 1 用心讨论,共获提升!
52Pj-deepin + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
zzccyydd + 1 + 1 谢谢@Thanks!
killjd + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
threeWHO + 1 + 1 谢谢@Thanks!
ContacNt + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
fengbin8606 + 1 + 1 我很赞同!
allspark + 1 + 1 用心讨论,共获提升!
唐小样儿 + 1 + 1 我很赞同!
iokeyz + 3 + 1 谢谢@Thanks!
香芋 + 1 + 1 用心讨论,共获提升!
soyiC + 1 + 1 谢谢@Thanks!
sunshysun + 1 + 1 用心讨论,共获提升!
liyitong + 2 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
fengbolee + 2 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
mikufiend + 1 + 1 用心讨论,共获提升!
vmoranv + 1 热心回复!
gaosld + 1 + 1 用心讨论,共获提升!
hyeiz + 1 + 1 谢谢@Thanks!
hlbj105 + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
MeciLOrz + 1 + 1 热心回复!
jk998 + 1 + 1 我很赞同!
溜玩音乐 + 3 + 1 谢谢@Thanks!
mscsky + 1 + 1 我很赞同!
nandiska + 1 谢谢@Thanks!
qiangq233 + 1 + 1 谢谢@Thanks!
52pojieplayer + 1 谢谢@Thanks!
sophieqd + 1 + 1 用心讨论,共获提升!
yaan + 1 + 1 热心回复!
Link_Stark + 2 + 1 谢谢@Thanks!
han163426 + 1 热心回复!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| DEATHTOUCH 发表于 2024-9-2 20:56
[ 本帖最后由 DEATHTOUCH 于 2024-9-4 20:49 编辑 ]

附赠内容

接下来的内容因为没什么技术含量,相信论坛的兄弟们大多应该都会基本的逆向操作,所以就不单独发了,放在这里补充一下。

前情提要

前面提到了 fract 函数造成了严重的性能问题,这是通过分析运行的火焰图得到的。主要是这一段代码:

fn interpolate(&self) -> f64 {
    let coef = self.pos;
    let mut interp = 0.0;
    let pos_max = self.filter.len() - 1;
    for (i, s) in self.buf.iter().enumerate() {
        let index = i as f64 - self.half_order;
        let pos = (coef - index).abs() * self.quan;
        let pos_n = pos as usize;
        if pos_n < pos_max {
            let h1 = self.filter[pos_n];
            let h2 = self.filter[pos_n + 1];
            let h = h1 + (h2 - h1) * pos.fract(); // 严重性能问题
            interp += s * h;
        }
    }
    interp
}

其中的 pos.fract() 总耗时居然占整个运行时间的一半!也就是说绝对值、乘法、访存几个操作加起来还不如一个看似无害的函数的影响来的多。而点进去看 fract 的实现,发现其实是 self - self.trunc(),也就是说实际上耗时的是 trunc 函数,其通过编译器内在函数实现。

动态分析

我们可以写一个很简单的 Rust 程序来排查具体的情况:

fn main() {
    let x = 114.5; // 咳咳,太臭了
    let y = f64::trunc(x);
    println!("{y}");
}

用 debug 模式编译为 trunc.exe,然后用 WinDbg 启动看看。使用 x trunc!*main* 命令找到 main 函数。

00007ff6`273e7088 trunc!__scrt_native_dllmain_reason = 0xffffffff
00007ff6`273daba0 trunc!mainCRTStartup (void *)
00007ff6`273da9f0 trunc!__scrt_common_main_seh (void)
00007ff6`273dc894 trunc!`__scrt_common_main_seh'::`1'::filt$0 (void)
00007ff6`273c11c4 trunc!trunc::main (void)
00007ff6`273c123c trunc!main (main)
00007ff6`273c2b0c trunc!std::thread::Thread::new_main =  (inline caller) trunc!std::rt::lang_start_internal+70
00007ff6`273c38d0 trunc!std::io::buffered::bufwriter::impl$1::flush_buf::BufGuard::remaining =  (inline caller) trunc!std::io::buffered::bufwriter::BufWriter::flush_buf<std::io::stdio::StdoutRaw>+54

看起来是 trunc!trunc::main 或者 trunc!main,都断了看看。实际上的 main 函数是 trunc!trunc::main,断下来之后单步进入 trunc!std::f64::impl$0::trunc,根据反汇编结果发现,最后是 call 了 ucrtbase!trunc,继续跟进去发现 call 了 _d_int,再进去就非常复杂了,就留着接下来静态分析了。不过显然的是,ucrtbase 模块是微软的 UCRT 运行库。

trunc 应该怎么实现

众所周知,C 语言运行库包含了 C 语言标准中的所有基本函数,其中就有浮点数的操作。这里面和舍入相关的有 roundtruncfloorceil 等,其中今天的主角 trunc 就在里面,其效果就是取一个浮点数的整数部分,丢弃所有小数。所以 Rust 的 fract 就利用了这个机制,先获得整数,再减去其就可以获得小数部分了。

那么应该怎么实现这个功能呢?其实很简单,我去年还发了一篇如何实现软件浮点数的内容:https://www.52pojie.cn/thread-1830228-1-1.html,有兴趣可以看看。其中有这么一段代码:

int32_t sf32_to_i32(sfloat32 sf)
{
    int exp = SF32_EXP(sf) - 127;
    if (exp < 0) // 指数过小返回0
        return 0;
    bool sign = SF32_SIGN(sf);
    if (exp > 30) // 指数过大就返回负数最大值,也是主流做法
        return (int32_t)0x80000000;
    uint32_t sig = SF32_SIG(sf) | 0x800000;
    int shift = 23 - exp;
    int32_t res;
    if (shift > 0)
        res = sig >> shift;
    else
        res = sig << (-shift);
    if (sign)
        res = -res;
    return res;
}

上面这段代码实现了将一个 32 位浮点数转换到 32 位整数,看起来是不是很简单。其实稍微一改就可以实现将 32 位浮点数的小数部分去除,只保留整数部分的功能。具体做法就是先将浮点数各个部分拆开,先判断指数,然后修改尾数,最后重新打包。

由于 32 位浮点数尾数只有 23 位,所以当指数大于等于 23 时,说明全部尾数都用来表示整数部分,此时不需要任何修改。当指数小于 0 时,说明此时全部尾数都用来表示小数部分,整数部分就是 0,直接返回 0 即可。剩下就是指数在 0 和 22 之间的,就只需要将尾数的剩下的 (23-e) 位全都置为 0 就行了。所以只需要两个 if 语句加一些简单的位运算就行了。

那么同样的,对于 64 位浮点数,也只不过是变化了一下指数和尾数的大小而已,原理是完全一样的。

我们看看 glibc 是怎么实现的(我这里选择了 glibc 2.40 的代码,路径是 glibc-2.40/sysdeps/ieee754/dbl-64/s_trunc.c):

double
__trunc (double x)
{
#if USE_TRUNC_BUILTIN
  return __builtin_trunc (x);
#else
  /* Use generic implementation.  */
  int64_t i0, j0;
  int64_t sx;

  EXTRACT_WORDS64 (i0, x);
  sx = i0 & UINT64_C (0x8000000000000000);
  j0 = ((i0 >> 52) & 0x7ff) - 0x3ff;
  if (j0 < 52)
    {
      if (j0 < 0)
        /* The magnitude of the number is < 1 so the result is +-0.  */
        INSERT_WORDS64 (x, sx);
      else
        INSERT_WORDS64 (x, sx | (i0 & ~(UINT64_C (0x000fffffffffffff) >> j0)));
    }
  else
    {
      if (j0 == 0x400)
        /* x is inf or NaN.  */
        return x + x;
    }

  return x;
#endif /* ! USE_TRUNC_BUILTIN  */
}
#ifndef __trunc
libm_alias_double (__trunc, trunc)
#endif

是不是很明确,就是先判断指数,再计算置 0 的位数。而且 glibc 还针对 x86_64 有专门的优化,在 glibc-2.40/sysdeps/x86_64/fpu/multiarch 目录就有 s_trunc_sse4_1.S,还有 avx 的实现版本。

静态分析

那么 Windows 是怎么实现的呢?我们接着分析 UCRT 库。

系统使用的 UCRT 路径是 C:\Windows\System32\ucrtbase.dll,具体版本取决于系统,比如 Win11 22H2 就是 10.0.22621.xxx,最新的 Win11 24H2 是 10.0.26100.xxx,xxx 表示具体的补丁号。比如我一台 x64 电脑使用的是 23H2 的系统,那版本号是不是 10.0.22631.xxx 呢?嘿嘿,不是,依然是 22621 的哦。也就是说微软偷懒了,23H2 其实根本没更新自己的 C 库,用的还是 22H2,其实看版本号也知道,22621 和 22631 的跨度远不如 26100 来得大。怪不得之前有人说 24H2 本来是 Windows 12 的。而且还有一个幽默的是,22621 的 arm 版 C 库还是 aarch32 架构的,还不是 arm64,26100 才有 arm64,还好我提前升级了 24H2,不然更难受。

先不吐槽微软的偷懒了。其实为了获得不同版本的 C 库,一个最简单的办法就是安装 Windows SDK,这个在安装 Visual Studio 的时候就可以选择。其路径通常是 C:\Program Files (x86)\Windows Kits\10\Redist\<版本号>\ucrt\DLLs\<CPU架构>\ucrtbase.dll,我们可以根据这个路径来分析不同版本的 C 库的代码了。

现在,拿出你喜欢的静态分析工具,比如 IDA Pro,或者 Ghidra 等,我们来好好窥探一下微软的 C 库是怎么实现的吧。为了能同时分析不同架构的代码,再加上我用的 arm64 的机器,所以就用 Ghidra 来分析几种不同架构的实现。

首先进厂的是 22621 的 ucrtbase.dll(x64 架构),可以看到这位兄弟非常自信啊。那么我们,开始体检吧。

搜索一下 trunc (或者 truncl,导出表里是同一个地址) ,很快啊,在 1800275c0 这个地方,具体就不代码了,前面也说了主要就是调用了 _d_int,所以我们主要看看这个函数。因为前面已经知道了非常复杂,所以直接贴出转成 C 的伪代码:

ulonglong _d_int(ushort *param_1,short param_2)

{
  ushort uVar1;
  ushort uVar2;
  short sVar3;
  short sVar4;

                    /* 0x27740  209  _d_int */
  uVar2 = param_1[3];
  uVar1 = uVar2 >> 4 & 0x7ff;
  if (uVar1 == 0x7ff) {
    if (((((uVar2 & 0xf) == 0) && (param_1[2] == 0)) && (param_1[1] == 0)) && (*param_1 == 0)) {
      return 1;
    }
    return 2;
  }
  if (((((uVar2 & 0x7fff) == 0) && (param_1[2] == 0)) && ((param_1[1] == 0 && (*param_1 == 0)))) ||
     (sVar3 = (0x433 - uVar1) - param_2, sVar3 < 1)) {
    return 0;
  }
  if (0x34 < sVar3) {
    *(undefined4 *)(param_1 + 1) = 0;
    *param_1 = 0;
    param_1[3] = uVar2 & 0x8000;
    return 0xffffffff;
  }
  sVar4 = sVar3 >> 4;
  uVar2 = *(ushort *)(&DAT_1800daed8 + (ulonglong)((int)sVar3 & 0xf) * 2) &
          param_1[*(longlong *)(&DAT_1800daf08 + (longlong)sVar4 * 8)];
  param_1[*(longlong *)(&DAT_1800daf08 + (longlong)sVar4 * 8)] =
       param_1[*(longlong *)(&DAT_1800daf08 + (longlong)sVar4 * 8)] ^ uVar2;
  if (sVar4 != 1) {
    if (sVar4 != 2) {
      if (sVar4 != 3) goto LAB_1800277f2;
      uVar2 = uVar2 | param_1[2];
      param_1[2] = 0;
    }
    uVar2 = uVar2 | param_1[1];
    param_1[1] = 0;
  }
  uVar2 = uVar2 | *param_1;
  *param_1 = 0;
LAB_1800277f2:
  return (ulonglong)(ushort)-(ushort)(uVar2 != 0);
}

看到这,我瞬间傻眼了。不是,哥们,这……无话可说。微软啊微软,一个 C 库写这么烂,看看人家 glibc,再看看自己。自己还吹致力于减少碳排放,结果让千千万万电脑运行这么低效的代码,太不环保了。

这段代码就不仔细分析了,让 AI 来看看,好吧,果然傻傻的不聪明。算了不管了,鬼知道谁写的这段,可能是实习生吧,我反正看不下去。

缓解办法

如果确实要用 trunc,或者就是为了获得浮点数的小数部分,应该怎么办呢?

  1. 使用 floor 或者 ceil,如果确定是正数,就用 floor,负数则用 ceil,如果不确定,就加一个分支,开销相对较小。
  2. 照着 glibc 实现一个 trunc 函数,或者自己实现一个 fract 函数。
  3. 用汇编手搓一个,可以是 X87,也可以 SSE,也可以 SSE4.1,反正方法挺多,查查手册就有。
  4. 更新到 Windows 11 24H2 大版本。

关于最后一点,因为在 24H2 的 UCRT,微软终于是更新了 trunc 的实现:

ulonglong trunc(void)

{
  uint uVar1;
  ulonglong uVar2;
  int iVar3;
  ulonglong uVar4;
  undefined in_XMM0 [16];

                    /* 0xf70c0  2434  trunc */
  uVar4 = in_XMM0._0_8_;
  if (__isa_available < 2) {
    uVar1 = in_XMM0._4_4_ >> 0x14 & 0x7ff;
    if ((uVar1 != 0x7ff) &&
       (((uVar1 != 0 || ((in_XMM0 & (undefined  [16])0x7fffffffffffffff) != (undefined  [16])0x0))
        && (iVar3 = 0x433 - uVar1, 0 < iVar3)))) {
      if (iVar3 < 0x35) {
        uVar2 = ~((1L << ((byte)iVar3 & 0x3f)) - 1U);
      }
      else {
        uVar2 = 0x8000000000000000;
      }
      return uVar4 & uVar2;
    }
  }
  else {
    uVar4 = trunc_sse41();
  }
  return uVar4;
}

Ghidra 的控制流判断有些问题,需要手动调整。看上面的伪代码可以清晰地看到,微软终于用了 SSE4.1,也调整了常规逻辑。这下终于可以比肩 glibc 的性能了。

不过微软虽然改了一些,但还是不怎么上心,明明都 SSE4.1 了,都用 roundsd 指令了,结果 round 函数里却没用。

结语

分析了一下 C 库的性能,这下知道为啥音视频相关领域的一部分代码要手写汇编了吧,不然怎么压榨性能啊。

同时说明了在 x64 的环境下,Windows 和 Linux 巨大性能差异的来源,但是即便 Windows 的 C 库更新了,总体性能达到了 Linux 的水平,依然有这么一个问题:几乎同期推出的 AMD R7 4800H 和高通骁龙 865,在同样的 Linux 环境下却依然存在性能差距。

下面再次单独列出二者的性能数据:

平台 44.1->48 (init/1s) 48->44.1 (init/1s) 96->44.1 (init/1s) 96->48 (init/1s)
865 28ms/56ms 31ms/56ms 61ms/137ms 31ms/53ms
4800H 23ms/79ms 25ms/81ms 50ms/240ms 26ms/76ms

注意到初始化速度确实是 4800H 快,但是插值的速度依然不如 865,由于跨越了不同的 CPU 架构,所以想要进一步分析确实更有难度。所以这个问题,就留到以后吧。

补充

由于 trunc 函数的不确定性,以及微软 C 库的性能问题,所以最终我选择避开 C 库,对代码进行了如下改动:

-           let h = h1 + (h2 - h1) * pos.fract();
+           let h = h1 + (h2 - h1) * (pos - pos_n as f64);

由于 pos_n 在之前通过浮点数转换成整数,本质上就是进行了一次 trunc,只不过这样完美绕开了 C 库,因为通常编译器就会直接生成对应的汇编指令。

于是这个修改使得在 x86 平台的性能大幅度提升。不过对于 arm64 来说则是基本没什么变化。

平台 44.1->48 (init/1s) 48->44.1 (init/1s) 96->44.1 (init/1s) 96->48 (init/1s)
865 28ms/56ms 31ms/56ms 61ms/137ms 31ms/53ms
4800H 23ms/64ms 25ms/64ms 50ms/164ms 26ms/63ms
155H 13ms/37ms 14ms/37ms 28ms/78ms 14ms/40ms

此时,同期的 865 和 4800H,arm64 和 x86-64,几乎不相上下了。

从初始化速度来看,4800H 稍微领先,说明纯浮点计算能力是更强的;但是从插值计算来看,865 又略胜一筹,说明访存能力应该更好。当然二者的三级缓存看起来都不太够大,因此 96->44.1 的性能下降有点严重。

当然,和去年年底上市的 155H 比,还是差远了。不过条件限制,我没有同期的骁龙 8 Gen 3 和天玑 9300 来进行对比。

免费评分

参与人数 2吾爱币 +3 热心值 +2 收起 理由
fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
唐小样儿 + 1 + 1 我很赞同!

查看全部评分

zwk123456 发表于 2024-8-30 20:44
congcongzhidao 发表于 2024-8-30 18:21
yyy999888 发表于 2024-8-30 22:27
好教程支持
yanaying 发表于 2024-8-31 00:42
不明觉厉。。。。
 楼主| DEATHTOUCH 发表于 2024-9-2 01:42
更新补充一下,Windows的x64版ucrt库背大锅,trunc的实现过于离谱,不过好消息是win11 24H2终于是高效的实现了。
过一段时间考虑出一个详细分析。
codestar 发表于 2024-9-2 17:04
真大佬,666
sophieqd 发表于 2024-9-2 17:12
督促我们不停的前进学习。
谢谢分享。辛苦了
whitegold 发表于 2024-9-2 21:07
膜拜,学习了,感谢分享
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-11-24 11:39

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表