管理中心
我的
|

HarmonyOS 6

HarmonyOS 5

HarmonyOS 7

平板接蓝牙 MIDI 钢琴做低延迟可视化,怎样把 MIDI 事件时间戳和音频输出时钟对齐,避免音符掉落和画面滞后?

10
平板接蓝牙 MIDI 钢琴做低延迟可视化,怎样把 MIDI 事件时间戳和音频输出时钟对齐,避免音符掉落和画面滞后?
点赞
1
收藏
回复
14
分享
举报
浏览95 发布于2026-10-22 09:25北京
全部评论
最多点赞
最新发布
最早发布
|
技术支持
尊敬的开发者,您好,
关于您的反馈的问题,
问题分析
MIDI 事件时间戳、音频输出时钟、渲染 vsync 是三条独立时间轴,未统一到同一基准。当可视化以 MIDI 到达时刻驱动音符下落时,BLE 传输抖动(连接间隔 7.5~15ms+)直接传导到画面,导致音符掉落和画面滞后。根因是时钟未收敛:MIDI 到达时刻 ≠ 真实发声时刻,而画面却以 MIDI 到达时刻为准。
解决方案
以音频输出时钟为主时钟,MIDI 到达只负责触发,音符的"出生时间"以它写入音频输出帧的帧号为准,可视化每帧用音频时钟闭式换算位置。分三层实施:
第 1 层:MIDI 接收——尽早打戳 + 防掉落
- 在 Native/NAPI notify 回调入口处立即取 CLOCK_MONOTONIC 时间戳,不要等事件分发到 ArkTS 层再打
- 回调只做两件事:打戳 → 推入无锁环形队列;解析放消费线程
- 环形队列满时丢最旧不丢最新;BLE MIDI 包内 13-bit 时间戳在 10~20ms 窗口内重排序消除乱序
- 订阅 GATT 断连事件,断连后重建订阅
第 2 层:音频输出——构建主时钟
- 使用 OHAudio!Audio 低时延通路(AUDIO_LATENCY_MODE_FAST),采样率与设备原本一致(48k),不插 SRC
- 合成引擎直接跑在音频回调线程,NoteOn 到达 → 取空闲 Voice → 混音写入
- 周期性(≥200ms,建议 500ms)调用 OH_AudioRenderer_GetTimestamp(clockId=CLOCK_MONOTONIC)获取锚点 (framePosition, timestamp_ns)
- 帧间内插推进:audioClockUs(now) = (anchorFrame + (now - anchorTimeNs) × sampleRate / 1e9) × 1e6 / sampleRate
第 3 层:对齐与渲染
- NoteOn 写入音频通路的第 W 帧时,记录 (noteId, W),音符发声时刻 = W × 1e6 / sampleRate
- 可视化判定线击中时刻用音频域时刻,而非 MIDI 到达时刻
- 每帧 vsync 回调:y位置 = 判定线Y - (音符播出时刻 - audioClockUs(now)) × 下落!落速度
- 给合成写入加 10~20ms 固定前瞻(lookahead)吸收 BLE 抖动
- 音画同步阈值:偏移在 -90ms(音频滞后)~ +20ms(音频超前)内人感知不到
具体可参考如下链接

https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/audio-video-synchronization
https://juejin.cn/post/7545756451494150187

代码示例
// 锚点结构
typedef struct {
    int64_t anchorFrame;      // GetTimestamp 返回的 framePosition
    int64_t anchorTimeNs;     // GetTimestamp 返回的 timestamp(纳秒)
    int32_t sampleRate;       // 采样率
} AudioClockAnchor;
// 周期性更新锚点(建议 500ms 一次,不低于 200ms)
void updateAnchor(OH_AudioRenderer* renderer, AudioClockAnchor* anchor) {
    OH_AudioRenderer_GetTimestamp(renderer, CLOCK_MONOTONIC,
                                  &anchor->anchorFrame, &anchor->anchorTimeNs);
}
// 任意时刻的音频时钟(微秒)——闭式计算,误差不累积
double audioClockUs(int64_t nowNs, const AudioClockAnchor* anchor) {
    int64_t dFrame = (int64_t)((nowNs - anchor->anchorTimeNs) * anchor->sampleRate / 1e9);
    return (anchor->anchorFrame + dFrame) * 1e6 / anchor->sampleRate;
}
// vsync 回调中计算音符 Y 位置
float noteYPosition(double notePlayTimeUs, double currentAudioClockUs,
                    float judgeLineY, float fallSpeedUsToPixel) {
    return judgeLineY - (notePlayTimeUs - currentAudioClockUs) * fallSpeedUsToPixel;
}
// MIDI notify 回调——尽早打戳
void onMidiNotify(const uint8_t* data, size_t len) {
    int64_t timestampNs = getClockMonotonicNs();  // 第一时间打戳
    ringBufferPush(timestampNs, data, len);        // 推入无锁环形队列
}

合作愉快。
华为开发者联盟
展开全部
1
6楼回复于7天前 来自广东
  • 匿名用户匿名用户
    厉害了,值得学习
    6天前 来自北京

