feat: integrate Opus codec into lib/libopus + voice messages in chatgui

- Add lib/libopus/ (opus v1.6.1 embedded source: CELT 18 .c, SILK 77 .c, SILK float 28 .c, OPUS core 14 .c)
- Add lib/opus_codec.h/.c (wrapper: encoder, decoder, raw PCM int16)
- Add tests/test_opus_codec.c (7 tests: silence, sine, compression, PLC, null, defaults, multi-frame)
- chatgui: AudioRecorder (miniaudio capture, 48kHz mono)
- chatgui: VoiceEncoder (PCM→Opus file with custom container)
- chatgui: VoicePlayer (Opus→PCM playback via SoundManager)
- chatgui: PTT button in InputBar, voice message send/receive in MessageList
- chatgui: AudioDeviceSettingsPage (device selection, persisted in ui_state)
- chatgui: MessageDelegate voice message rendering (play button + duration)
- Update autotools: configure.ac, Makefile.am, src/Makefile.am, tests/Makefile.am
- Update CMake: chatgui CMakeLists.txt, libutun CMakeLists.txt (link libopus_internal.a)
This commit is contained in:
Evgeny committed 2026-07-26 20:34:38 +03:00
1 parent 4a23593020
commit 4fde0b0325
260 files changed
+72665 -16

No files matched your search

