diff --git a/tools/chatgui/src/audiorecorder.cpp b/tools/chatgui/src/audiorecorder.cpp index d9267265..8bfb09a7 100644 --- a/tools/chatgui/src/audiorecorder.cpp +++ b/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; 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) @@ -72,6 +82,8 @@ void AudioRecorder::startRecording() { if (!m_initialized) { qWarning("AudioRecorder: not initialized"); return; } m_pcmBuffer.clear(); + m_waveformLevels.clear(); + m_rmsIdx = 0; m_elapsedMs = 0; ma_context* ctx = SoundManager::instance()->context(); diff --git a/tools/chatgui/src/audiorecorder.h b/tools/chatgui/src/audiorecorder.h index 652479b5..e2b56466 100644 --- a/tools/chatgui/src/audiorecorder.h +++ b/tools/chatgui/src/audiorecorder.h @@ -28,6 +28,7 @@ public: float peakLevel() const; const std::vector& buffer() const { return m_pcmBuffer; } + const std::vector& waveformLevels() const { return m_waveformLevels; } int durationMs() const; signals: @@ -46,6 +47,9 @@ private: std::vector m_pcmBuffer; mutable float m_peakLevel = 0.0f; + std::vector m_recentRms = std::vector(5, 0.0f); + int m_rmsIdx = 0; + std::vector m_waveformLevels; QTimer m_durationTimer; int m_elapsedMs = 0; diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 14421717..20d158d2 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -224,8 +224,24 @@ void InputBar::onPttReleased() { int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec); if (frames > 0) { 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); } } diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index e4d7cbab..965fd4a1 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -18,7 +18,7 @@ public: signals: void sendMessage(const QString &text); - void sendVoiceMessage(const QString &filePath, int durationMs); + void sendVoiceMessage(const QString &filePath, int durationMs, const QString& waveformStr); protected: bool eventFilter(QObject *obj, QEvent *event) override; diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index b9e128a5..105e13e6 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/tools/chatgui/src/messagedelegate.cpp @@ -190,20 +190,26 @@ MessageDelegate::Layout MessageDelegate::calcLayout( L.isVoice = (contentType == "audio/opus"); if (L.isVoice) { L.voiceDuration = index.data(MsgVoiceDurationRole).toString(); - QFontMetrics fm(option.font); - int lineH = fm.lineSpacing(); - L.totalHeight = qMax(lineH * 2 + kGap * 2, kAvatar) + kItemGap; - L.bubbleWidth = 160; + int pbs = 16; // play button size + int wh = 25; // waveform height + int th = 14; // text line height + L.bubbleWidth = 220; + int bubbleH = kPadTop + wh + 4 + th + kPadBot; + L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; + int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); - if (!L.isOutgoing) - L.bubbleRect = QRect(contentEdge, 0, L.bubbleWidth, L.totalHeight - kItemGap); - else - L.bubbleRect = QRect(contentEdge - L.bubbleWidth, 0, L.bubbleWidth, L.totalHeight - kItemGap); - L.voicePlayBtnRect = QRect(L.bubbleRect.left() + kPadH, L.bubbleRect.top() + kPadTop + 4, 28, 28); - L.voiceDurationRect = QRect(L.bubbleRect.left() + kPadH + 36, L.bubbleRect.top() + kPadTop + 4, - L.bubbleWidth - kPadH * 2 - 36, 28); + int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth) : contentEdge; + L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH); + + L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs); + int wx = bx + kPadH + pbs + 4; + int ww = L.bubbleWidth - 2*kPadH - pbs - 4; + 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) { int avatarY = L.bubbleRect.bottom() - kAvatar; if (!L.isOutgoing) @@ -611,14 +617,25 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio bool isPlaying = st.playing; 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; - /* Play/Pause button */ + /* Play/Pause icon (small — 10px triangle) */ painter->setPen(Qt::NoPen); painter->setBrush(QColor(0x33, 0x90, 0xEC)); - if (isPlaying || isPaused) { - /* Pause icon (two vertical bars) */ qreal bw = playR.width() / 5.0; qreal gap = playR.width() / 8.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, top, bw, bot - top), 1, 1); } else { - /* Play icon (triangle) */ - QPainterPath playPath; + QPainterPath path; qreal cx = playR.center().x() - playR.width() / 6.0; qreal cy = playR.center().y(); qreal hs = playR.height() / 2.5; - playPath.moveTo(cx, cy - hs); - playPath.lineTo(cx + hs * 1.5, cy); - playPath.lineTo(cx, cy + hs); - playPath.closeSubpath(); - painter->drawPath(playPath); + path.moveTo(cx, cy - hs); + path.lineTo(cx + hs * 1.5, cy); + path.lineTo(cx, cy + hs); + path.closeSubpath(); + painter->drawPath(path); } - /* Duration text */ - painter->setPen(option.palette.text().color()); - painter->setFont(option.font); - if (!L.voiceDuration.isEmpty()) - painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, L.voiceDuration); - - /* Progress bar */ - int pbLeft = L.bubbleRect.left() + kPadH; - int pbWidth = L.bubbleWidth - 2 * kPadH; - int pbTop = L.voicePlayBtnRect.bottom() + 6; - int pbHeight = 3; - - if ((isPlaying || isPaused) && st.durationSec > 0) { - float elapsed; - if (isPaused) { - elapsed = st.pausedPosSec; - } else { - qint64 now = QDateTime::currentMSecsSinceEpoch(); - elapsed = (now - st.startMs) / 1000.0f; - if (elapsed > st.durationSec) elapsed = st.durationSec; + /* Waveform (25px) */ + QList wf = index.data(MsgVoiceWaveformRole).toList(); + int wfN = wf.size(); + QRect wfR = L.voiceWaveformRect; + if (wfN > 0) { + float barW = (float)wfR.width() / wfN; + int ph = wfR.height() - 3; /* leave 3px for progress bar at bottom */ + for (int i = 0; i < wfN; i++) { + float v = wf[i].toFloat(); + int barH = 1 + (int)(v * (ph - 1)); + int x = wfR.left() + (int)(i * barW); + int y = wfR.top() + ph - barH; + int bw = qMax(2, (int)(barW * 0.8f)); + bool before = wfN > 0 && (float)i / wfN <= frac; + painter->setBrush(before ? QColor(0x33, 0x90, 0xEC) : QColor(0x33, 0x90, 0xEC, 45)); + painter->drawRect(QRect(x, y, bw, barH)); } - float frac = elapsed / st.durationSec; + } - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0x33, 0x90, 0xEC, 60)); - painter->drawRoundedRect(QRect(pbLeft, pbTop, pbWidth, pbHeight), 2, 2); + /* Progress bar (3px, at bottom of waveform area) */ + int ph = 3; + 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->drawRoundedRect(QRect(pbLeft, pbTop, (int)(pbWidth * frac), pbHeight), 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); + painter->drawRoundedRect(QRect(wfR.left(), pbY, (int)(wfR.width() * frac), ph), 2, 2); } - /* Time */ + /* Duration + elapsed on bottom line */ QFont timeFont = option.font; - timeFont.setPointSize(timeFont.pointSize() - 1); + timeFont.setPointSize(timeFont.pointSize() - 2); painter->setFont(timeFont); painter->setPen(option.palette.text().color().lighter(150)); - QString timeStr = index.data(MsgTimeRole).toString(); - QRect timeR = QRect(L.bubbleRect.right() - kPadH - 50, pbTop + 4, 50, 16); - painter->drawText(timeR, Qt::AlignRight | Qt::AlignVCenter, timeStr); + int eSec = (int)elapsed, dSec = (int)durSec; + QString durText = QString("%1:%2 / %3:%4") + .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(); return; diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 69abbab5..853f7319 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -19,6 +19,7 @@ enum MessageDataRole { MsgVoiceFileRole = Qt::UserRole + 14, MsgVoicePlayStateRole = Qt::UserRole + 15, MsgVoicePlayPosRole = Qt::UserRole + 16, + MsgVoiceWaveformRole = Qt::UserRole + 17, MsgAuthorNodeIdRole = Qt::UserRole + 20, }; @@ -56,7 +57,9 @@ private: bool isOutgoing = false; bool isVoice = false; QRect voicePlayBtnRect; + QRect voiceWaveformRect; QRect voiceDurationRect; + QRect voiceTimeRect; QString voiceDuration; }; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 467c8f54..a1229137 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -96,22 +96,31 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, if (ct == "audio/opus") { QString raw = QString::fromUtf8(data); - int pipe = raw.indexOf('|'); - if (pipe < 0 && raw.startsWith('{')) { + int pipe1 = raw.indexOf('|'); + if (pipe1 < 0 && raw.startsWith('{')) { /* old double-nested JSON from before the fix — skip */ return; } - QString fname = pipe >= 0 ? raw.left(pipe) : raw; - QString dur = pipe >= 0 ? raw.mid(pipe + 1) : QString(); + int pipe2 = raw.indexOf('|', pipe1 + 1); + 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 (!fname.isEmpty()) { QString fullPath = mediaDirBase + "/" + channelId + "/" + fname; item->setData(fullPath, MsgVoiceFileRole); - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: voice file=%s dur=%s exists=%d", - qPrintable(fullPath), qPrintable(dur), QFile::exists(fullPath)); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: voice file=%s dur=%s wf=%d exists=%d", + qPrintable(fullPath), qPrintable(dur), + wfData.split(',').size(), QFile::exists(fullPath)); } else { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: empty filename, raw='%s'", qPrintable(raw)); } + if (!wfData.isEmpty()) { + QList 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); }); - 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; QFileInfo fi(filePath); QString fileName = fi.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(); struct chat_msg_submit* req = (struct chat_msg_submit*)