Browse Source

feat: waveform display + 3px progress bar in bubble + sliding window RMS

- AudioRecorder: sliding window RMS (avg 5 frames), waveformLevels() getter
- InputBar: downsample RMS to 100 samples, encode as wf0,wf1,... in textData
- MessageList: parse 3rd pipe-separated field as waveform, store in MsgVoiceWaveformRole
- MessageDelegate: new voice layout (16px play btn + 25px waveform + 3px progress bar)
- MessageDelegate: waveform bars colored by progress (blue before, gray after)
- Progress bar integrated at bottom of waveform area as colored fill
topo_upd
Evgeny 2 months ago
parent
commit
05c7b4d47e
  1. 14
      tools/chatgui/src/audiorecorder.cpp
  2. 4
      tools/chatgui/src/audiorecorder.h
  3. 20
      tools/chatgui/src/inputbar.cpp
  4. 2
      tools/chatgui/src/inputbar.h
  5. 136
      tools/chatgui/src/messagedelegate.cpp
  6. 3
      tools/chatgui/src/messagedelegate.h
  7. 25
      tools/chatgui/src/messagelist.cpp

14
tools/chatgui/src/audiorecorder.cpp

@ -26,7 +26,17 @@ void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput,
float v = (float)src[i] / 32768.0f; float v = (float)src[i] / 32768.0f;
sumSq += v * v; sumSq += v * v;
} }
self->m_peakLevel = sqrtf(sumSq / (float)(frameCount * self->m_channels)); float rms = sqrtf(sumSq / (float)(frameCount * self->m_channels));
self->m_peakLevel = rms;
self->m_recentRms[self->m_rmsIdx % 5] = rms;
self->m_rmsIdx++;
if (self->m_rmsIdx >= 5) {
float avg = 0.0f;
for (int i = 0; i < 5; i++) avg += self->m_recentRms[i];
avg /= 5.0f;
self->m_waveformLevels.push_back(avg);
}
} }
AudioRecorder::AudioRecorder(QObject* parent) AudioRecorder::AudioRecorder(QObject* parent)
@ -72,6 +82,8 @@ void AudioRecorder::startRecording() {
if (!m_initialized) { qWarning("AudioRecorder: not initialized"); return; } if (!m_initialized) { qWarning("AudioRecorder: not initialized"); return; }
m_pcmBuffer.clear(); m_pcmBuffer.clear();
m_waveformLevels.clear();
m_rmsIdx = 0;
m_elapsedMs = 0; m_elapsedMs = 0;
ma_context* ctx = SoundManager::instance()->context(); ma_context* ctx = SoundManager::instance()->context();

4
tools/chatgui/src/audiorecorder.h

@ -28,6 +28,7 @@ public:
float peakLevel() const; float peakLevel() const;
const std::vector<int16_t>& buffer() const { return m_pcmBuffer; } const std::vector<int16_t>& buffer() const { return m_pcmBuffer; }
const std::vector<float>& waveformLevels() const { return m_waveformLevels; }
int durationMs() const; int durationMs() const;
signals: signals:
@ -46,6 +47,9 @@ private:
std::vector<int16_t> m_pcmBuffer; std::vector<int16_t> m_pcmBuffer;
mutable float m_peakLevel = 0.0f; mutable float m_peakLevel = 0.0f;
std::vector<float> m_recentRms = std::vector<float>(5, 0.0f);
int m_rmsIdx = 0;
std::vector<float> m_waveformLevels;
QTimer m_durationTimer; QTimer m_durationTimer;
int m_elapsedMs = 0; int m_elapsedMs = 0;

20
tools/chatgui/src/inputbar.cpp

@ -224,8 +224,24 @@ void InputBar::onPttReleased() {
int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec); int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec);
if (frames > 0) { if (frames > 0) {
m_voiceFilePath = filePath; m_voiceFilePath = filePath;
GUI_INFO("InputBar: voice message ready: %s (%.1fs)", qPrintable(filePath), durationSec);
emit sendVoiceMessage(filePath, durationMs); const auto& wf = m_recorder->waveformLevels();
QString wfStr;
int N = (int)wf.size();
for (int i = 0; i < 100; i++) {
int s = i * N / 100, e = (i + 1) * N / 100;
if (s >= N) s = N - 1;
if (e <= s) e = s + 1;
if (e > N) e = N;
float sum = 0; int cnt = 0;
for (int j = s; j < e; j++) { sum += wf[j]; cnt++; }
float v = cnt > 0 ? sum / cnt : 0;
if (i > 0) wfStr += ',';
wfStr += QString::number(v, 'f', 3);
}
GUI_INFO("InputBar: voice message ready: %s (%.1fs) wf=%d levels",
qPrintable(filePath), durationSec, N);
emit sendVoiceMessage(filePath, durationMs, wfStr);
} }
} }

