Audacity 的 StretchingSequence 如何实现反向与随机采样访问?
【免费下载链接】audacityAudio Editor项目地址: https://gitcode.com/GitHub_Trending/au/audacity
如果你在阅读或扩展 Audacity 新一代 au3 架构中的变速变调(time-and-pitch)播放管线,会碰到 au3/libraries/au3-stretching-sequence 这个库:StretchingSequence把一个由多个 clip 组成的可播放序列,包装成支持变速后的采样访问入口,同时承担 Audacity scrubbing(拖动播放)功能中的反向播放,以及任意起始位置的随机采样访问。本文沿真实源码把这两条路径走一遍:从GetFloats入口,到段(segment)链的构建,到 clip 读取时的原地翻转,最后用库内自带的 Catch2 测试核对行为。适用前提是你在 C++ 环境下列读或二次开发 au3 目录下的源码。
设计前提:STFT 的“有状态性”决定了访问成本
readme.md 先给出一个概念模型:变速实现基于 STFT,可以简化成一条长度为 1 的延迟线。由此推出三种访问的成本差异:
- 连续正向:请求样本 0 后,只需再推入样本 1,0 就弹出来。每次请求对应一次 push,成本最低,也是最常见的场景。
- 连续反向:实现被扩展为“在 0 之后推入 -1、-2……”,即反向请求与正向走同一条延迟线模型,方向相反。
- 随机访问:如果刚取出样本 0,用户突然请求样本 10,就需要两次push 而不是两次中的两次里的……准确说是两次 push 而非一次。readme 的结论是:
StretchingSequence的处理方式是重新填充缓冲,并用变速算法产出请求的样本——可行,但有计算开销。
对应到代码里,这个“状态”由几样东西构成:当前段链mAudioSegments、段迭代器mActiveAudioSegmentIt、期望起始采样位置mExpectedStart、播放方向mPlaybackDirection,以及每个ClipSegment内部持有的StaffPadTimeAndPitch处理器自身状态。
统一入口:一次 GetFloats 调用如何决定方向与是否重置
创建入口是静态工厂 StretchingSequence.h 中的StretchingSequence::Create(sequence, clips),它用序列的采样率构造AudioSegmentFactory。所有采样读取都经过DoGet(..., start, len, backwards, ...),后者委托给MutableGet(见 StretchingSequence.cpp#L174-L189):
if ( !mExpectedStart.has_value() || *mExpectedStart != start || (mPlaybackDirection == PlaybackDirection::backward != backwards)) { const auto t = start.as_double() / mSequence.GetRate(); ResetCursor( t, backwards ? PlaybackDirection::backward : PlaybackDirection::forward); } return GetNext(reinterpret_cast<float* const*>(buffers), nBuffers, len);三个重置条件对应三类情形:首次未初始化、请求的start与上次预期位置不符(随机访问)、方向标志与当前状态不一致(方向翻转)。start除以序列速率换算成时间t,然后交给ResetCursor。方向本身由 PlaybackDirection.h 中的enum class PlaybackDirection { forward, backward }表达。
GetNext(StretchingSequence.cpp#L46-L87)是纯粹的消费循环:从当前段GetFloats取出样本,段Empty()后迭代到下一段,剩余部分补零,最后按方向把mExpectedStart加或减numSamples。注意其中的注释——反向样本在进入变速算法前已经是反过来的,GetNext层不需要再做翻转:
numProcessedSamples += segment->GetFloats( offsetBuffers, numSamples - numProcessedSamples); // No need to reverse, we feed the // time-stretching algorithm with // reversed samples already.反向访问:段链倒序构建 + clip 读取后原地翻转
ResetCursor(StretchingSequence.cpp#L37-L44)把方向传给工厂:mAudioSegmentFactory->CreateAudioSegmentSequence(t, direction)。工厂在 AudioSegmentFactory.cpp 中按方向分派到两条完全不同的构建路径:
- 正向
CreateAudioSegmentSequenceForward(第 39–65 行):按GetPlayStartTime()升序排序 clip;clip 起点晚于t0时插入SilenceSegment补齐静默间隔;起点早于t0的部分通过durationToDiscard = t0 - clipStart丢弃,产出ClipSegment(clip, discard, forward),t0推进到该 clip 的播放终点。 - 反向
CreateAudioSegmentSequenceBackward(第 67–94 行):按GetPlayEndTime()降序排序;clip 终点早于t0时同样插入静默;durationToDiscard = clip->GetPlayEndTime() - t0,产出ClipSegment(clip, discard, backward),t0回推到 clip 起点。
也就是说,反向播放并不是把整段音频倒过来再正向读,而是从右往左重排 clip 的遍历顺序,并把“从哪一端读起、先丢弃多少”编码进段参数。
真正读到原始样本的是 ClipTimeAndPitchSource.cpp 的Pull(第 43–85 行)。它始终以正向方式向ClipInterface请求样本视图,反向时请求区间从mLastReadSample - numSamplesToRead开始,拷贝完成后调用ReverseSamples把刚读到的样本块原地翻转,然后mLastReadSample向负方向推进;越过 clip 起点后对缓冲补零:
const auto start =forward ? mLastReadSample : mLastReadSample - numSamplesToRead; ... if (!forward) { ReverseSamples( reinterpret_cast<samplePtr>(buffers[i]), floatSample, 0, numSamplesToRead); }每个ClipSegment(ClipSegment.cpp#L37-L62)拿到方向参数后,用它构造ClipTimeAndPitchSource作为输入源,并创建StaffPadTimeAndPitch处理器(参数来自clip.GetStretchRatio()、GetCentShift()等)。反向与正向共用同一个处理器,方向差异完全在“喂给它的样本序列”这一层完成。ClipSegment::GetFloats还会在每次取样本前检查两个原子标志,把播放中实时变化的音高偏移(cent shift)与共振峰保持开关同步给处理器。
随机采样访问:检测到跳变就整链重建
随机访问的处理就是前面重置条件的直接结果:start与mExpectedStart不符,MutableGet就调用ResetCursor,丢弃整条旧段链,从请求位置重新走一遍工厂构建——这正是 readme 所说的“refill the buffer and have it time stretched to produce the requested sample”。开销来自两处:重新构建段链与ClipSegment(含重新创建StaffPadTimeAndPitch),以及durationToDiscard决定要跳过的部分仍会被变速算法“读过去再丢弃”。readme 在 Afterthoughts 一节指出,循环播放是最常见的随机访问来源;目前实现是 loop-unaware(不感知循环边界)的,每次绕圈都会触发一次状态重建,readme 将其列为可能延后的优化方向(如果告知循环边界,可以避免这些重置)。
用库内 Catch2 测试核对行为
该库的测试位于 au3/libraries/au3-stretching-sequence/tests/,基于 Catch2(#include <catch2/catch.hpp>)。与本文两个主题直接相关的断言:
反向读取的正确性(StretchingSequenceTest.cpp#L37-L70,“Samples can be queried backwards”):构造“1s 静默 + 1s clip{1,2,3} + 1s 静默 + 1s clip{4,5,6}”的序列(采样率 3),从start = 12起、len = 2、backwards = true连续查询,注释说明这是在模拟客户端按旧 API 反向读样本的方式。文档给出的期望序列为{6,5} {4,0} {0,0} {3,2} {1,0} {0,0}——能看出反向读依次命中第二支 clip 的尾部、静默区、第一支 clip 的尾部,越界与静默区补零。
更底层的单测 ClipTimeAndPitchSourceTest.cpp 对{1,2,3,4,5}的单声道 clip 用GENERATE(forward, backward)分别拉取两次:forward 得到{1,2,3}然后{4,5,0};backward 得到{5,4,3}然后{2,1,0},验证的正是“正向读 + 原地翻转 + 越界补零”这条机制。
随机访问的重建开销(StretchingSequenceTest.cpp#L72-L110,“reconstructs segment sequence when expected”)用MockAudioSegmentFactory统计工厂调用次数(以下为文档中的示例计数):
| 操作 | 工厂调用次数变化 |
|---|---|
首次查询start=10(初始化,不计) | — |
紧接着查start=13(位置连续、方向不变) | 0 |
重复查start=13(与预期不符) | +1 |
查start=16且backwards=true(方向翻转) | +1 |
| 回到预期位置的 backward 查询 | 0 |
这组断言把“随机访问 = 整链重建、连续访问 = 零重建成本”从 readme 的纸面说法落实成了可运行的验证。
此外 StretchingSequenceTest.cpp#L114-L189 有一组“with real audio”用例:读取CMAKE_SOURCE_DIR下tests/samples/FifeAndDrumsStereo.wav,对单 clip(stretchRatio取 0.75 / 1.5)与多 clip 布局分别以backwards为GENERATE(false, true)跑通整条GetFloats路径;测试中写 wav 输出的outputDir默认被注释掉,仅用于开发者本地听感核对。
边界与限制
- 声道数:
GetNext中有assert(mSequence.NChannels() <= 2),注释标明 “More-than-stereo isn't supported”,即多于立体声的序列不受支持。 - 随机访问开销是已知代价:readme 明确承认 loop-unaware 实现在循环场景下“suboptimally”,并且是刻意选择更简单代码的结果;如果你扩展该库做循环播放优化,
ResetCursor的触发条件是主要切入点。 - 设计阶段的定位:StretchingSequence.h#L25-L27 的注释写明该类最初 “assumes forward reading”,第一目标是支撑导出与渲染(export and rendering);反向与随机访问是在这个骨架上按
backwards标志扩展进来的,读代码时留意这条演进脉络。 ClipSegment头文件提示其对象必须在同一线程构造与销毁(因为它持有Observer::Subscription),扩展时不要跨线程挪动段对象。
进一步阅读可从 ClipInterface.h(clip 提供的采样视图与订阅接口)和 StretchingSequenceIntegrationTest.cpp 入手;处理器本身的实现位于 au3-time-and-pitch 库的StaffPadTimeAndPitch,它消费的就是上面描述的“已按方向整理好”的样本流。
【免费下载链接】audacityAudio Editor项目地址: https://gitcode.com/GitHub_Trending/au/audacity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考