如果声音由平板合成,建议以“实际播放位置”为基准,让音频和画面走同一条时间线,别按 MIDI 回调到达时间直接刷新 UI。

  • 统一时钟:API 24+ 的 OH_MIDIEvent.timestamp 使用 CLOCK_MONOTONIC 纳秒时间;音频用 OH_AudioRenderer_GetTimestamp(..., CLOCK_MONOTONIC, ...) 获取播放位置 P 和时间 T。正常速率下,目标播放时间 t 对应位置约为 P + (t-T)×采样率/10⁹,不要混用 Date.now()。MIDI 时间戳、音频播放时间戳
  • 接收只负责入队:MIDI 回调里复制数据、保留顺序并快速返回;原始数据指针只在回调期间有效。音频线程消费事件,UI 每帧批量读取,避免每个音符都跨语言刷新。官方 MIDI 说明
  • 抖动和迟到分开处理:设置小幅、可调的缓冲预算;已经赶不上播放位置的事件放到下一个可写音频块,并让画面跟随实际安排的播放时间。保留 Note On/Off,队列溢出要记录并恢复按键状态,别靠丢音符降负载。
  • 画面按时间算位置:每帧根据音频时间线计算音符位置,掉帧后直接追上,不累加固定位移;暂停恢复、切换输出设备后重新建立时间锚点。

注意:如果声音来自钢琴自身扬声器,平板音频时钟就不是它的出声时钟,只能结合实测校准显示偏移;蓝牙传输已经造成的延迟,无法靠时间戳消除。

8楼回复于7天前 来自广东
我怎么记得我回答过这个问题?你要用OH_MIDIEvent.timestamp 做统一时间基准,把 MIDI 事件放进按时间排序的播放队列,再让音频时钟和渲染时钟都去对齐这个队列。 在鸿蒙上, OH_MIDIEvent.timestamp 来自 clock_gettime(CLOCK_MONOTONIC) ,单位纳秒,适合作为跨 MIDI、音频、渲染的公共时间轴。
12楼回复于7天前 来自山西

MIDI 接收时刻、设备事件时间戳和音频播放时钟不能直接相减,先确认各自单位、原点、回绕及蓝牙传输延迟。建立持续校准的时钟映射,再按音频输出位置调度可视化,避免每收到一个包就直接推动画面一帧。

MIDI 队列设上限并保留音符开/关配对,忙时可合并视觉进度但不能丢 note-off;音频与 UI 分线程,UI 按当前播放位置读快照。仅把数据搬到 Worker 不能保证低延迟,需分别量输入传输、缓冲、音频输出和显示延迟,补所用 MIDI/音频 API 后再选对应时间戳接口。

11楼回复于7天前 来自四川

这个问题的核心是两条链路的时间基准统一:MIDI 事件进来到画面渲染走的是 MIDI 时钟,音频合成输出走的是音频 DMA 时钟,两条路不对齐就一定出"音画分离"和"音符丢失"。

1. MIDI 时间戳和音频时钟用同一个 CLOCK_MONOTONIC 桥接

HarmonyOS 的 MIDI C API(API 24 起)接收事件时,OH_MIDIEvent.timestamp 单位是纳秒,基准就是 clock_gettime(CLOCK_MONOTONIC)。音频侧的 OH_AudioRenderer_GetTimestamp(renderer, CLOCK_MONOTONIC, &framePos, &timestamp) 返回的也是 CLOCK_MONOTONIC 纳秒。两边天然共享同一个单调时钟,不需要自己做时钟换算。

对齐思路:收到 MIDI 事件时,用事件的 timestamp 减去当前音频渲染位置对应的 timestamp,差值就是该音符相对当前音频输出的时间偏移。可视化线程拿这个偏移决定什么时候亮这个音符,音频合成线程拿它决定什么时候把 PCM 填进 buffer。

2. 避免音符掉落:用低时延模式 + 音频工作组

音符掉落一般是两个原因:BLE 传输抖动导致事件堆积,或者音频线程被系统调度饿死。

音频侧必须开低时延模式:

OH_AudioStream_LatencyMode latencyMode = AUDIOSTREAM_LATENCY_MODE_FAST;
OH_AudioStreamBuilder_SetLatencyMode(builder, latencyMode);

开低时延后 buffer 更小,数据回调更频繁,回调里不能做任何耗时操作,否则 underrun 直接变静音。用 OH_AudioRenderer_GetUnderflowCount() 可以监控欠载次数,持续增长就说明数据喂得不够快。

然后注册音频工作组(API 20 起),让系统给音频关键线程更高的调度优先级:

OH_AudioResourceManager *resMgr;
OH_AudioManager_GetAudioResourceManager(&resMgr);
OH_AudioWorkgroup *grp = nullptr;
OH_AudioResourceManager_CreateWorkgroup(resMgr, "midi_viz", &grp);
int32_t tokenId;
OH_AudioWorkgroup_AddCurrentThread(grp, &tokenId);
// 每个 callback 周期
OH_AudioWorkgroup_Start(grp, startTimeMs, startTimeMs + intervalMs);
// ... 数据处理 ...
OH_AudioWorkgroup_Stop(grp);

文档在这:音频工作组管理、低时延音频播放

3. 避免画面滞后:MIDI 收到后不要走 ArkTS 主线程刷新 UI

