请添加图片描述

1. 前言

旅行结束后,照片通常很好整理,声音却没有那么直观。雨声来自窗边的一次录音,翻书声录在室内,街角铃声又用了另一套录制设置。我们希望把它们放进一张20秒的“声音明信片”,以后再制作“窗边阅读”和“街角散步”两种空间安排。

在摆放声源之前,有一个更基础的问题:这三段声音是否使用同一种时间尺度?

界面上都显示“20秒”,并不意味着文件包含完全相同的时间长度。显示可能经过取整,录音格式也可能不同。如果读取端把44.1 kHz的数据按48 kHz解释,即使字节一个不少,播放时长仍会改变。此时调整空间参数,只会让问题更难定位。

我倾向于先建立一个简单约定:三段素材从项目零点开始,统一转换为48 kHz、双声道、S16LE;短素材在末尾补静音,长素材保留前20秒,不自动循环。雨声、翻书声和街角铃声各自保留独立身份。

这个约定把问题分成了两步:先正确理解并转换声音,再决定它占据项目中的哪段时间。两步都影响长度,却不能合并成一次含糊的“归一化”。

请添加图片描述

图1:格式卡描述原始素材,下面的轨道描述项目范围。文件大小、素材时长与项目时长是三个不同的量。

2. 功能定义

HarmonyOS从API 26开始提供独立的PCM格式转换能力,可转换采样率、声道布局和采样格式。这是本文使用的7.0能力增量。音频编创框架本身已有更早的版本基础,不能把整个处理链都归为此次新增。

转换器接收PCM。MP3或AAC需要先解码,裸PCM则需要随附格式信息。仅凭一个文件名,我们无法知道其中每两个字节代表一个样本,还是每四个字节代表一个浮点数。

理解时间轴的关键单位是样本帧:同一采样时刻,各声道样本组成的一组数据。对于本文采用的交错存储格式:

每样本帧字节数 = 声道数 × 每样本字节数
样本帧数       = 有效PCM字节数 ÷ 每样本帧字节数
时长(秒)     = 样本帧数 ÷ 采样率

双声道S16LE的每个样本帧占4字节。48 kHz表示每秒48,000个样本帧,因此20秒项目包含960,000个样本帧,每路占3,840,000字节裸PCM。双声道已经包含在4字节中,计算时长时不能再除一次声道数。

这个数字规定的是项目长度,并不要求转换器天然输出恰好这么多数据。重采样后的实际长度需要先记录,随后才执行裁切或补齐。

下面用一个小函数把单位约束放进代码。它只负责本文使用的、每样本占整数个字节的交错PCM,不尝试推断文件格式。

#include <cstdint>
#include <stdexcept>

struct PcmLayout {
    uint32_t sampleRate;
    uint32_t channels;
    uint32_t bytesPerSample;
};

uint64_t frameCount(uint64_t bytes, const PcmLayout& format)
{
    if (!format.sampleRate || !format.channels ||
        !format.bytesPerSample) {
        throw std::invalid_argument("invalid PCM format");
    }
    const uint64_t stride =
        uint64_t(format.channels) * format.bytesPerSample;
    if (bytes % stride != 0) {
        throw std::invalid_argument("incomplete sample frame");
    }
    return bytes / stride;
}

素材清单也应该使用同样的语言。为雨声、翻书声、街角铃声分别保存素材ID、授权来源、源文件哈希、源格式、有效字节数和帧数,再记录转换输出与项目素材的路径。裸PCM的格式说明是读取契约的一部分。

请添加图片描述

图2:读写使用字节偏移,工程游标使用样本帧,界面显示秒。每次换算都必须带上对应的格式。

3. 方案与实现

格式转换可以发生在导入阶段,也可以发生在预览阶段。两种路线都能成立,差别在于谁承担状态管理和处理成本。

路线主要收益承担的代价
导入时归一并缓存后续预览重复使用统一输入导入等待、缓存空间和缓存失效管理
预览时实时转换减少预先生成完整缓存的需求播放路径需要处理转换、供数与缓存排空

