Qt/QML 音频波形图模块实现:从 PCM 数据到可缩放波形

前言

在音频工具类软件里,波形图是一个很实用的能力:它能让用户直观看到音频的振幅变化、声道分布和时间位置。本文结合本项目的波形图模块,介绍一个基于 Qt Quick + C++ 自绘的实现方案。

这个模块的核心思路是:FFmpeg 先把音频解码成 PCM,C++ 将 PCM 数据暴露给 QML,然后用 QQuickPaintedItem 在界面中绘制坐标轴、多声道波形、时间刻度,并支持滚轮缩放和右键拖拽平移。

先看效果图:

双通道音频,分通道展示:

支持鼠标滚轮缩放:

模块结构

波形图相关代码主要由三部分组成:

文件 作用
waveformitem.h/.cpp C++ 自绘波形控件,负责 PCM 解析、绘制、缩放和平移
pages/WaveformPage.qml QML 页面,负责布局、按钮和数据绑定
main.cpp 将 C++ 控件注册为 QML 类型 WaveformView

注册逻辑很简单:

cpp 复制代码
qmlRegisterType<WaveformItem>("AudioTools", 1, 0, "WaveformView");

注册之后,QML 页面就可以像普通控件一样使用它:

qml 复制代码
WaveformView {
    id: waveform
    anchors.fill: parent
    pcmData: mediaAnalyzer.pcmData
    sampleRate: appRoot.currentPcmValue("sampleRate", 44100)
    channels: appRoot.currentPcmValue("channels", 2)
    bitsPerSample: appRoot.currentPcmValue("bitsPerSample", 16)
}

这也是整个模块的关键:QML 不直接解析 PCM,而是把数据和格式参数交给 C++ 控件,让 C++ 完成绘制。

数据流:从音频文件到波形图

用户在页面点击"解码 PCM"后,QML 调用:

qml 复制代码
mediaAnalyzer.decodeCurrentToPcm()

MediaAnalyzer 再调用 AudioDecoder::decodeToPcm(),使用 FFmpeg 将当前音频文件转换成 PCM,在上一篇博文中有介绍,点击查看。解码成功后,它会保存两类信息:

  1. pcmData:原始 PCM 字节数据,类型是 QByteArray
  2. pcmInfo:PCM 格式信息,比如采样率、声道数、位深、大小等

其中 pcmData 通过 Q_PROPERTY 暴露给 QML:

cpp 复制代码
Q_PROPERTY(QByteArray pcmData READ pcmData NOTIFY pcmDataChanged)

因此当 PCM 数据变化时,WaveformView 会收到新的数据并触发重绘。

当前项目的解码输出默认是 s16,也就是 16-bit little-endian PCM。不过 WaveformItem 本身兼容了 8-bit、16-bit、32-bit 三种读取方式。

为什么使用 QQuickPaintedItem

QML 本身可以用 Canvas 画图,但波形图有几个特点:

  1. PCM 数据量可能很大
  2. 绘制逻辑需要大量采样、取最小值和最大值
  3. 需要处理滚轮、鼠标拖拽等交互
  4. 后续可能继续扩展游标、选区、标记点等功能

这些逻辑放在 C++ 中更合适。WaveformItem 继承自 QQuickPaintedItem,重写 paint(QPainter *painter),就可以在 Qt Quick 界面里使用传统 QPainter 绘制。

cpp 复制代码
class WaveformItem : public QQuickPaintedItem
{
    Q_OBJECT
    Q_PROPERTY(QByteArray pcmData READ pcmData WRITE setPcmData NOTIFY pcmDataChanged)
    Q_PROPERTY(int sampleRate READ sampleRate WRITE setSampleRate NOTIFY formatChanged)
    Q_PROPERTY(int channels READ channels WRITE setChannels NOTIFY formatChanged)
    Q_PROPERTY(int bitsPerSample READ bitsPerSample WRITE setBitsPerSample NOTIFY formatChanged)
    Q_PROPERTY(qreal zoom READ zoom NOTIFY viewChanged)
    Q_PROPERTY(qreal offset READ offset NOTIFY viewChanged)
};

这些属性把 QML 和 C++ 连接起来:QML 负责给控件喂数据,C++ 负责解释和绘制。