MIDI 回调 OH_MIDIDevice_OnReceived 跑在 Native 线程,如果每收到一个事件就通过 NAPI 回调到 ArkTS 侧 setState 刷新 UI,JS 主线程的帧调度延迟至少 16ms,加上 VSync 等待很容易累积到 40-60ms。

推荐做法:在 Native 侧维护一个环形缓冲区存 MIDI 事件,ArkTS 侧用 @Reusable 组件 + DisplaySync(或者用 NAPI 的线程安全函数 napi_call_threadsafe_function)按 VSync 节拍从缓冲区取事件批量刷新,而不是来一个刷一个。

4. BLE MIDI 的权限和注意事项

BLE 设备要用 OH_MIDIClient_OpenBLEDevice 异步打开,需要声明 ohos.permission.ACCESS_BLUETOOTH 权限。如果没拿到蓝牙权限,蓝牙 MIDI 设备不会出现在设备列表里。BLE 本身有 7.5ms~20ms 的连接间隔抖动,这是硬件层面的,软件没法消除,只能靠上面说的时钟对齐策略在渲染侧补偿。

完整最小 demo

#include <ohmidi/native_midi.h>
#include <ohmidi/native_midi_base.h>
#include <ohaudio/native_audiostreambuilder.h>
#include <ohaudio/native_audiorenderer.h>
#include <ohaudio/native_audio_resource_manager.h>
#include <time.h>
#include <stdio.h>

#define SAMPLE_RATE 48000
#define CHANNEL_COUNT 2
#define FRAME_SIZE 256

static OH_AudioRenderer *g_renderer = nullptr;
static OH_AudioWorkgroup *g_workgroup = nullptr;
static int64_t g_audioStartTimestamp = 0;
static int64_t g_audioStartFrame = 0;

typedef struct {
    int64_t timestamp;
    uint32_t note;
    uint8_t velocity;
    uint8_t isOn;
} MidiNoteEvent;

#define RING_SIZE 1024
static MidiNoteEvent g_ring[RING_SIZE];
static volatile int g_ringHead = 0;
static volatile int g_ringTail = 0;

static void PushMidiEvent(int64_t ts, uint32_t note, uint8_t vel, uint8_t isOn) {
    int next = (g_ringTail + 1) % RING_SIZE;
    if (next != g_ringHead) {
        g_ring[g_ringTail].timestamp = ts;
        g_ring[g_ringTail].note = note;
        g_ring[g_ringTail].velocity = vel;
        g_ring[g_ringTail].isOn = isOn;
        g_ringTail = next;
    }
}

static int64_t GetMonotonicNs() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}

static int64_t GetCurrentAudioTimeNs() {
    int64_t framePos = 0;
    int64_t timestamp = 0;
    OH_AudioStream_Result ret =
        OH_AudioRenderer_GetTimestamp(g_renderer, CLOCK_MONOTONIC, &framePos, &timestamp);
    if (ret == AUDIOSTREAM_SUCCESS) {
        return timestamp;
    }
    return GetMonotonicNs();
}

static void OnMidiReceived(void *userData, const OH_MIDIEvent *events, size_t eventCount) {
    for (size_t i = 0; i < eventCount; i++) {
        int64_t midiTs = events[i].timestamp;
        if (midiTs == 0) {
            midiTs = GetMonotonicNs();
        }
        uint32_t *data = events[i].data;
        uint8_t status = (data[0]) & 0xFF;
        uint8_t isOn = (status & 0xF0) == 0x90;
        uint8_t note = (data[0] >> 8) & 0xFF;
        uint8_t velocity = (data[0] >> 16) & 0xFF;
        PushMidiEvent(midiTs, note, velocity, isOn);
    }
}

static OH_AudioData_Callback_Result OnWriteData(
    OH_AudioRenderer *renderer, void *userData, void *audioData, int32_t audioDataSize) {
    int64_t startTime = GetMonotonicNs();
    int32_t intervalMs = (audioDataSize / (CHANNEL_COUNT * 2)) * 1000 / SAMPLE_RATE;
    OH_AudioWorkgroup_Start(g_workgroup, startTime / 1000000, startTime / 1000000 + intervalMs);

    memset(audioData, 0, audioDataSize);
    // TODO: 根据 MIDI 事件合成 PCM 数据填入 audioData
    // 用 GetCurrentAudioTimeNs() 和 MIDI event timestamp 对齐

    OH_AudioWorkgroup_Stop(g_workgroup);
    return AUDIO_DATA_CALLBACK_RESULT_VALID;
}