声音明信片选择前者。20秒素材的范围明确,而后续需要反复比较两种空间安排,把转换留在导入阶段可以减少试听路径中的变量。这个选择并不证明所有场景都应提前转换;长音频或持续输入会改变取舍。

整个导入过程保留三个层次:原始PCM、完整转换输出、20秒项目素材。后两个文件看起来有些重复,但它们回答不同的问题。完整输出用来检查转换是否保留了尾部;项目素材服从剪辑规则。如果直接把输出补成20秒,就可能用静音掩盖一次提前结束。

请添加图片描述

图3:先排空转换缓存并记录完整输出,再执行裁切或补齐。三路素材分别经过相同流程,不在此处混音。

接入转换器需要链接libohaudiosuite.so并包含ohaudiosuite/native_audio_converter.h。调用顺序为OH_AudioConverter_Create、OH_AudioConverter_SetInputCallback、反复调用OH_AudioConverter_Process,最后OH_AudioConverter_Destroy。

输入格式来自素材清单,目标格式则在项目中集中定义:

#include <ohaudiosuite/native_audio_converter.h>

OH_AudioConverter_Format targetFormat = {
    .encodingType = OH_Audio_EncodingType::AUDIO_ENCODING_TYPE_RAW,
    .samplingRate = OH_Audio_SampleRate::SAMPLE_RATE_48000,
    .channelLayout = OH_AudioChannelLayout::CH_LAYOUT_STEREO,
    .sampleFormat = OH_Audio_SampleFormat::AUDIO_SAMPLE_S16LE
};

不要通过修改源格式中的采样率来“完成转换”。源格式是在陈述已有数据的含义,目标格式才是在提出处理要求。

每段素材持有自己的输入上下文,包括源样本帧游标、供数状态和缓冲区。输入偏移按源格式计算;输出帧数按目标格式计算。两个游标即使数值相等,也未必对应相同时间。

真正容易出错的是结束条件。输入回调有三种不同含义的状态:

状态含义调用方需要做什么
HAVE_DATA本次有有效数据提供数据并保持其有效期
NO_AVAILABLE_DATA暂时无数据保留任务,等待供数恢复
DATA_FINISHED输入已经结束继续获取尚未输出的转换数据

表中省略了共同的AUDIOCONVERTER_INPUT_前缀。单次回调最多返回400KB,分块还应落在完整样本帧边界上。回调返回的数据指针必须保持有效,直到本次Process返回;把临时局部缓冲的地址交出去,会破坏这一约定。

“暂时没有”和“以后没有”是两个业务事实。把它们压成一个布尔值,往往会导致断供后无法恢复。下面的应用层伪代码将SDK调用、文件写入和等待机制分开表达:

step = processOnce()

if step.result != AUDIOCONVERTER_SUCCESS:
    failImport(step.result)
    return

if step.outputSize > 0:
    writeExactly(step.output, step.outputSize)
    scheduleNextStep()
    return

if input.finishedReported:
    finishConversion()
else if input.waitingForData:
    resumeWhenDataArrives()
else:
    scheduleNextStepWithProgressGuard()

这里的辅助函数由应用实现。写入失败必须终止导入;重新调度需要允许取消,并检测长期无进展,不能在零输出时无界忙循环。finishedReported表示回调已经报告结束,而不只是应用猜测“文件应该读完了”。

完成判断由三个条件共同构成:Process成功、输出长度为零、输入已结束。结束后继续处理是为了排空转换器缓存;写文件时则始终使用实际输出长度,而不是缓冲区容量。

请添加图片描述

图4:零输出只是一次调用的结果。只有结合输入结束状态和成功返回,才能决定结束转换;暂时断供需要保留恢复路径。

接入时还有两处值得单独核对。官方指南对最后有效块的文字说明与示例存在差异,末块应如何报告状态,需要依据所用SDK的接口契约并用短尾用例核验。不要把这处差异传播到整个业务流程中,应将它收拢在输入回调适配层。

