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:
1 parent
4a23593020
commit
4fde0b0325
260 files changed
+72665
-16
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user