int main() {
    // 1. 创建音频工作组
    OH_AudioResourceManager *resMgr;
    OH_AudioManager_GetAudioResourceManager(&resMgr);
    OH_AudioResourceManager_CreateWorkgroup(resMgr, "midi_viz", &g_workgroup);
    int32_t tokenId;
    OH_AudioWorkgroup_AddCurrentThread(g_workgroup, &tokenId);

    // 2. 创建低时延音频渲染器
    OH_AudioStreamBuilder *builder;
    OH_AudioStreamBuilder_Create(&builder, AUDIOSTREAM_TYPE_RENDERER);
    OH_AudioStreamBuilder_SetSamplingRate(builder, SAMPLE_RATE);
    OH_AudioStreamBuilder_SetChannelCount(builder, CHANNEL_COUNT);
    OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE);
    OH_AudioStreamBuilder_SetEncodingType(builder, AUDIOSTREAM_ENCODING_TYPE_RAW);
    OH_AudioStreamBuilder_SetLatencyMode(builder, AUDIOSTREAM_LATENCY_MODE_FAST);
    OH_AudioStreamBuilder_SetRendererInfo(builder, AUDIOSTREAM_USAGE_MUSIC);
    OH_AudioStreamBuilder_SetRendererWriteDataCallback(builder, OnWriteData, nullptr);
    OH_AudioStreamBuilder_GenerateRenderer(builder, &g_renderer);
    OH_AudioRenderer_Start(g_renderer);

    // 3. 创建 MIDI 客户端,打开 BLE 设备
    OH_MIDIClient *midiClient;
    OH_MIDICallbacks callbacks = {nullptr, nullptr};
    OH_MIDIClient_Create(&midiClient, callbacks, nullptr);

    // 发现 BLE 设备后异步打开
    // OH_MIDIClient_OpenBLEDevice(midiClient, deviceAddr, onDeviceOpened, nullptr);

    // 打开输入端口,注册回调
    // OH_MIDIPortDescriptor desc = {portIndex, OH_MIDI_PROTOCOL_1_0};
    // OH_MIDIDevice_OpenInputPort(device, desc, OnMidiReceived, nullptr);

    // 4. 主循环:可视化线程从 g_ring 取事件按 VSync 刷新
    while (1) {
        int64_t audioNow = GetCurrentAudioTimeNs();
        while (g_ringHead != g_ringTail) {
            MidiNoteEvent *evt = &g_ring[g_ringHead];
            int64_t delay = evt->timestamp - audioNow;
            // delay > 0: 事件还没到播放时间,可视化可以预留
            // delay < 0: 事件已过期,需要立即处理或丢弃
            printf("Note %u vel %u delay %ld ns\n", evt->note, evt->velocity, delay);
            g_ringHead = (g_ringHead + 1) % RING_SIZE;
        }
        usleep(8000); // ~120Hz 轮询
    }
    return 0;
}

环境信息:MIDI C API 从 API 24 起可用,低时延播放从 API 10 起,音频工作组从 API 20 起。BLE 设备需声明 ohos.permission.ACCESS_BLUETOOTH。我没在真机上跑过 BLE MIDI + 可视化的完整链路,时间戳对齐的精度取决于具体设备的 BLE 连接间隔,你先试试看延迟在不在可接受范围内。

展开全部
10楼回复于7天前 来自四川

HarmonyOS 的 MIDI 和 Audio 用的都是 CLOCK_MONOTONIC,时钟基准天然一致,不需要你自己做时钟域转换。问题主要出在链路延迟和可视化刷新策略上。

1. 时间戳对齐:两个 API 共享同一个时钟

OH_MIDIEvent 的 timestamp 字段单位是纳秒,基于 clock_gettime(CLOCK_MONOTONIC) 获取——这个在OH_MIDIEvent 结构体文档里有明确说明。

音频侧,OH_AudioRenderer_GetTimestamp 同样返回 CLOCK_MONOTONIC 基准的纳秒时间戳和对应的帧位置。文档原文:"该接口可以获取到音频通道实际播放位置(framePosition)以及播放到该位置时的时间戳(timestamp)。该接口通常用来实现音画同步。"——native_audiorenderer.h

所以对齐逻辑很简单:

// MIDI 回调中记录事件
static void OnMIDIReceived(void *userData, const OH_MIDIEvent *events, size_t eventCount) {
    for (size_t i = 0; i < eventCount; i++) {
        MidiNote note;
        note.timestamp = events[i].timestamp;  // CLOCK_MONOTONIC 纳秒
        // 解析 UMP data 获取音高、力度等
        ParseUMP(events[i].data, events[i].length, &note);
        noteQueue.Push(note);
    }
}

// 可视化线程中对比音频播放位置
void SyncVisual() {
    int64_t framePos = 0;
    int64_t audioTs = 0;
    OH_AudioRenderer_GetTimestamp(renderer, CLOCK_MONOTONIC, &framePos, &audioTs);
    // audioTs 就是当前正在播的音频帧对应的 CLOCK_MONOTONIC 时间
    // 用它来决定哪些 MIDI 事件该显示
    int64_t nowNs = audioTs;
    while (!noteQueue.Empty()) {
        auto &note = noteQueue.Front();
        if (note.timestamp <= nowNs) {
            TriggerVisual(note);  // 触发可视化
            noteQueue.Pop();
        } else {
            break;
        }
    }
}

2. 降低音频延迟:低时延模式 + 小帧长

这是避免"按键到出声"滞后的关键。两步:

第一步,创建 AudioRenderer 时设低时延模式:

OH_AudioStreamBuilder_SetLatencyMode(builder, AUDIOSTREAM_LATENCY_MODE_FAST);

第二步,缩小回调帧长,官方建议低时延模式可设 5ms 对应帧数:

// 48000Hz * 5ms = 240 帧
OH_AudioStreamBuilder_SetFrameSizeInCallback(builder, 240);

设置完后用 OH_AudioRenderer_GetFastStatus() 确认确实跑在低时延通路上。如果设备不支持或被高优先级流抢占,会自动降回普通模式——这个在低时延音频播放文档里有写。

