voice playback: miniaudio cursor ground truth + EMA smooth decay

- SoundManager: add currentPcmCursor()/isCurrentPcmPlaying() via ma_sound_get_cursor_in_pcm_frames / ma_sound_is_playing
- messagedelegate: replace wall-clock position tracking with real miniaudio cursor, remove 200ms hardcoded delay
- messagedelegate: end-of-playback detection via isCurrentPcmPlaying() instead of wall-clock comparison (was killing audio ~200ms early)
- messagedelegate: EMA smoothing of displayFrac/displayElapsed (0.15 factor, 16ms timer)
- messagedelegate: timer continues after audio stops to animate EMA decay to 0 (not mouse-move dependent)
- media_delivery: download progress callback, Ed25519 sig verify, author fallback, content hash check
- conn_mgr: already-connected returns OK with callback instead of error
- chatgui: voice download-pending auto-play, file download progress bar, media bar colors
This commit is contained in:
evgeny committed 2026-07-31 00:17:03 +03:00
1 parent 641c9f574f
commit 51d6c782ac
23 files changed
+1165 -162

No files matched your search

+121 -35
View File
@@ -18,6 +18,9 @@
#include <QDateTime>
#include <QTimer>
#include <QFile>
#include <QFileInfo>
#include <QDesktopServices>
#include <QUrl>
#include <QDebug>
extern "C" {
@@ -37,6 +40,8 @@ struct VoicePlayState {
qint64 startMs = 0;
float durationSec = 0.0f;
float pausedPosSec = 0.0f;
float displayFrac = 0.0f;
float displayElapsed = 0.0f;
};
static QMap<QString, VoicePlayState> s_voiceStates;
static QTimer* s_voiceTimer = nullptr;
@@ -45,26 +50,25 @@ static QAbstractItemView* s_voiceView = nullptr;
static void ensureVoiceTimer() {
if (!s_voiceTimer) {
s_voiceTimer = new QTimer();
s_voiceTimer->setInterval(100);
s_voiceTimer->setInterval(16);
QObject::connect(s_voiceTimer, &QTimer::timeout, []() {
bool anyPlaying = false;
qint64 now = QDateTime::currentMSecsSinceEpoch();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ) {
if (!it->playing && !it->paused) { ++it; continue; }
if (it->paused) { ++it; continue; }
if (!it->playing) { ++it; continue; }
float elapsed = (now - it->startMs) / 1000.0f;
if (elapsed >= it->durationSec && it->durationSec > 0) {
bool anyActive = false;
SoundManager* sm = SoundManager::instance();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
if (!it->playing && !it->paused) {
if (it->displayFrac > 0.001f || it->displayElapsed > 0.001f) anyActive = true;
continue;
}
if (it->paused) { anyActive = true; continue; }
if (!sm->isCurrentPcmPlaying()) {
it->playing = false;
it->paused = false;
SoundManager::instance()->stopCurrentPcm();
++it;
anyActive = true;
} else {
anyPlaying = true;
++it;
anyActive = true;
}
}
if (!anyPlaying) s_voiceTimer->stop();
if (!anyActive) s_voiceTimer->stop();
if (s_voiceView) s_voiceView->viewport()->update();
});
}
@@ -78,11 +82,18 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
if (L.isFile) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
if (dlState != 0) return false; /* already downloaded or in progress */
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
if (chId.isEmpty() || msgId <= 0) return false;
if (dlState == 2) { /* downloaded: open file */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp))
QDesktopServices::openUrl(QUrl::fromLocalFile(fp));
return true;
}
if (dlState != 0) return false; /* downloading — no action */
if (chId.isEmpty() || msgId <= 0) return false;
struct attachment_dl_req* req = (struct attachment_dl_req*)
u_malloc(sizeof(struct attachment_dl_req));
if (!req) return false;
@@ -103,6 +114,25 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
if (!onPlayBtn && !onWaveform) return false;
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
/* not downloaded: start download (play will start on completion) */
if (filePath.isEmpty() && dlState != 1) {
if (chId.isEmpty() || msgId <= 0) return false;
struct attachment_dl_req* req = (struct attachment_dl_req*)
u_malloc(sizeof(struct attachment_dl_req));
if (!req) return false;
memset(req, 0, sizeof(*req));
strncpy(req->channel_id, chId.toUtf8().constData(), sizeof(req->channel_id) - 1);
req->msg_id = msgId;
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
model->setData(index, true, MsgVoiceDownloadPendingRole);
return true;
}
if (filePath.isEmpty()) return false;
GUI_DEBUG("editorEvent voice click: filePath=%s exists=%d wave=%d",
qPrintable(filePath), QFile::exists(filePath), (int)onWaveform);
if (filePath.isEmpty()) return false;
@@ -128,6 +158,8 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
st.durationSec = dur;
st.startMs = now - (qint64)(targetSec * 1000.0);
st.pausedPosSec = targetSec;
st.displayFrac = (targetSec > 0 && dur > 0) ? (targetSec / dur) : 0;
st.displayElapsed = targetSec;
VoicePlayer::playOpusFile(filePath);
int sr = SoundManager::instance()->currentPcmSampleRate();
if (sr > 0)
@@ -141,6 +173,8 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
st.pausedPosSec = targetSec;
else if (!st.playing)
{ st.playing = true; s_voiceTimer->start(); }
st.displayFrac = (targetSec > 0 && dur > 0) ? (targetSec / dur) : 0;
st.displayElapsed = targetSec;
int sr = SoundManager::instance()->currentPcmSampleRate();
if (sr > 0)
SoundManager::instance()->seekCurrentPcm((unsigned long long)(targetSec * sr));
@@ -172,6 +206,8 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
st.playing = true;
qint64 now = QDateTime::currentMSecsSinceEpoch();
st.startMs = now - (qint64)(st.pausedPosSec * 1000.0);
st.displayFrac = (dur > 0) ? (st.pausedPosSec / dur) : 0;
st.displayElapsed = st.pausedPosSec;
SoundManager::instance()->resumeCurrentPcm();
s_voiceTimer->start();
model->setData(index, 1, MsgVoicePlayStateRole);
@@ -191,6 +227,8 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
st.pausedPosSec = 0;
st.startMs = QDateTime::currentMSecsSinceEpoch();
st.durationSec = dur;
st.displayFrac = 0;
st.displayElapsed = 0;
VoicePlayer::playOpusFile(filePath);
s_voiceTimer->start();
@@ -269,14 +307,23 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
int wh = 25; // waveform height
int th = 14; // text line height
L.bubbleWidth = 220;
int barW = 3, barPad = 4;
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));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth) : contentEdge;
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs);
int wx = bx + kPadH + pbs + 4;
@@ -302,14 +349,23 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
int th = fm.height();
int ih = 24; /* icon height */
L.bubbleWidth = 230;
int barW = 3, barPad = 4;
int bubbleH = kPadTop + ih + 2 + 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));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth) : contentEdge;
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.textRect = QRect(bx + kPadH + ih + 8, kPadTop, L.bubbleWidth - 2*kPadH - ih - 8, th);
L.statusRect = QRect(bx + kPadH + ih + 8 - 48, kPadTop + th + 2, L.bubbleWidth - 2*kPadH - ih - 8 + 48 - 40, th);
@@ -712,36 +768,44 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
VoicePlayState st;
VoicePlayState copySt;
VoicePlayState* st = &copySt;
if (!filePath.isEmpty() && s_voiceStates.contains(filePath)) {
st = s_voiceStates[filePath];
st = &s_voiceStates[filePath];
} else {
int stateVal = index.data(MsgVoicePlayStateRole).toInt();
st.playing = (stateVal == 1);
copySt.playing = (stateVal == 1);
st = &copySt;
}
bool isPlaying = st.playing;
bool isPaused = st.paused;
bool isPlaying = st->playing;
bool isPaused = st->paused;
/* compute elapsed/frac for progress */
float elapsed = 0, frac = 0, durSec = st.durationSec;
float elapsed = 0, targetFrac = 0, durSec = st->durationSec;
if (durSec <= 0) {
QString ds = index.data(MsgVoiceDurationRole).toString();
ds.remove('s'); durSec = ds.toFloat();
}
if (isPaused) {
elapsed = st.pausedPosSec;
elapsed = st->pausedPosSec;
} else if (isPlaying) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st.startMs) / 1000.0f;
SoundManager* sm = SoundManager::instance();
if (sm->isCurrentPcmPlaying()) {
int sr = sm->currentPcmSampleRate();
if (sr > 0) elapsed = (float)((double)sm->currentPcmCursor() / sr);
}
if (elapsed <= 0) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st->startMs) / 1000.0f;
}
if (elapsed > durSec) elapsed = durSec;
}
if (durSec > 0) {
float dispElapsed = elapsed - 0.2f; /* 200ms audio output delay */
if (dispElapsed < 0) dispElapsed = 0;
frac = dispElapsed / durSec;
}
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
targetFrac = (durSec > 0) ? (elapsed / durSec) : 0;
if (targetFrac < 0) targetFrac = 0; if (targetFrac > 1) targetFrac = 1;
st->displayFrac += (targetFrac - st->displayFrac) * 0.15f;
st->displayElapsed += (elapsed - st->displayElapsed) * 0.15f;
float frac = st->displayFrac;
QRectF playR = L.voicePlayBtnRect;
@@ -806,7 +870,7 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
timeFont.setPointSize(timeFont.pointSize() - 2);
painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150));
int eSec = (int)elapsed, dSec = (int)durSec;
int eSec = (int)st->displayElapsed, 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'));
@@ -819,6 +883,17 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->drawText(L.voiceTimeRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
@@ -853,12 +928,23 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->setPen(QColor(0x80, 0x80, 0x80));
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
QString sizeStr = formatFileSize(fsz);
QString stateStr = (dlState == 2) ? "Downloaded" : "Tap to download";
QString stateStr = (dlState == 2) ? QString::fromUtf8("\xE2\x9C\x85") : (dlState == 1) ? "Downloading..." : "Download";
QString info = sizeStr + " \xC2\xB7 " + stateStr;
int nameH = QFontMetrics(fileFont).height();
QRect sizeRect(L.textRect.left(), L.textRect.top() + nameH, L.textRect.width(), QFontMetrics(sizeFont).height());
painter->drawText(sizeRect, Qt::AlignLeft | Qt::AlignVCenter, info);
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;