PCM 数据如何读取

PCM 数据是按"帧"存储的。对于多声道音频,一帧包含所有声道在同一时刻的采样值。

如果是双声道 16-bit PCM,数据排列大致是:

text 复制代码
L0 R0 L1 R1 L2 R2 ...

每个采样点占 2 字节。代码中通过下面的公式定位某个采样点:

cpp 复制代码
const qint64 index = (frameIndex * m_channels + channel) * bytes;

也就是说:

  1. 先找到第几个时间帧
  2. 再找到这一帧里的第几个声道
  3. 最后乘以每个采样点的字节数

读取采样值时,模块分别处理了 8-bit、16-bit、32-bit:

cpp 复制代码
if (m_bitsPerSample == 8) {
    const unsigned char value = static_cast<unsigned char>(sample[0]);
    return static_cast<int>(value) - 128;
}
if (m_bitsPerSample == 16)
    return readLe16(sample);
if (m_bitsPerSample == 32)
    return readLe32(sample);

8-bit PCM 通常是无符号数据,所以这里减去 128,把它转换成以 0 为中心的振幅值。16-bit 和 32-bit 按 little-endian 有符号整数读取。

坐标系和多声道布局

波形区域不是直接铺满整个控件,而是预留了边距:

cpp 复制代码
constexpr qreal kLeftMargin = 76.0;
constexpr qreal kRightMargin = 18.0;
constexpr qreal kTopMargin = 30.0;
constexpr qreal kBottomMargin = 46.0;

左侧用于显示声道名和振幅刻度,底部用于显示时间刻度。

如果音频有多个声道,模块会把绘图区按声道拆分成多个 lane:

cpp 复制代码
const qreal laneHeight =
    (plotRect.height() - laneGap * (channelCount - 1)) / channelCount;

每个声道都有自己的中心线、网格线、振幅刻度和波形颜色。这样双声道、多声道音频不会叠在一起,阅读起来更清楚。

振幅范围:让波形尽量铺满视图

直接按照 16-bit 的最大范围 -32768 ~ 32767 绘制当然可以,但如果音频本身音量比较小,波形会显得很扁。

项目里采用了一个更友好的方式:扫描全部 PCM,找出实际出现过的最大绝对值,然后把显示范围扩展到一个合适的 2 倍阶梯。

核心逻辑在 updateAmplitudeRange()

cpp 复制代码
qreal maxAbs = 0.0;
for (qint64 frame = 0; frame < frames; ++frame) {
    for (int channel = 0; channel < m_channels; ++channel)
        maxAbs = std::max<qreal>(maxAbs, std::abs(sampleRawValue(frame, channel)));
}

qreal range = 128.0;
while (range < maxAbs && range < peak)
    range *= 2.0;

这样做的好处是:

  1. 小音量文件也能看清波形变化
  2. 刻度值仍然比较规整
  3. 不会超过当前位深允许的最大峰值

两种绘制模式:细节模式和压缩模式

波形绘制最重要的问题是:一个音频可能有几十万甚至几百万个采样帧,而屏幕只有几百到一千多个像素宽。

如果每个采样点都画出来,性能会很差,而且大量点会挤在一起,看不清。因此模块根据当前缩放比例选择两种绘制方式。

1. 放大后:逐点连线

samplesPerPixel <= 1.0 时,说明一个像素对应不到一个采样点,当前视图已经足够放大。这时可以逐点绘制:

cpp 复制代码
QPainterPath pointPath;
for (qint64 frame = visibleStart; frame <= visibleEnd; ++frame) {
    const qreal x = laneRect.left() + (frame - m_offset) * pixelsPerSample;
    const qreal y = centerY - sampleRawValue(frame, channel) * amplitudeScale;
    pointPath.lineTo(QPointF(x, y));
}

在这种模式下,模块还会绘制采样点圆点。如果放得足够大,还会显示帧编号,方便观察单个采样点。

2. 缩小时:按像素取峰值

samplesPerPixel > 1.0 时,一个屏幕像素会覆盖多个采样帧。这时如果只取一个采样点,会丢掉大量峰值信息,波形会失真。

项目采用的是常见的 min/max 峰值绘制法:

cpp 复制代码
for (int px = 0; px < static_cast<int>(laneRect.width()); ++px) {
    // 找到这个像素覆盖的采样帧范围
    // 计算这段范围内的 minValue 和 maxValue
    // 画一条从 max 到 min 的竖线
}

每个像素列绘制一条竖线,竖线的上下端分别代表这一段采样中的最大值和最小值。这样即使在缩小状态下,也能保留音频峰值轮廓。

为了避免一个像素列覆盖太多采样点时循环过重,代码还做了 stride:

cpp 复制代码
const qint64 stride = std::max<qint64>(1, (frameEnd - frameStart) / 160);

也就是说,一个像素列最多抽样约 160 个点来估计峰值。这是一个性能和精度之间的折中。

缩放实现:围绕鼠标位置缩放

滚轮缩放的体验很关键。如果只是简单改变 zoom,用户正在看的位置会跳动。

项目里的做法是:先根据鼠标所在的横向比例,算出当前鼠标指向的是哪一帧,然后改变 zoom,再反推新的 offset,让这个锚点仍然停留在鼠标附近。

cpp 复制代码
const qreal ratio = mouseX / plotWidth;
const qreal oldVisible = visibleFrames();
const qreal anchorFrame = m_offset + ratio * oldVisible;

m_zoom = clampValue<qreal>(m_zoom * std::pow(1.35, steps), kMinZoom, maximumZoom());

const qreal newVisible = visibleFrames();
m_offset = anchorFrame - ratio * newVisible;

这种方式比"永远围绕中心缩放"更自然,用户可以把鼠标放在想看的位置,然后直接滚轮放大。

平移实现:右键拖拽改变 offset

模块使用右键拖拽来平移波形:

cpp 复制代码
setAcceptedMouseButtons(Qt::RightButton);

拖动时根据鼠标横向位移换算成帧偏移:

cpp 复制代码
m_offset -= dx * visibleFrames() / plotWidth;

当前可见帧数越少,说明 zoom 越大,同样的鼠标移动对应的帧数越少,拖动会更精细。

每次修改 zoomoffset 后,都会调用 clampView(),防止视图超出 PCM 数据范围。

时间刻度如何计算

时间刻度来自帧序号和采样率:

cpp 复制代码
const qint64 ms = frameIndex * 1000.0 / m_sampleRate;

如果总时长不到 1 小时,格式化为:

text 复制代码
MM:SS.mmm

如果超过 1 小时,格式化为:

text 复制代码
HH:MM:SS.mmm

这样既能满足普通音频文件,也能兼容长音频。

QML 页面如何组织交互

WaveformPage.qml 做了三件事:

  1. 顶部显示当前 PCM 摘要
  2. 没有 PCM 时提供"解码 PCM"按钮
  3. 有 PCM 后提供"重置视图"按钮

波形图放在一个深色面板中:

qml 复制代码
Rectangle {
    Layout.fillWidth: true
    Layout.preferredHeight: 360
    radius: 8
    color: "#071526"
    border.color: "#173d5f"

    WaveformView {
        anchors.fill: parent
        anchors.margins: 20
        anchors.leftMargin: 2
    }
}

这里保持了整个软件的深色蓝绿色风格。真正的绘制细节都封装在 WaveformView 内,QML 页面只负责组合界面和绑定数据。

完整代码

cpp 复制代码
#include "waveformitem.h"

#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QVector>
#include <QWheelEvent>

#include <algorithm>
#include <cmath>

namespace {

constexpr qreal kMinZoom = 1.0;
constexpr qreal kLeftMargin = 76.0;
constexpr qreal kRightMargin = 18.0;
constexpr qreal kTopMargin = 30.0;
constexpr qreal kBottomMargin = 46.0;
constexpr qreal kPointLabelSpacing = 42.0;

template <typename T>
T clampValue(T value, T minValue, T maxValue)
{
    return std::max(minValue, std::min(value, maxValue));
}

int readLe16(const char *data)
{
    const unsigned char *bytes = reinterpret_cast<const unsigned char *>(data);
    return static_cast<qint16>(bytes[0] | (bytes[1] << 8));
}

qint32 readLe32(const char *data)
{
    const unsigned char *bytes = reinterpret_cast<const unsigned char *>(data);
    const quint32 value = static_cast<quint32>(bytes[0])
        | (static_cast<quint32>(bytes[1]) << 8)
        | (static_cast<quint32>(bytes[2]) << 16)
        | (static_cast<quint32>(bytes[3]) << 24);
    return static_cast<qint32>(value);
}

} // namespace