回调里别做耗时操作,数据填不满就返回 AUDIO_DATA_CALLBACK_RESULT_INVALID,别硬填导致杂音。

3. 可视化画面不滞后:用 Vsync 驱动,别用定时器

画面滞后的常见原因是用 setInterval 或 setTimeout 刷新可视化,调度精度不够。正确做法是在 UI 层用显示帧回调(Vsync)驱动渲染,每帧从 MIDI 事件队列里取当前时刻该显示的音符。

ArkUI 侧可以用 @Watch 监听状态变化触发更新,或者用 XComponent + Native 层直接在 Vsync 回调里刷新。关键是用 OH_AudioRenderer_GetTimestamp 拿到音频当前播放时刻,以此为基准判断 MIDI 事件是否该亮起,而不是用系统时间自己估算。

4. BLE MIDI 额外延迟注意

蓝牙 MIDI 本身就有传输延迟(BLE 连接间隔通常 15-30ms),这个是硬件协议层面的,软件无法消除。如果对延迟极度敏感,优先用 USB MIDI 连接。用 BLE 的话,OH_MIDIClient_OpenBLEDevice 打开设备,枚举端口和接收数据的流程跟 USB 一样,但时间戳里已经包含了 BLE 传输耗时,你不需要额外补偿。

BLE MIDI 需要在 module.json5 里声明 ohos.permission.ACCESS_BLUETOOTH 权限。

相关文档:

展开全部
9楼回复于7天前 来自四川
平板接 BLE MIDI 做低延迟可视化,核心是用音频输出时钟当主时钟,MIDI 只做“事件入队+按音频时间调度”,不按收到时刻直接画。

1) MIDI 采集与防丢

用 OH_MIDI:BLE 声明 "ohos.permission.ACCESS_BLUETOOTH","OH_MIDIClient_OpenBLEDevice" 异步打开、"OH_MIDIDevice_OpenInputPort" 注册 "OnReceived"。回调在独立线程,events 仅回调期有效,必须先 memcpy 出 UMP/解析结果再入队;用互斥保护共享队列,回调内禁重活/IO。

入队按 "(deviceId,port,note,velocity,type)" 去重;NoteOn 无 NoteOff 用超时自动 off,CC/SysEx 单独限流;回发用 "OH_MIDIDevice_Send","WOULD_BLOCK" 重投不丢。

2) 音频时钟为主轴

AudioRenderer 开低延迟/固定采样率,周期调用 "OH_AudioRenderer_GetTimestamp(CLOCK_MONOTONIC,&framePosition,×tamp)" 取硬件已播帧与时间,≥200ms 一次;音频当前秒="framePosition/sampleRate",再加输出延迟估算得“预计发声时间”。

3) 事件→画面对齐

MIDI 解析后写入按音频时间排序的待播队列,look‑ahead 调度:

- 音源:把合成样本按目标音频时间写入 Renderer 缓冲;
- 可视化:计算每音在音频时间 T 激活,渲染循环用音频时钟插值高亮,未到 T 不画、已过 T 且超 jitter 窗口(如>30ms)判迟到,合并/丢弃重发,不补重复音。
蓝牙有无线抖动,接收时刻只打单调时间戳用于诊断,不用于硬对齐。

4) 回主线程更新 UI

Native 算出“当前音频时间→激活音符/力度/轨道”快照,经 TaskPool/NAPI 回 ArkTS,用 "UIContext.executeLater" 或动画帧更新 ArkUI;不用 setInterval 盲跟系统时间。
7楼回复于7天前 来自北京

结论:平板连接蓝牙 MIDI 钢琴进行低延迟可视化,关键不是让 ArkUI 每收到一个 MIDI 消息就立即刷新,而是建立统一的时间轴,让 MIDI 事件、音频播放进度和画面动画基于同一时钟计算。

推荐架构:

BLE MIDI → MIDI 时间戳校准 → 事件缓冲队列 → 音频时钟同步 → ArkUI/VSync 渲染。

另外,需要先区分两种业务:

  • 钢琴自己发声,平板只显示音符: 主要对齐钢琴实际发声时间和屏幕显示时间。

  • 平板接收 MIDI 并通过 AudioRenderer 合成声音: 需要同时对齐 MIDI 事件、PCM 音频采样位置和显示时间。

目前 HarmonyOS Audio Kit 已提供 USB MIDI、蓝牙 BLE MIDI 设备接入及 MIDI 消息收发能力,可优先采用官方 MIDI 能力,而不是自行从 BLE GATT 开始实现协议。

一、整体架构建议

cke_2398.png

不要直接使用 Date.now() 作为精确同步时钟。 它是墙上时间,可能受到系统时间调整影响。应优先使用系统单调时钟,并将不同来源的时间戳转换到统一时间基准。

二、MIDI 时间戳怎么与音频时钟对齐?

建议对每个 MIDI 事件保留以下信息:

interface MidiEvent {
  note: number;
  velocity: number;
  type: 'noteOn' | 'noteOff';

  // 统一后的单调时钟,单位纳秒
  eventTimeNs: number;

  // 实际收到消息的时间
  receivedTimeNs: number;

  sequence: number;
}

需要注意:BLE MIDI 协议中的时间戳通常是设备侧的相对时间信息,不是平板的系统时间。它还可能存在计数回绕。