2
tools/chatgui/src/inputbar.h

@ -18,7 +18,7 @@ public:
signals: signals:
void sendMessage(const QString &text); void sendMessage(const QString &text);
void sendVoiceMessage(const QString &filePath, int durationMs); void sendVoiceMessage(const QString &filePath, int durationMs, const QString& waveformStr);
protected: protected:
bool eventFilter(QObject *obj, QEvent *event) override; bool eventFilter(QObject *obj, QEvent *event) override;

136
tools/chatgui/src/messagedelegate.cpp

@ -190,20 +190,26 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
L.isVoice = (contentType == "audio/opus"); L.isVoice = (contentType == "audio/opus");
if (L.isVoice) { if (L.isVoice) {
L.voiceDuration = index.data(MsgVoiceDurationRole).toString(); L.voiceDuration = index.data(MsgVoiceDurationRole).toString();
QFontMetrics fm(option.font); int pbs = 16; // play button size
int lineH = fm.lineSpacing(); int wh = 25; // waveform height
L.totalHeight = qMax(lineH * 2 + kGap * 2, kAvatar) + kItemGap; int th = 14; // text line height
L.bubbleWidth = 160; L.bubbleWidth = 220;
int bubbleH = kPadTop + wh + 4 + th + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
if (!L.isOutgoing) int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth) : contentEdge;
L.bubbleRect = QRect(contentEdge, 0, L.bubbleWidth, L.totalHeight - kItemGap); L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
else
L.bubbleRect = QRect(contentEdge - L.bubbleWidth, 0, L.bubbleWidth, L.totalHeight - kItemGap); L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs);
L.voicePlayBtnRect = QRect(L.bubbleRect.left() + kPadH, L.bubbleRect.top() + kPadTop + 4, 28, 28); int wx = bx + kPadH + pbs + 4;
L.voiceDurationRect = QRect(L.bubbleRect.left() + kPadH + 36, L.bubbleRect.top() + kPadTop + 4, int ww = L.bubbleWidth - 2*kPadH - pbs - 4;
L.bubbleWidth - kPadH * 2 - 36, 28); L.voiceWaveformRect = QRect(wx, kPadTop + 1, ww, wh);
L.voiceDurationRect = QRect(wx, kPadTop + wh + 4, ww - 40, th);
L.voiceTimeRect = QRect(wx + ww - 40, kPadTop + wh + 4, 40, th);
if (!L.narrow) { if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar; int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing) if (!L.isOutgoing)
@ -611,14 +617,25 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
bool isPlaying = st.playing; bool isPlaying = st.playing;
bool isPaused = st.paused; bool isPaused = st.paused;
/* compute elapsed/frac for progress */
float elapsed = 0, frac = 0, durSec = st.durationSec;
if (isPaused) {
elapsed = st.pausedPosSec;
} else if (isPlaying) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st.startMs) / 1000.0f;
if (elapsed > durSec) elapsed = durSec;
}
if (durSec > 0) frac = elapsed / durSec;
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
QRectF playR = L.voicePlayBtnRect; QRectF playR = L.voicePlayBtnRect;
/* Play/Pause button */ /* Play/Pause icon (small — 10px triangle) */
painter->setPen(Qt::NoPen); painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->setBrush(QColor(0x33, 0x90, 0xEC));
if (isPlaying || isPaused) { if (isPlaying || isPaused) {
/* Pause icon (two vertical bars) */
qreal bw = playR.width() / 5.0; qreal bw = playR.width() / 5.0;
qreal gap = playR.width() / 8.0; qreal gap = playR.width() / 8.0;
qreal top = playR.center().y() - playR.height() / 3.0; qreal top = playR.center().y() - playR.height() / 3.0;
@ -626,66 +643,63 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1); painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1);
painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1); painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1);
} else { } else {
/* Play icon (triangle) */ QPainterPath path;
QPainterPath playPath;
qreal cx = playR.center().x() - playR.width() / 6.0; qreal cx = playR.center().x() - playR.width() / 6.0;
qreal cy = playR.center().y(); qreal cy = playR.center().y();
qreal hs = playR.height() / 2.5; qreal hs = playR.height() / 2.5;
playPath.moveTo(cx, cy - hs); path.moveTo(cx, cy - hs);
playPath.lineTo(cx + hs * 1.5, cy); path.lineTo(cx + hs * 1.5, cy);
playPath.lineTo(cx, cy + hs); path.lineTo(cx, cy + hs);
playPath.closeSubpath(); path.closeSubpath();
painter->drawPath(playPath); painter->drawPath(path);
} }
/* Duration text */ /* Waveform (25px) */
painter->setPen(option.palette.text().color()); QList<QVariant> wf = index.data(MsgVoiceWaveformRole).toList();
painter->setFont(option.font); int wfN = wf.size();
if (!L.voiceDuration.isEmpty()) QRect wfR = L.voiceWaveformRect;
painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, L.voiceDuration); if (wfN > 0) {
float barW = (float)wfR.width() / wfN;
/* Progress bar */ int ph = wfR.height() - 3; /* leave 3px for progress bar at bottom */
int pbLeft = L.bubbleRect.left() + kPadH; for (int i = 0; i < wfN; i++) {
int pbWidth = L.bubbleWidth - 2 * kPadH; float v = wf[i].toFloat();
int pbTop = L.voicePlayBtnRect.bottom() + 6; int barH = 1 + (int)(v * (ph - 1));
int pbHeight = 3; int x = wfR.left() + (int)(i * barW);
int y = wfR.top() + ph - barH;
if ((isPlaying || isPaused) && st.durationSec > 0) { int bw = qMax(2, (int)(barW * 0.8f));
float elapsed; bool before = wfN > 0 && (float)i / wfN <= frac;
if (isPaused) { painter->setBrush(before ? QColor(0x33, 0x90, 0xEC) : QColor(0x33, 0x90, 0xEC, 45));
elapsed = st.pausedPosSec; painter->drawRect(QRect(x, y, bw, barH));
} else {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st.startMs) / 1000.0f;
if (elapsed > st.durationSec) elapsed = st.durationSec;
} }
float frac = elapsed / st.durationSec; }
painter->setPen(Qt::NoPen); /* Progress bar (3px, at bottom of waveform area) */
painter->setBrush(QColor(0x33, 0x90, 0xEC, 60)); int ph = 3;
painter->drawRoundedRect(QRect(pbLeft, pbTop, pbWidth, pbHeight), 2, 2); int pbY = wfR.bottom() - ph;
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(QRect(wfR.left(), pbY, wfR.width(), ph), 2, 2);
if (frac > 0) {
painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->setBrush(QColor(0x33, 0x90, 0xEC));
painter->drawRoundedRect(QRect(pbLeft, pbTop, (int)(pbWidth * frac), pbHeight), 2, 2); painter->drawRoundedRect(QRect(wfR.left(), pbY, (int)(wfR.width() * frac), ph), 2, 2);
/* Position text */
QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 2);
painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150));
int secs = (int)elapsed;
QString posStr = QString("%1:%2").arg(secs / 60).arg(secs % 60, 2, 10, QChar('0'));
QRect posR(pbLeft, pbTop + 4, pbWidth / 2, 14);
painter->drawText(posR, Qt::AlignLeft | Qt::AlignVCenter, posStr);
} }
/* Time */ /* Duration + elapsed on bottom line */
QFont timeFont = option.font; QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 1); timeFont.setPointSize(timeFont.pointSize() - 2);
painter->setFont(timeFont); painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150)); painter->setPen(option.palette.text().color().lighter(150));
QString timeStr = index.data(MsgTimeRole).toString(); int eSec = (int)elapsed, dSec = (int)durSec;
QRect timeR = QRect(L.bubbleRect.right() - kPadH - 50, pbTop + 4, 50, 16); QString durText = QString("%1:%2 / %3:%4")
painter->drawText(timeR, Qt::AlignRight | Qt::AlignVCenter, timeStr); .arg(eSec / 60).arg(eSec % 60, 2, 10, QChar('0'))
.arg(dSec / 60).arg(dSec % 60, 2, 10, QChar('0'));
painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, durText);
/* Timestamp */
QFont tsFont = option.font;
tsFont.setPointSize(tsFont.pointSize() - 1);
painter->setFont(tsFont);
painter->drawText(L.voiceTimeRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
painter->restore(); painter->restore();
return; return;

3
tools/chatgui/src/messagedelegate.h

@ -19,6 +19,7 @@ enum MessageDataRole {
MsgVoiceFileRole = Qt::UserRole + 14, MsgVoiceFileRole = Qt::UserRole + 14,
MsgVoicePlayStateRole = Qt::UserRole + 15, MsgVoicePlayStateRole = Qt::UserRole + 15,
MsgVoicePlayPosRole = Qt::UserRole + 16, MsgVoicePlayPosRole = Qt::UserRole + 16,
MsgVoiceWaveformRole = Qt::UserRole + 17,
MsgAuthorNodeIdRole = Qt::UserRole + 20, MsgAuthorNodeIdRole = Qt::UserRole + 20,
}; };
@ -56,7 +57,9 @@ private:
bool isOutgoing = false; bool isOutgoing = false;
bool isVoice = false; bool isVoice = false;
QRect voicePlayBtnRect; QRect voicePlayBtnRect;
QRect voiceWaveformRect;
QRect voiceDurationRect; QRect voiceDurationRect;
QRect voiceTimeRect;
QString voiceDuration; QString voiceDuration;
}; };