WaveformItem::WaveformItem(QQuickItem *parent)
    : QQuickPaintedItem(parent)
{
    setAcceptedMouseButtons(Qt::RightButton);
    setAcceptHoverEvents(true);
    setAntialiasing(false);
}

QByteArray WaveformItem::pcmData() const
{
    return m_pcmData;
}

void WaveformItem::setPcmData(const QByteArray &data)
{
    if (m_pcmData == data)
        return;

    m_pcmData = data;
    updateAmplitudeRange();
    resetView();
    emit pcmDataChanged();
    update();
}

int WaveformItem::sampleRate() const
{
    return m_sampleRate;
}

void WaveformItem::setSampleRate(int sampleRate)
{
    if (m_sampleRate == sampleRate)
        return;

    m_sampleRate = sampleRate > 0 ? sampleRate : 44100;
    clampView();
    emit formatChanged();
    update();
}

int WaveformItem::channels() const
{
    return m_channels;
}

void WaveformItem::setChannels(int channels)
{
    if (m_channels == channels)
        return;

    m_channels = channels > 0 ? channels : 1;
    updateAmplitudeRange();
    resetView();
    emit formatChanged();
    update();
}

int WaveformItem::bitsPerSample() const
{
    return m_bitsPerSample;
}

void WaveformItem::setBitsPerSample(int bitsPerSample)
{
    if (m_bitsPerSample == bitsPerSample)
        return;

    m_bitsPerSample = bitsPerSample;
    updateAmplitudeRange();
    resetView();
    emit formatChanged();
    update();
}

qreal WaveformItem::zoom() const
{
    return m_zoom;
}

qreal WaveformItem::offset() const
{
    return m_offset;
}

void WaveformItem::resetView()
{
    m_zoom = 1.0;
    m_offset = 0.0;
    clampView();
    emit viewChanged();
    update();
}