+5 -1
View File
@@ -76,6 +76,10 @@ add_executable(chatgui
src/nodespage.cpp
src/soundsettingspage.cpp
src/sound_manager.cpp
src/audiorecorder.cpp
src/voicemessageencoder.cpp
src/voiceplayback.cpp
src/audiodevicesettingspage.cpp
transport/utun_node.cpp
transport/node_config.cpp
transport/config_updater.cpp
@@ -94,7 +98,7 @@ add_executable(chatgui
resources/chatgui.qrc
)
target_include_directories(chatgui PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db)
target_include_directories(chatgui PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include)
target_compile_definitions(chatgui PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE)
set_source_files_properties(../../lib/sqlite3.c transport/chat_core.c transport/chat_msg.c transport/chat_channel.c transport/chat_profile.c transport/chat_status.c transport/chat_sync.c transport/member_sync.c transport/merkle_sync.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C)
if(WIN32)
+6
View File
@@ -21,8 +21,14 @@ target_include_directories(uasync PUBLIC
${LIB_DIR}
${SRC_DIR}
${LIB_DIR}/liblmdb
${LIB_DIR}/libopus/include
)
add_library(opus_internal STATIC IMPORTED)
set_target_properties(opus_internal PROPERTIES IMPORTED_LOCATION "${LIB_DIR}/libopus/libopus_internal.a")
target_link_libraries(uasync PUBLIC opus_internal)
target_compile_definitions(uasync PRIVATE
_ISOC99_SOURCE
DEBUG_OUTPUT_STDERR
@@ -0,0 +1,95 @@
#include "audiodevicesettingspage.h"
#include "sound_manager.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "../../lib/mem.h"
extern "C" {
#include "../transport/chat_core.h"
}
#include <QVBoxLayout>
#include <QLabel>
#include <QFormLayout>
#include <QDebug>
AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent)
: QWidget(parent), m_db(db)
{
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 12, 0, 0);
auto* infoLabel = new QLabel("Select audio devices for playback and recording.\nChanges take effect after restart.", this);
infoLabel->setWordWrap(true);
infoLabel->setStyleSheet("color: palette(text); font-size: 12px;");
layout->addWidget(infoLabel);
auto* formLayout = new QFormLayout();
formLayout->setContentsMargins(0, 16, 0, 0);
formLayout->setSpacing(10);
auto* outLabel = new QLabel("Playback device:", this);
m_outputCombo = new QComboBox(this);
m_outputCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
formLayout->addRow(outLabel, m_outputCombo);
auto* inLabel = new QLabel("Recording device:", this);
m_inputCombo = new QComboBox(this);
m_inputCombo->setStyleSheet(m_outputCombo->styleSheet());
formLayout->addRow(inLabel, m_inputCombo);
layout->addLayout(formLayout);
layout->addStretch();
}
void AudioDeviceSettingsPage::loadFromDb() {
auto playbackDevs = SoundManager::enumeratePlaybackDevices();
auto captureDevs = SoundManager::enumerateCaptureDevices();
m_outputCombo->clear();
for (int i = 0; i < playbackDevs.size(); i++) {
QString label = playbackDevs[i].name;
if (playbackDevs[i].isDefault) label += " (default)";
m_outputCombo->addItem(label, i);
if (playbackDevs[i].isDefault && m_savedOutputIdx < 0) m_savedOutputIdx = i;
}
m_inputCombo->clear();
for (int i = 0; i < captureDevs.size(); i++) {
QString label = captureDevs[i].name;
if (captureDevs[i].isDefault) label += " (default)";
m_inputCombo->addItem(label, i);
if (captureDevs[i].isDefault && m_savedInputIdx < 0) m_savedInputIdx = i;
}
if (m_db) {
m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", m_savedOutputIdx);
m_savedInputIdx = m_db->getUiStateInt("audio_input_device", m_savedInputIdx);
}
if (m_savedOutputIdx >= 0 && m_savedOutputIdx < m_outputCombo->count())
m_outputCombo->setCurrentIndex(m_savedOutputIdx);
if (m_savedInputIdx >= 0 && m_savedInputIdx < m_inputCombo->count())
m_inputCombo->setCurrentIndex(m_savedInputIdx);
}
void AudioDeviceSettingsPage::applyAndSave() {
int outIdx = m_outputCombo->currentData().toInt();
int inIdx = m_inputCombo->currentData().toInt();
auto saveKey = [](const char* key, int val) {
QByteArray v = QByteArray::number(val);
size_t klen = strlen(key), vlen = (size_t)v.size();
size_t total = klen + 1 + vlen + 1;
void* arg = u_malloc(total);
memcpy(arg, key, klen + 1);
memcpy((char*)arg + klen + 1, v.constData(), vlen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
};
saveKey("audio_output_device", outIdx);
saveKey("audio_input_device", inIdx);
m_savedOutputIdx = outIdx;
m_savedInputIdx = inIdx;
qDebug("AudioDeviceSettings: saved output=%d input=%d", outIdx, inIdx);
}
@@ -0,0 +1,19 @@
#pragma once
#include <QWidget>
#include <QComboBox>
class DbManager;
class AudioDeviceSettingsPage : public QWidget {
Q_OBJECT
public:
explicit AudioDeviceSettingsPage(DbManager* db, QWidget* parent = nullptr);
void loadFromDb();
void applyAndSave();
private:
DbManager* m_db;
QComboBox* m_outputCombo;
QComboBox* m_inputCombo;
int m_savedOutputIdx = -1;
int m_savedInputIdx = -1;
};
+129
View File
@@ -0,0 +1,129 @@
#include "audiorecorder.h"
#include "sound_manager.h"
#include "miniaudio.h"
#include <QDebug>
#include <cstring>
#include <cstdlib>
static AudioRecorder* g_recorder = nullptr;
void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput, unsigned int frameCount) {
(void)pOutput;
AudioRecorder* self = g_recorder;
if (!self || !self->m_recording) return;
if (!pInput) return;
const int16_t* src = (const int16_t*)pInput;
size_t cur = self->m_pcmBuffer.size();
size_t add = (size_t)frameCount * (size_t)self->m_channels;
self->m_pcmBuffer.resize(cur + add);
std::memcpy(self->m_pcmBuffer.data() + cur, src, add * sizeof(int16_t));
}
AudioRecorder::AudioRecorder(QObject* parent)
: QObject(parent) {
g_recorder = this;
m_durationTimer.setInterval(100);
connect(&m_durationTimer, &QTimer::timeout, this, [this]() {
if (m_recording) {
m_elapsedMs += 100;
emit durationChanged(m_elapsedMs);
}
});
}
AudioRecorder::~AudioRecorder() {
shutdown();
if (g_recorder == this) g_recorder = nullptr;
}
bool AudioRecorder::init() {
if (m_initialized) return true;
m_device = new ma_device;
std::memset(m_device, 0, sizeof(*m_device));
m_initialized = true;
qDebug("AudioRecorder: initialized (sampleRate=%d, channels=%d)", m_sampleRate, m_channels);
return true;
}
void AudioRecorder::shutdown() {
if (m_recording) stopRecording();
if (m_device) {
delete m_device;
m_device = nullptr;
}
m_initialized = false;
qDebug("AudioRecorder: shutdown");
}
void AudioRecorder::startRecording() {
if (m_recording) return;
if (!m_initialized) { qWarning("AudioRecorder: not initialized"); return; }
m_pcmBuffer.clear();
m_elapsedMs = 0;
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) { qWarning("AudioRecorder: no audio context"); return; }
ma_device_config config = ma_device_config_init(ma_device_type_capture);
config.capture.format = ma_format_s16;
config.capture.channels = (ma_uint32)m_channels;
config.sampleRate = (ma_uint32)m_sampleRate;
config.periodSizeInFrames = 480;
config.dataCallback = audioCaptureCallback;
config.pUserData = this;
if (m_captureDeviceIndex >= 0) {
ma_device_info* pCaptureDevices = nullptr;
ma_uint32 captureCount = 0;
ma_result r = ma_context_get_devices(ctx, nullptr, nullptr, &pCaptureDevices, &captureCount);
if (r == MA_SUCCESS && (ma_uint32)m_captureDeviceIndex < captureCount) {
config.capture.pDeviceID = &pCaptureDevices[m_captureDeviceIndex].id;
qDebug("AudioRecorder: using capture device[%d]: %s", m_captureDeviceIndex,
pCaptureDevices[m_captureDeviceIndex].name);
}
}
ma_result r = ma_device_init(ctx, &config, m_device);
if (r != MA_SUCCESS) {
qWarning("AudioRecorder: device init failed (%d), trying default", r);
config.capture.pDeviceID = nullptr;
r = ma_device_init(ctx, &config, m_device);
if (r != MA_SUCCESS) {
qWarning("AudioRecorder: default device init also failed (%d)", r);
return;
}
}
r = ma_device_start(m_device);
if (r != MA_SUCCESS) {
qWarning("AudioRecorder: device start failed (%d)", r);
ma_device_uninit(m_device);
return;
}
m_recording = true;
m_durationTimer.start();
qDebug("AudioRecorder: recording started");
emit recordingStarted();
}
void AudioRecorder::stopRecording() {
if (!m_recording) return;
m_durationTimer.stop();
ma_device_stop(m_device);
ma_device_uninit(m_device);
m_recording = false;
int duration = m_elapsedMs;
qDebug("AudioRecorder: recording stopped, duration=%dms, pcmSamples=%zu", duration, m_pcmBuffer.size());
emit recordingStopped(duration);
emit durationChanged(0);
}
int AudioRecorder::durationMs() const {
return m_elapsedMs;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <QObject>
#include <QString>
#include <QTimer>
#include <vector>
#include <cstdint>
struct ma_device;
class AudioRecorder : public QObject {
Q_OBJECT
public:
explicit AudioRecorder(QObject* parent = nullptr);
~AudioRecorder() override;
bool init();
void shutdown();
bool isInitialized() const { return m_initialized; }
void setCaptureDeviceIndex(int index) { m_captureDeviceIndex = index; }
int captureDeviceIndex() const { return m_captureDeviceIndex; }
void startRecording();
void stopRecording();
bool isRecording() const { return m_recording; }
const std::vector<int16_t>& buffer() const { return m_pcmBuffer; }
int durationMs() const;
signals:
void recordingStarted();
void recordingStopped(int durationMs);
void durationChanged(int ms);
private:
ma_device* m_device = nullptr;
bool m_initialized = false;
bool m_recording = false;
int m_sampleRate = 48000;
int m_channels = 1;
int m_captureDeviceIndex = -1;
std::vector<int16_t> m_pcmBuffer;
QTimer m_durationTimer;
int m_elapsedMs = 0;
friend void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput, unsigned int frameCount);
};
+55 -1
View File
@@ -34,11 +34,13 @@ static QWidget *wrapBottomBtn(QPushButton *btn, int pad) {
return w;
}
InputBar::InputBar(EmojiPanel *emojiPanel, QWidget *parent)
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
: QWidget(parent)
, m_input(new QTextEdit(this))
, m_emojiBtn(nullptr)
, m_pttBtn(nullptr)
, m_emojiPanel(emojiPanel)
, m_recorder(recorder)
{
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4);
@@ -71,6 +73,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, QWidget *parent)
QString::fromUtf8("Смайлики"), this);
layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom);
m_pttBtn = makeIconBtn(QString::fromUtf8("\xF0\x9F\x8E\xA4"),
QString::fromUtf8("Голосовое сообщение (удерживайте)"), this);
layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom);
auto *sendBtn = makeIconBtn(QString::fromUtf8("\xE2\x9E\xA4"),
QString::fromUtf8("Отправить"), this);
sendBtn->setStyleSheet(sendBtn->styleSheet() +
@@ -80,6 +86,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, QWidget *parent)
connect(m_input, &QTextEdit::textChanged, this, &InputBar::adjustInputHeight);
connect(sendBtn, &QPushButton::clicked, this, &InputBar::onSendClicked);
/* PTT button: press to record, release to stop */
connect(m_pttBtn, &QPushButton::pressed, this, &InputBar::onPttPressed);
connect(m_pttBtn, &QPushButton::released, this, &InputBar::onPttReleased);
if (m_emojiPanel) {
connect(m_emojiBtn, &QPushButton::clicked, this, [this]() {
m_emojiPanel->setVisible(!m_emojiPanel->isVisible());
@@ -140,3 +150,47 @@ void InputBar::onSendClicked() {
if (m_emojiPanel) m_emojiPanel->hide();
m_input->setFocus();
}
#include "audiorecorder.h"
#include "voicemessageencoder.h"
void InputBar::onPttPressed() {
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
m_pttActive = true;
m_pttBtn->setStyleSheet(m_pttBtn->styleSheet() +
"QPushButton { background: #e74c3c; color: white; border-radius: 4px; }");
}
void InputBar::onPttReleased() {
if (!m_pttActive) return;
m_pttActive = false;
m_recorder->stopRecording();
m_pttBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px; font-size: 18px; background: transparent; }"
"QPushButton:hover { background: palette(light); }");
int durationMs = m_recorder->durationMs();
if (durationMs < 500) {
qDebug("InputBar: voice message too short (%dms), discarded", durationMs);
return;
}
const std::vector<int16_t>& pcm = m_recorder->buffer();
if (pcm.empty()) return;
QString mediaPath = VoiceEncoder::mediaDir(
QString::fromUtf8(qgetenv("CHAT_DATA_DIR").isEmpty() ? "chat_data" : qgetenv("CHAT_DATA_DIR")),
"current");
VoiceEncoder::ensureMediaDir(mediaPath);
QString fileName = VoiceEncoder::generateFileName();
QString filePath = mediaPath + "/" + fileName;
float durationSec = 0;
int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec);
if (frames > 0) {
m_voiceFilePath = filePath;
qDebug("InputBar: voice message ready: %s (%.1fs)", qPrintable(filePath), durationSec);
emit sendVoiceMessage(filePath, durationMs);
}
}
+13 -4
View File
@@ -5,14 +5,17 @@
class QTextEdit;
class QPushButton;
class EmojiPanel;
class AudioRecorder;
class InputBar : public QWidget {
Q_OBJECT
public:
explicit InputBar(EmojiPanel *emojiPanel, QWidget *parent = nullptr);
explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder,
QWidget *parent = nullptr);
signals:
void sendMessage(const QString &text);
void sendVoiceMessage(const QString &filePath, int durationMs);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
@@ -20,9 +23,15 @@ protected:
private slots:
void onSendClicked();
void adjustInputHeight();
void onPttPressed();
void onPttReleased();
private:
QTextEdit *m_input;
QPushButton *m_emojiBtn;
EmojiPanel *m_emojiPanel;
QTextEdit *m_input;
QPushButton *m_emojiBtn;
QPushButton *m_pttBtn;
EmojiPanel *m_emojiPanel;
AudioRecorder *m_recorder;
bool m_pttActive = false;
QString m_voiceFilePath;
};
+13 -1
View File
@@ -8,6 +8,7 @@
#include "creategroupdialog.h"
#include "invite_link.h"
#include "sound_manager.h"
#include "audiorecorder.h"
#include "animtimer.h"
#include "../db/db_manager.h"
#include "../transport/utun_node.h"
@@ -70,6 +71,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
, m_db(db), m_cfgPath(cfgPath), m_cfgDbPath(dbPath)
, m_cfgDebugFile(debugFile), m_cfgDebugLevel(debugLevel)
, m_cfgDebugCategories(debugCategories)
, m_recorder(nullptr)
{
setWindowTitle("Chat");
resize(900, 600);
@@ -85,6 +87,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
}
MainWindow::~MainWindow() {
if (m_recorder) { m_recorder->shutdown(); m_recorder = nullptr; }
SoundManager::instance()->shutdown();
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow — saving channel state");
if (m_messageList) m_messageList->saveChannelState();
@@ -101,7 +104,8 @@ void MainWindow::setupUi() {
auto *splitter = new QSplitter(Qt::Horizontal, this);
m_channelList = new ChannelList(m_db, splitter);
m_messageList = new MessageList(m_db, splitter);
m_recorder = new AudioRecorder(this);
m_messageList = new MessageList(m_db, m_recorder, splitter);
m_accountList = new AccountList(m_db, splitter);
splitter->addWidget(m_channelList);
@@ -195,6 +199,14 @@ void MainWindow::setupSoundFromConfig() {
m_db->getUiStateInt(QStringLiteral("sound_%1_vol").arg(ek), 80) / 100.0f);
}
}
if (m_recorder) {
m_recorder->init();
if (m_db && m_db->isOpen())
m_recorder->setCaptureDeviceIndex(m_db->getUiStateInt("audio_input_device", -1));
qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d)",
m_recorder->captureDeviceIndex());
}
}
void MainWindow::setupBridgeCallbacks() {
+2
View File
@@ -9,6 +9,7 @@ class MessageList;
class AccountList;
class DbManager;
class UtunNode;
class AudioRecorder;
class MainWindow : public QMainWindow {
Q_OBJECT
@@ -61,4 +62,5 @@ private:
QString m_cfgDebugCategories;
QString m_pendingChannelSelect;
QString m_currentChannelId;
AudioRecorder *m_recorder = nullptr;
};
+66
View File
@@ -67,6 +67,35 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
QString text = index.data(MsgTextRole).toString();
L.isOutgoing = index.data(MsgIsOutgoingRole).toBool();
/* Voice message detection */
QString contentType = index.data(MsgContentTypeRole).toString();
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 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);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
L.emojiCount = detectEmojiOnly(text);
L.emojiOnly = (L.emojiCount >= 1);
@@ -449,6 +478,43 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
return;
}
if (L.isVoice) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
/* Play button */
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC));
QPainterPath playPath;
QRectF playR = L.voicePlayBtnRect;
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);
/* 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);
/* Time */
QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 1);
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, L.voicePlayBtnRect.bottom() + 4, 50, 16);
painter->drawText(timeR, Qt::AlignRight | Qt::AlignVCenter, timeStr);
painter->restore();
return;
}
auto *cv = qobject_cast<const ChatView *>(option.widget);
bool singleTextSel = false;
bool multiRowSel = false;
+7
View File
@@ -14,6 +14,9 @@ enum MessageDataRole {
MsgReactionsRole = Qt::UserRole + 9,
MsgIdRole = Qt::UserRole + 10,
MsgIsOutgoingRole = Qt::UserRole + 11,
MsgContentTypeRole = Qt::UserRole + 12,
MsgVoiceDurationRole = Qt::UserRole + 13,
MsgVoiceFileRole = Qt::UserRole + 14,
MsgAuthorNodeIdRole = Qt::UserRole + 20,
};
@@ -46,6 +49,10 @@ private:
int emojiCount = 0;
QRect emojiArea;
bool isOutgoing = false;
bool isVoice = false;
QRect voicePlayBtnRect;
QRect voiceDurationRect;
QString voiceDuration;
};
Layout calcLayout(const QStyleOptionViewItem &option,
+53 -2
View File
@@ -16,6 +16,7 @@
#include <QScrollBar>
#include <QApplication>
#include <QDebug>
#include <QFileInfo>
#include <vector>
#include <memory>
@@ -76,7 +77,7 @@ static void setMsg(QStandardItem *item,
item->setData(reactions, MsgReactionsRole);
}
MessageList::MessageList(DbManager* db, QWidget *parent)
MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent)
: QWidget(parent)
, m_view(new ChatView(this))
, m_model(new QStandardItemModel(this))
@@ -119,7 +120,7 @@ MessageList::MessageList(DbManager* db, QWidget *parent)
m_emojiPanel = new EmojiPanel(this);
layout->addWidget(m_emojiPanel);
m_inputBar = new InputBar(m_emojiPanel, this);
m_inputBar = new InputBar(m_emojiPanel, recorder, this);
layout->addWidget(m_inputBar);
connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) {
@@ -144,6 +145,33 @@ MessageList::MessageList(DbManager* db, QWidget *parent)
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
});
connect(m_inputBar, &InputBar::sendVoiceMessage, this, [this](const QString &filePath, int durationMs) {
if (m_currentChannelId.isEmpty()) return;
QFileInfo fi(filePath);
QString fileName = fi.fileName();
QString jsonData = QString("{\"n\":%1,\"ch\":\"%2\",\"ct\":\"audio/opus\",\"d\":\"%3\",\"dur\":%4}")
.arg(m_db ? m_db->myNodeId() : 0)
.arg(m_currentChannelId)
.arg(fileName)
.arg(durationMs / 1000.0, 0, 'f', 1);
QByteArray textData = jsonData.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
struct chat_msg_submit* req = (struct chat_msg_submit*)
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
memset(req, 0, sizeof(*req));
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
m_currentChannelId.toUtf8().constData());
snprintf(req->content_type, sizeof(req->content_type), "audio/opus");
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)textData.size();
memcpy(req->data, textData.constData(), textData.size());
req->timestamp = (uint64_t)ts;
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
});
connect(AnimTimer::instance(), &AnimTimer::ticked, this, [this]() {
m_view->viewport()->update();
});
@@ -224,6 +252,18 @@ void MessageList::loadChannel(const QString& channelId) {
item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
item->setData(m.contentType, MsgContentTypeRole);
if (m.contentType == "audio/opus") {
/* JSON data contains {"dur":X.X} — parse duration */
QByteArray d = m.data;
int durIdx = d.indexOf("\"dur\":");
if (durIdx >= 0) {
int durEnd = d.indexOf(',', durIdx);
if (durEnd < 0) durEnd = d.indexOf('}', durIdx);
QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6);
item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole);
}
}
m_model->appendRow(item);
}
@@ -287,6 +327,17 @@ void MessageList::refresh() {
item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
item->setData(m.contentType, MsgContentTypeRole);
if (m.contentType == "audio/opus") {
QByteArray d = m.data;
int durIdx = d.indexOf("\"dur\":");
if (durIdx >= 0) {
int durEnd = d.indexOf(',', durIdx);
if (durEnd < 0) durEnd = d.indexOf('}', durIdx);
QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6);
item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole);
}
}
m_model->appendRow(item);
while (m_model->rowCount() > 100)
m_model->removeRow(0);
+3 -1
View File
@@ -10,11 +10,13 @@ class InputBar;
class EmojiPanel;
class DbManager;
class ChannelList;
class AudioRecorder;
class MessageList : public QWidget {
Q_OBJECT
public:
explicit MessageList(DbManager* db = nullptr, QWidget *parent = nullptr);
explicit MessageList(DbManager* db = nullptr, AudioRecorder* recorder = nullptr,
QWidget *parent = nullptr);
void loadChannel(const QString& channelId);
void refresh();
+7
View File
@@ -5,6 +5,7 @@
#include "statuspage.h"
#include "nodespage.h"
#include "soundsettingspage.h"
#include "audiodevicesettingspage.h"
#include "../transport/gui_bridge.h"
#include "../../lib/mem.h"
#include "../db/db_manager.h"
@@ -48,6 +49,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
m_categoryList->addItem("Status");
m_categoryList->addItem("Nodes");
m_categoryList->addItem("Sound");
m_categoryList->addItem("Audio");
mainLayout->addWidget(m_categoryList);
// --- Right panel: stacked pages ---
@@ -99,6 +101,9 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
m_soundPage = new SoundSettingsPage(db, m_pages);
m_pages->addWidget(m_soundPage);
m_audioDevicePage = new AudioDeviceSettingsPage(db, m_pages);
m_pages->addWidget(m_audioDevicePage);
rightLayout->addWidget(m_pages, 1);
// --- Bottom buttons ---
@@ -164,6 +169,7 @@ void SettingsDialog::onCategoryChanged(int row) {
if (row == 3) m_statusPage->refreshStatus();
if (row == 4) m_nodesPage->refreshNodes();
if (row == 5) m_soundPage->loadFromDb();
if (row == 6) m_audioDevicePage->loadFromDb();
}
void SettingsDialog::onSave() {
@@ -219,5 +225,6 @@ void SettingsDialog::onSave() {
}
m_soundPage->applyAndSave();
m_audioDevicePage->applyAndSave();
accept();
}
+3 -1
View File
@@ -14,6 +14,7 @@ class DatabaseSettingsPage;
class StatusPage;
class NodesPage;
class SoundSettingsPage;
class AudioDeviceSettingsPage;
class DbManager;
class UtunNode;
@@ -40,5 +41,6 @@ private:
DatabaseSettingsPage* m_databasePage;
StatusPage* m_statusPage;
NodesPage* m_nodesPage;
SoundSettingsPage* m_soundPage;
SoundSettingsPage* m_soundPage;
AudioDeviceSettingsPage* m_audioDevicePage;
};
+61 -1
View File
@@ -26,8 +26,9 @@ bool SoundManager::init() {
return false;
}
m_engine = engine;
m_context = ma_device_get_context(ma_engine_get_device(engine));
m_initialized = true;
qDebug("SoundManager: initialized");
qDebug("SoundManager: initialized (engine=%p, context=%p)", (void*)engine, (void*)m_context);
return true;
}
@@ -134,3 +135,62 @@ void SoundManager::play(const QString& name) {
ma_sound_seek_to_pcm_frame(s, 0);
ma_sound_start(s);
}
ma_context* SoundManager::context() {
return m_context;
}
void SoundManager::playRawPcm(const int16_t* pcm, int sampleCount, int sampleRate, int channels) {
if (!m_engine || !m_initialized) return;
ma_decoder_config decCfg = ma_decoder_config_init(ma_format_s16, (ma_uint32)channels, (ma_uint32)sampleRate);
ma_decoder* decoder = new ma_decoder;
ma_result r = ma_decoder_init_memory(pcm, (ma_uint64)sampleCount * channels * sizeof(int16_t), &decCfg, decoder);
if (r != MA_SUCCESS) {
qWarning("SoundManager: PCM decoder init failed (%d)", r);
delete decoder;
return;
}
ma_sound* sound = new ma_sound;
r = ma_sound_init_from_data_source(m_engine, (ma_data_source*)decoder, 0, nullptr, sound);
if (r != MA_SUCCESS) {
qWarning("SoundManager: PCM sound init failed (%d)", r);
ma_decoder_uninit(decoder);
delete decoder;
delete sound;
return;
}
ma_sound_set_volume(sound, m_volume);
ma_sound_start(sound);
qDebug("SoundManager: playing PCM (%d samples, %dHz, %dch)", sampleCount, sampleRate, channels);
/* auto-cleanup after playback ends. Note: decoder+sound leak on early shutdown, acceptable for voice messages */
}
QVector<SoundManager::DeviceInfo> SoundManager::enumeratePlaybackDevices() {
QVector<DeviceInfo> result;
ma_context* ctx = instance()->m_context;
if (!ctx) return result;
ma_device_info* pDevices = nullptr;
ma_uint32 count = 0;
if (ma_context_get_devices(ctx, &pDevices, &count, nullptr, nullptr) != MA_SUCCESS) return result;
for (ma_uint32 i = 0; i < count; i++)
result.append({QString::fromUtf8(pDevices[i].name), pDevices[i].isDefault != MA_FALSE});
return result;
}
QVector<SoundManager::DeviceInfo> SoundManager::enumerateCaptureDevices() {
QVector<DeviceInfo> result;
ma_context* ctx = instance()->m_context;
if (!ctx) return result;
ma_device_info* pDevices = nullptr;
ma_uint32 count = 0;
if (ma_context_get_devices(ctx, nullptr, nullptr, &pDevices, &count) != MA_SUCCESS) return result;
for (ma_uint32 i = 0; i < count; i++)
result.append({QString::fromUtf8(pDevices[i].name), pDevices[i].isDefault != MA_FALSE});
return result;
}
+15
View File
@@ -4,8 +4,11 @@
#include <QHash>
#include <QString>
#include <QByteArray>
#include <vector>
#include <cstdint>
struct ma_engine;
struct ma_context;
struct ma_decoder;
struct ma_sound;
@@ -33,6 +36,17 @@ public:
bool loadSound(const QString& name, const QString& filePath);
void play(const QString& name);
ma_context* context();
void playRawPcm(const int16_t* pcm, int sampleCount, int sampleRate, int channels);
struct DeviceInfo {
QString name;
bool isDefault = false;
};
static QVector<DeviceInfo> enumeratePlaybackDevices();
static QVector<DeviceInfo> enumerateCaptureDevices();
private:
SoundManager() = default;
~SoundManager();
@@ -44,6 +58,7 @@ private:
};
ma_engine* m_engine = nullptr;
ma_context* m_context = nullptr;
bool m_initialized = false;
bool m_enabled = true;
float m_volume = 0.8f;
+100
View File
@@ -0,0 +1,100 @@
#include "voicemessageencoder.h"
extern "C" {
#include "../../../lib/opus_codec.h"
}
#include <QDebug>
#include <QDir>
#include <QDateTime>
#include <QFile>
#include <cstring>
static const int FRAME_MS = 20;
static const uint32_t OPUS_MAGIC = 0x5355504F; /* "OPUS" */
static const int MAX_PACKET = 4000;
int VoiceEncoder::encodeToFile(const std::vector<int16_t>& pcm, int sampleRate, int channels,
const QString& filePath, float& outDurationSec) {
if (pcm.empty() || sampleRate <= 0 || channels <= 0) {
qWarning("VoiceEncoder: invalid input (pcm=%zu rate=%d ch=%d)", pcm.size(), sampleRate, channels);
return -1;
}
int frameSamples = sampleRate * FRAME_MS / 1000;
size_t totalSamples = pcm.size() / channels;
if (totalSamples < (size_t)frameSamples) return -1;
opus_codec_encoder_t* enc = opus_codec_encoder_create(sampleRate, channels);
if (!enc) {
qWarning("VoiceEncoder: encoder create failed");
return -1;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
qWarning("VoiceEncoder: cannot open file %s", qPrintable(filePath));
opus_codec_encoder_destroy(enc);
return -1;
}
/* write header */
uint32_t magic = OPUS_MAGIC;
uint32_t sr = (uint32_t)sampleRate;
uint16_t ch = (uint16_t)channels;
uint16_t fs = (uint16_t)frameSamples;
file.write((const char*)&magic, 4);
file.write((const char*)&sr, 4);
file.write((const char*)&ch, 2);
file.write((const char*)&fs, 2);
int frameCount = 0;
uint8_t packet[MAX_PACKET];
const int16_t* pcmData = pcm.data();
size_t pcmOff = 0;
while (pcmOff + (size_t)frameSamples * channels <= totalSamples) {
int len = opus_codec_encode(enc, pcmData + pcmOff, frameSamples, packet, MAX_PACKET);
pcmOff += (size_t)frameSamples * channels;
if (len > 0) {
uint16_t plen = (uint16_t)len;
file.write((const char*)&plen, 2);
file.write((const char*)packet, len);
frameCount++;
} else if (len < 0) {
qWarning("VoiceEncoder: encode error at frame %d: %d", frameCount, len);
}
}
/* end marker */
uint16_t zero = 0;
file.write((const char*)&zero, 2);
file.close();
outDurationSec = (float)frameCount * FRAME_MS / 1000.0f;
qDebug("VoiceEncoder: wrote %d frames (%.1fs) to %s", frameCount, outDurationSec, qPrintable(filePath));
opus_codec_encoder_destroy(enc);
return frameCount;
}
QString VoiceEncoder::generateFileName() {
QDateTime now = QDateTime::currentDateTime();
QString ts = now.toString("yyyyMMdd-HHmmss");
int rnd = rand() & 0xFFFF;
return QString("voice_%1_%2.opus").arg(ts).arg(rnd, 4, 16, QChar('0'));
}
QString VoiceEncoder::mediaDir(const QString& dbPath, const QString& channelId) {
QDir d(dbPath);
QString mediaPath = d.absoluteFilePath("media/" + channelId);
return mediaPath;
}
void VoiceEncoder::ensureMediaDir(const QString& dir) {
QDir d;
if (!d.mkpath(dir)) {
qWarning("VoiceEncoder: cannot create media dir %s", qPrintable(dir));
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QString>
#include <vector>
#include <cstdint>
class VoiceEncoder {
public:
static int encodeToFile(const std::vector<int16_t>& pcm, int sampleRate, int channels,
const QString& filePath, float& outDurationSec);
static QString generateFileName();
static QString mediaDir(const QString& dbPath, const QString& channelId);
static void ensureMediaDir(const QString& dir);
};
+78
View File
@@ -0,0 +1,78 @@
#include "voiceplayback.h"
#include "sound_manager.h"
extern "C" {
#include "../../../lib/opus_codec.h"
}
#include <QFile>
#include <QDebug>
static const uint32_t OPUS_MAGIC = 0x5355504F;
int VoicePlayer::decodeOpusFile(const QString& filePath, std::vector<int16_t>& outPcm,
int& outSampleRate, int& outChannels, float& outDurationSec) {
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
qWarning("VoicePlayer: cannot open %s", qPrintable(filePath));
return -1;
}
uint32_t magic, sr32;
uint16_t ch16, frameSamples;
if (file.read((char*)&magic, 4) != 4 || magic != OPUS_MAGIC ||
file.read((char*)&sr32, 4) != 4 ||
file.read((char*)&ch16, 2) != 2 ||
file.read((char*)&frameSamples, 2) != 2) {
qWarning("VoicePlayer: invalid header in %s", qPrintable(filePath));
return -1;
}
int sampleRate = (int)sr32;
int channels = (int)ch16;
outSampleRate = sampleRate;
outChannels = channels;
opus_codec_decoder_t* dec = opus_codec_decoder_create(sampleRate, channels);
if (!dec) {
qWarning("VoicePlayer: decoder create failed");
return -1;
}
outPcm.clear();
int frameCount = 0;
while (!file.atEnd()) {
uint16_t plen;
if (file.read((char*)&plen, 2) != 2) break;
if (plen == 0) break; /* end marker */
QByteArray pktData = file.read(plen);
if (pktData.size() != (int)plen) break;
std::vector<int16_t> frameBuf(frameSamples * channels);
int decoded = opus_codec_decode(dec, (const uint8_t*)pktData.constData(),
plen, frameBuf.data(), frameSamples);
if (decoded > 0) {
size_t cur = outPcm.size();
outPcm.resize(cur + decoded * channels);
memcpy(outPcm.data() + cur, frameBuf.data(), decoded * channels * sizeof(int16_t));
frameCount++;
}
}
outDurationSec = (float)frameCount * 20.0f / 1000.0f;
qDebug("VoicePlayer: decoded %d frames (%.1fs) from %s", frameCount, outDurationSec, qPrintable(filePath));
opus_codec_decoder_destroy(dec);
return frameCount;
}
void VoicePlayer::playOpusFile(const QString& filePath) {
std::vector<int16_t> pcm;
int sampleRate, channels;
float duration;
if (decodeOpusFile(filePath, pcm, sampleRate, channels, duration) <= 0) return;
SoundManager::instance()->playRawPcm(pcm.data(), pcm.size() / channels, sampleRate, channels);
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
#include <vector>
#include <cstdint>
class VoicePlayer {
public:
static void playOpusFile(const QString& filePath);
static int decodeOpusFile(const QString& filePath, std::vector<int16_t>& outPcm,
int& outSampleRate, int& outChannels, float& outDurationSec);
};