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++ 中处理,是一个比较适合桌面音频工具的实现方案。

相关推荐
hz567892 小时前
医院LIS系统如何对接视频会议系统?远程诊疗协同方案详解
音视频·实时音视频·信息与通信
鲲穹AI超级员工2 小时前
多款音视频 & 电子书格式工具实测分享,日常素材处理够用了
音视频·电子书格式
楼兰公子2 小时前
基于RK3588平台的ALSA音频学习与开发指南
音视频·rk3588
渡码桑2 小时前
英伟达与SK海力士合作,下一代AI内存技术路线解析
大数据·人工智能·音视频
资深流水灯工程师2 小时前
PySide6 + Qt Designer + PyCharm 完整开发流程
开发语言·qt·pycharm
BAGAE2 小时前
FEC-RS前向纠错编码理论及工程实施研究
c语言·c++·qt·算法·决策树·链表
ALINX技术博客2 小时前
【黑金云课堂】FPGA技术教程Linux开发:摄像头GPU渲染显示/Qt OpenGLES使用
linux·qt·fpga开发·gpu
1379003403 小时前
uBuntu20运行QGC RTSP拉流失败解决记录
qt·qgroundcontrol
小鹿研究点东西13 小时前
直播带货长视频AI自动剪辑开播:一场直播如何反复利用?
ffmpeg·自动化·音视频·语音识别