如果系统 MIDI API 已经给出明确时间基准的事件时间戳,优先使用系统提供的时间,不要自行重复校准;如果拿到的是原始 BLE MIDI 时间戳,则需要进行回绕展开、偏移估计和漂移校正。

可以用如下关系理解:

\[ T_{\text{host}} \approx aT_{\text{midi}}+b \]

cke_5339.png

其中:

  • \(T_{\text{midi}}\):设备 MIDI 时间戳。

  • \(a\):设备与平板时钟速率差的校正系数。

  • \(b\):两个时钟的时间偏移。

  • \(T_{\text{host}}\):转换后的平板单调时钟时间。

但通过蓝牙单向接收时间戳,通常无法精确分离无线传输延迟和两端时钟偏移。最好再通过实际测量估计延迟,不要把收到事件的时间直接认定为按键发生时间。

音频播放侧

如果平板负责发声,应以音频实际播放进度为参考。

例如 48kHz 采样率下:

\[ T_{\text{audio}}=T_0+\frac{N}{48000} \]

这里的 \(N\) 是相对于播放时间原点的音频采样帧数。

注意这里必须使用已经呈现或预计呈现的采样帧位置,不能简单使用应用已经写入缓冲区的帧数,因为音频队列和硬件还存在输出延迟。

HarmonyOS OHAudio 提供低延迟播放模式,适合对实时响应要求较高的音频场景,但是否真正进入低延迟链路还取决于设备和输出路由。

三、怎样让音符动画和声音同步?

不要在 MIDI 回调里直接执行:

// 不推荐
onMidiMessage(event) {
  this.notes.push(event);
  animateTo({ duration: 300 }, () => {
    // 移动音符
  });
}

这种写法把动画起点绑定到事件到达时间。一旦 BLE 数据有抖动、ArkUI 主线程繁忙,音符动画就会出现卡顿或落后。

更推荐根据统一时间轴,直接计算每一帧音符应处于的位置。

假设音符下落到判定线需要 1500ms:

\[ y(t)=y_{\text{start}}+ \frac{t-(T_{\text{hit}}-1500)}{1500} (y_{\text{hit}}-y_{\text{start}}) \]

其中 T_hit 是音符应该到达判定线的时间。渲染时使用预测屏幕呈现时间,而不是简单使用回调执行时间。

ArkTS 逻辑示例

interface FallingNote {
  note: number;
  hitTimeMs: number;
}

function calculateNoteY(
  note: FallingNote,
  renderTimeMs: number,
  startY: number,
  hitY: number
): number {
  const fallDurationMs = 1500;

  const progress = Math.max(
    0,
    Math.min(
      1,
      (renderTimeMs -
       (note.hitTimeMs - fallDurationMs)) /
       fallDurationMs
    )
  );

  return startY + (hitY - startY) * progress;
}

将它应用到统一渲染循环中,即使某一帧被跳过,下一帧也会计算出正确位置,不会因为少执行一次动画更新而持续落后。

不过有个重要区别:如果这是自由弹奏可视化,系统只能在音符事件到达后得知它发生了,无法提前 1500ms 预测下一次自由弹奏的音符。这套下落算法更适合已有乐谱、节奏游戏或教学引导;自由弹奏应实时点亮琴键、绘制音符轨迹,而不是假设可以提前生成音符。

四、避免掉音和卡顿的关键措施

问题

推荐处理

BLE MIDI 消息突发到达

使用有界队列,按事件时间戳排序

同时按多个琴键

按时间戳批量处理,保留所有 Note On/Off

主线程忙导致渲染延迟

Native/Worker 处理 MIDI,ArkUI 只绘制当前状态

音频出现爆音或断续

低延迟音频缓冲、避免音频回调内分配内存

音符偶发卡住

处理断连、丢失 Note Off、延音踏板 CC64

蓝牙设备重连

重新校准时钟偏移和连接状态

切换扬声器或耳机

重新测量输出延迟及音频时钟

屏幕帧率变化

按时间计算位置,不按帧数累计位移

对于高频 MIDI 数据,不建议每个事件都直接修改多个 @State 变量。可以让 Native 层维护实时状态,ArkUI 以屏幕刷新节奏获取一份快照,减少频繁的跨语言和状态更新开销。

另外,OHAudio 的音频数据回调应保持短小,不能执行耗时 I/O 或等待 ArkUI 响应,否则可能导致音频缓冲供给不及时。

五、延迟应该如何校准?

建议测量四段延迟:

cke_9996.png

可以先采用如下测试目标,而不是当作系统保证:

指标

初始优化目标

MIDI 接收到调度入队

尽量低于 2ms

同步抖动缓冲

从 5~10ms 起测

ArkUI 视觉偏差

尽量控制在一帧附近

连续弹奏漏处理率

目标为 0

音频缓冲欠载次数

目标为 0

特别建议使用高速摄像机或外部音频采集设备,同时记录琴键动作、平板画面和实际声音。单纯比较软件日志无法完整反映蓝牙传输、DAC 和显示扫描带来的实际延迟。

如果钢琴自己发声,要以钢琴声音为基准做补偿;如果平板自己合成声音,要根据音频播放位置估计实际出声时间,分别校准画面和音频链路。