void WaveformItem::paint(QPainter *painter)
{
    painter->setRenderHint(QPainter::Antialiasing, false);
    painter->fillRect(QRectF(0, 0, width(), height()), QColor("#071526"));

    const QRectF plotRect(kLeftMargin,
                          kTopMargin,
                          std::max<qreal>(1.0, width() - kLeftMargin - kRightMargin),
                          std::max<qreal>(1.0, height() - kTopMargin - kBottomMargin));

    painter->setPen(QColor("#85b6c9"));
    painter->drawText(QRectF(plotRect.left(), 4, plotRect.width(), 20),
                      Qt::AlignLeft | Qt::AlignVCenter,
                      QStringLiteral("采样率 %1 Hz").arg(m_sampleRate));

    const qint64 frames = frameCount();
    if (frames <= 0) {
        painter->setPen(QColor("#6f9aaf"));
        painter->drawText(plotRect, Qt::AlignCenter, QStringLiteral("暂无 PCM 数据,请先解码"));
        return;
    }

    clampView();

    const qreal visible = visibleFrames();
    const qreal samplesPerPixel = visible / plotRect.width();
    const qreal pixelsPerSample = plotRect.width() / visible;
    const qint64 visibleStart = std::max<qint64>(0, static_cast<qint64>(std::floor(m_offset)));
    const qint64 visibleEnd = std::min<qint64>(frames - 1, static_cast<qint64>(std::ceil(m_offset + visible)));
    const qreal amplitudeRange = std::max<qreal>(1.0, m_amplitudeRange);
    const int channelCount = std::max(1, m_channels);
    const qreal laneGap = channelCount > 1 ? 10.0 : 0.0;
    const qreal laneHeight = std::max<qreal>(1.0, (plotRect.height() - laneGap * (channelCount - 1)) / channelCount);
    const int yTickCount = 4;
    const int xTickCount = 6;

    painter->setPen(QPen(QColor("#173d5f"), 1));
    painter->drawRect(plotRect);

    painter->setPen(QPen(QColor("#21445f"), 1));
    for (int i = 0; i <= xTickCount; ++i) {
        const qreal x = plotRect.left() + plotRect.width() * i / xTickCount;
        painter->drawLine(QPointF(x, plotRect.top()), QPointF(x, plotRect.bottom()));
    }

    const QColor channelColors[] = {
        QColor("#37d996"),
        QColor("#20d8ff"),
        QColor("#f5c45e"),
        QColor("#b99cff"),
        QColor("#ff8f70"),
        QColor("#63e5b5"),
        QColor("#4cc9f0"),
        QColor("#e9f9ff")
    };

    for (int channel = 0; channel < channelCount; ++channel) {
        const QRectF laneRect(plotRect.left(),
                              plotRect.top() + channel * (laneHeight + laneGap),
                              plotRect.width(),
                              laneHeight);
        const qreal centerY = laneRect.center().y();
        const qreal amplitudeScale = laneRect.height() / (2.0 * amplitudeRange);
        const QColor waveColor = channelColors[channel % (sizeof(channelColors) / sizeof(channelColors[0]))];

        painter->setRenderHint(QPainter::Antialiasing, false);
        painter->setPen(QPen(QColor("#173d5f"), 1));
        painter->drawRect(laneRect);

        painter->setPen(QPen(QColor("#21445f"), 1));
        for (int i = 0; i <= yTickCount; ++i) {
            const qreal y = laneRect.top() + laneRect.height() * i / yTickCount;
            painter->drawLine(QPointF(laneRect.left(), y), QPointF(laneRect.right(), y));
        }

        painter->setPen(QPen(QColor("#7edcff"), 1));
        painter->drawLine(QPointF(laneRect.left(), centerY), QPointF(laneRect.right(), centerY));
        painter->drawLine(QPointF(laneRect.left(), laneRect.top()), QPointF(laneRect.left(), laneRect.bottom()));

        painter->setPen(QColor("#85b6c9"));
        painter->drawText(QRectF(8, laneRect.top() + 4, kLeftMargin - 14, 18),
                          Qt::AlignRight | Qt::AlignVCenter,
                          QStringLiteral("CH%1").arg(channel + 1));
        for (int i = 0; i <= yTickCount; ++i) {
            const qreal value = amplitudeRange - 2.0 * amplitudeRange * i / yTickCount;
            const qreal y = laneRect.top() + laneRect.height() * i / yTickCount;
            painter->drawText(QRectF(8, y - 10, kLeftMargin - 14, 20),
                              Qt::AlignRight | Qt::AlignVCenter,
                              sampleLabel(value));
        }

        if (samplesPerPixel <= 1.0) {
            QPainterPath pointPath;
            bool hasPointPath = false;
            QVector<QPointF> samplePoints;
            samplePoints.reserve(static_cast<int>(std::min<qint64>(visibleEnd - visibleStart + 1, 10000)));

            for (qint64 frame = visibleStart; frame <= visibleEnd; ++frame) {
                const qreal x = laneRect.left() + (frame - m_offset) * pixelsPerSample;
                if (x < laneRect.left() - 2 || x > laneRect.right() + 2)
                    continue;

                const qreal y = centerY - sampleRawValue(frame, channel) * amplitudeScale;
                const QPointF point(x, y);
                samplePoints.append(point);
                if (!hasPointPath) {
                    pointPath.moveTo(point);
                    hasPointPath = true;
                } else {
                    pointPath.lineTo(point);
                }
            }

            painter->setRenderHint(QPainter::Antialiasing, true);
            painter->setPen(QPen(waveColor, 1.4));
            if (hasPointPath)
                painter->drawPath(pointPath);

            painter->setPen(QPen(QColor("#071526"), 1));
            for (int i = 0; i < samplePoints.size(); ++i)
                painter->drawEllipse(samplePoints.at(i), 3.0, 3.0);

            if (pixelsPerSample >= kPointLabelSpacing) {
                painter->setPen(QColor("#f5c45e"));
                painter->setBrush(Qt::NoBrush);
                for (qint64 frame = visibleStart; frame <= visibleEnd; ++frame) {
                    const qreal x = laneRect.left() + (frame - m_offset) * pixelsPerSample;
                    if (x < laneRect.left() || x > laneRect.right())
                        continue;

                    const qreal y = centerY - sampleRawValue(frame, channel) * amplitudeScale;
                    const qreal labelY = y < centerY ? y - 22 : y + 4;
                    painter->drawText(QRectF(x - 24, labelY, 48, 18),
                                      Qt::AlignCenter,
                                      QString::number(frame));
                }
            }
        } else {
            for (int px = 0; px < static_cast<int>(laneRect.width()); ++px) {
                const qreal frameStartF = m_offset + px * samplesPerPixel;
                const qreal frameEndF = m_offset + (px + 1) * samplesPerPixel;
                qint64 frameStart = std::max<qint64>(0, static_cast<qint64>(std::floor(frameStartF)));
                qint64 frameEnd = std::min<qint64>(frames - 1, static_cast<qint64>(std::ceil(frameEndF)));
                if (frameEnd < frameStart)
                    frameEnd = frameStart;

                double minValue = amplitudeRange;
                double maxValue = -amplitudeRange;
                const qint64 stride = std::max<qint64>(1, (frameEnd - frameStart) / 160);
                for (qint64 frame = frameStart; frame <= frameEnd; frame += stride) {
                    const double value = sampleRawValue(frame, channel);
                    minValue = std::min(minValue, value);
                    maxValue = std::max(maxValue, value);
                }

                const qreal x = laneRect.left() + px;
                const qreal y1 = centerY - maxValue * amplitudeScale;
                const qreal y2 = centerY - minValue * amplitudeScale;
                painter->setPen(QPen(waveColor, 1));
                painter->drawLine(QPointF(x, y1), QPointF(x, y2));
            }
        }
    }

    painter->setPen(QColor("#85b6c9"));
    for (int i = 0; i <= xTickCount; ++i) {
        const qreal ratio = i / static_cast<qreal>(xTickCount);
        const qreal x = plotRect.left() + plotRect.width() * ratio;
        const qint64 frame = std::min<qint64>(frames - 1, static_cast<qint64>(std::round(m_offset + visible * ratio)));
        QRectF labelRect(x - 58, plotRect.bottom() + 8, 116, 20);
        int alignment = Qt::AlignCenter;
        if (i == 0) {
            labelRect = QRectF(plotRect.left(), plotRect.bottom() + 8, 116, 20);
            alignment = Qt::AlignLeft | Qt::AlignVCenter;
        } else if (i == xTickCount) {
            labelRect = QRectF(plotRect.right() - 116, plotRect.bottom() + 8, 116, 20);
            alignment = Qt::AlignRight | Qt::AlignVCenter;
        }
        painter->drawText(labelRect, alignment, timeLabel(frame));
    }
    painter->drawText(QRectF(plotRect.right() - 150, 4, 150, 20),
                      Qt::AlignRight | Qt::AlignVCenter,
                      QStringLiteral("缩放 %1x").arg(QString::number(m_zoom, 'f', m_zoom < 10 ? 1 : 0)));
}