25
tools/chatgui/src/messagelist.cpp

@ -96,22 +96,31 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data,
if (ct == "audio/opus") { if (ct == "audio/opus") {
QString raw = QString::fromUtf8(data); QString raw = QString::fromUtf8(data);
int pipe = raw.indexOf('|'); int pipe1 = raw.indexOf('|');
if (pipe < 0 && raw.startsWith('{')) { if (pipe1 < 0 && raw.startsWith('{')) {
/* old double-nested JSON from before the fix — skip */ /* old double-nested JSON from before the fix — skip */
return; return;
} }
QString fname = pipe >= 0 ? raw.left(pipe) : raw; int pipe2 = raw.indexOf('|', pipe1 + 1);
QString dur = pipe >= 0 ? raw.mid(pipe + 1) : QString(); QString fname = pipe1 >= 0 ? raw.left(pipe1) : raw;
QString dur = pipe2 >= 0 ? raw.mid(pipe1 + 1, pipe2 - pipe1 - 1) : raw.mid(pipe1 + 1);
QString wfData = pipe2 >= 0 ? raw.mid(pipe2 + 1) : QString();
if (!dur.isEmpty()) item->setData(dur + "s", MsgVoiceDurationRole); if (!dur.isEmpty()) item->setData(dur + "s", MsgVoiceDurationRole);
if (!fname.isEmpty()) { if (!fname.isEmpty()) {
QString fullPath = mediaDirBase + "/" + channelId + "/" + fname; QString fullPath = mediaDirBase + "/" + channelId + "/" + fname;
item->setData(fullPath, MsgVoiceFileRole); item->setData(fullPath, MsgVoiceFileRole);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: voice file=%s dur=%s exists=%d", DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: voice file=%s dur=%s wf=%d exists=%d",
qPrintable(fullPath), qPrintable(dur), QFile::exists(fullPath)); qPrintable(fullPath), qPrintable(dur),
wfData.split(',').size(), QFile::exists(fullPath));
} else { } else {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: empty filename, raw='%s'", qPrintable(raw)); DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: empty filename, raw='%s'", qPrintable(raw));
} }
if (!wfData.isEmpty()) {
QList<QVariant> wf;
for (const auto& s : wfData.split(','))
wf.append(s.toDouble());
item->setData(wf, MsgVoiceWaveformRole);
}
} }
} }
@ -203,13 +212,13 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
}); });
connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString &filePath, int durationMs) { connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString &filePath, int durationMs, const QString& wfStr) {
if (m_currentChannelId.isEmpty()) return; if (m_currentChannelId.isEmpty()) return;
QFileInfo fi(filePath); QFileInfo fi(filePath);
QString fileName = fi.fileName(); QString fileName = fi.fileName();
QByteArray textData = (fileName + "|" + QByteArray textData = (fileName + "|" +
QString::number(durationMs / 1000.0, 'f', 1)).toUtf8(); QString::number(durationMs / 1000.0, 'f', 1) + "|" + wfStr).toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch(); auto ts = QDateTime::currentMSecsSinceEpoch();
struct chat_msg_submit* req = (struct chat_msg_submit*) struct chat_msg_submit* req = (struct chat_msg_submit*)

Loading…
Cancel
Save