六、最终推荐实现

对于需要低延迟的 MIDI 教学、电子琴音符可视化应用,我建议:

  • MIDI 接入: 优先使用 HarmonyOS Audio Kit 的 MIDI 设备通信能力。

  • 事件处理: Native 或独立任务执行环境管理事件队列,使用单调时间戳。

  • 音频输出: 使用 OHAudio,评估 FAST 低延迟模式及当前输出设备能力。

  • UI 绘制: 使用统一时间轴和帧驱动位置计算,避免事件驱动的大量动画。

  • 延迟校准: 区分 MIDI 接收延迟、音频实际输出延迟和屏幕显示延迟。

最关键的一点:不要试图让 BLE 回调、音频回调和 ArkUI 刷新回调同时执行;应该让它们共享一个可校准的时间基准。

官方参考:HarmonyOS Audio Kit(含 MIDI 设备通信)。

如果要进一步做到音符与声音的毫秒级同步,真正需要优先确定的是:钢琴自己发声,还是平板接收 MIDI 后通过音源合成并发声? 前者主要处理视觉补偿,后者才需要完整的音频采样时钟同步设计。

展开全部
1
5楼回复于2026-10-22 10:19 来自山西
  • 匿名用户匿名用户
    回答的不错,很专业
    2026-10-22 23:33 来自北京
三个时钟本来就是同一个:MIDI 事件时间戳、音频播放时间戳、VSync 时间戳都是 CLOCK_MONOTONIC 的纳秒值,不用做时钟换算。正确做法是以音频输出位置为主时钟:MIDI 事件按"时间戳加固定提前量 L"换算成采样位置,在音频回调里精确起音,画面用同一个发声时刻去画,音符条位置用绝对时间算,不要逐帧累加。
原因
1、同源有官方依据:OH_MIDIEvent.timestamp 的基准是 clock_gettime(CLOCK_MONOTONIC);OH_AudioRenderer_GetTimestamp 传 CLOCK_MONOTONIC 取到的是实际播放位置和播放到该位置的时刻;OH_NativeVSync 回调的 timestamp 也是 CLOCK_MONOTONIC。所以只要建立"采样位置对应哪个时刻"的关系,三者就能互相换算。
2、为什么以音频为主时钟:声音不能抖,画面丢一帧人几乎察觉不到,官方的音画同步方案也是视频向音频对齐。
3、为什么要固定提前量 L:蓝牙事件到达时间不均匀,收到就发声会带着抖动。按"时间戳加 L"排程,延迟恒定,抖动被吸收。迟到的事件不丢,钳到当前块起点发声,并自动把 L 调大。
4、为什么画面滞后、音符条和声音错位:常见写法是 y += speed * dt 逐帧累加,误差会累积,而且画的是"此刻"而不是"这一帧上屏的时刻"。
结合你的场景
每 100ms 左右在渲染线程里更新一次锚点,别放在音频回调里:cpp
int64_t pos = 0, ns = 0;
if (OH_AudioRenderer_GetTimestamp(renderer, CLOCK_MONOTONIC, &pos, &ns) == AUDIOSTREAM_SUCCESS && pos > lastPos) {
    lastPos = pos;
        anchor.Store({pos, ns});  // 序列锁,音频线程只读
        }
        int64_t NsToSample(const Anchor &a, int64_t ns) { return a.pos + (ns - a.ns) * 48000 / 1000000000LL; }
        int64_t SampleToNs(const Anchor &a, int64_t s) { return a.ns + (s - a.pos) * 1000000000LL / 48000; }

音频回调每块开头,把 MIDI 队列里的事件排程:

int64_t target = NsToSample(a, ev.timestamp + leadNs);
if (target < blockStart) {  // 迟到:不丢,立即发声并加大 L
    target = blockStart;
        leadNs = std::min<int64_t>(leadNs + 2000000, 30000000);
        }
        // target 不在本块内的留到下一块再排程
        synth.Schedule(target - blockStart, ev.note, ev.vel, ev.on);  // 块内偏移起音
        visualQ.Push({SampleToNs(a, target), ev.note, ev.on});        // 同一个发声时刻交给画面

画面在 VSync 回调里按上屏时刻画:

void OnVsync(long long tv, void *data) {
      int64_t sceneNs = tv + displayDelayNs;  // 这一帧实际上屏的时刻
          // 琴键:audibleNs <= sceneNs 的事件生效
              // 音符条:y = (sceneNs - audibleNs) * speed,不做 y += speed * dt
                  OH_NativeVSync_RequestFrame(vsync, OnVsync, data);
                  }
}

MIDI 回调里只解析 UMP 里 MT 为 0x2 的 Note On 和 Note Off(力度为 0 的 Note On 按 Note Off 处理),写进无锁队列。官方要求回调里复制数据,且不能阻塞超过 1ms。OH_MIDI 从 API 24 起提供,先用 canIUse(“SystemCapability.Multimedia.Audio.MIDI”) 判断,蓝牙设备要声明 ohos.permission.ACCESS_BLUETOOTH。

参数怎么定:

1.音频流用 48000Hz 加 AUDIOSTREAM_LATENCY_MODE_FAST,需要每 5ms 供一次数据。设备不支持、采样率不是 48K 或低时延资源被占用时会回退普通模式,创建后用 OH_AudioRenderer_GetLatencyMode 检查。