void WaveformItem::wheelEvent(QWheelEvent *event)
{
    const qint64 frames = frameCount();
    if (frames <= 0 || width() <= 0) {
        event->ignore();
        return;
    }

    const qreal plotWidth = std::max<qreal>(1.0, width() - kLeftMargin - kRightMargin);
    const qreal mouseX = clampValue<qreal>(event->position().x() - kLeftMargin, 0.0, plotWidth);
    const qreal ratio = mouseX / plotWidth;
    const qreal oldVisible = visibleFrames();
    const qreal anchorFrame = m_offset + ratio * oldVisible;
    const qreal steps = event->angleDelta().y() / 120.0;

    m_zoom = clampValue<qreal>(m_zoom * std::pow(1.35, steps), kMinZoom, maximumZoom());
    const qreal newVisible = visibleFrames();
    m_offset = anchorFrame - ratio * newVisible;
    clampView();
    emit viewChanged();
    update();
    event->accept();
}

void WaveformItem::mousePressEvent(QMouseEvent *event)
{
    if (event->button() == Qt::RightButton) {
        m_dragging = true;
        m_lastMousePos = event->localPos();
        event->accept();
        return;
    }

    event->ignore();
}

void WaveformItem::mouseMoveEvent(QMouseEvent *event)
{
    if (!m_dragging || width() <= 0) {
        event->ignore();
        return;
    }

    const qreal dx = event->localPos().x() - m_lastMousePos.x();
    const qreal plotWidth = std::max<qreal>(1.0, width() - kLeftMargin - kRightMargin);
    m_offset -= dx * visibleFrames() / plotWidth;
    m_lastMousePos = event->localPos();
    clampView();
    emit viewChanged();
    update();
    event->accept();
}

