#include "call_audio_graphs.h" #include "../../../src/call/call_audio.h" #include "../../../lib/debug_config.h" #include #include #include #include #include #include #include #include #include #include static QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } struct GraphPoint { double x, y; bool gap; }; struct GraphLine { QColor color; std::vector points; }; /* Холст владеет последним снимком; компоновка окна не влияет на координаты графиков. */ class CallAudioGraphCanvas : public QWidget { public: explicit CallAudioGraphCanvas(QWidget* parent) : QWidget(parent) { setMinimumSize(440, 620); } call_audio_history m_data{}; bool m_available = false; protected: void paintEvent(QPaintEvent*) override; }; CallAudioGraphs::CallAudioGraphs(quint64 id, QWidget* diagnostics, QWidget* parent) : QWidget(parent, Qt::Window), m_callId(id) { setObjectName("callAudioGraphs"); setAttribute(Qt::WA_QuitOnClose, false); setWindowTitle(text("Diagnostics")); resize(1120, 800); auto* layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); auto* splitter = new QSplitter(Qt::Horizontal, this); layout->addWidget(splitter); m_canvas = new CallAudioGraphCanvas(splitter); auto* details = new QScrollArea(splitter); details->setObjectName("callDiagnosticDetails"); details->setWidgetResizable(true); details->setMinimumWidth(260); details->setFrameShape(QFrame::NoFrame); details->setWidget(diagnostics); diagnostics->show(); splitter->setSizes({760, 340}); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 0); splitter->setChildrenCollapsible(false); m_timer = new QTimer(this); m_timer->setInterval(250); connect(m_timer, &QTimer::timeout, this, [this] { call_audio_history snapshot; bool available = call_audio_get_history(m_callId, &snapshot) == 0; if (available) setSnapshot(snapshot); else if (m_canvas->m_available) { DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio graphs frozen call=%llx RX=%u TX=%u", (unsigned long long)m_callId, m_canvas->m_data.rx_count, m_canvas->m_data.tx_count); m_canvas->m_available = false; m_canvas->update(); } }); } void CallAudioGraphs::setSnapshot(const call_audio_history& snapshot) { m_canvas->m_data = snapshot; m_canvas->m_available = true; m_canvas->update(); } void CallAudioGraphs::showEvent(QShowEvent* event) { QWidget::showEvent(event); m_timer->start(); DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio graphs opened call=%llx window_ms=30000 half_life_ms=10000 headroom=after-pcm-block pause=0", (unsigned long long)m_callId); } void CallAudioGraphs::hideEvent(QHideEvent* event) { m_timer->stop(); QWidget::hideEvent(event); DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "audio graphs hidden call=%llx", (unsigned long long)m_callId); } void CallAudioGraphCanvas::paintEvent(QPaintEvent*) { QPainter p(this); p.fillRect(rect(), palette().window()); p.setRenderHint(QPainter::Antialiasing); const QColor foreground = palette().text().color(); QColor muted = foreground; muted.setAlphaF(.72); const QColor blue("#3498db"), green("#27ae60"), orange("#e67e22"), violet("#9b59b6"), red("#e74c3c"); p.setPen(foreground); p.drawText(QRectF(16, 8, width() - 32, 22), text("Audio history — last 30 seconds")); p.setPen(muted); QString status = m_available ? text("Real-time") : text("Waiting for audio / last snapshot"); double missing = m_data.observed_ms > 0 ? 100 * m_data.missing_ms / m_data.observed_ms : 0; p.drawText(QRectF(16, 30, width() - 32, 22), status + " · " + text("Missing audio: %1% (weighted)").arg(missing, 0, 'f', 1)); double row = (height() - 68) / 4.0; auto xValue = [this](uint64_t at) { return (double)CALL_HISTORY_MS - (double)m_data.now_ms + at; }; auto graph = [&](int index, const QString& title, const QString& legend, const std::vector& lines, double minimumSpan, const QString& smallUnit, const QString& largeUnit, bool threshold) { double low = INFINITY, high = -INFINITY; for (const auto& line : lines) for (const auto& v : line.points) { low = std::min(low, v.y); high = std::max(high, v.y); } bool have = std::isfinite(low); if (!have) low = high = 0; double factor = !largeUnit.isEmpty() && high >= 1024 ? 1024 : 1; QString unit = factor == 1024 ? largeUnit : smallUnit; double scaleLow = threshold ? 0.0 : low; // Запас показывается от нуля, включая паузы накопления. double span = std::max(high - scaleLow, minimumSpan), bottom = std::max(0.0, (high + scaleLow - span) / 2 - span * .05); double top = std::max(high, bottom + span) + span * .05; double y = 58 + index * row; p.setPen(foreground); p.drawText(QRectF(16, y, width() - 32, 20), title); p.setPen(muted); p.drawText(QRectF(16, y + 20, width() - 32, 18), have ? text("Min: %1 · Max: %2 %3").arg(low / factor, 0, 'f', factor == 1 ? 0 : 1) .arg(high / factor, 0, 'f', factor == 1 ? 0 : 1).arg(unit) : text("No samples")); p.drawText(QRectF(16, y + 38, width() - 32, 32), Qt::TextWordWrap, legend); QRectF box(62, y + 73, width() - 78, row - 96); auto px = [&](double time) { return box.left() + time / CALL_HISTORY_MS * box.width(); }; auto py = [&](double value) { return box.bottom() - (value - bottom) / (top - bottom) * box.height(); }; p.setPen(muted); p.drawText(QRectF(0, box.top(), 57, 16), Qt::AlignRight, QString::number(top / factor, 'f', factor == 1 ? 0 : 1)); p.drawText(QRectF(0, box.bottom() - 15, 57, 16), Qt::AlignRight, QString::number(bottom / factor, 'f', factor == 1 ? 0 : 1)); for (int second = 0; second <= 30; second += 10) { double x = px(second * 1000); p.setPen(QPen(muted, .4)); p.drawLine(QPointF(x, box.top()), QPointF(x, box.bottom())); p.drawText(QRectF(x - 20, box.bottom() + 2, 40, 17), Qt::AlignCenter, QString::number(second - 30) + " s"); } p.save(); p.setClipRect(box); if (threshold) { p.setPen(QPen(muted, 1, Qt::DashLine)); p.drawLine(QPointF(box.left(), py(0)), QPointF(box.right(), py(0))); } if (threshold && bottom <= 3000 && top >= 3000) { p.setPen(QPen(red, 1, Qt::DashLine)); p.drawLine(QPointF(box.left(), py(3000)), QPointF(box.right(), py(3000))); } for (const auto& line : lines) { QPainterPath path; bool started = false; for (const auto& v : line.points) { QPointF pos(px(v.x), py(v.y)); if (!started || v.gap) path.moveTo(pos); else path.lineTo(pos); if (v.gap) { p.setPen(Qt::NoPen); p.setBrush(line.color); p.drawEllipse(pos, 1.5, 1.5); p.setBrush(Qt::NoBrush); } started = true; } p.setPen(QPen(line.color, 1.5)); p.drawPath(path); } p.restore(); }; std::vector receive{{blue, {}}, {green, {}}, {orange, {}}, {red, {}}}; for (unsigned i = 0; i < m_data.rx_count; ++i) { const auto& v = m_data.rx[i]; bool gap = !i || v.at_ms - m_data.rx[i - 1].at_ms > 150; double x = xValue(v.at_ms); receive[0].points.push_back({x, (double)v.low_ms, gap}); receive[0].points.push_back({x, (double)v.high_ms, false}); receive[1].points.push_back({x, (double)v.reserve_ms, gap}); receive[2].points.push_back({x, (double)v.headroom_low_ms, gap}); receive[2].points.push_back({x, (double)v.headroom_high_ms, false}); if (v.missing_samples) receive[3].points.push_back({x, (double)v.low_ms, true}); } graph(0, text("Receive buffer"), text("Blue: buffer · Green: target · Orange: headroom (0 while paused) · Red: missing audio"), receive, 100, text("ms"), {}, true); std::vector transmit{{blue, {}}, {orange, {}}, {violet, {}}}, rate{{green, {}}}; for (unsigned i = 0; i < m_data.tx_count; ++i) { const auto& v = m_data.tx[i]; bool gap = !i || v.at_ms - m_data.tx[i - 1].at_ms > 150; double x = xValue(v.at_ms); transmit[0].points.push_back({x, (double)v.queued_bytes + v.inflight_bytes, gap}); transmit[1].points.push_back({x, (double)v.inflight_bytes, gap}); transmit[2].points.push_back({x, (double)v.transport_bytes, gap}); if (v.wire_bytes_per_sec >= 0) rate[0].points.push_back({x, v.wire_bytes_per_sec, gap || (i && (v.peer_id != m_data.tx[i - 1].peer_id || v.tx_epoch != m_data.tx[i - 1].tx_epoch || m_data.tx[i - 1].wire_bytes_per_sec < 0))}); } graph(1, text("All transmission queues"), text("Blue: Router + waiting audio · Orange: inflight · Purple: transport separately"), transmit, 1024, text("B"), text("KiB"), false); graph(2, text("ETCP send rate — call route"), text("All ETCP traffic including retries · Average over 1 second"), rate, 256, text("B/s"), text("KiB/s"), false); double y = 58 + 3 * row; p.setPen(foreground); p.drawText(QRectF(16, y, width() - 32, 20), text("Receive buffer distribution")); p.setPen(muted); p.drawText(QRectF(16, y + 38, width() - 32, 32), Qt::TextWordWrap, text("Time weighted · Older samples lose half their weight every 10 seconds")); int first = 0, last = CALL_HISTORY_BINS - 1; while (first < last && m_data.histogram[first] == 0) ++first; while (last > first && m_data.histogram[last] == 0) --last; if (!m_data.observed_ms) first = 0, last = 1; if (first == last) { first = std::max(0, first - 1); last = std::min(CALL_HISTORY_BINS - 1, last + 1); } double maximum = 0; for (double v : m_data.histogram) maximum = std::max(maximum, v); p.drawText(QRectF(16, y + 20, width() - 32, 18), text("Min: %1 · Max: %2 %3").arg(0).arg(maximum * 100, 0, 'f', 1).arg("%")); QRectF box(62, y + 73, width() - 78, row - 96); double scale = std::max(.05, maximum) * 1.05; double barWidth = box.width() / (last - first + 1); p.setPen(Qt::NoPen); p.setBrush(blue); for (int i = first; i <= last; ++i) { double h = m_data.histogram[i] / scale * box.height(); p.drawRect(QRectF(box.left() + (i - first) * barWidth, box.bottom() - h, std::max(1.0, barWidth - 1), h)); } p.setPen(muted); p.drawText(QRectF(0, box.top(), 57, 16), Qt::AlignRight, QString::number(scale * 100, 'f', 0) + "%"); p.drawText(QRectF(0, box.bottom() - 15, 57, 16), Qt::AlignRight, "0%"); p.drawText(QRectF(box.left(), box.bottom() + 2, box.width(), 17), Qt::AlignLeft, QString::number(first * CALL_HISTORY_BIN_MS) + " " + text("ms")); p.drawText(QRectF(box.left(), box.bottom() + 2, box.width(), 17), Qt::AlignRight, (last == 60 ? QStringLiteral("3000+") : QString::number((last + 1) * CALL_HISTORY_BIN_MS)) + " " + text("ms")); }