2.L 初值取 12ms,再统计回调里 NowNs() 减 ev.timestamp 的分布,把 L 的下限定在这个分布的高分位之上。

3.displayDelayNs 先取一个 VSync 周期,用手机慢动作同时拍屏幕和扬声器校准一次。

4.低延迟演奏时输出别用蓝牙耳机:文档说音频时延包含蓝牙传输延迟,但没说 GetTimestamp 的时刻是否已经算上这一段。

有两处文档没写,上线前要实测:蓝牙 MIDI 下 timestamp 是设备时间还是到达时间;自己累加的写入帧数和流的 framePosition 起点是否一致,可以用 OH_AudioRenderer_GetFramesWritten 对一次。

展开全部
3楼回复于2026-10-22 09:49 来自陕西

如果声音由平板合成播放,建议以“音频帧位置+单调时间”为同步基准,让音符动画跟随最终安排的播放位置,而不是 MIDI 回调到一次就刷新一次。若是钢琴自身发声,平板的音频时钟不能代表琴的出声时间,需要另外测量琴端发声与 MIDI 到达之间的偏差。

1. 先确认接入和时间戳

原生 OH_MIDIClient_OpenBLEDevice 自 API 24 提供 BLE MIDI 异步打开能力,需要 ohos.permission.ACCESS_BLUETOOTH;返回成功只表示请求已派发,还要等回调确认打开成功,再使用设备句柄。目标平板须具备相应 MIDI 系统能力,琴也须支持 BLE MIDI,普通蓝牙配对成功不能替代这些条件。

原生 OH_MIDIEvent.timestamp 使用 CLOCK_MONOTONIC 时基,单位纳秒;它不等于已测得的物理按键触发时间。接收时间戳为零或异常时,应标记并采用明确的降级策略,不要按零时刻播放。数据是 UMP,length 表示 32 位字数,不是字节数。若自己解析 BLE GATT 数据,要另外处理设备时间戳的单位、回绕和到主机单调时钟的映射,不能直接套用原生接口的契约。

2. 用音频锚点换算,不累计定时器误差

固定 1 倍速时,可通过 OH_AudioRenderer_GetTimestamp(renderer, CLOCK_MONOTONIC, &framePosition, &timestamp) 取得有效的帧位置 F0 和纳秒时间 T0,再用 OH_AudioRenderer_GetSamplingRate 取得采样率 R。先检查各接口返回值,在同一连续播放区间内近似计算:

帧 F 的呈现时间 ≈ T0 + (F − F0) × 1,000,000,000 / R。

F 是音频帧数,不是字节数;时间差要注意符号和整数范围。不能用 Date.now()、已写入帧数或回调次数代替播放位置。持续更新锚点;暂停恢复、Flush、流重建、路由切换后重新建立映射,等位置和时间稳定推进再使用。变速场景应改用适配变速的时间戳接口,并单独确认其时基、单位及帧位置语义,不能沿用固定速率公式。

3. 画面跟随最终调度帧

对平板合成音频,可先把“事件时间+安全余量”设为目标,换算成音频帧。余量依据输入抖动和输出缓冲实测选择,增加余量可容纳更多抖动,但会增加响应延迟。

若目标帧已经交给音频系统,就不能回头修改;记录迟到,并按策略安排到最早仍可写入的帧。击键高亮、音符到达判定线也跟随这个最终调度位置,避免声音顺延而画面仍按原始时间走。

UI 每帧按共享时间线重新计算位置,不要每帧固定累加位移。可用 displaySync 驱动更新,但先确认显示时间与音频单调时间的映射,不要因为都叫 timestamp 就直接相减;targetTimestamp 是预期时间,不保证画面已经显示。实时输入也无法预知未来音符,要提前下落必须有乐谱时间线,或接受额外延迟。

4. 丢事件和显示掉帧分开查

OH_MIDIDevice_OnReceived 的 events 及内部 data 只在回调内有效。异步处理前要把所需数据复制到预分配存储,不能仅保存指针;回调里不要阻塞、重计算或做 I/O。使用有界队列,明确溢出和断连恢复策略,保留 Note Off、踏板状态及事件顺序。UI 可以合并绘制,不能因此跳过音符状态处理;队列的线程安全需单独保证。

分别统计接收数、解析失败、队列溢出、迟到事件、显示帧耗时,以及 OH_AudioRenderer_GetUnderflowCount 的变化。先用平板扬声器或有线输出建立基线,再测蓝牙音频输出。音频时间戳不能等同于实测声学出声时间,无线输出的估计延迟也不要无条件叠加,最终偏差要做端到端测量。

建议补充平板系统版本、琴型号、原生 MIDI 还是自建 GATT、实际发声设备,以及几组事件时间、接收时刻、音频帧位置和时间戳,便于判断主要卡在输入、音频调度还是 UI。

展开全部
1
2楼回复于2026-10-22 09:48 未知归属地
  • 匿名用户匿名用户
    学习了,很实用
    2026-10-22 23:30 来自北京
共2页
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
  • 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
  • 如您发布的内容为转载内容,请注明内容来源。

我要提问题

了解社区公约,与您携手共创和谐专业的开发者社区。