void WaveformItem::mouseReleaseEvent(QMouseEvent *event)
{
    if (event->button() == Qt::RightButton) {
        m_dragging = false;
        event->accept();
        return;
    }

    event->ignore();
}

void WaveformItem::geometryChanged(const QRectF &newGeometry, const QRectF &oldGeometry)
{
    QQuickPaintedItem::geometryChanged(newGeometry, oldGeometry);
    clampView();
}

int WaveformItem::bytesPerSample() const
{
    if (m_bitsPerSample == 8)
        return 1;
    if (m_bitsPerSample == 16)
        return 2;
    if (m_bitsPerSample == 32)
        return 4;
    return 0;
}

qint64 WaveformItem::frameCount() const
{
    const int bytes = bytesPerSample();
    if (bytes <= 0 || m_channels <= 0)
        return 0;

    return m_pcmData.size() / (bytes * m_channels);
}

double WaveformItem::sampleValue(qint64 frameIndex, int channel) const
{
    const qreal peak = samplePeakValue();
    if (peak <= 0.0)
        return 0.0;

    return clampValue<double>(sampleRawValue(frameIndex, channel) / peak, -1.0, 1.0);
}

qreal WaveformItem::sampleRawValue(qint64 frameIndex, int channel) const
{
    const int bytes = bytesPerSample();
    if (bytes <= 0 || m_channels <= 0)
        return 0.0;
    if (channel < 0 || channel >= m_channels)
        return 0.0;

    const qint64 index = (frameIndex * m_channels + channel) * bytes;
    if (index < 0 || index + bytes > m_pcmData.size())
        return 0.0;

    const char *sample = m_pcmData.constData() + index;
    if (m_bitsPerSample == 8) {
        const unsigned char value = static_cast<unsigned char>(sample[0]);
        return static_cast<int>(value) - 128;
    }
    if (m_bitsPerSample == 16)
        return readLe16(sample);
    if (m_bitsPerSample == 32)
        return readLe32(sample);

    return 0.0;
}

qreal WaveformItem::samplePeakValue() const
{
    if (m_bitsPerSample == 8)
        return 128.0;
    if (m_bitsPerSample == 16)
        return 32768.0;
    if (m_bitsPerSample == 32)
        return 2147483648.0;
    return 1.0;
}

void WaveformItem::updateAmplitudeRange()
{
    const qreal peak = samplePeakValue();
    const qint64 frames = frameCount();
    if (frames <= 0 || peak <= 0.0) {
        m_amplitudeRange = 1.0;
        return;
    }

    qreal maxAbs = 0.0;
    for (qint64 frame = 0; frame < frames; ++frame) {
        for (int channel = 0; channel < m_channels; ++channel)
            maxAbs = std::max<qreal>(maxAbs, std::abs(sampleRawValue(frame, channel)));
    }

    maxAbs = std::max<qreal>(1.0, maxAbs);
    qreal range = 128.0;
    while (range < maxAbs && range < peak)
        range *= 2.0;

    m_amplitudeRange = std::min<qreal>(range, peak);
}

qreal WaveformItem::visibleFrames() const
{
    const qint64 frames = frameCount();
    if (frames <= 0)
        return 0.0;

    return std::max<qreal>(1.0, frames / m_zoom);
}

qreal WaveformItem::maximumZoom() const
{
    const qint64 frames = frameCount();
    if (frames <= 0)
        return kMinZoom;

    const qreal plotWidth = std::max<qreal>(1.0, width() - kLeftMargin - kRightMargin);
    const qreal minVisibleFrames = std::max<qreal>(2.0, std::floor(plotWidth / kPointLabelSpacing));
    return std::max<qreal>(kMinZoom, frames / minVisibleFrames);
}