缓冲容量也不能靠术语猜测。“采样率×声道数×每样本字节数”的单位是字节每秒,不是单个样本帧大小。指南中的容量描述和16KB示例不能直接组合成通用分配规则,应按接口要求检查容量与返回值。

完整输出保存后,20秒长度整理可以保持非常简单。下面的函数只接收已经确认格式为目标S16LE的数据:

#include <vector>
#include <cstdint>
#include <stdexcept>

std::vector<uint8_t> fitProject(std::vector<uint8_t> pcm)
{
    constexpr size_t stride = 2 * sizeof(int16_t);
    constexpr size_t targetFrames = 48000 * 20;

    if (pcm.empty() || pcm.size() % stride != 0) {
        throw std::invalid_argument("invalid project material");
    }
    pcm.resize(targetFrames * stride, uint8_t{0});
    return pcm;
}

缩短保留前段,扩展以零填充;零值对S16LE表示静音。这里主动拒绝空素材,避免把读取失败变成一段看似成功的静音。调用前保存完整输出帧数,调用后记录裁切或补齐量。

缓存应先写入临时产物,只有写入、关闭及元数据保存都成功后,才发布为可用素材。源文件哈希、源格式、目标格式和项目规则共同参与缓存识别。任一步骤失败,都应释放已创建资源,并保留失败阶段与返回值。

4. 测试

测试应沿着上述职责划分,而不是只检查最终文件是不是20秒。

第一层测试转换:输入是什么,实际输出多少,结束后是否仍有数据。第二层测试项目规则:短素材是否补齐,长素材是否裁切,两者是否都保留正确的起点。

用例输入安排需要检查的行为
格式组合44.1/48 kHz与单/双声道组合格式、帧数和时长换算正确
整块边界长度为分块整数倍,以及多一个样本帧没有重复或遗漏末块
合法短尾最后一块较短,但含完整样本帧末块交付后仍完成缓存排空
残缺样本帧末尾多出不足一帧的字节明确报告格式或长度错误
暂时断供中间暂停供数,再提供剩余数据不提前完成,恢复后不丢失或重复
空输入零长度并报告结束转换调用能退出,项目层拒绝空素材
项目长度短于、等于、长于20秒补齐、保持、裁切分别符合规则
写入失败在落盘阶段注入失败不发布半成品缓存

为了看清尾部,可以在测试副本末尾加入可定位的提示音。它是测量用信号,不是声音明信片的第四条轨道。比较输入与完整转换输出的尾部,再观察项目裁切是否移除了超出20秒的内容。

请添加图片描述

图5:尾部比较使用裁切前的完整输出,记录表同时保留输入状态、输出帧数和时长差。波形与记录应来自同一用例。

每次记录至少包含设备、系统、SDK、素材哈希、输入输出格式、累计帧数、末次供数状态、Process返回值和补齐或裁切量。对断供用例,使用同一素材的连续供数作为对照,保持分块策略及目标格式一致。

重采样后的理论帧数可以用输入帧数×目标采样率÷输入采样率估算,但实际输出是整数帧,还涉及转换器的尾部行为。因此应同时记录帧数差和时长差,不能未经核实便要求逐字节相等。

尤其要区分三种现象:按错误采样率解释数据、没有排空转换缓存、项目主动裁切。它们都可能表现为“声音变短”,修复方式却完全不同。一个960,000帧的文件只能证明项目长度满足约定,不能单独证明铃声的尾音得到了保留。

5. 结论

三段录音共享时间轴,需要的是一套明确的数据契约:源格式描述已有数据,转换器生成目标格式,项目规则决定最终长度。样本帧把这三者连接起来,同时保留各自的职责。

本篇形成的转换流程、素材清单字段、时间换算方法和短尾、断供用例,可以共同约束导入实现。后续读取归一素材时,仍需携带格式说明;裸PCM并不会因为保存成功,就自动成为普通播放器可识别的分享文件。

下一篇将使用同一批雨声、翻书声和街角铃声,为每路声音设置独立位置,建立“窗边阅读”和“街角散步”两套安排。两版保留相同素材、时间范围和增益基线。这样,比较时听到的差异才能更清楚地归因于空间安排。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