void WaveformItem::clampView()
{
    const qint64 frames = frameCount();
    if (frames <= 0) {
        m_offset = 0.0;
        m_zoom = 1.0;
        return;
    }

    m_zoom = clampValue<qreal>(m_zoom, kMinZoom, maximumZoom());
    const qreal maxOffset = std::max<qreal>(0.0, frames - visibleFrames());
    m_offset = clampValue<qreal>(m_offset, 0.0, maxOffset);
}

QString WaveformItem::sampleLabel(qreal value) const
{
    const qreal absValue = std::abs(value);
    const QString sign = value < 0 ? QStringLiteral("-") : QString();

    if (absValue >= 1073741824.0) {
        const qreal scaled = absValue / 1073741824.0;
        return sign + QStringLiteral("%1G").arg(QString::number(scaled, 'f', scaled >= 10.0 ? 0 : 1));
    }

    if (absValue >= 1048576.0) {
        const qreal scaled = absValue / 1048576.0;
        return sign + QStringLiteral("%1M").arg(QString::number(scaled, 'f', scaled >= 10.0 ? 0 : 1));
    }

    if (absValue >= 1024.0) {
        const qreal scaled = absValue / 1024.0;
        return sign + QStringLiteral("%1K").arg(QString::number(scaled, 'f', std::fmod(scaled, 1.0) == 0.0 ? 0 : 1));
    }

    return QString::number(static_cast<qint64>(std::round(value)));
}

QString WaveformItem::timeLabel(qint64 frameIndex) const
{
    if (m_sampleRate <= 0)
        return QStringLiteral("00:00.000");

    const qint64 ms = static_cast<qint64>(std::floor(frameIndex * 1000.0 / m_sampleRate));
    const qint64 totalFrames = frameCount();
    const qint64 totalMs = totalFrames > 0
        ? static_cast<qint64>(std::floor((totalFrames - 1) * 1000.0 / m_sampleRate))
        : ms;
    const qint64 hours = ms / 3600000;
    const qint64 minutes = (ms % 3600000) / 60000;
    const qint64 seconds = (ms % 60000) / 1000;
    const qint64 millis = ms % 1000;

    if (totalMs < 3600000) {
        const qint64 totalMinutes = ms / 60000;
        return QStringLiteral("%1:%2.%3")
            .arg(totalMinutes, 2, 10, QLatin1Char('0'))
            .arg(seconds, 2, 10, QLatin1Char('0'))
            .arg(millis, 3, 10, QLatin1Char('0'));
    }

    return QStringLiteral("%1:%2:%3.%4")
        .arg(hours, 2, 10, QLatin1Char('0'))
        .arg(minutes, 2, 10, QLatin1Char('0'))
        .arg(seconds, 2, 10, QLatin1Char('0'))
        .arg(millis, 3, 10, QLatin1Char('0'));
}

总结

这个波形图模块的核心并不复杂:把 PCM 看成按帧排列的多声道采样数据,然后把采样值映射到屏幕坐标上。

真正需要处理好的地方有三个:

  1. 数据读取:正确处理位深、声道和 little-endian 字节序
  2. 绘制策略:放大时画细节,缩小时画峰值
  3. 交互体验:缩放围绕鼠标锚点,拖拽平移时限制边界

通过 QQuickPaintedItem,项目把这些逻辑封装成一个 QML 可直接使用的 WaveformView。这种方式既保留了 Qt Quick 的界面开发效率,也让波形绘制这种偏底层、偏性能的逻辑留在 C++ 中处理,是一个比较适合桌面音频工具的实现方案。

相关推荐
用户805533698032 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner2 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz7 天前
QML Hello World 入门示例
qt
xcyxiner10 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner10 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner11 天前
DicomViewer (添加模型类)3
qt
xcyxiner11 天前
DicomViewer (目录调整) 2
qt
xcyxiner12 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
RTC实战笔记13 天前
Android 实时音视频接入教程:媒体补充增强信息(SEI)
音视频·媒体·rtc
潜创微科技13 天前
HDMI1.3 无线传输芯片方案 空旷 150 米量产级音视频方案
音视频