first
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/30.
|
||||
//
|
||||
|
||||
/**
|
||||
* @brief 中介类,避免直接让GLCore成为单例类
|
||||
* 虽然变得方便了,但也带来了危险,如果你肆意通过中介指针去调用GLCore的成员函数
|
||||
* 可能会导致渲染问题等
|
||||
*/
|
||||
#pragma once
|
||||
#include "GLCore.h"
|
||||
|
||||
class AppContext {
|
||||
public:
|
||||
// 注册GLCore
|
||||
static void RegisterGLCore(GLCore* core) { s_glCore = core; }
|
||||
// 注销GLCore
|
||||
static void UnregisterGLCore() { s_glCore = nullptr; }
|
||||
static GLCore* GetGLCore() { return s_glCore; }
|
||||
|
||||
private:
|
||||
static inline GLCore* s_glCore = nullptr; // C++17 内联静态成员
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/24.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* 客户端业务核心
|
||||
* 1. 处理来自服务端的数据,分发并执行
|
||||
* 2. 完成非阻塞的事件循环处理,构建业务状态机
|
||||
*/
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QHash>
|
||||
#ifdef Q_OS_WIN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
#ifdef Q_OS_LINUX
|
||||
#include <QAbstractNativeEventFilter>
|
||||
#endif
|
||||
#include "serialportmanager.h"
|
||||
|
||||
class DeviceTcpServer;
|
||||
class DeviceWebSocketServer;
|
||||
|
||||
class AppCore final : public QObject
|
||||
#if defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
, public QAbstractNativeEventFilter
|
||||
#endif
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(AppCore)
|
||||
|
||||
private:
|
||||
explicit AppCore(QObject *parent = nullptr);
|
||||
|
||||
static QScopedPointer<AppCore> m_instance;
|
||||
static QMutex m_mutex;
|
||||
|
||||
DeviceTcpServer *m_deviceTcpServer = nullptr;
|
||||
DeviceWebSocketServer *m_deviceWsServer = nullptr;
|
||||
|
||||
private slots:
|
||||
void onRecordingFinished_Byte(const QByteArray &wavData);
|
||||
|
||||
public:
|
||||
static AppCore *getInstance();
|
||||
static void destroy();
|
||||
|
||||
~AppCore() override;
|
||||
|
||||
void registerEmbeddedDevice(const QString &deviceId, SerialPortClient *client);
|
||||
void unregisterEmbeddedDevice(const QString &deviceId);
|
||||
|
||||
public:
|
||||
void SingleExchange();
|
||||
void tryToInit() { }
|
||||
|
||||
// PTT 按住说话
|
||||
void startPttRecording();
|
||||
void stopPttRecording();
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
void setupGlobalHotkey();
|
||||
void cleanupGlobalHotkey();
|
||||
#endif
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
private:
|
||||
static LRESULT CALLBACK lowLevelKeyboardHook(int nCode, WPARAM wParam, LPARAM lParam);
|
||||
HHOOK m_keyboardHook = nullptr;
|
||||
bool m_isPttDown = false;
|
||||
UINT m_hotkeyVKey = VK_OEM_3;
|
||||
|
||||
#elif defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
private:
|
||||
bool nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result) override;
|
||||
void onDebounceStop();
|
||||
bool m_isPttDown = false;
|
||||
int m_hotkeyCode = 0;
|
||||
class QTimer *m_pttDebounce = nullptr;
|
||||
|
||||
#elif defined(Q_OS_MACOS)
|
||||
private:
|
||||
void *m_eventTap = nullptr;
|
||||
void *m_runLoopSource = nullptr;
|
||||
bool m_isPttDown = false;
|
||||
int m_hotkeyCode = 50;
|
||||
#endif
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <QtWidgets/QWidget>
|
||||
#include <QOpenGLWidget>
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
#include "menu.h"
|
||||
#endif
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
class GLCore final : public QOpenGLWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GLCore(int width, int height, QWidget* parent = nullptr);
|
||||
// 删除拷贝构造函数
|
||||
GLCore(const GLCore&) = delete;
|
||||
// 删除拷贝运算符
|
||||
GLCore& operator=(const GLCore&) = delete;
|
||||
// 删除移动构造函数
|
||||
GLCore(GLCore&&) = delete;
|
||||
// 删除移动运算符
|
||||
GLCore& operator=(GLCore&&) = delete;
|
||||
|
||||
~GLCore() override;
|
||||
|
||||
// 帧率控制
|
||||
void setFrameRate(double fps);
|
||||
[[nodiscard]] double getFrameRate() const;
|
||||
// 帧率表
|
||||
static QMap<QString, double> getFrameRateMap();
|
||||
static QStringList getFrameRateList();
|
||||
|
||||
// 安全地设置窗口的实际像素尺寸
|
||||
void setWindowSize(int w, int h);
|
||||
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||
|
||||
|
||||
// 重写函数
|
||||
void initializeGL() override;
|
||||
void paintGL() override;
|
||||
void resizeGL(int w, int h) override;
|
||||
|
||||
void closeEvent(QCloseEvent* event) override;
|
||||
|
||||
private:
|
||||
void closeGL(); // 关闭当前窗口
|
||||
|
||||
|
||||
private:
|
||||
/*
|
||||
* 在这个地方我遇到一个很抽象的问题,就是私有成员变量顺序的问题
|
||||
* 出问题的顺序是:
|
||||
* bool isLeftPressed; /// 鼠标左键是否按下
|
||||
bool isRightPressed; /// 鼠标右键是否按下
|
||||
QPoint currentPos; /// 当前鼠标位置
|
||||
Menu *contextMenu; /// 使用 Menu 类
|
||||
AudioInput *audioInput; /// 音频录制类
|
||||
AudioOutput *audioOutput; /// 音频播放类
|
||||
这样的顺序导致了我的鼠标一放在窗口上,窗口就往右下瞬移
|
||||
改成现在下面的顺序就正常了
|
||||
我一开始以为是我音频录制类里面多线程导致的
|
||||
但想了想我都没new这个对象,哪来的多线程
|
||||
后面问了问AI,它的解释是:
|
||||
可能与C++中类成员的初始化顺序有关。
|
||||
在C++中,类成员变量按照它们在类中声明的顺序进行初始化,
|
||||
而不是根据它们在构造函数初始化列表中的顺序。
|
||||
如果某些成员变量的初始化依赖于其他成员变量的状态
|
||||
,而它们的实际初始化顺序与预期不符,可能会导致未定义行为或其他意外问题。
|
||||
我感觉这不一定是根本原因,谁能告诉我到底发生了啥???
|
||||
|
||||
2025.3.30(Misaki): 上述问题已经解决,原因是isLeftPressed与isRightPressed
|
||||
这两个成员变量没有初始化,导致其值是随机的,进而产生bug
|
||||
*/
|
||||
|
||||
double frameRate = 60.0; /// 帧率
|
||||
static QMap<QString, double> frameRateMap; /// 帧率映射表
|
||||
QTimer* frameTimer; /// 帧控制定时器
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
Menu *contextMenu; /// 使用 Menu 类
|
||||
#endif
|
||||
|
||||
bool isLeftPressed; /// 鼠标左键是否按下
|
||||
bool isRightPressed; /// 鼠标右键是否按下
|
||||
QPoint currentPos; /// 当前鼠标位置
|
||||
#ifdef Q_OS_WIN
|
||||
private:
|
||||
HWND hwnd; // Windows窗口句柄
|
||||
void setWindowTransparentForMouse(bool transparent);
|
||||
#endif
|
||||
};
|
||||
@@ -0,0 +1,352 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/24.
|
||||
//
|
||||
|
||||
#include "AppCore.h"
|
||||
#include <QDebug>
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
#include "AudioDataHandle.h"
|
||||
#include "AutoAgentHandle.h"
|
||||
#include "ScreenShotReqDataHandle.h"
|
||||
#include "DeviceDataHandle.h"
|
||||
|
||||
#include "AudioInput.h"
|
||||
#include "NetWorkDO.h"
|
||||
#include "websocketmanager.h"
|
||||
#include "DeviceTcpServer.h"
|
||||
#include "DeviceWebSocketServer.h"
|
||||
|
||||
#if defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/keysym.h>
|
||||
#include <X11/XKBlib.h>
|
||||
#endif
|
||||
|
||||
#ifdef Q_OS_MACOS
|
||||
#include <CoreGraphics/CoreGraphics.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#endif
|
||||
|
||||
// 初始化静态成员
|
||||
QScopedPointer<AppCore> AppCore::m_instance;
|
||||
QMutex AppCore::m_mutex;
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
static AppCore *g_hotkeyInstance = nullptr;
|
||||
#endif
|
||||
|
||||
// 单例实现 (QScopedPointer + Mutex)
|
||||
AppCore* AppCore::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
// 使用 reset 创建实例,因为构造函数是私有的
|
||||
m_instance.reset(new AppCore());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void AppCore::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset(); // 这会触发析构函数
|
||||
}
|
||||
}
|
||||
|
||||
AppCore::AppCore(QObject *parent) : QObject(parent)
|
||||
{
|
||||
DeviceDataHandle *deviceHandle = DeviceDataHandle::getInstance();
|
||||
|
||||
// 启动嵌入式设备 TCP 服务器
|
||||
m_deviceTcpServer = new DeviceTcpServer(10001, this);
|
||||
connect(m_deviceTcpServer, &DeviceTcpServer::deviceConnected,
|
||||
deviceHandle, [=](const QString &deviceId, const QString &) {
|
||||
deviceHandle->registerDevice(deviceId, "tcp", m_deviceTcpServer);
|
||||
});
|
||||
connect(m_deviceTcpServer, &DeviceTcpServer::deviceDisconnected,
|
||||
deviceHandle, &DeviceDataHandle::unregisterDevice);
|
||||
connect(m_deviceTcpServer, &DeviceTcpServer::jsonReceived,
|
||||
deviceHandle, &DeviceDataHandle::onTcpDeviceData);
|
||||
m_deviceTcpServer->start();
|
||||
|
||||
// 启动嵌入式设备 WebSocket 服务器
|
||||
m_deviceWsServer = new DeviceWebSocketServer(10002, this);
|
||||
connect(m_deviceWsServer, &DeviceWebSocketServer::deviceConnected,
|
||||
deviceHandle, [=](const QString &deviceId, const QString &) {
|
||||
deviceHandle->registerDevice(deviceId, "websocket", m_deviceWsServer);
|
||||
});
|
||||
connect(m_deviceWsServer, &DeviceWebSocketServer::deviceDisconnected,
|
||||
deviceHandle, &DeviceDataHandle::unregisterDevice);
|
||||
connect(m_deviceWsServer, &DeviceWebSocketServer::jsonReceived,
|
||||
deviceHandle, &DeviceDataHandle::onWsDeviceData);
|
||||
m_deviceWsServer->start();
|
||||
|
||||
// 初始化业务解析单例
|
||||
AudioDataHandle::getInstance();
|
||||
AutoAgentHandle::getInstance();
|
||||
ScreenShotReqDataHandle::getInstance();
|
||||
// 注入发送接口
|
||||
NetworkDO::getInstance()->registerSender([](const QString& type, const QJsonObject& data){
|
||||
WebSocketClient::getInstance()->sendJson(type, data);
|
||||
});
|
||||
// TODO Test
|
||||
AudioInput::getInstance()->setAudioPath(QDir::currentPath(), "/temp.wav");
|
||||
// 连接必要的信号
|
||||
connect(AudioInput::getInstance(), &AudioInput::recordingFinished_Byte,
|
||||
this, &AppCore::onRecordingFinished_Byte);
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
setupGlobalHotkey();
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
AppCore::~AppCore()
|
||||
{
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
cleanupGlobalHotkey();
|
||||
#endif
|
||||
|
||||
if (m_deviceTcpServer) m_deviceTcpServer->stop();
|
||||
if (m_deviceWsServer) m_deviceWsServer->stop();
|
||||
|
||||
ScreenShotReqDataHandle::destroy();
|
||||
AutoAgentHandle::destroy();
|
||||
AudioDataHandle::destroy();
|
||||
DeviceDataHandle::destroy();
|
||||
|
||||
qDebug() << "AppCore destroyed";
|
||||
}
|
||||
|
||||
void AppCore::registerEmbeddedDevice(const QString &deviceId, SerialPortClient *client)
|
||||
{
|
||||
DeviceDataHandle::getInstance()->registerDevice(deviceId, QStringLiteral("serial"), client);
|
||||
}
|
||||
|
||||
void AppCore::unregisterEmbeddedDevice(const QString &deviceId)
|
||||
{
|
||||
DeviceDataHandle::getInstance()->unregisterDevice(deviceId);
|
||||
}
|
||||
|
||||
void AppCore::SingleExchange() {
|
||||
// 开始录音,录音结束后会触发录音完成信号
|
||||
AudioInput::getInstance()->startAutoStopAudio(AudioInput::getInstance()->getSilenceThreshold(), 800);
|
||||
}
|
||||
|
||||
void AppCore::onRecordingFinished_Byte(const QByteArray &wavData) {
|
||||
// 将录音数据发送给服务端
|
||||
AudioDataTransferObject packet;
|
||||
packet.setData("isStream", false).setData("data", wavData.toBase64().data());
|
||||
NetworkDO::getInstance()->sendPacket(packet);
|
||||
}
|
||||
|
||||
void AppCore::startPttRecording() {
|
||||
qDebug() << "PTT recording started";
|
||||
AudioInput::getInstance()->startAudio();
|
||||
}
|
||||
|
||||
void AppCore::stopPttRecording() {
|
||||
qDebug() << "PTT recording stopped";
|
||||
AudioInput::getInstance()->stopAudio();
|
||||
}
|
||||
|
||||
// ===================== Windows =====================
|
||||
#ifdef Q_OS_WIN
|
||||
void AppCore::setupGlobalHotkey() {
|
||||
g_hotkeyInstance = this;
|
||||
HMODULE hMod = GetModuleHandle(nullptr);
|
||||
m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, lowLevelKeyboardHook, hMod, 0);
|
||||
if (m_keyboardHook) {
|
||||
qDebug() << "[AppCore] 全局 PTT 热键钩子已安装 (键码:" << m_hotkeyVKey << ")";
|
||||
} else {
|
||||
qWarning() << "[AppCore] 全局 PTT 热键钩子安装失败:" << GetLastError();
|
||||
}
|
||||
}
|
||||
|
||||
void AppCore::cleanupGlobalHotkey() {
|
||||
if (m_keyboardHook) {
|
||||
UnhookWindowsHookEx(m_keyboardHook);
|
||||
m_keyboardHook = nullptr;
|
||||
qDebug() << "[AppCore] 全局 PTT 热键钩子已卸载";
|
||||
}
|
||||
g_hotkeyInstance = nullptr;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK AppCore::lowLevelKeyboardHook(int nCode, WPARAM wParam, LPARAM lParam) {
|
||||
if (nCode == HC_ACTION && g_hotkeyInstance) {
|
||||
KBDLLHOOKSTRUCT *pKb = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||||
if (pKb->vkCode == g_hotkeyInstance->m_hotkeyVKey) {
|
||||
if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) {
|
||||
if (!g_hotkeyInstance->m_isPttDown) {
|
||||
g_hotkeyInstance->m_isPttDown = true;
|
||||
QMetaObject::invokeMethod(qApp, []() {
|
||||
AppCore::getInstance()->startPttRecording();
|
||||
}, Qt::QueuedConnection);
|
||||
}
|
||||
return 1;
|
||||
} else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP) {
|
||||
if (g_hotkeyInstance->m_isPttDown) {
|
||||
g_hotkeyInstance->m_isPttDown = false;
|
||||
QMetaObject::invokeMethod(qApp, []() {
|
||||
AppCore::getInstance()->stopPttRecording();
|
||||
}, Qt::QueuedConnection);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// ===================== Linux (X11) =====================
|
||||
#elif defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
void AppCore::setupGlobalHotkey() {
|
||||
Display *display = XOpenDisplay(nullptr);
|
||||
if (!display) {
|
||||
qWarning() << "[AppCore] 无法打开 X11 Display,全局热键不可用";
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试开启 detectable auto-repeat(不一定持久影响其他连接,不影响 debounce 方案)
|
||||
int orig = 0;
|
||||
XkbSetDetectableAutoRepeat(display, True, &orig);
|
||||
|
||||
KeyCode keycode = XKeysymToKeycode(display, XK_grave);
|
||||
if (!keycode) {
|
||||
qWarning() << "[AppCore] 未找到波浪号键的键码";
|
||||
XCloseDisplay(display);
|
||||
return;
|
||||
}
|
||||
m_hotkeyCode = keycode;
|
||||
|
||||
XGrabKey(display, keycode, AnyModifier, DefaultRootWindow(display),
|
||||
True, GrabModeAsync, GrabModeAsync);
|
||||
XCloseDisplay(display);
|
||||
|
||||
// debounce 定时器:KeyRelease 后等待 50ms,若没有新的 KeyPress 再停
|
||||
m_pttDebounce = new QTimer(this);
|
||||
m_pttDebounce->setSingleShot(true);
|
||||
connect(m_pttDebounce, &QTimer::timeout, this, &AppCore::onDebounceStop);
|
||||
|
||||
qApp->installNativeEventFilter(this);
|
||||
qDebug() << "[AppCore] Linux 全局 PTT 热键已注册 (键码:" << m_hotkeyCode << ")";
|
||||
}
|
||||
|
||||
void AppCore::cleanupGlobalHotkey() {
|
||||
qApp->removeNativeEventFilter(this);
|
||||
if (m_pttDebounce) {
|
||||
m_pttDebounce->stop();
|
||||
delete m_pttDebounce;
|
||||
m_pttDebounce = nullptr;
|
||||
}
|
||||
Display *display = XOpenDisplay(nullptr);
|
||||
if (display) {
|
||||
XUngrabKey(display, m_hotkeyCode, AnyModifier, DefaultRootWindow(display));
|
||||
XCloseDisplay(display);
|
||||
}
|
||||
m_hotkeyCode = 0;
|
||||
qDebug() << "[AppCore] Linux 全局 PTT 热键已卸载";
|
||||
}
|
||||
|
||||
void AppCore::onDebounceStop() {
|
||||
if (m_isPttDown) {
|
||||
m_isPttDown = false;
|
||||
stopPttRecording();
|
||||
}
|
||||
}
|
||||
|
||||
bool AppCore::nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result) {
|
||||
if (eventType != "xcb_generic_event_t")
|
||||
return false;
|
||||
|
||||
auto *data = static_cast<const uint8_t *>(message);
|
||||
uint8_t type = data[0] & 0x7f;
|
||||
|
||||
if ((type == 2 || type == 3) && data[1] == static_cast<uint8_t>(m_hotkeyCode)) {
|
||||
if (type == 2) { // KeyPress
|
||||
m_pttDebounce->stop(); // 取消待决的停止
|
||||
if (!m_isPttDown) {
|
||||
m_isPttDown = true;
|
||||
startPttRecording();
|
||||
}
|
||||
} else { // KeyRelease
|
||||
// 不立即停,等待 50ms 确认没有连发 KeyPress
|
||||
if (m_isPttDown)
|
||||
m_pttDebounce->start(50);
|
||||
}
|
||||
if (result) *result = 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===================== macOS (CGEventTap) =====================
|
||||
#elif defined(Q_OS_MACOS)
|
||||
static CGEventRef pttEventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
|
||||
auto *core = static_cast<AppCore *>(refcon);
|
||||
|
||||
if (type == kCGEventTapDisabledByTimeout) {
|
||||
if (core && core->m_eventTap)
|
||||
CGEventTapEnable(static_cast<CFMachPortRef>(core->m_eventTap), true);
|
||||
return event;
|
||||
}
|
||||
if (type != kCGEventKeyDown && type != kCGEventKeyUp)
|
||||
return event;
|
||||
|
||||
CGKeyCode kc = static_cast<CGKeyCode>(CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode));
|
||||
if (kc != core->m_hotkeyCode)
|
||||
return event;
|
||||
|
||||
if (type == kCGEventKeyDown && !core->m_isPttDown) {
|
||||
core->m_isPttDown = true;
|
||||
core->startPttRecording();
|
||||
} else if (type == kCGEventKeyUp && core->m_isPttDown) {
|
||||
core->m_isPttDown = false;
|
||||
core->stopPttRecording();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void AppCore::setupGlobalHotkey() {
|
||||
CFMachPortRef tap = CGEventTapCreate(
|
||||
kCGHIDEventTap,
|
||||
kCGHeadInsertEventTap,
|
||||
kCGEventTapOptionDefault,
|
||||
CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp),
|
||||
&pttEventTapCallback,
|
||||
this);
|
||||
|
||||
if (!tap) {
|
||||
qWarning() << "[AppCore] macOS PTT 热键创建失败 (需要辅助功能权限)";
|
||||
return;
|
||||
}
|
||||
|
||||
m_eventTap = tap;
|
||||
CFRunLoopSourceRef src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0);
|
||||
m_runLoopSource = src;
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), src, kCFRunLoopCommonModes);
|
||||
CGEventTapEnable(tap, true);
|
||||
qDebug() << "[AppCore] macOS 全局 PTT 热键已注册 (键码:" << m_hotkeyCode << ")";
|
||||
}
|
||||
|
||||
void AppCore::cleanupGlobalHotkey() {
|
||||
if (m_eventTap) {
|
||||
auto *tap = static_cast<CFMachPortRef>(m_eventTap);
|
||||
CGEventTapEnable(tap, false);
|
||||
if (m_runLoopSource) {
|
||||
auto *src = static_cast<CFRunLoopSourceRef>(m_runLoopSource);
|
||||
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), src, kCFRunLoopCommonModes);
|
||||
CFRelease(src);
|
||||
}
|
||||
CFRelease(tap);
|
||||
m_eventTap = nullptr;
|
||||
m_runLoopSource = nullptr;
|
||||
}
|
||||
qDebug() << "[AppCore] macOS 全局 PTT 热键已卸载";
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#include "LAppDelegate.hpp" // 必须要放在第一个,否则会出现头文件顺序错误
|
||||
#include "LAppView.hpp"
|
||||
#include "LAppPal.hpp"
|
||||
#include "LAppLive2DManager.hpp"
|
||||
#include "LAppDefine.hpp"
|
||||
#include "GLCore.h"
|
||||
#include <QTimer>
|
||||
#include <QMouseEvent>
|
||||
#include <QDebug>
|
||||
|
||||
#include <QFont>
|
||||
#include <QApplication>
|
||||
#include <QFontDatabase>
|
||||
#include <algorithm>
|
||||
|
||||
#include "TextRenderer.h"
|
||||
// #include "AudioOutput.h"
|
||||
#include "AppContext.h"
|
||||
QMap<QString, double> GLCore::frameRateMap = {
|
||||
{"30", 30.0},
|
||||
{"60", 60.0},
|
||||
{"120", 120.0},
|
||||
{"144", 144.0},
|
||||
{"165", 165.0},
|
||||
{"240", 240.0}
|
||||
};
|
||||
|
||||
GLCore::GLCore(const int width, const int height, QWidget *parent)
|
||||
: QOpenGLWidget(parent),
|
||||
isLeftPressed(false), // 显式初始化
|
||||
isRightPressed(false) // 显式初始化
|
||||
{
|
||||
// 启用高分辨率位图(High DPI Pixmaps)支持
|
||||
#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0))
|
||||
QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
|
||||
QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
|
||||
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||||
#else
|
||||
//根据实际屏幕缩放比例更改
|
||||
qputenv("QT_SCALE_FACTOR", "1.5");
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// 不为窗口创建额外的兄弟窗口,从而简化窗口管理并可能提高性能
|
||||
QApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings);
|
||||
// 设置字体
|
||||
QFontDatabase::addApplicationFont("Resources/Font/ElaAwesome.ttf");
|
||||
QApplication::setFont(QFont("Microsoft YaHei", 13));
|
||||
|
||||
// new一些必要的对象
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
contextMenu = new Menu(this);
|
||||
#endif
|
||||
|
||||
// 设置窗口大小
|
||||
setFixedSize(width, height);
|
||||
// 设置文本渲染器窗口大小
|
||||
TextRenderer::getInstance()->setWindowSize(width, height);
|
||||
TextRenderer::getInstance()->setGlobalFont(QFont("Microsoft YaHei", 14, QFont::Bold));
|
||||
TextRenderer::getInstance()->setHoldDuration(1.0f); // 停留1.2秒
|
||||
TextRenderer::getInstance()->setGravity(600.0f); // 更快的下坠速度
|
||||
TextRenderer::getInstance()->setDampFactor(0.85f); // 更强的弹性效果
|
||||
|
||||
this->setWindowFlag(Qt::FramelessWindowHint); // 设置无边框窗口
|
||||
this->setWindowFlag(Qt::WindowStaysOnTopHint); // 设置窗口始终在顶部
|
||||
this->setWindowFlag(Qt::Tool); // 隐藏应用程序图标
|
||||
this->setAttribute(Qt::WA_TranslucentBackground); // 设置窗口背景透明
|
||||
|
||||
// 帧率控制初始化
|
||||
frameTimer = new QTimer(this);
|
||||
connect(frameTimer, &QTimer::timeout, [&]() {
|
||||
update();
|
||||
});
|
||||
frameTimer->start(static_cast<int>((1.0 / frameRate) * 1000)); // 使用成员变量计算间隔
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
// 保存窗口句柄
|
||||
hwnd = reinterpret_cast<HWND>(this->winId());
|
||||
#endif
|
||||
|
||||
// 启用鼠标跟踪,不启用的话鼠标按下才会回调mouseMoveEvent函数
|
||||
this->setMouseTracking(true);
|
||||
|
||||
// 连接一些必要的信号与槽
|
||||
#ifndef EMBEDDED_LINUX // 如果不是嵌入式Linux系统(注意如果你的嵌入式Linux平台启用了桌面系统,那么可能需要去掉这个条件宏)
|
||||
connect(contextMenu, &Menu::closeMainWindow, this, &GLCore::closeGL); // 关闭窗口信号
|
||||
#endif
|
||||
|
||||
// 注册当前实例到中介类
|
||||
AppContext::RegisterGLCore(this);
|
||||
}
|
||||
|
||||
|
||||
GLCore::~GLCore()
|
||||
{
|
||||
// 注销实例
|
||||
AppContext::UnregisterGLCore();
|
||||
|
||||
// 释放TextRender单例
|
||||
TextRenderer::releaseInstance();
|
||||
|
||||
// 释放Live2D 单例
|
||||
LAppDelegate::ReleaseInstance();
|
||||
}
|
||||
|
||||
// 帧率设置
|
||||
void GLCore::setFrameRate(double fps)
|
||||
{
|
||||
if (qFuzzyCompare(fps, frameRate)) // 避免无意义更新
|
||||
return;
|
||||
|
||||
if (fps <= 0.0) {
|
||||
qWarning() << "Invalid frame rate:" << fps << "using default 60.0";
|
||||
fps = 60.0;
|
||||
}
|
||||
|
||||
frameRate = fps;
|
||||
frameTimer->setInterval(static_cast<int>((1.0 / frameRate) * 1000));
|
||||
}
|
||||
|
||||
// 获取当前帧率
|
||||
double GLCore::getFrameRate() const
|
||||
{
|
||||
return frameRate;
|
||||
}
|
||||
|
||||
QMap<QString, double> GLCore::getFrameRateMap()
|
||||
{
|
||||
return frameRateMap;
|
||||
}
|
||||
|
||||
QStringList GLCore::getFrameRateList()
|
||||
{
|
||||
// 将 frameRateMap中的String部分转换为 QStringList
|
||||
QStringList frameRateList;
|
||||
for (auto it = frameRateMap.begin(); it != frameRateMap.end(); ++it) {
|
||||
frameRateList.append(it.key());
|
||||
}
|
||||
// 将frameRateList的数字字符从小到大排序
|
||||
std::sort(frameRateList.begin(), frameRateList.end(), [](const QString& a, const QString& b) {
|
||||
return a.toDouble() < b.toDouble();
|
||||
});
|
||||
// 将60放在第一个位置
|
||||
std::swap(frameRateList[0], frameRateList[frameRateList.indexOf("60")]);
|
||||
return frameRateList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭窗口
|
||||
*/
|
||||
void GLCore::closeGL()
|
||||
{
|
||||
this->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主要是为setWindowFlag(Qt::Tool)这段代码擦屁股。
|
||||
* 在 Qt 中,程序的退出通常依赖于主事件循环(QApplication的事件循环)的退出。当主窗口关闭时,通常会触发QApplication的lastWindowClosed信号,从而退出事件循环,导致程序退出。
|
||||
然而,当你将窗口设置为工具窗口(Qt::Tool)时,这个窗口可能不会被视为应用程序的“主窗口”,因此关闭它可能不会触发lastWindowClosed信号,导致程序不会正常退出。
|
||||
* @param event
|
||||
*/
|
||||
void GLCore::closeEvent(QCloseEvent* event)
|
||||
{
|
||||
QApplication::quit(); // 显式退出事件循环
|
||||
event->accept(); // 确保关闭事件被接受
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
void GLCore::setWindowTransparentForMouse(const bool transparent) const {
|
||||
if (!hwnd) return;
|
||||
|
||||
LONG exStyle = GetWindowLong(hwnd, GWL_EXSTYLE);
|
||||
|
||||
if (transparent) {
|
||||
// 启用鼠标穿透
|
||||
exStyle |= WS_EX_TRANSPARENT;
|
||||
exStyle |= WS_EX_LAYERED;
|
||||
} else {
|
||||
// 禁用鼠标穿透
|
||||
exStyle &= ~WS_EX_TRANSPARENT;
|
||||
exStyle &= ~WS_EX_LAYERED;
|
||||
}
|
||||
|
||||
SetWindowLong(hwnd, GWL_EXSTYLE, exStyle);
|
||||
// 刷新窗口
|
||||
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
#endif
|
||||
|
||||
void GLCore::mouseMoveEvent(QMouseEvent* event)
|
||||
{
|
||||
const float x = static_cast<float>(event->position().x());
|
||||
const float y = static_cast<float>(event->position().y());
|
||||
LAppDelegate::GetInstance()->GetView()->OnTouchesMoved(x, y); // 将当前鼠标位置传递给LAppDelegate
|
||||
|
||||
if (isLeftPressed) { // 鼠标左键按下
|
||||
const QPoint newPos = event->globalPos() - currentPos;
|
||||
this->move(newPos);
|
||||
}
|
||||
}
|
||||
|
||||
void GLCore::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
const float x = static_cast<float>(event->position().x());
|
||||
const float y = static_cast<float>(event->position().y());
|
||||
// 检测是否在模型上
|
||||
bool onModel = false;
|
||||
if (LAppDelegate::GetInstance() && LAppDelegate::GetInstance()->GetView()) {
|
||||
onModel = LAppDelegate::GetInstance()->GetView()->IsModelHit(x, y);
|
||||
}
|
||||
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
LAppDelegate::GetInstance()->GetView()->OnTouchesBegan(x, y);
|
||||
this->currentPos = event->globalPos() - this->frameGeometry().topLeft();
|
||||
if (onModel) {
|
||||
// 窗口拖动
|
||||
this->isLeftPressed = true;
|
||||
#ifdef Q_OS_WIN
|
||||
// 确保窗口不穿透
|
||||
setWindowTransparentForMouse(false);
|
||||
#endif
|
||||
} else {
|
||||
// 透明区域:透传(只有WIndows完美实现了,Linux由于平台差异,只是简单实现,并没有完美透传功能)
|
||||
#ifdef Q_OS_WIN
|
||||
// 设置窗口为鼠标穿透
|
||||
setWindowTransparentForMouse(true);
|
||||
|
||||
// 发送鼠标按下事件到底层窗口
|
||||
POINT pt = { event->globalPos().x(), event->globalPos().y() };
|
||||
HWND hWndBelow = WindowFromPoint(pt);
|
||||
if (hWndBelow && hWndBelow != hwnd) {
|
||||
// 转换坐标
|
||||
ScreenToClient(hWndBelow, &pt);
|
||||
|
||||
// 发送鼠标按下消息
|
||||
PostMessage(hWndBelow, WM_LBUTTONDOWN,
|
||||
MK_LBUTTON, MAKELPARAM(pt.x, pt.y));
|
||||
PostMessage(hWndBelow, WM_LBUTTONUP,
|
||||
0, MAKELPARAM(pt.x, pt.y));
|
||||
}
|
||||
|
||||
// 恢复窗口不穿透状态(下一次鼠标移动时会重新检测)
|
||||
QTimer::singleShot(100, this, [this]() {
|
||||
setWindowTransparentForMouse(false);
|
||||
});
|
||||
#endif
|
||||
this->isLeftPressed = false;
|
||||
}
|
||||
}
|
||||
// TODO: 右键菜单等
|
||||
if (event->button() == Qt::RightButton) {
|
||||
// 在鼠标右键点击的位置创建菜单,显示自定义右键菜单
|
||||
if (onModel) {
|
||||
#if !defined(EMBEDDED_LINUX)
|
||||
contextMenu->showMenu(event->globalPos());
|
||||
#endif
|
||||
this->isRightPressed = true;
|
||||
}
|
||||
else {
|
||||
#ifdef Q_OS_WIN
|
||||
// 设置窗口为鼠标穿透
|
||||
setWindowTransparentForMouse(true);
|
||||
|
||||
// 发送鼠标按下事件到底层窗口
|
||||
POINT pt = { event->globalPos().x(), event->globalPos().y() };
|
||||
HWND hWndBelow = WindowFromPoint(pt);
|
||||
if (hWndBelow && hWndBelow != hwnd) {
|
||||
// 转换坐标
|
||||
ScreenToClient(hWndBelow, &pt);
|
||||
|
||||
// 发送鼠标按下消息
|
||||
PostMessage(hWndBelow, WM_LBUTTONDOWN,
|
||||
MK_LBUTTON, MAKELPARAM(pt.x, pt.y));
|
||||
PostMessage(hWndBelow, WM_LBUTTONUP,
|
||||
0, MAKELPARAM(pt.x, pt.y));
|
||||
}
|
||||
|
||||
// 恢复窗口不穿透状态(下一次鼠标移动时会重新检测)
|
||||
QTimer::singleShot(100, this, [this]() {
|
||||
setWindowTransparentForMouse(false);
|
||||
});
|
||||
#endif
|
||||
this->isRightPressed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GLCore::mouseReleaseEvent(QMouseEvent* event)
|
||||
{
|
||||
const float x = static_cast<float>(event->position().x());
|
||||
const float y = static_cast<float>(event->position().y());
|
||||
LAppDelegate::GetInstance()->GetView()->OnTouchesEnded(x, y);
|
||||
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
isLeftPressed = false;
|
||||
}
|
||||
if (event->button() == Qt::RightButton) {
|
||||
isRightPressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
void GLCore::initializeGL()
|
||||
{
|
||||
LAppDelegate::GetInstance()->Initialize(this);
|
||||
}
|
||||
|
||||
void GLCore::paintGL()
|
||||
{
|
||||
LAppDelegate::GetInstance()->update(); // Live2D画面渲染
|
||||
// 渲染文本
|
||||
TextRenderer::getInstance()->update();
|
||||
TextRenderer::getInstance()->render();
|
||||
}
|
||||
|
||||
void GLCore::resizeGL(const int w, const int h)
|
||||
{
|
||||
// 设置文本渲染器窗口大小
|
||||
TextRenderer::getInstance()->setWindowSize(w, h);
|
||||
|
||||
LAppDelegate::GetInstance()->resize(w, h);
|
||||
}
|
||||
|
||||
// 设置窗口大小,并触发 resizeGL 事件
|
||||
void GLCore::setWindowSize(const int w, const int h)
|
||||
{
|
||||
// 检查是否需要更新,避免重复调用
|
||||
if (this->width() == w && this->height() == h) {
|
||||
return;
|
||||
}
|
||||
// 调用 QWidget::resize 或 setFixedSize 来改变窗口的实际尺寸
|
||||
setFixedSize(w, h);
|
||||
// 调用 setFixedSize 会自动触发 QOpenGLWidget 的 resizeEvent,
|
||||
// 进而调用 resizeGL(w, h),无需手动调用 resizeGL
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/13.
|
||||
//
|
||||
|
||||
/**
|
||||
* 数据传输对象 (DTO) 定义
|
||||
* AudioDataTransferObject
|
||||
* 与Yosuga_server对等
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include "DataTransferObjectBase.h"
|
||||
// 前向声明,减少依赖
|
||||
class QJsonObject;
|
||||
class AudioDataTransferObject final : public DataTransferObjectBase{
|
||||
public:
|
||||
// 构造函数(带默认值)
|
||||
explicit AudioDataTransferObject(QString owner = "client",
|
||||
bool isStream = false,
|
||||
bool isStart = false,
|
||||
bool isEnd = false,
|
||||
int sequence = 0,
|
||||
QByteArray data = {},
|
||||
int sampleRate = 16000,
|
||||
int channelCount = 1,
|
||||
int bitDepth = 16,
|
||||
double duration = 0.0,
|
||||
QString text = "");
|
||||
// 静态工厂方法
|
||||
static AudioDataTransferObject fromJson(const QJsonObject& json);
|
||||
|
||||
[[nodiscard]] QString type() const override { return "audio_data"; }
|
||||
|
||||
// 序列化
|
||||
[[nodiscard]] QJsonObject toJson() const override; // 通过多态即可统一调用方式
|
||||
|
||||
// 链式调用设置
|
||||
AudioDataTransferObject& setData(const QString& key, const QJsonValue& value) override;
|
||||
|
||||
[[nodiscard]] QString owner() const { return m_owner; }
|
||||
[[nodiscard]] bool isStream() const { return m_isStream; }
|
||||
[[nodiscard]] bool isStart() const { return m_isStart; }
|
||||
[[nodiscard]] bool isEnd() const { return m_isEnd; }
|
||||
[[nodiscard]] int sequence() const { return m_sequence; }
|
||||
[[nodiscard]] QByteArray audioData() const { return m_data; }
|
||||
[[nodiscard]] int sampleRate() const { return m_sampleRate; }
|
||||
[[nodiscard]] int channelCount() const { return m_channelCount; }
|
||||
[[nodiscard]] int bitDepth() const { return m_bitDepth; }
|
||||
[[nodiscard]] double duration() const { return m_duration; }
|
||||
[[nodiscard]] QString text() const { return m_text; }
|
||||
|
||||
private:
|
||||
QString m_owner; /// 音频数据的拥有者(server or client)
|
||||
bool m_isStream; /// 音频数据是否为流式数据
|
||||
bool m_isStart; /// 音频数据是否开始(流式时有效)
|
||||
bool m_isEnd; /// 音频数据是否结束(流式时有效)
|
||||
int m_sequence; /// 音频数据块序列号(流式时有效)
|
||||
QByteArray m_data; /// 音频数据,流式时为分块数据,base64编码
|
||||
int m_sampleRate; /// 音频采样率
|
||||
int m_channelCount; /// 音频通道数
|
||||
int m_bitDepth; /// 音频采样位数
|
||||
double m_duration; /// 音频时长
|
||||
QString m_text; /// 音频对应的文本
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
|
||||
/**
|
||||
* 自动代理数据对象
|
||||
* 非对等传输对象,只被用于将服务端返回的auto_agent json转换为对象
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include "DataTransferObjectBase.h"
|
||||
class QJsonObject;
|
||||
class AutoAgentDataObject final : public DataTransferObjectBase {
|
||||
public:
|
||||
// 构造函数(带默认值)
|
||||
explicit AutoAgentDataObject(const QString& Action,
|
||||
int X1,
|
||||
int Y1,
|
||||
int X2,
|
||||
int Y2,
|
||||
const QString& Key,
|
||||
const QString& Content,
|
||||
const QString& Direction);
|
||||
// 静态工厂方法
|
||||
static AutoAgentDataObject fromJson(const QJsonObject& json);
|
||||
|
||||
[[nodiscard]] QString type() const override { return "auto_agent"; }
|
||||
|
||||
[[nodiscard]] QJsonObject toJson() const override; // 通过多态即可统一调用方式
|
||||
|
||||
// 链式调用设置
|
||||
AutoAgentDataObject& setData(const QString& key, const QJsonValue& value) override;
|
||||
|
||||
[[nodiscard]] QString getAction() const { return m_action; }
|
||||
[[nodiscard]] int getX1() const { return m_x1; }
|
||||
[[nodiscard]] int getY1() const { return m_y1; }
|
||||
[[nodiscard]] int getX2() const { return m_x2; }
|
||||
[[nodiscard]] int getY2() const { return m_y2; }
|
||||
[[nodiscard]] QString getKey() const { return m_key; }
|
||||
[[nodiscard]] QString getContent() const { return m_content; }
|
||||
[[nodiscard]] QString getDirection() const { return m_direction; }
|
||||
|
||||
private:
|
||||
QString m_action; /// 自动化动作名称
|
||||
int m_x1; /// 鼠标起始位置x1
|
||||
int m_y1; /// 鼠标起始位置y1
|
||||
int m_x2; /// 鼠标结束位置x2
|
||||
int m_y2; /// 鼠标结束位置y2
|
||||
QString m_key; /// 快捷键
|
||||
QString m_content; /// 输入文本内容
|
||||
QString m_direction; /// 滚动方向
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/13.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
class DataTransferObjectBase
|
||||
{
|
||||
public:
|
||||
virtual ~DataTransferObjectBase() = default;
|
||||
|
||||
// 获取类型,用于区分不同的DTO子类对象
|
||||
[[nodiscard]] virtual QString type() const = 0;
|
||||
|
||||
// 序列化
|
||||
[[nodiscard]] virtual QJsonObject toJson() const = 0;
|
||||
|
||||
// 链式调用设置
|
||||
virtual DataTransferObjectBase& setData(const QString& key, const QJsonValue& value) = 0;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Created by Yosuga on 2026/4/25.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include "DataTransferObjectBase.h"
|
||||
|
||||
class DeviceDataTransferObject final : public DataTransferObjectBase {
|
||||
public:
|
||||
explicit DeviceDataTransferObject(
|
||||
QString action = "",
|
||||
QString deviceId = "",
|
||||
QJsonObject payload = {}
|
||||
);
|
||||
|
||||
static DeviceDataTransferObject fromJson(const QJsonObject& json);
|
||||
|
||||
// WebSocket 类型固定为 "device_data"
|
||||
[[nodiscard]] QString type() const override { return QStringLiteral("device_data"); }
|
||||
|
||||
[[nodiscard]] QJsonObject toJson() const override;
|
||||
DeviceDataTransferObject& setData(const QString& key, const QJsonValue& value) override;
|
||||
|
||||
[[nodiscard]] QString action() const { return m_action; }
|
||||
[[nodiscard]] QString deviceId() const { return m_deviceId; }
|
||||
[[nodiscard]] QJsonObject payload() const { return m_payload; }
|
||||
|
||||
private:
|
||||
QString m_action;
|
||||
QString m_deviceId;
|
||||
QJsonObject m_payload;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Created by misaki on 2025/12/29.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* 本类为网络数据流会使用到的数据访问对象封装
|
||||
* 目的是便于统一数据访问方式
|
||||
* 同时屏蔽了端到端数据交换格式,使得上层调用不再需要关心数据格式,而只需要填入数据即可
|
||||
*/
|
||||
|
||||
/**
|
||||
* 简单描述一下Yosuga客户端所需要使用到的数据
|
||||
* 主要为音频数据,控制信息,文本信息。
|
||||
* 其中文本信息与音频数据为捆绑收发,并且其中还包括了一些特别的信息,例如音频时长等
|
||||
* 控制信息与各种业务逻辑相关,例如模拟点击,模拟输入等
|
||||
*/
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QScopedPointer>
|
||||
#include <QMutex>
|
||||
#include <functional>
|
||||
|
||||
#include "DataTransferObjectBase.h"
|
||||
#include "AudioDataTransferObject.h"
|
||||
#include "AutoAgentDataObject.h"
|
||||
#include "ScreenShotDataTransferObject.h"
|
||||
#include "DeviceDataTransferObject.h"
|
||||
/**
|
||||
* NetworkDO
|
||||
*/
|
||||
class NetworkDO final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(NetworkDO) // 禁用拷贝
|
||||
|
||||
public:
|
||||
// 单例访问点
|
||||
static NetworkDO* getInstance();
|
||||
// 显式销毁
|
||||
static void destroy();
|
||||
|
||||
// 定义发送回调函数类型
|
||||
using SenderFunc = std::function<void(const QString& type, const QJsonObject& data)>;
|
||||
|
||||
public:
|
||||
// 注入发送接口
|
||||
void registerSender(SenderFunc sender);
|
||||
|
||||
// 业务发送函数
|
||||
void sendPacket(const DataTransferObjectBase& packet);
|
||||
|
||||
signals:
|
||||
// 业务接收信号
|
||||
void audioPacketReceived(const AudioDataTransferObject& packet); // 音频数据准备完成信号
|
||||
void autoAgentPacketReceived(const AutoAgentDataObject& packet); // 自动代理数据包接收信号
|
||||
void screenShotPacketReceived(const ScreenShotDataTransferObject& packet); // 截图数据包接收信号
|
||||
void deviceCommandReceived(const DeviceDataTransferObject& packet); // 设备控制命令(服务端→客户端)
|
||||
|
||||
void errorOccurred(const QString& errorMsg); // 错误信号
|
||||
|
||||
public slots:
|
||||
// 接收底层 JSON 数据
|
||||
void onDataReceived(const QString& type, const QJsonObject& data);
|
||||
public:
|
||||
~NetworkDO() override;
|
||||
private:
|
||||
// 构造/析构函数私有化
|
||||
explicit NetworkDO(QObject *parent = nullptr);
|
||||
static QScopedPointer<NetworkDO> m_instance;
|
||||
static QMutex m_mutex;
|
||||
|
||||
SenderFunc m_sender; // 注入的发送器
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
|
||||
/**
|
||||
* 数据传输对象 (DTO) 定义
|
||||
* ScreenShotDataTransferObject
|
||||
* 与Yosuga_server中的是对等DTO
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include "DataTransferObjectBase.h"
|
||||
// 前向声明,减少依赖
|
||||
class QJsonObject;
|
||||
class ScreenShotDataTransferObject final : public DataTransferObjectBase{
|
||||
public:
|
||||
// 构造函数(带默认值)
|
||||
explicit ScreenShotDataTransferObject(QString owner = "client",
|
||||
bool isSuccess = true,
|
||||
QString realtimeScreenShot = "",
|
||||
int width = 0, int height = 0,
|
||||
QString describeInfo = "", QString LLMResponse = ""
|
||||
);
|
||||
// 静态工厂方法
|
||||
static ScreenShotDataTransferObject fromJson(const QJsonObject& json);
|
||||
|
||||
[[nodiscard]] QString type() const override { return "screenshot_data"; }
|
||||
|
||||
// 序列化
|
||||
[[nodiscard]] QJsonObject toJson() const override; // 通过多态即可统一调用方式
|
||||
|
||||
// 链式调用设置
|
||||
ScreenShotDataTransferObject& setData(const QString& key, const QJsonValue& value) override;
|
||||
|
||||
[[nodiscard]] QString owner() const { return m_owner; }
|
||||
[[nodiscard]] bool isSuccess() const { return m_isSuccess; }
|
||||
[[nodiscard]] QString realtimeScreenShot() const { return m_realtimeScreenShot; }
|
||||
[[nodiscard]] int width() const { return m_width; }
|
||||
[[nodiscard]] int height() const { return m_height; }
|
||||
[[nodiscard]] QString describeInfo() const { return m_describeInfo; }
|
||||
[[nodiscard]] QString LLMResponse() const { return m_LLMResponse; }
|
||||
|
||||
private:
|
||||
QString m_owner; /// 数据的拥有者(server or client)
|
||||
bool m_isSuccess; /// 截图是否成功
|
||||
QString m_realtimeScreenShot; /// 客户端设备的实时截图数据(base64)
|
||||
int m_width; /// 截图宽度 非必要字段
|
||||
int m_height; /// 截图高度 非必要字段
|
||||
QString m_describeInfo; /// 设备的描述信息(告知模型以做出更加准确的判断) 非必要字段
|
||||
QString m_LLMResponse; /// LLM的响应结果(由服务端发送时携带)
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/13.
|
||||
//
|
||||
#include "AudioDataTransferObject.h"
|
||||
#include <QJsonValue>
|
||||
#include <utility>
|
||||
// 构造函数实现(初始化列表)
|
||||
AudioDataTransferObject::AudioDataTransferObject(QString owner,
|
||||
bool isStream,
|
||||
bool isStart,
|
||||
bool isEnd,
|
||||
int sequence,
|
||||
QByteArray data,
|
||||
int sampleRate,
|
||||
int channelCount,
|
||||
int bitDepth,
|
||||
double duration,
|
||||
QString text)
|
||||
: m_owner(std::move(owner))
|
||||
, m_isStream(isStream)
|
||||
, m_isStart(isStart)
|
||||
, m_isEnd(isEnd)
|
||||
, m_sequence(sequence)
|
||||
, m_data(std::move(data))
|
||||
, m_sampleRate(sampleRate)
|
||||
, m_channelCount(channelCount)
|
||||
, m_bitDepth(bitDepth)
|
||||
, m_duration(duration)
|
||||
, m_text(std::move(text)) {
|
||||
}
|
||||
|
||||
// 静态工厂方法:从 JSON 反序列化
|
||||
AudioDataTransferObject AudioDataTransferObject::fromJson(const QJsonObject& json) {
|
||||
// 逐个字段读取,不存在则用默认值
|
||||
QString owner = json.value("Owner").toString("server");
|
||||
bool isStream = json.value("isStream").toBool(false);
|
||||
bool isStart = json.value("isStart").toBool(false);
|
||||
bool isEnd = json.value("isEnd").toBool(false);
|
||||
int sequence = json.value("sequence").toInt(0);
|
||||
// 处理 base64 编码的 data 字段
|
||||
QByteArray data;
|
||||
if (json.contains("data")) {
|
||||
const QString base64Str = json.value("data").toString();
|
||||
data = QByteArray::fromBase64(base64Str.toUtf8());
|
||||
}
|
||||
int sampleRate = json.value("sampleRate").toInt(16000);
|
||||
int channelCount = json.value("channelCount").toInt(1);
|
||||
int bitDepth = json.value("bitDepth").toInt(16);
|
||||
double duration = json.value("duration").toDouble(0.0);
|
||||
QString text = json.value("text").toString();
|
||||
|
||||
// 调用构造函数创建对象
|
||||
return AudioDataTransferObject(owner, isStream, isStart, isEnd,
|
||||
sequence, data, sampleRate, channelCount,
|
||||
bitDepth, duration, text);
|
||||
}
|
||||
|
||||
// 序列化为 JSON
|
||||
QJsonObject AudioDataTransferObject::toJson() const {
|
||||
QJsonObject json;
|
||||
json["Owner"] = m_owner;
|
||||
json["isStream"] = m_isStream;
|
||||
json["isStart"] = m_isStart;
|
||||
json["isEnd"] = m_isEnd;
|
||||
json["sequence"] = m_sequence;
|
||||
// data 字段 base64 编码
|
||||
json["data"] = QString(m_data.toBase64());
|
||||
json["sampleRate"] = m_sampleRate;
|
||||
json["channelCount"] = m_channelCount;
|
||||
json["bitDepth"] = m_bitDepth;
|
||||
json["duration"] = m_duration;
|
||||
json["text"] = m_text;
|
||||
return json;
|
||||
}
|
||||
|
||||
// 链式设置
|
||||
AudioDataTransferObject& AudioDataTransferObject::setData(const QString& key,
|
||||
const QJsonValue& value) {
|
||||
if (key == "Owner") {
|
||||
m_owner = value.toString();
|
||||
} else if (key == "isStream") {
|
||||
m_isStream = value.toBool();
|
||||
} else if (key == "isStart") {
|
||||
m_isStart = value.toBool();
|
||||
} else if (key == "isEnd") {
|
||||
m_isEnd = value.toBool();
|
||||
} else if (key == "sequence") {
|
||||
m_sequence = value.toInt();
|
||||
} else if (key == "data") {
|
||||
// 这里要求传入的是 base64 字符串
|
||||
m_data = QByteArray::fromBase64(value.toString().toUtf8());
|
||||
} else if (key == "sampleRate") {
|
||||
m_sampleRate = value.toInt();
|
||||
} else if (key == "channelCount") {
|
||||
m_channelCount = value.toInt();
|
||||
} else if (key == "bitDepth") {
|
||||
m_bitDepth = value.toInt();
|
||||
} else if (key == "duration") {
|
||||
m_duration = value.toDouble();
|
||||
} else if (key == "text") {
|
||||
m_text = value.toString();
|
||||
} else {
|
||||
qWarning() << "Unknown key or invalid value type:" << key << value;
|
||||
}
|
||||
|
||||
return *this; // 返回自身引用,支持链式调用
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
#include "AutoAgentDataObject.h"
|
||||
|
||||
AutoAgentDataObject::AutoAgentDataObject(const QString& Action,
|
||||
const int X1,
|
||||
const int Y1,
|
||||
const int X2,
|
||||
const int Y2,
|
||||
const QString& Key,
|
||||
const QString& Content,
|
||||
const QString& Direction)
|
||||
: m_action(Action),
|
||||
m_x1(X1),
|
||||
m_y1(Y1),
|
||||
m_x2(X2),
|
||||
m_y2(Y2),
|
||||
m_key(Key),
|
||||
m_content(Content),
|
||||
m_direction(Direction) {}
|
||||
|
||||
AutoAgentDataObject AutoAgentDataObject::fromJson(const QJsonObject& json) {
|
||||
// 从JSON对象中提取数据,如果不存在则使用默认值
|
||||
const QString action = json.value("Action").toString("");
|
||||
const int x1 = json.value("x1").toInt(-1);
|
||||
const int y1 = json.value("y1").toInt(-1);
|
||||
const int x2 = json.value("x2").toInt(-1);
|
||||
const int y2 = json.value("y2").toInt(-1);
|
||||
const QString key = json.value("key").toString("");
|
||||
const QString content = json.value("content").toString("");
|
||||
const QString direction = json.value("direction").toString("");
|
||||
|
||||
return AutoAgentDataObject(action, x1, y1, x2, y2, key, content, direction);
|
||||
}
|
||||
|
||||
QJsonObject AutoAgentDataObject::toJson() const {
|
||||
QJsonObject json;
|
||||
json["Action"] = m_action;
|
||||
json["x1"] = m_x1;
|
||||
json["y1"] = m_y1;
|
||||
json["x2"] = m_x2;
|
||||
json["y2"] = m_y2;
|
||||
json["key"] = m_key;
|
||||
json["content"] = m_content;
|
||||
json["direction"] = m_direction;
|
||||
return json;
|
||||
}
|
||||
|
||||
AutoAgentDataObject& AutoAgentDataObject::setData(const QString& key, const QJsonValue& value) {
|
||||
// 根据键名设置对应的成员变量
|
||||
if (key == "Action" && value.isString()) {
|
||||
m_action = value.toString();
|
||||
} else if (key == "x1" && (value.isDouble() || value.isString())) {
|
||||
m_x1 = value.toInt();
|
||||
} else if (key == "y1" && (value.isDouble() || value.isString())) {
|
||||
m_y1 = value.toInt();
|
||||
} else if (key == "x2" && (value.isDouble() || value.isString())) {
|
||||
m_x2 = value.toInt();
|
||||
} else if (key == "y2" && (value.isDouble() || value.isString())) {
|
||||
m_y2 = value.toInt();
|
||||
} else if (key == "key" && value.isString()) {
|
||||
m_key = value.toString();
|
||||
} else if (key == "content" && value.isString()) {
|
||||
m_content = value.toString();
|
||||
} else if (key == "direction" && value.isString()) {
|
||||
m_direction = value.toString();
|
||||
} else {
|
||||
qWarning() << "Unknown key or invalid value type:" << key << value;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/13.
|
||||
//
|
||||
#include "DataTransferObjectBase.h"
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Created by Yosuga on 2026/4/25.
|
||||
//
|
||||
|
||||
#include "DeviceDataTransferObject.h"
|
||||
|
||||
DeviceDataTransferObject::DeviceDataTransferObject(
|
||||
QString action,
|
||||
QString deviceId,
|
||||
QJsonObject payload
|
||||
) : m_action(std::move(action))
|
||||
, m_deviceId(std::move(deviceId))
|
||||
, m_payload(std::move(payload))
|
||||
{
|
||||
}
|
||||
|
||||
DeviceDataTransferObject DeviceDataTransferObject::fromJson(const QJsonObject& json)
|
||||
{
|
||||
DeviceDataTransferObject obj;
|
||||
obj.m_action = json.value("action").toString("device_command");
|
||||
obj.m_deviceId = json.value("device_id").toString("");
|
||||
QJsonValue payloadVal = json.value("payload");
|
||||
if (payloadVal.isString()) {
|
||||
obj.m_payload["rpc_call"] = payloadVal.toString();
|
||||
} else if (payloadVal.isObject()) {
|
||||
obj.m_payload = payloadVal.toObject();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject DeviceDataTransferObject::toJson() const
|
||||
{
|
||||
QJsonObject json;
|
||||
json["action"] = m_action;
|
||||
if (!m_deviceId.isEmpty()) {
|
||||
json["device_id"] = m_deviceId;
|
||||
}
|
||||
json["payload"] = m_payload;
|
||||
return json;
|
||||
}
|
||||
|
||||
DeviceDataTransferObject& DeviceDataTransferObject::setData(const QString& key, const QJsonValue& value)
|
||||
{
|
||||
if (key == "action") m_action = value.toString();
|
||||
else if (key == "device_id") m_deviceId = value.toString();
|
||||
else if (key == "payload") m_payload = value.toObject();
|
||||
return *this;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Created by misaki on 2025/12/29.
|
||||
//
|
||||
#include "NetWorkDO.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QMutexLocker>
|
||||
|
||||
// 初始化静态成员
|
||||
QScopedPointer<NetworkDO> NetworkDO::m_instance;
|
||||
QMutex NetworkDO::m_mutex;
|
||||
|
||||
// 单例实现 (QScopedPointer + Mutex)
|
||||
NetworkDO* NetworkDO::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
// 使用 reset 创建实例,因为构造函数是私有的
|
||||
m_instance.reset(new NetworkDO());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void NetworkDO::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset(); // 这会触发析构函数
|
||||
}
|
||||
}
|
||||
|
||||
NetworkDO::NetworkDO(QObject *parent) : QObject(parent)
|
||||
{
|
||||
qDebug() << "NetworkDO initialized";
|
||||
}
|
||||
|
||||
NetworkDO::~NetworkDO()
|
||||
{
|
||||
qDebug() << "NetworkDO destroyed";
|
||||
}
|
||||
|
||||
// 业务逻辑实现
|
||||
void NetworkDO::registerSender(SenderFunc sender)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex); // 加个小锁,简单保护一下赋值
|
||||
m_sender = std::move(sender);
|
||||
}
|
||||
|
||||
void NetworkDO::sendPacket(const DataTransferObjectBase &packet)
|
||||
{
|
||||
// 检查发送器是否已注入
|
||||
if (!m_sender) {
|
||||
emit errorOccurred("Sender not registered! Call registerSender() first.");
|
||||
return;
|
||||
}
|
||||
// 依赖注入 + 多态实现完美解耦
|
||||
m_sender(packet.type(), packet.toJson());
|
||||
}
|
||||
|
||||
// 接受并没有完全解耦
|
||||
void NetworkDO::onDataReceived(const QString& type, const QJsonObject& data)
|
||||
{
|
||||
// 根据类型分发数据包
|
||||
// 为什么分发做在这里,而不是统一数据再去分发,如果不在这里做分发通知,分开发信号,而使用统一的信号
|
||||
// 如果有多个观察者,让观察者自动识别数据包,这会导致信号广播,容易引起性能问题(因为这里依赖的是Qt的信号与槽机制)
|
||||
// TODO: 考虑在此处使用工厂模式,根据type内容快速创建对应的对象
|
||||
if (type == "audio_data") {
|
||||
emit audioPacketReceived(AudioDataTransferObject::fromJson(data)); // 构造并发送音频对象
|
||||
}
|
||||
else if (type == "auto_agent") {
|
||||
emit autoAgentPacketReceived(AutoAgentDataObject::fromJson(data));
|
||||
}
|
||||
else if (type == "screenshot_data") {
|
||||
emit screenShotPacketReceived(ScreenShotDataTransferObject::fromJson(data));
|
||||
}
|
||||
else if (type == "device_command") {
|
||||
emit deviceCommandReceived(DeviceDataTransferObject::fromJson(data));
|
||||
}
|
||||
else {
|
||||
qWarning() << "[NetworkDO] Received unknown type:" << type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
#include "ScreenShotDataTransferObject.h"
|
||||
#include <QJsonValue>
|
||||
#include <utility>
|
||||
#include <QDebug>
|
||||
|
||||
// 构造函数实现(初始化列表)
|
||||
ScreenShotDataTransferObject::ScreenShotDataTransferObject(QString owner,
|
||||
bool isSuccess,
|
||||
QString realtimeScreenShot,
|
||||
int width, int height,
|
||||
QString describeInfo, QString LLMResponse)
|
||||
: m_owner(std::move(owner))
|
||||
, m_isSuccess(isSuccess)
|
||||
, m_realtimeScreenShot(std::move(realtimeScreenShot))
|
||||
, m_width(width)
|
||||
, m_height(height)
|
||||
, m_describeInfo(std::move(describeInfo))
|
||||
, m_LLMResponse(std::move(LLMResponse))
|
||||
{}
|
||||
|
||||
// 静态工厂方法:从 JSON 反序列化
|
||||
ScreenShotDataTransferObject ScreenShotDataTransferObject::fromJson(const QJsonObject& json) {
|
||||
// 逐个字段读取,不存在则用默认值
|
||||
const QString owner = json.value("Owner").toString("client");
|
||||
const bool isSuccess = json.value("isSuccess").toBool(false);
|
||||
const QString realtimeScreenShot = json.value("RealTimeScreenShot").toString();
|
||||
const int width = json.value("Width").toInt(0);
|
||||
const int height = json.value("Height").toInt(0);
|
||||
const QString describeInfo = json.value("DescribeInfo").toString();
|
||||
const QString LLMResponse = json.value("LLMResponse").toString();
|
||||
|
||||
// 调用构造函数创建对象
|
||||
return ScreenShotDataTransferObject(owner, isSuccess, realtimeScreenShot,
|
||||
width, height, describeInfo, LLMResponse);
|
||||
}
|
||||
|
||||
// 序列化为 JSON
|
||||
QJsonObject ScreenShotDataTransferObject::toJson() const {
|
||||
QJsonObject json;
|
||||
json["Owner"] = m_owner;
|
||||
json["isSuccess"] = m_isSuccess;
|
||||
json["RealTimeScreenShot"] = m_realtimeScreenShot;
|
||||
json["Width"] = m_width;
|
||||
json["Height"] = m_height;
|
||||
json["DescribeInfo"] = m_describeInfo;
|
||||
json["LLMResponse"] = m_LLMResponse;
|
||||
return json;
|
||||
}
|
||||
|
||||
// 链式设置
|
||||
ScreenShotDataTransferObject& ScreenShotDataTransferObject::setData(const QString& key,
|
||||
const QJsonValue& value) {
|
||||
if (key == "Owner") {
|
||||
m_owner = value.toString();
|
||||
} else if (key == "isSuccess") {
|
||||
m_isSuccess = value.toBool();
|
||||
} else if (key == "RealTimeScreenShot") {
|
||||
m_realtimeScreenShot = value.toString();
|
||||
} else if (key == "Width") {
|
||||
m_width = value.toInt();
|
||||
} else if (key == "Height") {
|
||||
m_height = value.toInt();
|
||||
} else if (key == "DescribeInfo") {
|
||||
m_describeInfo = value.toString();
|
||||
} else if (key == "LLMResponse") {
|
||||
m_LLMResponse = value.toString();
|
||||
} else {
|
||||
qWarning() << "Unknown key:" << key << "for ScreenShotDataTransferObject";
|
||||
}
|
||||
|
||||
return *this; // 返回自身引用,支持链式调用
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/17.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <QAudioSource>
|
||||
#include <QMediaDevices>
|
||||
#include <QAudioDevice>
|
||||
#include <QAudioFormat>
|
||||
#include <QTimer>
|
||||
#include <QDir>
|
||||
#include <vector>
|
||||
#include <QScopedPointer>
|
||||
#include <QMutex>
|
||||
|
||||
/**
|
||||
* @brief 录音模块
|
||||
* @author Misaki
|
||||
* @date 2025/1/17(first) 2025/11/30(update)
|
||||
* 单例类
|
||||
* 使用 QAudioSource 获取原始 PCM 数据,实现 RMS 计算和 WAV 保存
|
||||
*/
|
||||
class AudioInput : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(AudioInput) // 禁用拷贝
|
||||
private:
|
||||
/**
|
||||
* @brief 构造函数
|
||||
* @param parent
|
||||
*/
|
||||
explicit AudioInput(QObject *parent = nullptr);
|
||||
static QScopedPointer<AudioInput> instance;
|
||||
static QMutex mutex;
|
||||
public:
|
||||
/**
|
||||
* @brief 获取实例
|
||||
* @return AudioInput*
|
||||
*/
|
||||
static AudioInput* getInstance();
|
||||
/**
|
||||
* @brief 析构函数
|
||||
*/
|
||||
~AudioInput() override;
|
||||
|
||||
/**
|
||||
* @brief 配置音频参数 (Qt6 中推荐使用 float 或 int16)
|
||||
*/
|
||||
void setAudioSettings(int rate = 44100, int channels = 2);
|
||||
/**
|
||||
* @brief 设置录音文件输出路径与文件名
|
||||
* @param path 输出路径
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
void setAudioPath(const QString &path, const QString &fileName);
|
||||
|
||||
/**
|
||||
* @brief 开始录音
|
||||
*/
|
||||
void startAudio();
|
||||
|
||||
/**
|
||||
* @brief 停止录音
|
||||
*/
|
||||
void stopAudio();
|
||||
|
||||
/**
|
||||
* @brief 设置录音时间并开始录音
|
||||
* @param duration 录音时长,单位为秒
|
||||
*/
|
||||
void startAudioWithDuration(int duration);
|
||||
|
||||
/**
|
||||
* @brief 开始自动录音,根据声音判断是否停止
|
||||
* @param silenceThreshold 静音阈值,低于该值则认为没有声音
|
||||
* @param silenceDuration 静音持续时间,单位为毫秒
|
||||
*/
|
||||
void startAutoStopAudio(qreal silenceThreshold = 1200, int silenceDuration = 1500);
|
||||
|
||||
/**
|
||||
* @brief 开始最佳阈值计算
|
||||
* @param Duration 持续时间,单位为毫秒
|
||||
*/
|
||||
void startAutoThresholdClu(int Duration = 5000);
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统所有的音频输入设备
|
||||
* @return 音频输入设备名称列表
|
||||
*/
|
||||
static QList<QString> getAvailableAudioInputDevices();
|
||||
/**
|
||||
* @brief 设置当前录音设备
|
||||
* @param deviceName 设备名称
|
||||
*/
|
||||
void setAudioInputDevice(const QString &deviceName);
|
||||
|
||||
/**
|
||||
* @brief 设置静音阈值
|
||||
* @param silenceThreshold 阈值
|
||||
*/
|
||||
void setSilenceThreshold(qreal silenceThreshold);
|
||||
[[nodiscard]] qreal getSilenceThreshold() const;
|
||||
|
||||
private:
|
||||
// WAV头生成工具函数
|
||||
[[nodiscard]] QByteArray generateWavHeader(quint32 dataSize) const;
|
||||
// 计算RMS值工具函数
|
||||
static qreal calculateRMS(const QByteArray& buffer);
|
||||
|
||||
signals:
|
||||
// 录音完成信号
|
||||
void recordingFinished();
|
||||
void recordingFinished_Byte(const QByteArray &wavData); // 携带音频数据
|
||||
// 实时RMS值信号
|
||||
void rmsRealValue(qreal value);
|
||||
// 阈值计算完成信号
|
||||
void thresholdCalculated(qreal bestThreshold);
|
||||
|
||||
private slots:
|
||||
void onTimeout(); // 定时器超时槽函数
|
||||
void thresholdTimeout(); // 阈值超时槽函数
|
||||
// void processBuffer(const QAudioBuffer& buffer); // 处理缓冲区数据
|
||||
void onReadyRead(); // 替代原先的 processBuffer,当有音频数据来时触发
|
||||
|
||||
|
||||
private:
|
||||
QAudioSource *m_audioSource = nullptr; /// Qt6 核心录音对象
|
||||
QIODevice *m_ioDevice = nullptr; /// 用于读取数据的 IO 设备
|
||||
QAudioFormat m_format; /// 音频格式
|
||||
QAudioDevice m_currentDevice; /// 当前选中的输入设备
|
||||
|
||||
// 数据缓存
|
||||
QByteArray m_rawPCMData; /// 存储原始PCM数据
|
||||
QString m_outputFilePath;
|
||||
|
||||
// 逻辑控制变量
|
||||
bool isAutoRecording = false; /// 是否自动录音状态
|
||||
bool isAutoThreshold = false; /// 是否自动计算阈值
|
||||
qreal m_rmsValue = 0.0; /// 实时RMS值
|
||||
|
||||
// 定时器
|
||||
QTimer *m_timer; /// 总时长定时器
|
||||
QTimer *m_silenceTimer; /// 静音检测定时器
|
||||
QTimer *m_thresholdTimer; /// 阈值计算定时器
|
||||
|
||||
// 阈值算法相关
|
||||
std::vector<qreal> m_rmsValues; /// RMS值vector
|
||||
qreal m_silenceThreshold = 1200; /// 静音阈值
|
||||
int m_silenceDuration = 1500; /// 静音持续时间
|
||||
qreal m_smoothRms = 0.0; /// 平滑RMS值(用于防止低频杂波突然打断静音检测)
|
||||
|
||||
bool m_hasVoiceDetected = false; /// 是否已检测到人声
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/17.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QMediaPlayer> // 音频播放模块
|
||||
#include <QAudioOutput> // QMediaPlayer 的音量控制组件
|
||||
#include <QAudioSink> // 音频输出组件, 用于原始数据播放
|
||||
#include <QThread>
|
||||
#include <QMutex>
|
||||
#include <QQueue>
|
||||
#include <QUrl>
|
||||
#include <QBuffer>
|
||||
#include <QAudioFormat>
|
||||
|
||||
/**
|
||||
* @brief 音频播放模块
|
||||
* @author Misaki
|
||||
* 单例类
|
||||
* 本模块重新基于Qt6重构 2026.1.31第三次重构
|
||||
* 实现的功能
|
||||
* 1. 流式wav音频播放
|
||||
* 2. 根据音频文件路径播放音频
|
||||
*/
|
||||
|
||||
// Worker 类定义 (负责流式音频的底层处理) 注意:此类实例将完全运行在子线程中
|
||||
class StreamAudioWorker : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit StreamAudioWorker(QObject* parent = nullptr) : QObject(parent) {}
|
||||
~StreamAudioWorker() override;
|
||||
|
||||
public slots:
|
||||
// 初始化并启动音频设备
|
||||
void start(int sampleRate, int channelCount, int bitDepth);
|
||||
// 处理接收到的音频数据块
|
||||
void processChunk(const QByteArray& chunk);
|
||||
// 停止播放并清理资源
|
||||
void stop();
|
||||
|
||||
signals:
|
||||
void errorOccurred(const QString& msg);
|
||||
void playbackFinished(); // 流播放结束(通常指队列空了)
|
||||
|
||||
private:
|
||||
QScopedPointer<QAudioSink> m_sink;
|
||||
QIODevice* m_ioDevice = nullptr; // 由 m_sink->start() 返回,不需要且不能手动 delete
|
||||
bool m_firstChunk = true; // 标记是否是第一块数据(用于剥离WAV头)
|
||||
};
|
||||
|
||||
// AudioOutput 主类 (单例,线程安全)
|
||||
class AudioOutput : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(AudioOutput)
|
||||
|
||||
private:
|
||||
explicit AudioOutput(QObject *parent = nullptr);
|
||||
static QScopedPointer<AudioOutput> m_instance;
|
||||
static QMutex m_mutex;
|
||||
|
||||
public:
|
||||
static AudioOutput *getInstance();
|
||||
static void destroy(); // 显式销毁
|
||||
~AudioOutput() override;
|
||||
|
||||
// 通用控制接口
|
||||
// 停止所有播放 (文件和流)
|
||||
void stopPlayback();
|
||||
// 设置音量 (0-100)
|
||||
void setVolume(int volume);
|
||||
// 获取当前状态
|
||||
bool isPlaying() const;
|
||||
|
||||
// 文件/URL 播放接口 (基于 QMediaPlayer)
|
||||
void playUrl(const QUrl& url);
|
||||
void playData(const QByteArray& data); // 播放完整的内存文件
|
||||
|
||||
// 流式播放接口 (基于 QAudioSink + Worker Thread)
|
||||
/**
|
||||
* @brief 开启流式播放会话
|
||||
* @param sampleRate 采样率 (默认 32000)
|
||||
* @param channelCount 通道数 (默认 1)
|
||||
* @param bitDepth 位深 (默认 16)
|
||||
*/
|
||||
void startStream(int sampleRate = 32000, int channelCount = 1, int bitDepth = 16);
|
||||
|
||||
/**
|
||||
* @brief 写入流数据
|
||||
* @param chunk 音频数据块
|
||||
*/
|
||||
void pushStreamData(const QByteArray& chunk);
|
||||
|
||||
/**
|
||||
* @brief 结束流 (停止接收新数据,播放完当前缓冲后停止)
|
||||
*/
|
||||
void stopStream();
|
||||
|
||||
signals:
|
||||
// 内部转发给 Worker 的信号
|
||||
void sigOperateStreamStart(int sampleRate, int channelCount, int bitDepth);
|
||||
void sigOperateStreamChunk(const QByteArray& chunk);
|
||||
void sigOperateStreamStop();
|
||||
|
||||
// 对外通知信号
|
||||
void playbackFinished();
|
||||
void errorOccurred(const QString& error);
|
||||
|
||||
private:
|
||||
// 文件播放组件
|
||||
QMediaPlayer* m_player = nullptr;
|
||||
QAudioOutput* m_audioOutput = nullptr;
|
||||
|
||||
// 流式播放组件
|
||||
QThread* m_workerThread = nullptr;
|
||||
StreamAudioWorker* m_streamWorker = nullptr;
|
||||
|
||||
// 状态管理
|
||||
bool m_isStreaming = false;
|
||||
};
|
||||
@@ -0,0 +1,366 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/17.
|
||||
//
|
||||
|
||||
#include "AudioInput.h"
|
||||
#include <QDebug>
|
||||
#include <QtMath>
|
||||
#include <QtEndian> // 用于处理字节序
|
||||
|
||||
QScopedPointer<AudioInput> AudioInput::instance;
|
||||
QMutex AudioInput::mutex;
|
||||
AudioInput *AudioInput::getInstance()
|
||||
{
|
||||
// 懒汉式 依旧单线程无需加锁
|
||||
if (instance.isNull()) {
|
||||
QMutexLocker locker(&mutex);
|
||||
if (instance.isNull()) {
|
||||
instance.reset(new AudioInput);
|
||||
}
|
||||
}
|
||||
return instance.data();
|
||||
}
|
||||
|
||||
AudioInput::AudioInput(QObject *parent) : QObject(parent)
|
||||
{
|
||||
// new一些必要的对象
|
||||
// 初始化定时器
|
||||
m_timer = new QTimer(this);
|
||||
m_silenceTimer = new QTimer(this);
|
||||
m_thresholdTimer = new QTimer(this);
|
||||
m_thresholdTimer->setSingleShot(true);
|
||||
|
||||
// 连接定时器信号
|
||||
connect(m_timer, &QTimer::timeout, this, &AudioInput::onTimeout); // 录音超时槽函数
|
||||
connect(m_silenceTimer, &QTimer::timeout, this, &AudioInput::stopAudio); // 录音超时槽函数
|
||||
connect(m_thresholdTimer, &QTimer::timeout, this, &AudioInput::thresholdTimeout); // 阈值检测超时槽函数
|
||||
|
||||
// 初始化默认设备和格式
|
||||
m_currentDevice = QMediaDevices::defaultAudioInput();
|
||||
setAudioSettings(); // 使用默认参数
|
||||
}
|
||||
|
||||
AudioInput::~AudioInput()
|
||||
{
|
||||
stopAudio(); // 停止录音
|
||||
if (m_audioSource) {
|
||||
delete m_audioSource;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void AudioInput::setAudioSettings(const int rate, const int channels)
|
||||
{
|
||||
m_format.setSampleRate(rate);
|
||||
m_format.setChannelCount(channels);
|
||||
// 为了生成标准 WAV 且方便计算 RMS,强制设为 Int16
|
||||
m_format.setSampleFormat(QAudioFormat::Int16);
|
||||
|
||||
// 检查设备是否支持该格式,不支持则使用最接近的
|
||||
if (!m_currentDevice.isFormatSupported(m_format)) {
|
||||
qWarning() << "Requested format not supported, using preferred format.";
|
||||
m_format = m_currentDevice.preferredFormat();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void AudioInput::setAudioPath(const QString &path, const QString &fileName)
|
||||
{
|
||||
this->m_outputFilePath = path + fileName;
|
||||
}
|
||||
|
||||
|
||||
void AudioInput::startAudio()
|
||||
{
|
||||
// 每次开始前重新创建 QAudioSource,确保状态重置
|
||||
if (m_audioSource) {
|
||||
delete m_audioSource;
|
||||
m_audioSource = nullptr;
|
||||
}
|
||||
|
||||
m_audioSource = new QAudioSource(m_currentDevice, m_format, this);
|
||||
|
||||
// 调大缓冲区以避免溢出
|
||||
m_audioSource->setBufferSize(128000);
|
||||
|
||||
// start() 返回一个 QIODevice,可以从中读取数据
|
||||
m_ioDevice = m_audioSource->start();
|
||||
|
||||
if (m_ioDevice) {
|
||||
connect(m_ioDevice, &QIODevice::readyRead, this, &AudioInput::onReadyRead);
|
||||
qDebug() << "Started recording with device:" << m_currentDevice.description();
|
||||
} else {
|
||||
qCritical() << "Failed to start audio recording.";
|
||||
}
|
||||
}
|
||||
|
||||
void AudioInput::stopAudio()
|
||||
{
|
||||
if (m_audioSource) {
|
||||
m_audioSource->stop();
|
||||
// 注意:不要立即 delete m_audioSource,某些情况下可能导致 crash,停止即可
|
||||
}
|
||||
|
||||
// 停止所有定时器
|
||||
m_timer->stop();
|
||||
m_silenceTimer->stop();
|
||||
m_thresholdTimer->stop();
|
||||
|
||||
// 生成 WAV 数据
|
||||
QByteArray wavData;
|
||||
if (!m_rawPCMData.isEmpty()) {
|
||||
wavData = generateWavHeader(m_rawPCMData.size());
|
||||
wavData.append(m_rawPCMData);
|
||||
#ifdef QT_DEBUG
|
||||
// 如果需要保存文件(Debug下启用)
|
||||
if (!m_outputFilePath.isEmpty()) {
|
||||
QFile file(m_outputFilePath);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(wavData);
|
||||
file.close();
|
||||
qDebug() << "Saved WAV to:" << m_outputFilePath;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
m_rawPCMData.clear();
|
||||
}
|
||||
isAutoRecording = false;
|
||||
isAutoThreshold = false;
|
||||
|
||||
emit recordingFinished();
|
||||
emit recordingFinished_Byte(wavData);
|
||||
qDebug() << "Recording stopped.";
|
||||
}
|
||||
|
||||
// 阈值检测超时槽函数
|
||||
void AudioInput::onReadyRead()
|
||||
{
|
||||
if (!m_ioDevice) return;
|
||||
|
||||
// 读取当前所有可用的音频数据
|
||||
QByteArray data = m_ioDevice->readAll();
|
||||
if (data.isEmpty()) return;
|
||||
|
||||
// 保存原始 PCM 数据
|
||||
m_rawPCMData.append(data);
|
||||
|
||||
// 计算 RMS (仅用于分析,计算当前块的RMS)
|
||||
const qreal currentRms = calculateRMS(data);
|
||||
m_rmsValue = currentRms;
|
||||
// 计算平滑RMS (用于防止低频杂波突然打断静音检测)
|
||||
constexpr qreal alpha = 0.15; // 85% 历史权重, 15% 当前权重
|
||||
if (qFuzzyIsNull(m_smoothRms)) {
|
||||
// 如果是第一帧数据,直接赋值,避免从0开始慢慢爬升
|
||||
m_smoothRms = currentRms;
|
||||
} else {
|
||||
// 新值 = (旧值 * (1 - alpha)) + (当前值 * alpha)
|
||||
m_smoothRms = (m_smoothRms * (1.0 - alpha)) + (currentRms * alpha);
|
||||
}
|
||||
|
||||
// 自动停止逻辑 (VAD)
|
||||
if (isAutoRecording) {
|
||||
// 输出 RMS 用于调试
|
||||
qDebug() << "Raw:" << currentRms << " Smooth:" << m_smoothRms;
|
||||
|
||||
if (m_smoothRms < m_silenceThreshold) {
|
||||
// [当前是静音]
|
||||
|
||||
// 如果之前已经检测到过人声(说明是话说完了,或者是句间停顿)
|
||||
if (m_hasVoiceDetected) {
|
||||
// 启动/保持“短时”静音检测 (由 AppCore 传入,例如 500ms 或 1500ms)
|
||||
if (!m_silenceTimer->isActive()) {
|
||||
m_silenceTimer->start(m_silenceDuration);
|
||||
}
|
||||
// 如果 Timer 正在运行,就让它继续倒计时,超时会自动触发 stopAudio
|
||||
}
|
||||
else {
|
||||
// [还没有检测到过人声] (起始静音)
|
||||
// 这里不需要做额外操作,startAutoStopAudio 里设置的 5000ms 长定时器在跑
|
||||
// 允许用户深呼吸或准备
|
||||
}
|
||||
} else {
|
||||
// [当前有声音]
|
||||
m_hasVoiceDetected = true; // 标记:已经有人说话了
|
||||
|
||||
// 重置静音定时器
|
||||
// 只要有人说话,就不断重置定时器,防止断录
|
||||
m_silenceTimer->stop();
|
||||
// 这里可以预设启动,也可以不启动,只要有声音就会一直 stop
|
||||
// 为了安全,设为 silenceDuration
|
||||
m_silenceTimer->start(m_silenceDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// 自动阈值计算逻辑
|
||||
if (isAutoThreshold) {
|
||||
m_rmsValues.push_back(m_smoothRms);
|
||||
emit rmsRealValue(m_smoothRms);
|
||||
}
|
||||
}
|
||||
|
||||
qreal AudioInput::calculateRMS(const QByteArray& buffer)
|
||||
{
|
||||
if (buffer.isEmpty()) return 0;
|
||||
|
||||
// 设定为 Int16 格式 (16位深)
|
||||
// 如果是 Stereo,数据排列是 L R L R...
|
||||
// 简单的 RMS 计算可以将所有通道数据视为一个长序列
|
||||
|
||||
const qint16 *data = reinterpret_cast<const qint16*>(buffer.constData());
|
||||
const int sampleCount = buffer.size() / sizeof(qint16); // 样本数量
|
||||
|
||||
if (sampleCount == 0) return 0;
|
||||
|
||||
qreal sumSquared = 0;
|
||||
for (int i = 0; i < sampleCount; ++i) {
|
||||
const qreal sample = static_cast<qreal>(data[i]);
|
||||
sumSquared += sample * sample;
|
||||
}
|
||||
|
||||
return qSqrt(sumSquared / sampleCount);
|
||||
}
|
||||
|
||||
// 启动带时长的录音
|
||||
void AudioInput::startAudioWithDuration(int duration)
|
||||
{
|
||||
startAudio();
|
||||
m_timer->start(duration * 1000);
|
||||
}
|
||||
|
||||
void AudioInput::onTimeout()
|
||||
{
|
||||
stopAudio();
|
||||
qDebug() << "Recording stopped by duration timeout.";
|
||||
}
|
||||
|
||||
// 获取所有音频输入设备
|
||||
QList<QString> AudioInput::getAvailableAudioInputDevices()
|
||||
{
|
||||
QList<QString> list;
|
||||
const auto devices = QMediaDevices::audioInputs();
|
||||
for (const auto &device : devices) {
|
||||
list.append(device.description());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 设置当前录音设备
|
||||
void AudioInput::setAudioInputDevice(const QString &deviceName)
|
||||
{
|
||||
const auto devices = QMediaDevices::audioInputs();
|
||||
for (const auto &device : devices) {
|
||||
if (device.description() == deviceName) {
|
||||
m_currentDevice = device;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动录音 (VAD)
|
||||
void AudioInput::startAutoStopAudio(const qreal silenceThreshold, const int silenceDuration)
|
||||
{
|
||||
isAutoRecording = true;
|
||||
m_silenceThreshold = silenceThreshold;
|
||||
m_silenceDuration = silenceDuration;
|
||||
|
||||
// 重置状态
|
||||
m_hasVoiceDetected = false;
|
||||
m_smoothRms = 0.0;
|
||||
startAudio();
|
||||
|
||||
// 延迟200ms是为了避开硬件启动时的爆音,但不需要立即启动短时倒计时
|
||||
// 延迟启动静音检测,给一点缓冲时间
|
||||
QTimer::singleShot(200, this, [this](){
|
||||
if(isAutoRecording) { // 确保还在录音状态
|
||||
// 如果还没检测到声音,给5秒的等待时间;如果检测到了,逻辑由onReadyRead接管
|
||||
if(!m_hasVoiceDetected) {
|
||||
m_silenceTimer->start(5000); // 5秒没声音就停止
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启动阈值计算
|
||||
void AudioInput::startAutoThresholdClu(const int Duration)
|
||||
{
|
||||
isAutoThreshold = true;
|
||||
m_rmsValues.clear();
|
||||
startAudio();
|
||||
m_thresholdTimer->start(Duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2025.12.30重构 Misaki
|
||||
* 从均值阈值计算的基础上增加了N倍标准差
|
||||
* 即阈值 = 均值 + N * 标准差(N取3)
|
||||
*/
|
||||
void AudioInput::thresholdTimeout()
|
||||
{
|
||||
isAutoThreshold = false;
|
||||
stopAudio(); // 内部会处理 stop
|
||||
|
||||
if (m_rmsValues.empty()) {
|
||||
emit thresholdCalculated(0);
|
||||
return;
|
||||
}
|
||||
// 计算均值
|
||||
const double mean = std::accumulate(m_rmsValues.begin(), m_rmsValues.end(), 0.0) / m_rmsValues.size();
|
||||
// 计算标准差
|
||||
const double sq_sum = std::inner_product(m_rmsValues.begin(), m_rmsValues.end(), m_rmsValues.begin(), 0.0);
|
||||
double variance = (sq_sum / m_rmsValues.size()) - (mean * mean);
|
||||
// 防止浮点误差导致负数
|
||||
if (variance < 0) variance = 0;
|
||||
const double stdDev = std::sqrt(variance);
|
||||
|
||||
// 增加一个固定的偏移量 offset
|
||||
// 确保即使环境有轻微波动,也不会触发录音
|
||||
constexpr double offset = 80.0;
|
||||
// 阈值 = 均值 + 2 * 标准差
|
||||
const double bestThreshold = mean + (3 * stdDev) + offset;
|
||||
m_silenceThreshold = std::max(bestThreshold, 150.0);
|
||||
m_silenceThreshold = std::min(m_silenceThreshold, 30000.0);
|
||||
qDebug() << "Auto Threshold Calc -> Mean:" << mean
|
||||
<< " StdDev:" << stdDev
|
||||
<< " Result:" << m_silenceThreshold;
|
||||
emit thresholdCalculated(m_silenceThreshold);
|
||||
}
|
||||
|
||||
QByteArray AudioInput::generateWavHeader(const quint32 dataSize) const {
|
||||
// WAV头结构定义
|
||||
struct WavHeader {
|
||||
char riff[4] = {'R','I','F','F'};
|
||||
quint32 chunkSize;
|
||||
char wave[4] = {'W','A','V','E'};
|
||||
char fmt[4] = {'f','m','t',' '};
|
||||
quint32 fmtSize = 16;
|
||||
quint16 audioFormat = 1; // PCM
|
||||
quint16 numChannels;
|
||||
quint32 sampleRate;
|
||||
quint32 byteRate;
|
||||
quint16 blockAlign;
|
||||
quint16 bitsPerSample;
|
||||
char data[4] = {'d','a','t','a'};
|
||||
quint32 dataSize;
|
||||
} header;
|
||||
|
||||
header.numChannels = static_cast<quint16>(m_format.channelCount());
|
||||
header.sampleRate = static_cast<quint32>(m_format.sampleRate());
|
||||
header.bitsPerSample = 16; // 强制使用了 Int16
|
||||
|
||||
header.byteRate = header.sampleRate * header.numChannels * (header.bitsPerSample / 8);
|
||||
header.blockAlign = header.numChannels * (header.bitsPerSample / 8);
|
||||
header.dataSize = dataSize;
|
||||
header.chunkSize = 36 + dataSize;
|
||||
|
||||
return QByteArray(reinterpret_cast<const char*>(&header), sizeof(WavHeader));
|
||||
}
|
||||
|
||||
void AudioInput::setSilenceThreshold(const qreal silenceThreshold)
|
||||
{
|
||||
this->m_silenceThreshold = silenceThreshold;
|
||||
}
|
||||
|
||||
qreal AudioInput::getSilenceThreshold() const
|
||||
{
|
||||
return this->m_silenceThreshold;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/17.
|
||||
//
|
||||
|
||||
#include "AudioOutput.h"
|
||||
#include <QMediaDevices>
|
||||
#include <QDebug>
|
||||
#include <QCoreApplication>
|
||||
#include <QDataStream>
|
||||
|
||||
// Static Helpers (WAV Header Parser)
|
||||
static bool hasWavHeader(const QByteArray& data) {
|
||||
if (data.size() < 44) return false;
|
||||
return data.startsWith("RIFF") && data.mid(8, 4) == "WAVE";
|
||||
}
|
||||
|
||||
// StreamAudioWorker 实现
|
||||
|
||||
StreamAudioWorker::~StreamAudioWorker() {
|
||||
// 确保析构时资源释放
|
||||
stop();
|
||||
}
|
||||
|
||||
void StreamAudioWorker::start(int sampleRate, int channelCount, int bitDepth) {
|
||||
if (m_sink) {
|
||||
m_sink->stop();
|
||||
m_sink.reset();
|
||||
}
|
||||
|
||||
m_firstChunk = true;
|
||||
|
||||
// 配置音频格式
|
||||
QAudioFormat format;
|
||||
format.setSampleRate(sampleRate);
|
||||
format.setChannelCount(channelCount);
|
||||
|
||||
if (bitDepth == 8) format.setSampleFormat(QAudioFormat::UInt8);
|
||||
else if (bitDepth == 16) format.setSampleFormat(QAudioFormat::Int16);
|
||||
else if (bitDepth == 32) format.setSampleFormat(QAudioFormat::Float); // 32位通常为Float
|
||||
else format.setSampleFormat(QAudioFormat::Int16); // 默认回退
|
||||
|
||||
// 检查设备是否支持
|
||||
auto device = QMediaDevices::defaultAudioOutput();
|
||||
if (!device.isFormatSupported(format)) {
|
||||
qWarning() << "[Worker] Device does not support format, using preferred format.";
|
||||
format = device.preferredFormat();
|
||||
}
|
||||
|
||||
// 创建 Sink (必须在 Worker 线程中创建)
|
||||
m_sink.reset(new QAudioSink(device, format));
|
||||
|
||||
// 监听状态
|
||||
connect(m_sink.data(), &QAudioSink::stateChanged, this, [this](QAudio::State state){
|
||||
if (state == QAudio::IdleState) {
|
||||
emit playbackFinished();
|
||||
}
|
||||
else if (state == QAudio::StoppedState) {
|
||||
if (m_sink->error() != QAudio::NoError) {
|
||||
emit errorOccurred("Audio Sink Error: " + QString::number(m_sink->error()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 启动,获取 IO 设备
|
||||
m_ioDevice = m_sink->start();
|
||||
if (!m_ioDevice) {
|
||||
emit errorOccurred("Failed to start audio device");
|
||||
} else {
|
||||
qDebug() << "[Worker] Stream started:" << sampleRate << "Hz";
|
||||
}
|
||||
}
|
||||
|
||||
void StreamAudioWorker::processChunk(const QByteArray& chunk) {
|
||||
if (chunk.isEmpty() || !m_ioDevice || !m_sink) return;
|
||||
QByteArray dataToWrite = chunk;
|
||||
// 智能处理 WAV 头
|
||||
if (m_firstChunk) {
|
||||
if (hasWavHeader(chunk)) {
|
||||
qDebug() << "[Worker] Detected WAV header, stripping 44 bytes.";
|
||||
dataToWrite = chunk.mid(44);
|
||||
}
|
||||
m_firstChunk = false;
|
||||
}
|
||||
|
||||
// 写入音频设备 (QAudioSink 内部有缓冲区,这里直接 write 即可)
|
||||
// 如果数据量过大,write 可能会阻塞,但在独立线程中这是可以接受的
|
||||
qint64 written = m_ioDevice->write(dataToWrite);
|
||||
if (written != dataToWrite.size()) {
|
||||
qWarning() << "[Worker] Incomplete write:" << written << "/" << dataToWrite.size();
|
||||
}
|
||||
}
|
||||
|
||||
void StreamAudioWorker::stop() {
|
||||
if (m_sink) {
|
||||
m_sink->stop();
|
||||
m_sink.reset(); // 删除对象
|
||||
}
|
||||
m_ioDevice = nullptr;
|
||||
qDebug() << "[Worker] Stream stopped";
|
||||
}
|
||||
|
||||
// AudioOutput 主类实现
|
||||
|
||||
QScopedPointer<AudioOutput> AudioOutput::m_instance;
|
||||
QMutex AudioOutput::m_mutex;
|
||||
|
||||
AudioOutput* AudioOutput::getInstance() {
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
m_instance.reset(new AudioOutput());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void AudioOutput::destroy() {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset();
|
||||
}
|
||||
}
|
||||
|
||||
AudioOutput::AudioOutput(QObject *parent) : QObject(parent) {
|
||||
// 初始化文件播放器
|
||||
m_player = new QMediaPlayer(this);
|
||||
m_audioOutput = new QAudioOutput(this);
|
||||
m_player->setAudioOutput(m_audioOutput);
|
||||
|
||||
connect(m_player, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status){
|
||||
if (status == QMediaPlayer::EndOfMedia) emit playbackFinished();
|
||||
});
|
||||
|
||||
// 预先初始化流式 Worker 线程
|
||||
// 保持一个常驻线程Worker,通过信号控制
|
||||
m_streamWorker = new StreamAudioWorker(); // 不能指定 parent,因为要 moveToThread
|
||||
m_workerThread = new QThread(this);
|
||||
|
||||
m_streamWorker->moveToThread(m_workerThread);
|
||||
|
||||
// 连接信号槽
|
||||
// 主线程 -> Worker
|
||||
connect(this, &AudioOutput::sigOperateStreamStart, m_streamWorker, &StreamAudioWorker::start);
|
||||
connect(this, &AudioOutput::sigOperateStreamChunk, m_streamWorker, &StreamAudioWorker::processChunk);
|
||||
connect(this, &AudioOutput::sigOperateStreamStop, m_streamWorker, &StreamAudioWorker::stop);
|
||||
|
||||
// Worker -> 主线程
|
||||
connect(m_streamWorker, &StreamAudioWorker::errorOccurred, this, &AudioOutput::errorOccurred);
|
||||
|
||||
// 线程启动
|
||||
m_workerThread->start();
|
||||
}
|
||||
|
||||
AudioOutput::~AudioOutput() {
|
||||
stopPlayback();
|
||||
|
||||
// 清理线程
|
||||
if (m_workerThread) {
|
||||
m_workerThread->quit();
|
||||
m_workerThread->wait(3000); // 等待退出
|
||||
delete m_streamWorker;
|
||||
}
|
||||
}
|
||||
|
||||
// 对外接口
|
||||
|
||||
void AudioOutput::stopPlayback() {
|
||||
// 停止文件播放
|
||||
if (m_player->playbackState() != QMediaPlayer::StoppedState) {
|
||||
m_player->stop();
|
||||
}
|
||||
|
||||
// 停止流播放
|
||||
if (m_isStreaming) {
|
||||
stopStream();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioOutput::setVolume(int volume) {
|
||||
if (m_audioOutput) m_audioOutput->setVolume(volume / 100.0);
|
||||
// 注意:流式播放的音量控制需要在 Worker 内单独实现
|
||||
}
|
||||
|
||||
bool AudioOutput::isPlaying() const {
|
||||
return (m_player->playbackState() == QMediaPlayer::PlayingState) || m_isStreaming;
|
||||
}
|
||||
|
||||
// 文件播放
|
||||
void AudioOutput::playUrl(const QUrl& url) {
|
||||
stopPlayback(); // 互斥,播放新文件前停止旧的
|
||||
m_player->setSource(url);
|
||||
m_player->play();
|
||||
}
|
||||
|
||||
void AudioOutput::playData(const QByteArray& data) {
|
||||
// 这个方法对于 QMediaPlayer 比较麻烦,需要自定义 QIODevice
|
||||
// 建议直接走 stream 接口,或者使用 QBuffer + StreamWorker 的一次性模式
|
||||
// 为了简单,这里将 buffer 视为 stream 播放
|
||||
stopPlayback();
|
||||
startStream(44100, 2, 16); // 假设默认 wav 格式,Worker 会自动解析头
|
||||
pushStreamData(data);
|
||||
// 不需要显式 stopStream,让它播完
|
||||
}
|
||||
|
||||
// 流式播放
|
||||
|
||||
void AudioOutput::startStream(int sampleRate, int channelCount, int bitDepth) {
|
||||
stopPlayback(); // 确保干净的状态
|
||||
m_isStreaming = true;
|
||||
|
||||
// 通过信号跨线程调用 Worker 的 start
|
||||
emit sigOperateStreamStart(sampleRate, channelCount, bitDepth);
|
||||
}
|
||||
|
||||
void AudioOutput::pushStreamData(const QByteArray& chunk) {
|
||||
if (!m_isStreaming) return;
|
||||
|
||||
// 直接发射信号,Qt 会把 chunk copy 到子线程事件队列
|
||||
emit sigOperateStreamChunk(chunk);
|
||||
}
|
||||
|
||||
void AudioOutput::stopStream() {
|
||||
if (!m_isStreaming) return;
|
||||
|
||||
m_isStreaming = false;
|
||||
emit sigOperateStreamStop();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
实现了录音和播放的功能
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QMutex>
|
||||
|
||||
#include "AudioDataTransferObject.h"
|
||||
|
||||
class AudioDataHandle final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(AudioDataHandle) // 禁用拷贝
|
||||
private:
|
||||
/**
|
||||
* 构造函数私有化
|
||||
* @param parent
|
||||
*/
|
||||
explicit AudioDataHandle(QObject *parent = nullptr); // 并不将本模块挂在对象树当中,因为本模块为单例类,内存自行管理
|
||||
|
||||
static QScopedPointer<AudioDataHandle> m_instance; // 单例类
|
||||
static QMutex m_mutex;
|
||||
private slots:
|
||||
// 业务接收槽函数,当获取到音频数据包时,进行解析并播放
|
||||
void onAudioPacketReceived(const AudioDataTransferObject& packet);
|
||||
public:
|
||||
// 单例访问点
|
||||
static AudioDataHandle *getInstance();
|
||||
// 显式销毁
|
||||
static void destroy();
|
||||
|
||||
~AudioDataHandle() override;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
|
||||
/**
|
||||
* 本模块通过解析AutoAgentDataObject的内容并调用 AutoGUI 模块
|
||||
* 来完成自动化GUI操作
|
||||
* 对于GUI自动化执行器而言,运行时只需要有一个实例即可,因此采用单例模式,并在AppCore当中进行创建
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QMutex>
|
||||
#include "AutoAgentDataObject.h"
|
||||
|
||||
class AutoAgentHandle final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(AutoAgentHandle) // 禁用拷贝
|
||||
private:
|
||||
/**
|
||||
* 构造函数私有化
|
||||
* @param parent
|
||||
*/
|
||||
explicit AutoAgentHandle(QObject *parent = nullptr); // 并不将本模块挂在对象树当中,因为本模块为单例类,内存自行管理
|
||||
|
||||
static QScopedPointer<AutoAgentHandle> m_instance; // 单例类
|
||||
static QMutex m_mutex;
|
||||
private slots:
|
||||
// 业务接收槽函数,当获取到自动化agent数据包时,进行解析并调用 AutoGUI 模块
|
||||
void onAutoAgentPacketReceived(const AutoAgentDataObject& packet);
|
||||
public:
|
||||
// 单例访问点
|
||||
static AutoAgentHandle *getInstance();
|
||||
// 显式销毁
|
||||
static void destroy();
|
||||
|
||||
~AutoAgentHandle() override;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Created by Yosuga on 2026/4/25.
|
||||
//
|
||||
|
||||
/**
|
||||
* 设备数据处理模块 — 统一路由器
|
||||
*
|
||||
* 管理三种设备连接通道:
|
||||
* - TCP (RK3566 等通过 TCP 接入)
|
||||
* - WebSocket (ESP32 等通过 WebSocket 接入)
|
||||
* - 串口 (STM32 等通过串口接入)
|
||||
*
|
||||
* 数据流向:
|
||||
* Device →(TCP/WS/Serial)→ DeviceDataHandle → NetworkDO → WebSocket → YosugaServer
|
||||
* YosugaServer → WebSocket → NetworkDO → DeviceDataHandle →(TCP/WS/Serial)→ Device
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QMutex>
|
||||
#include <QScopedPointer>
|
||||
#include <QHash>
|
||||
#include <functional>
|
||||
#include "DeviceDataTransferObject.h"
|
||||
#include "serialportmanager.h"
|
||||
|
||||
class DeviceDataHandle final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(DeviceDataHandle)
|
||||
|
||||
private:
|
||||
explicit DeviceDataHandle(QObject *parent = nullptr);
|
||||
static QScopedPointer<DeviceDataHandle> m_instance;
|
||||
static QMutex m_mutex;
|
||||
|
||||
public:
|
||||
static DeviceDataHandle *getInstance();
|
||||
static void destroy();
|
||||
~DeviceDataHandle() override;
|
||||
|
||||
// 注册设备到路由表(由各 Server/Client 在设备握手完成后调用)
|
||||
void registerDevice(const QString &deviceId, const QString &deviceType, QObject *connection);
|
||||
|
||||
// 移除设备
|
||||
void unregisterDevice(const QString &deviceId);
|
||||
|
||||
// 向设备发送数据(自动选择正确的通道)
|
||||
void sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
// 获取设备连接类型
|
||||
[[nodiscard]] QString deviceConnectionType(const QString &deviceId) const;
|
||||
|
||||
public slots:
|
||||
// 收到来自 YosugaServer 的设备命令(通过 device_command 信号)
|
||||
void onDeviceCommandReceived(const DeviceDataTransferObject &packet);
|
||||
|
||||
// 收到来自 TCP 设备的 JSON 数据
|
||||
void onTcpDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
// 收到来自 WebSocket 设备的 JSON 数据
|
||||
void onWsDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
// 收到来自串口设备的 JSON 数据
|
||||
void onSerialDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
private:
|
||||
// 转发设备数据到 YosugaServer
|
||||
void forwardToServer(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
struct DeviceEntry {
|
||||
QString deviceId;
|
||||
QString deviceType; // "tcp", "websocket", "serial"
|
||||
QObject *connection; // DeviceTcpServer / DeviceWebSocketServer / SerialPortClient
|
||||
};
|
||||
QHash<QString, DeviceEntry> m_devices;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QMutex>
|
||||
#include "ScreenShotDataTransferObject.h"
|
||||
class ScreenShotReqDataHandle final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(ScreenShotReqDataHandle) // 禁用拷贝
|
||||
private:
|
||||
/**
|
||||
* 构造函数私有化
|
||||
* @param parent
|
||||
*/
|
||||
explicit ScreenShotReqDataHandle(QObject *parent = nullptr); // 并不将本模块挂在对象树当中,因为本模块为单例类,内存自行管理
|
||||
|
||||
static QScopedPointer<ScreenShotReqDataHandle> m_instance; // 单例类
|
||||
static QMutex m_mutex;
|
||||
|
||||
private slots:
|
||||
// 业务接收槽函数,当获取到截图数据包时,进行解析并处理
|
||||
void onScreenShotPacketReceived(const ScreenShotDataTransferObject& packet) const;
|
||||
|
||||
signals:
|
||||
// 发送截图处理完成的信号,供界面显示使用
|
||||
void screenShotProcessed(const QPixmap& screenshot, const QString& description);
|
||||
public:
|
||||
// 单例访问点
|
||||
static ScreenShotReqDataHandle *getInstance();
|
||||
// 显式销毁
|
||||
static void destroy();
|
||||
|
||||
~ScreenShotReqDataHandle() override;
|
||||
private:
|
||||
QString m_systemInfo;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
#include "AudioDataHandle.h"
|
||||
#include "NetWorkDO.h"
|
||||
#include "AudioOutput.h"
|
||||
// 初始化静态成员
|
||||
QScopedPointer<AudioDataHandle> AudioDataHandle::m_instance;
|
||||
QMutex AudioDataHandle::m_mutex;
|
||||
|
||||
// 单例实现 (QScopedPointer + Mutex)
|
||||
AudioDataHandle* AudioDataHandle::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
// 使用 reset 创建实例,因为构造函数是私有的
|
||||
m_instance.reset(new AudioDataHandle());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void AudioDataHandle::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset(); // 这会触发析构函数
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AudioDataHandle::AudioDataHandle(QObject *parent) : QObject(parent)
|
||||
{
|
||||
connect(NetworkDO::getInstance(), &NetworkDO::audioPacketReceived, this, &AudioDataHandle::onAudioPacketReceived);
|
||||
}
|
||||
|
||||
AudioDataHandle::~AudioDataHandle()
|
||||
{
|
||||
qDebug() << "AutoAgentHandle destroyed";
|
||||
}
|
||||
|
||||
void AudioDataHandle::onAudioPacketReceived(const AudioDataTransferObject &packet) {
|
||||
// 管理并调用AudioOutput播放流式wav音频
|
||||
if (packet.isEnd()) { // 如果是结束包(空包)
|
||||
AudioOutput::getInstance()->stopStream(); // 停止播放
|
||||
return;
|
||||
}
|
||||
if (packet.isStart()) { // 如果是开始包(单wav 44字节头)
|
||||
AudioOutput::getInstance()->startStream(packet.sampleRate(), packet.channelCount(), packet.bitDepth());; // 播放开始
|
||||
return;
|
||||
}
|
||||
// 否则加入播放队列即可
|
||||
AudioOutput::getInstance()->pushStreamData(packet.audioData());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/30.
|
||||
//
|
||||
|
||||
#include "AutoAgentHandle.h"
|
||||
#include "NetWorkDO.h"
|
||||
#if defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
#include <SimpleAutoGUI.h> // 引入 AutoGUI 头文件
|
||||
#endif
|
||||
|
||||
// 初始化静态成员
|
||||
QScopedPointer<AutoAgentHandle> AutoAgentHandle::m_instance;
|
||||
QMutex AutoAgentHandle::m_mutex;
|
||||
|
||||
// 单例实现 (QScopedPointer + Mutex)
|
||||
AutoAgentHandle* AutoAgentHandle::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
// 使用 reset 创建实例,因为构造函数是私有的
|
||||
m_instance.reset(new AutoAgentHandle());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void AutoAgentHandle::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset(); // 这会触发析构函数
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AutoAgentHandle::AutoAgentHandle(QObject *parent) : QObject(parent)
|
||||
{
|
||||
connect(NetworkDO::getInstance(), &NetworkDO::autoAgentPacketReceived, this, &AutoAgentHandle::onAutoAgentPacketReceived);
|
||||
}
|
||||
|
||||
AutoAgentHandle::~AutoAgentHandle()
|
||||
{
|
||||
qDebug() << "AutoAgentHandle destroyed";
|
||||
}
|
||||
|
||||
void AutoAgentHandle::onAutoAgentPacketReceived(const AutoAgentDataObject &packet) {
|
||||
qDebug() << "Received AutoAgent packet: " << packet.getAction();
|
||||
#if defined(Q_OS_LINUX) && !defined(EMBEDDED_LINUX)
|
||||
if (packet.getAction() == "click") { // 单击
|
||||
qDebug() << "Click: " << packet.getX1() << ", " << packet.getY1();
|
||||
AutoGUI::moveToOnCurrentScreen(packet.getX1(), packet.getY1(), 0.6);
|
||||
AutoGUI::click(packet.getX1(), packet.getY1());
|
||||
}
|
||||
if (packet.getAction() == "left_double") { // 双击
|
||||
qDebug() << "Double click: " << packet.getX1() << ", " << packet.getY1();
|
||||
AutoGUI::moveToOnCurrentScreen(packet.getX1(), packet.getY1(), 0.6);
|
||||
AutoGUI::leftDouble(packet.getX1(), packet.getY1());
|
||||
}
|
||||
if (packet.getAction() == "right_single") { // 右键单击
|
||||
qDebug() << "Right click: " << packet.getX1() << ", " << packet.getY1();
|
||||
AutoGUI::moveToOnCurrentScreen(packet.getX1(), packet.getY1(), 0.6);
|
||||
AutoGUI::rightSingle(packet.getX1(), packet.getY1());
|
||||
}
|
||||
if (packet.getAction() == "drag") { // 拖拽
|
||||
qDebug() << "Drag: " << packet.getX1() << ", " << packet.getY1() << " to " << packet.getX2() << ", " << packet.getY2();
|
||||
AutoGUI::drag(packet.getX1(), packet.getY1(), packet.getX2(), packet.getY2(), 1.2);
|
||||
}
|
||||
// TODO: 快捷键,输入文本,滚动待实现
|
||||
if (packet.getAction() == "type") { // 输入文本
|
||||
qDebug() << "Type: " << packet.getContent();
|
||||
AutoGUI::type(packet.getContent().toStdString(), 0.08);
|
||||
}
|
||||
if (packet.getAction() == "scroll") { // 滚动
|
||||
qDebug() << "Scroll: " << packet.getX1() << ", " << packet.getY1() << packet.getDirection();
|
||||
if (packet.getDirection() == "up") {
|
||||
AutoGUI::moveToOnCurrentScreen(packet.getX1(), packet.getY1(), 0.1);
|
||||
AutoGUI::scroll(40, 1);
|
||||
}
|
||||
if (packet.getDirection() == "down") {
|
||||
AutoGUI::moveToOnCurrentScreen(packet.getX1(), packet.getY1(), 0.1);
|
||||
AutoGUI::scroll(40, -1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// Created by Yosuga on 2026/4/25.
|
||||
//
|
||||
|
||||
#include "DeviceDataHandle.h"
|
||||
#include "NetWorkDO.h"
|
||||
#include <QDebug>
|
||||
#include <QMetaMethod>
|
||||
|
||||
QScopedPointer<DeviceDataHandle> DeviceDataHandle::m_instance;
|
||||
QMutex DeviceDataHandle::m_mutex;
|
||||
|
||||
DeviceDataHandle *DeviceDataHandle::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
m_instance.reset(new DeviceDataHandle());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void DeviceDataHandle::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
m_instance.reset();
|
||||
}
|
||||
|
||||
DeviceDataHandle::DeviceDataHandle(QObject *parent) : QObject(parent)
|
||||
{
|
||||
qRegisterMetaType<QJsonObject>("QJsonObject");
|
||||
|
||||
connect(NetworkDO::getInstance(), &NetworkDO::deviceCommandReceived,
|
||||
this, &DeviceDataHandle::onDeviceCommandReceived);
|
||||
}
|
||||
|
||||
DeviceDataHandle::~DeviceDataHandle()
|
||||
{
|
||||
qDebug() << "[DeviceDataHandle] destroyed";
|
||||
}
|
||||
|
||||
void DeviceDataHandle::registerDevice(const QString &deviceId, const QString &deviceType, QObject *connection)
|
||||
{
|
||||
DeviceEntry entry;
|
||||
entry.deviceId = deviceId;
|
||||
entry.deviceType = deviceType;
|
||||
entry.connection = connection;
|
||||
m_devices.insert(deviceId, entry);
|
||||
|
||||
qDebug() << "[DeviceDataHandle] 设备已注册:" << deviceId << "类型:" << deviceType;
|
||||
}
|
||||
|
||||
void DeviceDataHandle::unregisterDevice(const QString &deviceId)
|
||||
{
|
||||
m_devices.remove(deviceId);
|
||||
qDebug() << "[DeviceDataHandle] 设备已移除:" << deviceId;
|
||||
}
|
||||
|
||||
void DeviceDataHandle::sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
DeviceEntry entry = m_devices.value(deviceId);
|
||||
if (entry.connection == nullptr) {
|
||||
qWarning() << "[DeviceDataHandle] 未知设备:" << deviceId;
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过 QMetaObject::invokeMethod 动态调用对应 Server 的 sendToDevice
|
||||
bool ok = QMetaObject::invokeMethod(
|
||||
entry.connection,
|
||||
"sendToDevice",
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(QString, deviceId),
|
||||
Q_ARG(QString, type),
|
||||
Q_ARG(QJsonObject, data)
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
// 兜底:如果是串口设备,直接调用 SerialPortClient::sendJson
|
||||
auto *serialClient = qobject_cast<SerialPortClient*>(entry.connection);
|
||||
if (serialClient) {
|
||||
serialClient->sendJson(type, data);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
qWarning() << "[DeviceDataHandle] 发送失败到设备:" << deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
QString DeviceDataHandle::deviceConnectionType(const QString &deviceId) const
|
||||
{
|
||||
return m_devices.value(deviceId).deviceType;
|
||||
}
|
||||
|
||||
void DeviceDataHandle::onDeviceCommandReceived(const DeviceDataTransferObject &packet)
|
||||
{
|
||||
const QString deviceId = packet.deviceId();
|
||||
if (deviceId.isEmpty()) {
|
||||
qWarning() << "[DeviceDataHandle] device_command 缺少 device_id";
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取 RPC 调用字符串
|
||||
QJsonObject payload = packet.payload();
|
||||
QString rpcCall;
|
||||
if (payload.contains("rpc_call")) {
|
||||
rpcCall = payload.value("rpc_call").toString();
|
||||
} else {
|
||||
rpcCall = QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QJsonObject forwardPayload;
|
||||
forwardPayload["rpc_call"] = rpcCall;
|
||||
sendToDevice(deviceId, "rpc_call", forwardPayload);
|
||||
qDebug() << "[DeviceDataHandle] 已转发命令到设备:" << deviceId;
|
||||
}
|
||||
|
||||
void DeviceDataHandle::onTcpDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
forwardToServer(deviceId, type, data);
|
||||
}
|
||||
|
||||
void DeviceDataHandle::onWsDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
forwardToServer(deviceId, type, data);
|
||||
}
|
||||
|
||||
void DeviceDataHandle::onSerialDeviceData(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
forwardToServer(deviceId, type, data);
|
||||
}
|
||||
|
||||
void DeviceDataHandle::forwardToServer(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
// 所有设备数据统一封装为 DeviceDataTransferObject 发往 YosugaServer
|
||||
DeviceDataTransferObject packet(type, deviceId, data);
|
||||
NetworkDO::getInstance()->sendPacket(packet);
|
||||
qDebug() << "[DeviceDataHandle] 设备数据已转发到服务端:" << deviceId << type;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
#include "ScreenShotReqDataHandle.h"
|
||||
#include "NetWorkDO.h"
|
||||
#include <QDebug>
|
||||
#include <QPixmap>
|
||||
#include "ScreenShotDataTransferObject.h"
|
||||
#include "ScreenHelperUtil.hpp"
|
||||
// 初始化静态成员
|
||||
QScopedPointer<ScreenShotReqDataHandle> ScreenShotReqDataHandle::m_instance;
|
||||
QMutex ScreenShotReqDataHandle::m_mutex;
|
||||
|
||||
// 单例实现 (QScopedPointer + Mutex)
|
||||
ScreenShotReqDataHandle* ScreenShotReqDataHandle::getInstance()
|
||||
{
|
||||
if (m_instance.isNull()) {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
// 使用 reset 创建实例,因为构造函数是私有的
|
||||
m_instance.reset(new ScreenShotReqDataHandle());
|
||||
}
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void ScreenShotReqDataHandle::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset(); // 这会触发析构函数
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ScreenShotReqDataHandle::ScreenShotReqDataHandle(QObject *parent) : QObject(parent)
|
||||
{
|
||||
connect(NetworkDO::getInstance(), &NetworkDO::screenShotPacketReceived,
|
||||
this, &ScreenShotReqDataHandle::onScreenShotPacketReceived);
|
||||
// 初始化时候就构造好关于当前运行平台的信息
|
||||
ScreenHelper::SystemInfo sysInfo = ScreenHelper::getSystemInfo();
|
||||
const QString sysText = QString("System: %1 OS Version: %2 Display Server: %3")
|
||||
.arg(sysInfo.osType, sysInfo.osVersion, sysInfo.displayServer);
|
||||
this->m_systemInfo = sysText;
|
||||
qDebug() << "当前平台信息为: " << sysText;
|
||||
}
|
||||
|
||||
ScreenShotReqDataHandle::~ScreenShotReqDataHandle()
|
||||
{
|
||||
qDebug() << "ScreenShotDataHandle destroyed";
|
||||
}
|
||||
|
||||
void ScreenShotReqDataHandle::onScreenShotPacketReceived(const ScreenShotDataTransferObject &packet) const {
|
||||
qDebug() << "ScreenShot packet request from:" << packet.owner();
|
||||
// 截图当前画面并构造对等DTO发送
|
||||
const ScreenHelper::ScreenshotResult result = ScreenHelper::captureFocusedScreen(); // 获取当前屏幕截图
|
||||
if (!result.success) { // 如果截图失败
|
||||
// TODO: 考虑失败时候构造一个错误DTO给服务端
|
||||
qDebug() << "截图失败: " << result.errorMsg;
|
||||
return;
|
||||
}
|
||||
ScreenShotDataTransferObject reback; // 构造返回的DTO
|
||||
reback.setData("isSuccess", true).setData("RealTimeScreenShot", result.base64Data)
|
||||
.setData("Width", result.width).setData("Height", result.height)
|
||||
.setData("DescribeInfo", this->m_systemInfo).setData("LLMResponse", packet.LLMResponse());
|
||||
// 发送DTO
|
||||
NetworkDO::getInstance()->sendPacket(reback);
|
||||
qDebug() << "ScreenShot packet sent to:" << packet.owner();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
### 本模块负责将服务端返回的数据对象做解析并执行相应的动作
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Created by misaki on 2026/4/25.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QTcpServer>
|
||||
#include <QTcpSocket>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QByteArray>
|
||||
|
||||
class DeviceTcpServer final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(DeviceTcpServer)
|
||||
|
||||
public:
|
||||
explicit DeviceTcpServer(quint16 port = 10001, QObject *parent = nullptr);
|
||||
~DeviceTcpServer() override;
|
||||
|
||||
bool start();
|
||||
void stop();
|
||||
[[nodiscard]] bool isListening() const { return m_server && m_server->isListening(); }
|
||||
[[nodiscard]] quint16 serverPort() const { return m_port; }
|
||||
|
||||
Q_INVOKABLE void sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
signals:
|
||||
void deviceConnected(const QString &deviceId, const QString &deviceName);
|
||||
void deviceDisconnected(const QString &deviceId);
|
||||
void jsonReceived(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
void serverError(const QString &errorMsg);
|
||||
|
||||
private slots:
|
||||
void onNewConnection();
|
||||
void onClientDisconnected();
|
||||
void onReadyRead();
|
||||
|
||||
private:
|
||||
struct DeviceSession {
|
||||
QString deviceId;
|
||||
QString deviceName;
|
||||
QTcpSocket *socket;
|
||||
QByteArray buffer;
|
||||
};
|
||||
|
||||
QTcpServer *m_server;
|
||||
quint16 m_port;
|
||||
QHash<QString, DeviceSession*> m_deviceSessions; // deviceId -> session
|
||||
QHash<QTcpSocket*, DeviceSession*> m_socketSessions; // socket -> session
|
||||
|
||||
void parseIncomingData(DeviceSession *session);
|
||||
void removeSession(QTcpSocket *socket);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Created by misaki on 2026/4/25.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QWebSocketServer>
|
||||
#include <QWebSocket>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
|
||||
class DeviceWebSocketServer final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(DeviceWebSocketServer)
|
||||
|
||||
public:
|
||||
explicit DeviceWebSocketServer(quint16 port = 10002, QObject *parent = nullptr);
|
||||
~DeviceWebSocketServer() override;
|
||||
|
||||
bool start();
|
||||
void stop();
|
||||
[[nodiscard]] bool isListening() const { return m_server && m_server->isListening(); }
|
||||
[[nodiscard]] quint16 serverPort() const { return m_port; }
|
||||
|
||||
Q_INVOKABLE void sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
|
||||
signals:
|
||||
void deviceConnected(const QString &deviceId, const QString &deviceName);
|
||||
void deviceDisconnected(const QString &deviceId);
|
||||
void jsonReceived(const QString &deviceId, const QString &type, const QJsonObject &data);
|
||||
void serverError(const QString &errorMsg);
|
||||
|
||||
private slots:
|
||||
void onNewConnection();
|
||||
void onClientDisconnected();
|
||||
void onTextMessageReceived(const QString &message);
|
||||
|
||||
private:
|
||||
struct DeviceSession {
|
||||
QString deviceId;
|
||||
QString deviceName;
|
||||
QWebSocket *socket;
|
||||
};
|
||||
|
||||
QWebSocketServer *m_server;
|
||||
quint16 m_port;
|
||||
QHash<QString, DeviceSession*> m_deviceSessions;
|
||||
QHash<QWebSocket*, DeviceSession*> m_socketSessions;
|
||||
|
||||
void removeSession(QWebSocket *socket);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/19.
|
||||
//
|
||||
|
||||
/**
|
||||
* 已废弃
|
||||
*/
|
||||
|
||||
#ifndef AIRI_DESKTOPGRIL_NETWORKMANAGER_H
|
||||
#define AIRI_DESKTOPGRIL_NETWORKMANAGER_H
|
||||
|
||||
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QMutex>
|
||||
#include <QWaitCondition>
|
||||
|
||||
class NetWorkManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit NetWorkManager(QObject *parent = nullptr);
|
||||
~NetWorkManager();
|
||||
|
||||
// GET 请求
|
||||
void get(const QString &url);
|
||||
|
||||
// POST 请求(JSON 数据)
|
||||
void post(const QString &url, const QJsonObject &json);
|
||||
|
||||
// 文件下载
|
||||
void downloadFile(const QString &url, const QString &savePath);
|
||||
|
||||
// 文件上传
|
||||
void uploadFile(const QString &url, const QString &filePath);
|
||||
|
||||
// 设置超时时间(毫秒)
|
||||
void setTimeout(int timeout);
|
||||
|
||||
// 设置请求头
|
||||
void setHeader(const QString &key, const QString &value);
|
||||
|
||||
// 清除请求头
|
||||
void clearHeaders();
|
||||
|
||||
signals:
|
||||
// 请求完成信号,返回响应数据
|
||||
void requestFinished(const QByteArray &response);
|
||||
|
||||
// 下载进度信号
|
||||
void downloadProgress(qint64 bytesReceived, qint64 bytesTotal);
|
||||
|
||||
// 上传进度信号
|
||||
void uploadProgress(qint64 bytesSent, qint64 bytesTotal);
|
||||
|
||||
// 错误信号
|
||||
void errorOccurred(const QString &errorString);
|
||||
|
||||
// 超时信号
|
||||
void timeoutOccurred();
|
||||
|
||||
private slots:
|
||||
// 请求完成槽函数
|
||||
void onReplyFinished(QNetworkReply *reply);
|
||||
|
||||
// 下载进度槽函数
|
||||
void onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal);
|
||||
|
||||
// 上传进度槽函数
|
||||
void onUploadProgress(qint64 bytesSent, qint64 bytesTotal);
|
||||
|
||||
// 超时槽函数
|
||||
void onTimeout();
|
||||
|
||||
private:
|
||||
QNetworkAccessManager *manager; /// 网络管理对象
|
||||
QFile *file; /// 文件对象(用于下载/上传)
|
||||
QNetworkReply *reply; /// 网络响应对象
|
||||
QTimer *timer; /// 超时计时器
|
||||
QMutex mutex; /// 互斥锁
|
||||
QWaitCondition condition; /// 条件变量
|
||||
QMap<QString, QString> headers; /// 请求头
|
||||
int timeout; /// 超时时间
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif //AIRI_DESKTOPGRIL_NETWORKMANAGER_H
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/26.
|
||||
//
|
||||
|
||||
/**
|
||||
* 串口通信模块 —— 支持多设备并行 + JSON 协议
|
||||
* 每个 SerialPortClient 实例对应一个物理串口
|
||||
*/
|
||||
#pragma once
|
||||
#include <QSerialPort>
|
||||
#include <QSerialPortInfo>
|
||||
#include <QObject>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QMutex>
|
||||
#include <QScopedPointer>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <utility>
|
||||
|
||||
/**
|
||||
* @brief 串口管理器(工作线程侧)
|
||||
* @details 负责单一串口的实际 I/O、参数配置、心跳、JSON 自动编解码
|
||||
* 与 WebSocketManager 对称设计,支持多实例并行
|
||||
*/
|
||||
class SerialPortManager final : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SerialPortManager(QString deviceName, QObject *parent = nullptr);
|
||||
~SerialPortManager() override;
|
||||
|
||||
// 禁止拷贝
|
||||
SerialPortManager(const SerialPortManager&) = delete;
|
||||
SerialPortManager& operator=(const SerialPortManager&) = delete;
|
||||
|
||||
// 设备标识(用于多实例区分)
|
||||
[[nodiscard]] QString deviceName() const { return m_deviceName; }
|
||||
|
||||
// 配置结构体
|
||||
struct SerialPortConfig {
|
||||
QString portName; // 端口名: "COM3", "/dev/ttyUSB0"
|
||||
qint32 baudRate; // 波特率: 9600, 115200
|
||||
QSerialPort::DataBits dataBits;
|
||||
QSerialPort::Parity parity;
|
||||
QSerialPort::StopBits stopBits;
|
||||
QSerialPort::FlowControl flowControl;
|
||||
QByteArray heartbeatData; // 心跳包内容(为空则禁用)
|
||||
int maxJsonSize; // JSON 最大长度(防内存溢出)
|
||||
|
||||
// 默认配置:115200-N-8-1 + 无流控 + 心跳禁用 + JSON 上限 64KB
|
||||
explicit SerialPortConfig(
|
||||
QString port = "",
|
||||
qint32 baud = 115200,
|
||||
QSerialPort::DataBits db = QSerialPort::Data8,
|
||||
QSerialPort::Parity p = QSerialPort::NoParity,
|
||||
QSerialPort::StopBits sb = QSerialPort::OneStop,
|
||||
QSerialPort::FlowControl fc = QSerialPort::NoFlowControl,
|
||||
QByteArray hb = QByteArray(),
|
||||
int maxJson = 65536
|
||||
) : portName(std::move(port)), baudRate(baud), dataBits(db), parity(p),
|
||||
stopBits(sb), flowControl(fc), heartbeatData(std::move(hb)), maxJsonSize(maxJson) {}
|
||||
};
|
||||
|
||||
signals:
|
||||
// 状态与数据信号
|
||||
void opened(); // 串口成功打开
|
||||
void closed(); // 串口关闭
|
||||
void dataReceived(const QByteArray &data); // 原始二进制数据
|
||||
void textReceived(const QString &text); // 文本数据(UTF-8 解码)
|
||||
void hexReceived(const QString &hex); // 十六进制字符串: "AA BB CC"
|
||||
void jsonReceived(const QString &type, const QJsonObject &data); // JSON 已解析
|
||||
void error(const QString &errorMsg); // 错误信息
|
||||
void log(const QString &msg); // 运行日志
|
||||
void reconnecting(int attempt); // 自动重连中
|
||||
|
||||
public slots:
|
||||
bool setConfig(const SerialPortManager::SerialPortConfig& config); // 设置配置
|
||||
[[nodiscard]] SerialPortManager::SerialPortConfig currentConfig() const;
|
||||
|
||||
bool open(); // 打开串口
|
||||
void close(); // 关闭串口
|
||||
|
||||
// 多层次发送接口(JSON、文本、HEX、原始二进制)
|
||||
void sendJson(const QString &type, const QJsonObject &data); // 发送 JSON(自动封装)
|
||||
void sendText(const QString &text); // 发送文本(UTF-8)
|
||||
void sendHex(const QString &hex); // 发送十六进制: "12 AB CD"
|
||||
void sendRaw(const QByteArray &data); // 发送原始二进制(重命名为 sendRaw 更清晰)
|
||||
|
||||
void setAutoReconnect(bool enabled); // 是否开启自动重连
|
||||
void setHeartbeatInterval(int msecs); // 心跳间隔(毫秒)
|
||||
|
||||
[[nodiscard]] bool isOpen() const; // 串口是否已打开
|
||||
|
||||
private slots:
|
||||
void onReadyRead(); // 串口有数据到达
|
||||
void onErrorOccurred(QSerialPort::SerialPortError error);
|
||||
void sendHeartbeat(); // 定时发送心跳
|
||||
void tryReconnect(); // 重连逻辑
|
||||
|
||||
void processCOBSBuffer(); // 尝试解析 COBS帧
|
||||
|
||||
private:
|
||||
QSerialPort *m_serial; /// 串口实例
|
||||
QString m_deviceName; /// 设备标识(如 "STM32_Master", "ESP32_Slave")
|
||||
SerialPortConfig m_config; /// 当前配置
|
||||
QTimer *m_heartbeatTimer; /// 心跳定时器
|
||||
QTimer *m_reconnectTimer; /// 重连定时器
|
||||
bool m_isAutoReconnect; /// 是否启用自动重连
|
||||
int m_reconnectAttempts; /// 重连尝试次数
|
||||
|
||||
QByteArray m_cobsBuffer; /// COBS 解码缓冲区
|
||||
bool m_cobsInFrame; /// 帧状态
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @brief 串口客户端(主线程接口层)
|
||||
* @details 每个实例对应一个物理串口,支持构造多个并行工作
|
||||
* 封装线程迁移、信号转发、生命周期管理
|
||||
*/
|
||||
class SerialPortClient final : public QObject {
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(SerialPortClient)
|
||||
|
||||
public:
|
||||
// 构造函数:deviceName 为设备标识,用于日志和多实例区分
|
||||
explicit SerialPortClient(const QString &deviceName, QObject *parent = nullptr);
|
||||
~SerialPortClient() override;
|
||||
|
||||
// 设备标识
|
||||
[[nodiscard]] QString deviceName() const { return m_deviceName; }
|
||||
|
||||
// 配置串口
|
||||
bool setConfiguration(const SerialPortManager::SerialPortConfig& config);
|
||||
|
||||
// 串口操作
|
||||
void open();
|
||||
void close();
|
||||
void reconnect();
|
||||
|
||||
// 多层次发送接口
|
||||
void sendJson(const QString &type, const QJsonObject &data);
|
||||
void sendText(const QString &text);
|
||||
void sendHex(const QString &hex);
|
||||
void sendRaw(const QByteArray &data);
|
||||
|
||||
// 状态查询
|
||||
[[nodiscard]] bool isOpen() const;
|
||||
[[nodiscard]] bool hasConfiguration() const { return !m_config.portName.isEmpty(); }
|
||||
[[nodiscard]] SerialPortManager::SerialPortConfig currentConfig() const { return m_config; }
|
||||
|
||||
// 高级功能
|
||||
void setAutoReconnect(bool enabled);
|
||||
void setHeartbeatInterval(int msecs);
|
||||
[[nodiscard]] static QStringList availablePorts(); // 枚举系统可用串口
|
||||
|
||||
signals:
|
||||
// 事件信号(与 SerialPortManager 一一对应,转发到主线程)
|
||||
void opened();
|
||||
void closed();
|
||||
void dataReceived(const QByteArray &data);
|
||||
void textReceived(const QString &text);
|
||||
void hexReceived(const QString &hex);
|
||||
void jsonReceived(const QString &type, const QJsonObject &data);
|
||||
void error(const QString &errorMsg);
|
||||
void log(const QString &msg);
|
||||
void reconnecting(int attempt);
|
||||
|
||||
// 配置变更
|
||||
void configurationChanged(const SerialPortManager::SerialPortConfig &oldConfig,
|
||||
const SerialPortManager::SerialPortConfig &newConfig);
|
||||
|
||||
private:
|
||||
// 内部信号(用于跨线程通信,对标 internal*)
|
||||
Q_SIGNAL void internalSetConfig(const SerialPortManager::SerialPortConfig& config);
|
||||
Q_SIGNAL void internalOpen();
|
||||
Q_SIGNAL void internalClose();
|
||||
Q_SIGNAL void internalSendJson(const QString &type, const QJsonObject &data);
|
||||
Q_SIGNAL void internalSendText(const QString &text);
|
||||
Q_SIGNAL void internalSendHex(const QString &hex);
|
||||
Q_SIGNAL void internalSendRaw(const QByteArray &data);
|
||||
Q_SIGNAL void internalSetAutoReconnect(bool enabled);
|
||||
Q_SIGNAL void internalSetHeartbeatInterval(int msecs);
|
||||
|
||||
QString m_deviceName; /// 设备标识
|
||||
QThread *m_workerThread; /// 工作线程
|
||||
SerialPortManager *m_serialManager; /// 管理器实例(工作线程侧)
|
||||
SerialPortManager::SerialPortConfig m_config; /// 配置缓存
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/5.
|
||||
//
|
||||
|
||||
#ifndef AIRI_DESKTOPGRIL_SOCKETMANAGER_H
|
||||
#define AIRI_DESKTOPGRIL_SOCKETMANAGER_H
|
||||
|
||||
#include <QTcpSocket>
|
||||
#include <QObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QUrl>
|
||||
#include <QDataStream>
|
||||
#include <QHostAddress>
|
||||
#include <QDateTime>
|
||||
#include <QMutex>
|
||||
/**
|
||||
* @author Misaki
|
||||
* @brief SocketManager类
|
||||
* 本模块基于QTcpSocket进一步拓展,主要针对音频文件的上传与下载
|
||||
* 基于CS架构与服务端进行通信
|
||||
* Socket提供长连接支持,异步通信
|
||||
*/
|
||||
|
||||
class SocketManager : public QTcpSocket {
|
||||
Q_OBJECT
|
||||
private:
|
||||
// 构造函数私有化
|
||||
explicit SocketManager(QObject *parent = nullptr);
|
||||
public:
|
||||
// 删除拷贝构造函数和赋值运算符,禁止复制
|
||||
SocketManager(const SocketManager&) = delete;
|
||||
SocketManager& operator=(const SocketManager&) = delete;
|
||||
~SocketManager();
|
||||
|
||||
// 获取单例的静态方法
|
||||
static SocketManager* getInstance();
|
||||
|
||||
void connectToServer();
|
||||
void disconnectFromServer();
|
||||
|
||||
/**
|
||||
* 发送文件(wav) \n
|
||||
* 使用前需要先确保连接到服务端,不要调用完connectToServer后就直接调用这个函数 \n
|
||||
* TCP握手是需要一个很短的时间的,等连接稳定了多次调用这个函数就没有问题了 \n
|
||||
* 注意:调用这个函数请显示传入QString类型,否则编译器会不知道使用哪一个重载 \n
|
||||
* 原因如下: \n
|
||||
* 当调用 sendWavFile 时,如果传递的参数类型可能会被这两种函数参数类型接受或隐式转换,编译器就会报 “ambigous” 错误。例如: \n
|
||||
如果调用 sendWavFile("audio.wav"),因为 "audio.wav" 是一个 C 风格的字符串(const char*), \n
|
||||
而 QString 和 QByteArray 都可以接受 const char* 的隐式转换: \n
|
||||
QString 的构造函数可以接受一个 const char*。 \n
|
||||
QByteArray 的构造函数也可以接受一个 const char*。 \n
|
||||
因此,编译器无法确定是要调用 sendWavFile(const QString &filePath) 还是 sendWavFile(const QByteArray &wavData),从而导致歧义。\n
|
||||
* @author Misaki
|
||||
* @param filePath
|
||||
*/
|
||||
void sendWavFile(const QString &filePath);
|
||||
|
||||
/**
|
||||
* 直接发送二进制数据(wav)
|
||||
* 使用前需要先确保连接到服务端,不要调用完connectToServer后就直接调用这个函数
|
||||
* TCP握手是需要一个很短的时间的,等连接稳定了多次调用这个函数就没有问题了
|
||||
* @author Misaki
|
||||
* @param wavData
|
||||
*/
|
||||
void sendWavFile(const QByteArray &wavData);
|
||||
|
||||
// 设置目标服务端ip和端口的get&set方法
|
||||
void setIp(const QString &ip);
|
||||
QString getIp();
|
||||
|
||||
void setPort(qint16 port);
|
||||
qint16 getPort();
|
||||
|
||||
|
||||
|
||||
signals:
|
||||
void revWavFileFinish(const QString &filePath, const QString &response, const float duration);
|
||||
void revWavDataFinish(const QByteArray &wavData); // 字节流信号
|
||||
|
||||
private slots:
|
||||
/**
|
||||
* 处理接收到的数据
|
||||
* @author Misaki
|
||||
*/
|
||||
void handleReadyRead();
|
||||
|
||||
private:
|
||||
// 单例实例指针
|
||||
static SocketManager* m_instance;
|
||||
static QMutex m_mutex; // 互斥锁确保线程安全
|
||||
|
||||
QString ip = "127.0.0.1"; /// 目标ip
|
||||
qint16 port = 12345; /// 目标端口
|
||||
|
||||
QString filePath = "WavFiles\\"; /// 文件路径
|
||||
QString receiveBuffer; /// 接收缓冲区
|
||||
const char *endMarker = "<Eden*>"; /// 结束标记
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
#endif //AIRI_DESKTOPGRIL_SOCKETMANAGER_H
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/4.
|
||||
//
|
||||
#pragma once
|
||||
#include <QWebSocket>
|
||||
#include <QObject>
|
||||
#include <QThread>
|
||||
#include <QJsonDocument>
|
||||
#include <QTimer>
|
||||
#include <QQueue>
|
||||
#include <QAtomicPointer>
|
||||
#include <QMutexLocker>
|
||||
|
||||
/**
|
||||
* 2025.12.25重构 Misaki
|
||||
* 多线程websocket实现
|
||||
*/
|
||||
class WebSocketManager final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
// 构造函数
|
||||
explicit WebSocketManager(QObject *parent = nullptr); // 不携带URL参数
|
||||
~WebSocketManager() override;
|
||||
// 删除拷贝构造函数和赋值操作符
|
||||
WebSocketManager(const WebSocketManager&) = delete;
|
||||
WebSocketManager& operator=(const WebSocketManager&) = delete;
|
||||
public:
|
||||
bool setRequestContent(const QString& requestToken); // 设置自定义websocket首次请求Token(鉴权用) 服务端应保持Token一致
|
||||
bool setSocketUrl(QUrl url); // 设置URL
|
||||
|
||||
signals:
|
||||
// 发给主线程的信号
|
||||
void connected(); // 连接
|
||||
void disconnected(); // 断开
|
||||
void textReceived(const QString &message); // 接收文本
|
||||
void jsonReceived(const QString &type, const QJsonObject &data); // 接收JSON
|
||||
void binaryReceived(const QByteArray &data); // 接收二进制数据
|
||||
void error(const QString &errorMsg); // 错误
|
||||
void log(const QString &msg); // 日志
|
||||
void reconnecting(int attempt); // 重连
|
||||
|
||||
public slots:
|
||||
// 主线程调用的槽
|
||||
bool connectToServer(); // 连接到服务器
|
||||
void disconnectFromServer(); // 断开连接
|
||||
void sendText(const QString &message); // 发送文本
|
||||
void sendJson(const QString &type, const QJsonObject &data); // 发送JSON
|
||||
void sendBinary(const QByteArray &data); // 发送二进制数据
|
||||
void setReconnectEnabled(bool enabled); // 设置重连
|
||||
void setRequestEnabled(bool enabled); // 设置自定义首次请求
|
||||
|
||||
[[nodiscard]] bool isConnected() const; // 是否已连接
|
||||
|
||||
private slots:
|
||||
void onConnected(); // 连接成功
|
||||
void onDisconnected(); // 断开连接
|
||||
void onTextMessageReceived(const QString &message); // 接收到文本消息
|
||||
void onError(QAbstractSocket::SocketError socketError); // 错误
|
||||
void onSslErrors(const QList<QSslError> &errors); // SSL错误
|
||||
void onPong(quint64 elapsedTime, const QByteArray &payload); // Pong
|
||||
void sendPing() const; // 发送Ping
|
||||
void tryReconnect(); // 尝试重连
|
||||
|
||||
private:
|
||||
QWebSocket *m_socket; /// WebSocket对象
|
||||
QUrl m_url; /// 服务器地址
|
||||
QTimer *m_pingTimer; /// Ping定时器
|
||||
QTimer *m_reconnectTimer; /// 重连定时器
|
||||
int m_reconnectAttempts; /// 重连尝试次数
|
||||
bool m_isReconnectEnabled; /// 是否启用重连
|
||||
QNetworkRequest m_request; /// 自定义websocket首次请求(鉴权用)
|
||||
bool m_isRequest; /// 是否启用websocket首次请求
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSocket 客户端单例管理类
|
||||
* 负责管理 WebSocket 线程和全局访问
|
||||
*/
|
||||
class WebSocketClient final : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY(WebSocketClient)
|
||||
|
||||
public:
|
||||
static WebSocketClient* getInstance();
|
||||
static void destroy();
|
||||
|
||||
// 初始化/重新配置 WebSocket
|
||||
bool setConfiguration(const QUrl& url, const QString& authToken = QString());
|
||||
|
||||
// WebSocket 操作
|
||||
void connectToServer();
|
||||
void disconnectFromServer();
|
||||
void reconnect();
|
||||
|
||||
void sendText(const QString& message);
|
||||
void sendJson(const QString& type, const QJsonObject& data);
|
||||
void sendBinary(const QByteArray& data);
|
||||
|
||||
[[nodiscard]] bool isConnected() const;
|
||||
[[nodiscard]] bool hasConfiguration() const { return m_url.isValid(); }
|
||||
|
||||
void setAutoReconnect(bool enabled);
|
||||
void setPingInterval(int milliseconds);
|
||||
|
||||
[[nodiscard]] QUrl currentUrl() const { return m_url; }
|
||||
[[nodiscard]] QString currentToken() const { return m_authToken; }
|
||||
|
||||
// 获取内部管理器(仅供高级使用)
|
||||
[[nodiscard]] WebSocketManager* manager() const { return m_webSocketManager; }
|
||||
|
||||
signals:
|
||||
// WebSocket 事件
|
||||
void connected();
|
||||
void disconnected();
|
||||
void textReceived(const QString &message);
|
||||
void jsonReceived(const QString &type, const QJsonObject &data);
|
||||
void binaryReceived(const QByteArray &data);
|
||||
void error(const QString &errorMsg);
|
||||
void log(const QString &msg);
|
||||
void reconnecting(int attempt);
|
||||
|
||||
// 配置变更
|
||||
void configurationChanged(const QUrl& oldUrl, const QUrl& newUrl);
|
||||
|
||||
// 内部信号(用于跨线程通信)
|
||||
void internalSetUrl(const QUrl& url);
|
||||
void internalSetAuthToken(const QString& token);
|
||||
void internalConnect();
|
||||
void internalDisconnect();
|
||||
void internalSendText(const QString& message);
|
||||
void internalSendJson(const QString& type, const QJsonObject& data);
|
||||
void internalSendBinary(const QByteArray& data);
|
||||
void internalSetAutoReconnect(bool enabled);
|
||||
void internalSetRequestEnabled(bool enabled);
|
||||
public:
|
||||
~WebSocketClient() override;
|
||||
private:
|
||||
explicit WebSocketClient(QObject *parent = nullptr);
|
||||
|
||||
static QMutex m_mutex;
|
||||
static QScopedPointer<WebSocketClient> m_instance;
|
||||
|
||||
QThread* m_workerThread;
|
||||
WebSocketManager* m_webSocketManager;
|
||||
QUrl m_url;
|
||||
QString m_authToken;
|
||||
bool m_hasAuthToken;
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// Created by misaki on 2026/4/25.
|
||||
//
|
||||
|
||||
#include "DeviceTcpServer.h"
|
||||
#include <QDebug>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QDateTime>
|
||||
|
||||
DeviceTcpServer::DeviceTcpServer(quint16 port, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_server(new QTcpServer(this))
|
||||
, m_port(port)
|
||||
{
|
||||
connect(m_server, &QTcpServer::newConnection,
|
||||
this, &DeviceTcpServer::onNewConnection);
|
||||
}
|
||||
|
||||
DeviceTcpServer::~DeviceTcpServer()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
bool DeviceTcpServer::start()
|
||||
{
|
||||
if (m_server->isListening()) {
|
||||
qDebug() << "[DeviceTcpServer] Already listening on port" << m_port;
|
||||
return true;
|
||||
}
|
||||
if (!m_server->listen(QHostAddress::Any, m_port)) {
|
||||
QString err = QString("Failed to listen on TCP port %1: %2")
|
||||
.arg(m_port).arg(m_server->errorString());
|
||||
qWarning() << "[DeviceTcpServer]" << err;
|
||||
emit serverError(err);
|
||||
return false;
|
||||
}
|
||||
qDebug() << "[DeviceTcpServer] Listening for embedded devices on TCP port" << m_port;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DeviceTcpServer::stop()
|
||||
{
|
||||
for (auto it = m_socketSessions.begin(); it != m_socketSessions.end(); ++it) {
|
||||
DeviceSession *session = it.value();
|
||||
if (session->socket->state() == QAbstractSocket::ConnectedState) {
|
||||
session->socket->disconnectFromHost();
|
||||
}
|
||||
delete session;
|
||||
}
|
||||
m_deviceSessions.clear();
|
||||
m_socketSessions.clear();
|
||||
|
||||
if (m_server->isListening()) {
|
||||
m_server->close();
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceTcpServer::sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
DeviceSession *session = m_deviceSessions.value(deviceId);
|
||||
if (!session || !session->socket) {
|
||||
qWarning() << "[DeviceTcpServer] Cannot send to unknown device:" << deviceId;
|
||||
return;
|
||||
}
|
||||
QJsonObject msg;
|
||||
msg["type"] = type;
|
||||
msg["data"] = data;
|
||||
msg["timestamp"] = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
QByteArray payload = QJsonDocument(msg).toJson(QJsonDocument::Compact);
|
||||
session->socket->write(payload);
|
||||
session->socket->write("\n");
|
||||
session->socket->flush();
|
||||
}
|
||||
|
||||
void DeviceTcpServer::onNewConnection()
|
||||
{
|
||||
while (m_server->hasPendingConnections()) {
|
||||
QTcpSocket *socket = m_server->nextPendingConnection();
|
||||
if (!socket) continue;
|
||||
|
||||
auto *session = new DeviceSession{};
|
||||
session->socket = socket;
|
||||
m_socketSessions.insert(socket, session);
|
||||
|
||||
connect(socket, &QTcpSocket::disconnected,
|
||||
this, &DeviceTcpServer::onClientDisconnected);
|
||||
connect(socket, &QTcpSocket::readyRead,
|
||||
this, &DeviceTcpServer::onReadyRead);
|
||||
|
||||
qDebug() << "[DeviceTcpServer] New TCP connection from"
|
||||
<< socket->peerAddress().toString() << ":" << socket->peerPort();
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceTcpServer::onClientDisconnected()
|
||||
{
|
||||
auto *socket = qobject_cast<QTcpSocket*>(sender());
|
||||
if (socket) {
|
||||
removeSession(socket);
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceTcpServer::onReadyRead()
|
||||
{
|
||||
auto *socket = qobject_cast<QTcpSocket*>(sender());
|
||||
if (!socket) return;
|
||||
|
||||
DeviceSession *session = m_socketSessions.value(socket);
|
||||
if (!session) return;
|
||||
|
||||
session->buffer.append(socket->readAll());
|
||||
parseIncomingData(session);
|
||||
}
|
||||
|
||||
void DeviceTcpServer::parseIncomingData(DeviceSession *session)
|
||||
{
|
||||
// TCP: messages are newline-delimited JSON
|
||||
int newlinePos;
|
||||
while ((newlinePos = session->buffer.indexOf('\n')) >= 0) {
|
||||
QByteArray line = session->buffer.left(newlinePos).trimmed();
|
||||
session->buffer.remove(0, newlinePos + 1);
|
||||
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
QJsonParseError err;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(line, &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
qWarning() << "[DeviceTcpServer] Invalid JSON from device:" << err.errorString();
|
||||
continue;
|
||||
}
|
||||
|
||||
QJsonObject msg = doc.object();
|
||||
QString type = msg.value("type").toString();
|
||||
QString deviceId = msg.value("device_id").toString();
|
||||
QJsonObject payload = msg.value("payload").toObject();
|
||||
|
||||
// If this is a registration message, process it
|
||||
if (type == "register" || !session->deviceId.isEmpty()) {
|
||||
if (session->deviceId.isEmpty() && type == "register") {
|
||||
session->deviceId = deviceId;
|
||||
session->deviceName = payload.value("device").toObject().value("name").toString(deviceId);
|
||||
|
||||
m_deviceSessions.insert(session->deviceId, session);
|
||||
qDebug() << "[DeviceTcpServer] Device registered:"
|
||||
<< session->deviceId << "(" << session->deviceName << ")";
|
||||
|
||||
// Send ack
|
||||
QJsonObject ack;
|
||||
ack["status"] = "ok";
|
||||
ack["device_id"] = session->deviceId;
|
||||
sendToDevice(session->deviceId, "register_ack", ack);
|
||||
|
||||
emit deviceConnected(session->deviceId, session->deviceName);
|
||||
}
|
||||
|
||||
if (!session->deviceId.isEmpty()) {
|
||||
emit jsonReceived(session->deviceId, type, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceTcpServer::removeSession(QTcpSocket *socket)
|
||||
{
|
||||
DeviceSession *session = m_socketSessions.take(socket);
|
||||
if (!session) return;
|
||||
|
||||
QString deviceId = session->deviceId;
|
||||
if (!deviceId.isEmpty()) {
|
||||
m_deviceSessions.remove(deviceId);
|
||||
emit deviceDisconnected(deviceId);
|
||||
qDebug() << "[DeviceTcpServer] Device disconnected:" << deviceId;
|
||||
}
|
||||
|
||||
delete session;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// Created by misaki on 2026/4/25.
|
||||
//
|
||||
|
||||
#include "DeviceWebSocketServer.h"
|
||||
#include <QDebug>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QDateTime>
|
||||
|
||||
DeviceWebSocketServer::DeviceWebSocketServer(quint16 port, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_server(nullptr)
|
||||
, m_port(port)
|
||||
{
|
||||
}
|
||||
|
||||
DeviceWebSocketServer::~DeviceWebSocketServer()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
bool DeviceWebSocketServer::start()
|
||||
{
|
||||
if (m_server && m_server->isListening()) {
|
||||
qDebug() << "[DeviceWsServer] Already listening on port" << m_port;
|
||||
return true;
|
||||
}
|
||||
|
||||
m_server = new QWebSocketServer("Yosuga-Device-WS", QWebSocketServer::NonSecureMode, this);
|
||||
if (!m_server->listen(QHostAddress::Any, m_port)) {
|
||||
QString err = QString("Failed to listen on WS port %1: %2")
|
||||
.arg(m_port).arg(m_server->errorString());
|
||||
qWarning() << "[DeviceWsServer]" << err;
|
||||
emit serverError(err);
|
||||
m_server->deleteLater();
|
||||
m_server = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
connect(m_server, &QWebSocketServer::newConnection,
|
||||
this, &DeviceWebSocketServer::onNewConnection);
|
||||
|
||||
qDebug() << "[DeviceWsServer] Listening for embedded devices on WS port" << m_port;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::stop()
|
||||
{
|
||||
for (auto it = m_socketSessions.begin(); it != m_socketSessions.end(); ++it) {
|
||||
DeviceSession *session = it.value();
|
||||
if (session->socket->state() == QAbstractSocket::ConnectedState) {
|
||||
session->socket->close();
|
||||
}
|
||||
delete session;
|
||||
}
|
||||
m_deviceSessions.clear();
|
||||
m_socketSessions.clear();
|
||||
|
||||
if (m_server) {
|
||||
m_server->close();
|
||||
m_server->deleteLater();
|
||||
m_server = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::sendToDevice(const QString &deviceId, const QString &type, const QJsonObject &data)
|
||||
{
|
||||
DeviceSession *session = m_deviceSessions.value(deviceId);
|
||||
if (!session || !session->socket) {
|
||||
qWarning() << "[DeviceWsServer] Cannot send to unknown device:" << deviceId;
|
||||
return;
|
||||
}
|
||||
QJsonObject msg;
|
||||
msg["type"] = type;
|
||||
msg["data"] = data;
|
||||
msg["timestamp"] = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
QByteArray payload = QJsonDocument(msg).toJson(QJsonDocument::Compact);
|
||||
session->socket->sendTextMessage(QString::fromUtf8(payload));
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::onNewConnection()
|
||||
{
|
||||
while (m_server->hasPendingConnections()) {
|
||||
QWebSocket *socket = m_server->nextPendingConnection();
|
||||
if (!socket) continue;
|
||||
|
||||
auto *session = new DeviceSession{};
|
||||
session->socket = socket;
|
||||
m_socketSessions.insert(socket, session);
|
||||
|
||||
connect(socket, &QWebSocket::disconnected,
|
||||
this, &DeviceWebSocketServer::onClientDisconnected);
|
||||
connect(socket, &QWebSocket::textMessageReceived,
|
||||
this, &DeviceWebSocketServer::onTextMessageReceived);
|
||||
|
||||
qDebug() << "[DeviceWsServer] New WS connection from"
|
||||
<< socket->peerAddress().toString() << ":" << socket->peerPort();
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::onClientDisconnected()
|
||||
{
|
||||
auto *socket = qobject_cast<QWebSocket*>(sender());
|
||||
if (socket) {
|
||||
removeSession(socket);
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::onTextMessageReceived(const QString &message)
|
||||
{
|
||||
auto *socket = qobject_cast<QWebSocket*>(sender());
|
||||
if (!socket) return;
|
||||
|
||||
DeviceSession *session = m_socketSessions.value(socket);
|
||||
if (!session) return;
|
||||
|
||||
QJsonParseError err;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
qWarning() << "[DeviceWsServer] Invalid JSON:" << err.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonObject msg = doc.object();
|
||||
QString type = msg.value("type").toString();
|
||||
QString deviceId = msg.value("device_id").toString();
|
||||
QJsonObject payload = msg.value("payload").toObject();
|
||||
|
||||
if (type == "register") {
|
||||
session->deviceId = deviceId;
|
||||
session->deviceName = payload.value("device").toObject().value("name").toString(deviceId);
|
||||
|
||||
m_deviceSessions.insert(session->deviceId, session);
|
||||
qDebug() << "[DeviceWsServer] Device registered:" << session->deviceId;
|
||||
|
||||
QJsonObject ack;
|
||||
ack["status"] = "ok";
|
||||
ack["device_id"] = session->deviceId;
|
||||
sendToDevice(session->deviceId, "register_ack", ack);
|
||||
|
||||
emit deviceConnected(session->deviceId, session->deviceName);
|
||||
}
|
||||
|
||||
if (!session->deviceId.isEmpty()) {
|
||||
emit jsonReceived(session->deviceId, type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceWebSocketServer::removeSession(QWebSocket *socket)
|
||||
{
|
||||
DeviceSession *session = m_socketSessions.take(socket);
|
||||
if (!session) return;
|
||||
|
||||
QString deviceId = session->deviceId;
|
||||
if (!deviceId.isEmpty()) {
|
||||
m_deviceSessions.remove(deviceId);
|
||||
emit deviceDisconnected(deviceId);
|
||||
qDebug() << "[DeviceWsServer] Device disconnected:" << deviceId;
|
||||
}
|
||||
|
||||
delete session;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/19.
|
||||
//
|
||||
|
||||
#include "networkmanager.h"
|
||||
#include <QHttpMultiPart>
|
||||
#include <QHttpPart>
|
||||
#include <QFileInfo>
|
||||
#include <QThread>
|
||||
|
||||
NetWorkManager::NetWorkManager(QObject *parent) : QObject(parent)
|
||||
{
|
||||
this->manager = new QNetworkAccessManager(this);
|
||||
this->file = nullptr;
|
||||
this->reply = nullptr;
|
||||
this->timer = new QTimer(this);
|
||||
this->timeout = 30000; // 默认超时时间为 30 秒
|
||||
|
||||
// 连接请求完成信号
|
||||
connect(this->manager, &QNetworkAccessManager::finished, this, &NetWorkManager::onReplyFinished);
|
||||
|
||||
// 连接超时信号
|
||||
connect(this->timer, &QTimer::timeout, this, &NetWorkManager::onTimeout);
|
||||
}
|
||||
|
||||
NetWorkManager::~NetWorkManager()
|
||||
{
|
||||
if (this->reply) {
|
||||
this->reply->abort();
|
||||
this->reply->deleteLater();
|
||||
}
|
||||
if (this->file) {
|
||||
this->file->close();
|
||||
delete this->file;
|
||||
}
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
void NetWorkManager::get(const QString &url)
|
||||
{
|
||||
QNetworkRequest request;
|
||||
request.setUrl(QUrl(url));
|
||||
|
||||
// 设置请求头
|
||||
for (auto it = headers.begin(); it != headers.end(); ++it) {
|
||||
request.setRawHeader(it.key().toUtf8(), it.value().toUtf8());
|
||||
}
|
||||
|
||||
this->reply = manager->get(request);
|
||||
|
||||
// 启动超时计时器
|
||||
this->timer->start(timeout);
|
||||
|
||||
// 连接下载进度信号
|
||||
connect(this->reply, &QNetworkReply::downloadProgress, this, &NetWorkManager::onDownloadProgress);
|
||||
}
|
||||
|
||||
// POST 请求(JSON 数据)
|
||||
void NetWorkManager::post(const QString &url, const QJsonObject &json)
|
||||
{
|
||||
QNetworkRequest request;
|
||||
request.setUrl(QUrl(url));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
|
||||
// 设置请求头
|
||||
for (auto it = headers.begin(); it != headers.end(); ++it) {
|
||||
request.setRawHeader(it.key().toUtf8(), it.value().toUtf8());
|
||||
}
|
||||
|
||||
QByteArray data = QJsonDocument(json).toJson();
|
||||
this->reply = manager->post(request, data);
|
||||
|
||||
// 启动超时计时器
|
||||
this->timer->start(timeout);
|
||||
|
||||
// 连接上传进度信号
|
||||
connect(this->reply, &QNetworkReply::uploadProgress, this, &NetWorkManager::onUploadProgress);
|
||||
}
|
||||
|
||||
// 文件下载
|
||||
void NetWorkManager::downloadFile(const QString &url, const QString &savePath)
|
||||
{
|
||||
QNetworkRequest request;
|
||||
request.setUrl(QUrl(url));
|
||||
|
||||
// 设置请求头
|
||||
for (auto it = headers.begin(); it != headers.end(); ++it) {
|
||||
request.setRawHeader(it.key().toUtf8(), it.value().toUtf8());
|
||||
}
|
||||
|
||||
this->reply = manager->get(request);
|
||||
|
||||
// 启动超时计时器
|
||||
this->timer->start(timeout);
|
||||
|
||||
// 打开文件
|
||||
this->file = new QFile(savePath);
|
||||
if (!this->file->open(QIODevice::WriteOnly)) {
|
||||
emit errorOccurred("Failed to open file for writing");
|
||||
delete this->file;
|
||||
this->file = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接下载进度信号
|
||||
connect(this->reply, &QNetworkReply::downloadProgress, this, &NetWorkManager::onDownloadProgress);
|
||||
|
||||
// 读取数据并写入文件
|
||||
connect(reply, &QNetworkReply::readyRead, this, [this]() {
|
||||
if (this->file) {
|
||||
this->file->write(reply->readAll());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
void NetWorkManager::uploadFile(const QString &url, const QString &filePath)
|
||||
{
|
||||
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
|
||||
// 创建文件部分
|
||||
QHttpPart filePart;
|
||||
filePart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"file\"; filename=\"" + QFileInfo(filePath).fileName() + "\""));
|
||||
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("application/octet-stream"));
|
||||
|
||||
QFile *file = new QFile(filePath);
|
||||
if (!file->open(QIODevice::ReadOnly)) {
|
||||
emit errorOccurred("Failed to open file for reading");
|
||||
delete file;
|
||||
return;
|
||||
}
|
||||
|
||||
filePart.setBodyDevice(file);
|
||||
file->setParent(multiPart); // 将文件对象绑定到 multiPart,由 multiPart 负责释放
|
||||
|
||||
multiPart->append(filePart);
|
||||
|
||||
// 发送请求
|
||||
QNetworkRequest request;
|
||||
request.setUrl(QUrl(url));
|
||||
|
||||
// 设置请求头
|
||||
for (auto it = headers.begin(); it != headers.end(); ++it) {
|
||||
request.setRawHeader(it.key().toUtf8(), it.value().toUtf8());
|
||||
}
|
||||
|
||||
this->reply = manager->post(request, multiPart);
|
||||
multiPart->setParent(reply); // 将 multiPart 绑定到 reply,由 reply 负责释放
|
||||
|
||||
// 启动超时计时器
|
||||
this->timer->start(timeout);
|
||||
|
||||
// 连接上传进度信号
|
||||
connect(reply, &QNetworkReply::uploadProgress, this, &NetWorkManager::onUploadProgress);
|
||||
}
|
||||
|
||||
// 设置超时时间
|
||||
void NetWorkManager::setTimeout(int timeout)
|
||||
{
|
||||
this->timeout = timeout;
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
void NetWorkManager::setHeader(const QString &key, const QString &value)
|
||||
{
|
||||
this->headers[key] = value;
|
||||
}
|
||||
|
||||
// 清除请求头
|
||||
void NetWorkManager::clearHeaders()
|
||||
{
|
||||
this->headers.clear();
|
||||
}
|
||||
|
||||
// 请求完成槽函数
|
||||
void NetWorkManager::onReplyFinished(QNetworkReply *reply)
|
||||
{
|
||||
this->timer->stop(); // 停止超时计时器
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
QByteArray response = reply->readAll();
|
||||
emit requestFinished(response);
|
||||
} else {
|
||||
emit errorOccurred(reply->errorString());
|
||||
}
|
||||
|
||||
// 关闭并释放文件对象
|
||||
if (file) {
|
||||
file->close();
|
||||
delete file;
|
||||
file = nullptr;
|
||||
}
|
||||
|
||||
reply->deleteLater();
|
||||
}
|
||||
|
||||
// 下载进度槽函数
|
||||
void NetWorkManager::onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal)
|
||||
{
|
||||
emit downloadProgress(bytesReceived, bytesTotal);
|
||||
}
|
||||
|
||||
// 上传进度槽函数
|
||||
void NetWorkManager::onUploadProgress(qint64 bytesSent, qint64 bytesTotal)
|
||||
{
|
||||
emit uploadProgress(bytesSent, bytesTotal);
|
||||
}
|
||||
|
||||
// 超时槽函数
|
||||
void NetWorkManager::onTimeout()
|
||||
{
|
||||
if (reply) {
|
||||
this->reply->abort();
|
||||
emit timeoutOccurred();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/26.
|
||||
//
|
||||
|
||||
#include "serialportmanager.h"
|
||||
#include <QDebug>
|
||||
#include <QMutexLocker>
|
||||
#include <QJsonDocument>
|
||||
#include <utility>
|
||||
#include "cobs.hpp"
|
||||
|
||||
/// SerialPortManager
|
||||
SerialPortManager::SerialPortManager(QString deviceName, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_serial(new QSerialPort(this))
|
||||
, m_deviceName(std::move(deviceName))
|
||||
, m_heartbeatTimer(new QTimer(this))
|
||||
, m_reconnectTimer(new QTimer(this))
|
||||
, m_isAutoReconnect(false)
|
||||
, m_reconnectAttempts(0)
|
||||
, m_cobsBuffer()
|
||||
, m_cobsInFrame(false)
|
||||
{
|
||||
// 心跳定时器配置(默认 5 秒)
|
||||
m_heartbeatTimer->setInterval(5000);
|
||||
connect(m_heartbeatTimer, &QTimer::timeout,
|
||||
this, &SerialPortManager::sendHeartbeat);
|
||||
|
||||
// 重连定时器(单次触发)
|
||||
m_reconnectTimer->setSingleShot(true);
|
||||
connect(m_reconnectTimer, &QTimer::timeout,
|
||||
this, &SerialPortManager::tryReconnect);
|
||||
|
||||
// 串口信号连接
|
||||
connect(m_serial, &QSerialPort::readyRead,
|
||||
this, &SerialPortManager::onReadyRead);
|
||||
connect(m_serial, QOverload<QSerialPort::SerialPortError>::of(&QSerialPort::errorOccurred),
|
||||
this, &SerialPortManager::onErrorOccurred);
|
||||
|
||||
emit log(QString("[%1] SerialPortManager initialized in worker thread").arg(m_deviceName));
|
||||
}
|
||||
|
||||
SerialPortManager::~SerialPortManager() {
|
||||
if (m_serial->isOpen()) {
|
||||
m_serial->close();
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialPortManager::setConfig(const SerialPortConfig &config) {
|
||||
if (m_serial->isOpen()) {
|
||||
emit error(QString("[%1] Cannot change config while port is open. Close it first.").arg(m_deviceName));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.portName.isEmpty()) {
|
||||
emit error(QString("[%1] Port name cannot be empty!").arg(m_deviceName));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_config = config;
|
||||
emit log(QString("[%1] Config updated: %2 @ %3 bps, JSON max: %4 bytes")
|
||||
.arg(m_deviceName, config.portName).arg(config.baudRate).arg(config.maxJsonSize));
|
||||
return true;
|
||||
}
|
||||
|
||||
SerialPortManager::SerialPortConfig SerialPortManager::currentConfig() const {
|
||||
return m_config;
|
||||
}
|
||||
|
||||
bool SerialPortManager::open() {
|
||||
if (m_serial->isOpen()) {
|
||||
emit log(QString("[%1] Port already opened, closing first...").arg(m_deviceName));
|
||||
m_serial->close();
|
||||
}
|
||||
// 配置串口参数
|
||||
m_serial->setPortName(m_config.portName);
|
||||
m_serial->setBaudRate(m_config.baudRate);
|
||||
m_serial->setDataBits(m_config.dataBits);
|
||||
m_serial->setParity(m_config.parity);
|
||||
m_serial->setStopBits(m_config.stopBits);
|
||||
m_serial->setFlowControl(m_config.flowControl);
|
||||
|
||||
emit log(QString("[%1] Opening %2...").arg(m_deviceName, m_config.portName));
|
||||
|
||||
if (!m_serial->open(QIODevice::ReadWrite)) {
|
||||
QString errMsg = QString("[%1] Failed to open %2: %3")
|
||||
.arg(m_deviceName, m_config.portName, m_serial->errorString());
|
||||
emit error(errMsg);
|
||||
if (m_isAutoReconnect) {
|
||||
m_reconnectTimer->start(3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
m_reconnectAttempts = 0;
|
||||
m_cobsBuffer.clear(); // 清空 COBS 缓冲区
|
||||
m_cobsInFrame = false; // 重置帧状态
|
||||
emit opened();
|
||||
emit log(QString("[%1] Serial port opened successfully").arg(m_deviceName));
|
||||
|
||||
// 启动心跳(如果有配置心跳包)
|
||||
if (!m_config.heartbeatData.isEmpty()) {
|
||||
m_heartbeatTimer->start();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialPortManager::close() {
|
||||
m_isAutoReconnect = false;
|
||||
m_heartbeatTimer->stop();
|
||||
m_reconnectTimer->stop();
|
||||
if (m_serial->isOpen()) {
|
||||
m_serial->close();
|
||||
m_cobsBuffer.clear(); // 清空 COBS 缓冲区
|
||||
m_cobsInFrame = false; // 重置帧状态
|
||||
emit closed();
|
||||
emit log(QString("[%1] Serial port closed").arg(m_deviceName));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::sendJson(const QString &type, const QJsonObject &data) {
|
||||
if (!m_serial->isOpen()) {
|
||||
emit error(QString("[%1] Cannot send JSON: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
|
||||
// 封装成 { "type": "...", "data": {...}, "timestamp": 123456 }
|
||||
QJsonObject wrapper;
|
||||
wrapper["type"] = type;
|
||||
wrapper["data"] = data;
|
||||
wrapper["timestamp"] = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
QJsonDocument doc(wrapper);
|
||||
QByteArray jsonBytes = doc.toJson(QJsonDocument::Compact);
|
||||
// COBS 编码
|
||||
std::vector<uint8_t> encoded;
|
||||
auto result = cobs::encode(encoded, std::span<const uint8_t>(
|
||||
reinterpret_cast<const uint8_t*>(jsonBytes.constData()), jsonBytes.size()
|
||||
));
|
||||
if (result.status != cobs::Status::OK) { // 编码失败
|
||||
emit error(QString("[%1] COBS encode failed: status=%2").arg(m_deviceName).arg(static_cast<int>(result.status)));
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送编码数据 + 0x00
|
||||
const qint64 written = m_serial->write(reinterpret_cast<const char*>(encoded.data()), static_cast<qint64>(encoded.size()));
|
||||
if (written == -1) { // 写入失败
|
||||
emit error(QString("[%1] Write error: %2").arg(m_deviceName, m_serial->errorString()));
|
||||
} else { // 发送成功
|
||||
m_serial->flush();
|
||||
m_serial->write("\0", 1); // 帧结束符
|
||||
emit log(QString("[%1] Sent JSON: type=%2, %3 bytes encoded").arg(m_deviceName, type).arg(encoded.size() + 1));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::sendText(const QString &text) {
|
||||
if (!m_serial->isOpen()) {
|
||||
emit error(QString("[%1] Cannot send text: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray data = text.toUtf8();
|
||||
qint64 written = m_serial->write(data);
|
||||
if (written == -1) {
|
||||
emit error(QString("[%1] Write error: %2").arg(m_deviceName, m_serial->errorString()));
|
||||
} else if (written != data.size()) {
|
||||
emit error(QString("[%1] Incomplete write: %2/%3 bytes sent").arg(m_deviceName).arg(written).arg(data.size()));
|
||||
} else {
|
||||
m_serial->flush();
|
||||
emit log(QString("[%1] Sent text: %2 bytes").arg(m_deviceName).arg(written));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::sendHex(const QString &hex) {
|
||||
if (!m_serial->isOpen()) {
|
||||
emit error(QString("[%1] Cannot send hex: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析十六进制字符串: "AA BB 1A" → QByteArray
|
||||
QString cleaned = hex.simplified().remove(' ');
|
||||
QByteArray data = QByteArray::fromHex(cleaned.toUtf8());
|
||||
|
||||
if (data.isEmpty() && !cleaned.isEmpty()) {
|
||||
emit error(QString("[%1] Invalid hex format. Use: 'AA BB CC' or 'AABBCC'").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
|
||||
qint64 written = m_serial->write(data);
|
||||
if (written == -1) {
|
||||
emit error(QString("[%1] Write error: %2").arg(m_deviceName, m_serial->errorString()));
|
||||
} else {
|
||||
m_serial->flush();
|
||||
emit log(QString("[%1] Sent hex: %2").arg(m_deviceName, cleaned.left(50)));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::sendRaw(const QByteArray &data) {
|
||||
if (!m_serial->isOpen()) {
|
||||
emit error(QString("[%1] Cannot send raw: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
|
||||
qint64 written = m_serial->write(data);
|
||||
if (written == -1) {
|
||||
emit error(QString("[%1] Write error: %2").arg(m_deviceName, m_serial->errorString()));
|
||||
} else {
|
||||
m_serial->flush();
|
||||
emit log(QString("[%1] Sent raw: %2 bytes").arg(m_deviceName).arg(written));
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialPortManager::isOpen() const {
|
||||
return m_serial->isOpen();
|
||||
}
|
||||
|
||||
void SerialPortManager::setAutoReconnect(bool enabled) {
|
||||
m_isAutoReconnect = enabled;
|
||||
if (!enabled) {
|
||||
m_reconnectTimer->stop();
|
||||
}
|
||||
emit log(QString("[%1] Auto reconnect %2").arg(m_deviceName, enabled ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
void SerialPortManager::setHeartbeatInterval(int msecs) {
|
||||
m_heartbeatTimer->setInterval(msecs);
|
||||
emit log(QString("[%1] Heartbeat interval: %2 ms").arg(m_deviceName).arg(msecs));
|
||||
}
|
||||
|
||||
void SerialPortManager::onReadyRead() {
|
||||
// 读取所有可用数据到 COBS 缓冲区
|
||||
QByteArray chunk = m_serial->readAll(); // 读取原始字节流
|
||||
m_cobsBuffer.append(chunk); // 追加到cobs缓冲区
|
||||
|
||||
// 触发原始数据信号
|
||||
emit dataReceived(chunk);
|
||||
emit textReceived(QString::fromUtf8(chunk)); // 尝试 UTF-8 解码
|
||||
emit hexReceived(chunk.toHex(' ').toUpper()); // 十六进制表示
|
||||
|
||||
// 处理 COBS 帧
|
||||
processCOBSBuffer();
|
||||
}
|
||||
|
||||
void SerialPortManager::processCOBSBuffer() {
|
||||
while (true) {
|
||||
// 查找帧结束符 0x00
|
||||
int zeroPos = m_cobsBuffer.indexOf('\0');
|
||||
// 未找到完整帧,继续等待
|
||||
if (zeroPos == -1) {
|
||||
m_cobsInFrame = true;
|
||||
// 防溢出
|
||||
if (m_cobsBuffer.size() > m_config.maxJsonSize * 2) {
|
||||
emit error(QString("[%1] COBS buffer overflow, clearing").arg(m_deviceName));
|
||||
m_cobsBuffer.clear();
|
||||
m_cobsInFrame = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 提取编码帧(不含 0x00)
|
||||
QByteArray encodedFrame = m_cobsBuffer.left(zeroPos);
|
||||
m_cobsBuffer.remove(0, zeroPos + 1); // 移除结束符
|
||||
m_cobsInFrame = false;
|
||||
// 跳过空帧
|
||||
if (encodedFrame.isEmpty()) {
|
||||
emit log(QString("[%1] Empty COBS frame, skipped").arg(m_deviceName));
|
||||
continue;
|
||||
}
|
||||
// COBS 解码
|
||||
std::vector<uint8_t> decoded;
|
||||
auto result = cobs::decode(decoded, std::span<const uint8_t>(
|
||||
reinterpret_cast<const uint8_t*>(encodedFrame.constData()), encodedFrame.size()
|
||||
));
|
||||
|
||||
if (result.status != cobs::Status::OK) { // 解码失败
|
||||
emit error(QString("[%1] COBS decode failed: status=%2").arg(m_deviceName).arg(static_cast<int>(result.status)));
|
||||
continue;
|
||||
}
|
||||
// 解析 JSON
|
||||
QByteArray jsonData(reinterpret_cast<const char*>(decoded.data()), static_cast<qint64>(decoded.size()));
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonData, &parseError);
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
emit error(QString("[%1] JSON parse error: %2").arg(m_deviceName, parseError.errorString()));
|
||||
continue;
|
||||
}
|
||||
if (!doc.isObject()) {
|
||||
emit error(QString("[%1] JSON is not an object").arg(m_deviceName));
|
||||
continue;
|
||||
}
|
||||
|
||||
QJsonObject obj = doc.object();
|
||||
const QString type = obj.value("type").toString();
|
||||
const QJsonObject data = obj.value("data").toObject();
|
||||
|
||||
if (type.isEmpty()) {
|
||||
emit error(QString("[%1] JSON missing 'type' field").arg(m_deviceName));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 成功,向上层交付
|
||||
emit jsonReceived(type, data);
|
||||
emit log(QString("[%1] JSON delivered: type=%2, size=%3").arg(m_deviceName, type).arg(jsonData.size()));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::onErrorOccurred(QSerialPort::SerialPortError error) {
|
||||
if (error == QSerialPort::NoError) return;
|
||||
|
||||
QString errorMsg;
|
||||
switch (error) {
|
||||
case QSerialPort::DeviceNotFoundError:
|
||||
errorMsg = "Device not found";
|
||||
break;
|
||||
case QSerialPort::PermissionError:
|
||||
errorMsg = "Permission denied. Check udev rules or run with sudo";
|
||||
break;
|
||||
case QSerialPort::OpenError:
|
||||
errorMsg = "Already opened or system error";
|
||||
break;
|
||||
case QSerialPort::WriteError:
|
||||
errorMsg = "Write error";
|
||||
break;
|
||||
case QSerialPort::ReadError:
|
||||
errorMsg = "Read error";
|
||||
break;
|
||||
case QSerialPort::ResourceError:
|
||||
errorMsg = "Resource error: device removed or I/O error";
|
||||
// 设备被拔插,触发重连
|
||||
if (m_isAutoReconnect) {
|
||||
m_reconnectTimer->start(2000);
|
||||
}
|
||||
break;
|
||||
case QSerialPort::UnsupportedOperationError:
|
||||
errorMsg = "Unsupported operation";
|
||||
break;
|
||||
case QSerialPort::TimeoutError:
|
||||
errorMsg = "Operation timed out";
|
||||
break;
|
||||
case QSerialPort::NotOpenError:
|
||||
errorMsg = "Device not open";
|
||||
break;
|
||||
default:
|
||||
errorMsg = m_serial->errorString();
|
||||
}
|
||||
|
||||
emit this->error(QString("[%1] Serial error: %2").arg(m_deviceName, errorMsg));
|
||||
}
|
||||
|
||||
void SerialPortManager::sendHeartbeat() {
|
||||
if (!m_serial->isOpen() || m_config.heartbeatData.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
qint64 written = m_serial->write(m_config.heartbeatData);
|
||||
if (written == -1) {
|
||||
emit error(QString("[%1] Heartbeat write failed: %2").arg(m_deviceName, m_serial->errorString()));
|
||||
} else {
|
||||
emit log(QString("[%1] Heartbeat sent").arg(m_deviceName));
|
||||
}
|
||||
}
|
||||
|
||||
void SerialPortManager::tryReconnect() {
|
||||
if (!m_isAutoReconnect) return;
|
||||
|
||||
m_reconnectAttempts++;
|
||||
emit reconnecting(m_reconnectAttempts);
|
||||
emit log(QString("[%1] Reconnecting... (attempt %2)").arg(m_deviceName).arg(m_reconnectAttempts));
|
||||
|
||||
// 直接调用 open()
|
||||
open();
|
||||
}
|
||||
|
||||
/// SerialPortClient
|
||||
SerialPortClient::SerialPortClient(const QString &deviceName, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_deviceName(deviceName)
|
||||
, m_workerThread(new QThread(this))
|
||||
, m_serialManager(new SerialPortManager(deviceName))
|
||||
, m_config()
|
||||
{
|
||||
// 命名线程,方便调试
|
||||
m_workerThread->setObjectName(QString("SerialPortThread_%1").arg(deviceName));
|
||||
|
||||
// 将 Manager 移到工作线程
|
||||
m_serialManager->moveToThread(m_workerThread);
|
||||
|
||||
// 线程结束时清理 Manager
|
||||
connect(m_workerThread, &QThread::finished,
|
||||
m_serialManager, &QObject::deleteLater);
|
||||
|
||||
// 信号转发:Manager → Client(主线程)
|
||||
connect(m_serialManager, &SerialPortManager::opened,
|
||||
this, &SerialPortClient::opened);
|
||||
connect(m_serialManager, &SerialPortManager::closed,
|
||||
this, &SerialPortClient::closed);
|
||||
connect(m_serialManager, &SerialPortManager::dataReceived,
|
||||
this, &SerialPortClient::dataReceived);
|
||||
connect(m_serialManager, &SerialPortManager::textReceived,
|
||||
this, &SerialPortClient::textReceived);
|
||||
connect(m_serialManager, &SerialPortManager::hexReceived,
|
||||
this, &SerialPortClient::hexReceived);
|
||||
connect(m_serialManager, &SerialPortManager::jsonReceived,
|
||||
this, &SerialPortClient::jsonReceived);
|
||||
connect(m_serialManager, &SerialPortManager::error,
|
||||
this, &SerialPortClient::error);
|
||||
connect(m_serialManager, &SerialPortManager::log,
|
||||
this, &SerialPortClient::log);
|
||||
connect(m_serialManager, &SerialPortManager::reconnecting,
|
||||
this, &SerialPortClient::reconnecting);
|
||||
|
||||
// 内部信号:Client → Manager(跨线程调用)
|
||||
connect(this, &SerialPortClient::internalSetConfig,
|
||||
m_serialManager, [this](const SerialPortManager::SerialPortConfig& cfg) {
|
||||
bool ok = m_serialManager->setConfig(cfg);
|
||||
if (!ok) emit error(QString("[%1] Failed to set config").arg(m_deviceName));
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalOpen,
|
||||
m_serialManager, &SerialPortManager::open,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalClose,
|
||||
m_serialManager, &SerialPortManager::close,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSendJson,
|
||||
m_serialManager, &SerialPortManager::sendJson,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSendText,
|
||||
m_serialManager, &SerialPortManager::sendText,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSendHex,
|
||||
m_serialManager, &SerialPortManager::sendHex,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSendRaw,
|
||||
m_serialManager, &SerialPortManager::sendRaw,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSetAutoReconnect,
|
||||
m_serialManager, &SerialPortManager::setAutoReconnect,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &SerialPortClient::internalSetHeartbeatInterval,
|
||||
m_serialManager, &SerialPortManager::setHeartbeatInterval,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
// 启动工作线程
|
||||
m_workerThread->start();
|
||||
|
||||
emit log(QString("[%1] SerialPortClient initialized, worker thread started").arg(m_deviceName));
|
||||
}
|
||||
|
||||
SerialPortClient::~SerialPortClient() {
|
||||
emit log(QString("[%1] Shutting down SerialPortClient...").arg(m_deviceName));
|
||||
|
||||
// 关闭串口
|
||||
close();
|
||||
|
||||
// 优雅退出工作线程
|
||||
if (m_workerThread && m_workerThread->isRunning()) {
|
||||
m_workerThread->quit();
|
||||
if (!m_workerThread->wait(3000)) {
|
||||
m_workerThread->terminate();
|
||||
m_workerThread->wait();
|
||||
}
|
||||
delete m_workerThread;
|
||||
m_workerThread = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialPortClient::setConfiguration(const SerialPortManager::SerialPortConfig &config) {
|
||||
if (config.portName.isEmpty()) {
|
||||
emit error(QString("[%1] Invalid config: port name empty").arg(m_deviceName));
|
||||
return false;
|
||||
}
|
||||
|
||||
auto oldConfig = m_config;
|
||||
m_config = config;
|
||||
|
||||
// 跨线程设置
|
||||
emit internalSetConfig(config);
|
||||
|
||||
// 通知配置变更
|
||||
if (oldConfig.portName != config.portName || oldConfig.baudRate != config.baudRate) {
|
||||
emit configurationChanged(oldConfig, config);
|
||||
}
|
||||
|
||||
emit log(QString("[%1] Configuration updated: %2 @ %3 bps")
|
||||
.arg(m_deviceName, config.portName).arg(config.baudRate));
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialPortClient::open() {
|
||||
if (!hasConfiguration()) {
|
||||
emit error(QString("[%1] Not configured. Call setConfiguration() first.").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit log(QString("[%1] Opening serial port: %2").arg(m_deviceName, m_config.portName));
|
||||
emit internalOpen();
|
||||
}
|
||||
|
||||
void SerialPortClient::close() {
|
||||
emit log(QString("[%1] Closing serial port...").arg(m_deviceName));
|
||||
emit internalClose();
|
||||
}
|
||||
|
||||
void SerialPortClient::reconnect() {
|
||||
if (!hasConfiguration()) {
|
||||
emit error(QString("[%1] No configuration. Cannot reconnect.").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit log(QString("[%1] Attempting to reconnect...").arg(m_deviceName));
|
||||
close();
|
||||
QTimer::singleShot(100, this, [this]() { open(); });
|
||||
}
|
||||
|
||||
void SerialPortClient::sendJson(const QString &type, const QJsonObject &data) {
|
||||
if (!isOpen()) {
|
||||
emit error(QString("[%1] Cannot send JSON: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit internalSendJson(type, data);
|
||||
}
|
||||
|
||||
void SerialPortClient::sendText(const QString &text) {
|
||||
if (!isOpen()) {
|
||||
emit error(QString("[%1] Cannot send text: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit internalSendText(text);
|
||||
}
|
||||
|
||||
void SerialPortClient::sendHex(const QString &hex) {
|
||||
if (!isOpen()) {
|
||||
emit error(QString("[%1] Cannot send hex: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit internalSendHex(hex);
|
||||
}
|
||||
|
||||
void SerialPortClient::sendRaw(const QByteArray &data) {
|
||||
if (!isOpen()) {
|
||||
emit error(QString("[%1] Cannot send raw: serial port not opened").arg(m_deviceName));
|
||||
return;
|
||||
}
|
||||
emit internalSendRaw(data);
|
||||
}
|
||||
|
||||
bool SerialPortClient::isOpen() const {
|
||||
if (m_serialManager) {
|
||||
bool opened = false;
|
||||
QMetaObject::invokeMethod(const_cast<SerialPortManager*>(m_serialManager),
|
||||
[&opened, mgr = m_serialManager]() {
|
||||
opened = mgr->isOpen();
|
||||
},
|
||||
Qt::BlockingQueuedConnection);
|
||||
return opened;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void SerialPortClient::setAutoReconnect(bool enabled) {
|
||||
emit log(QString("[%1] Auto reconnect %2").arg(m_deviceName, enabled ? "enabled" : "disabled"));
|
||||
emit internalSetAutoReconnect(enabled);
|
||||
}
|
||||
|
||||
void SerialPortClient::setHeartbeatInterval(int msecs) {
|
||||
emit log(QString("[%1] Heartbeat interval: %2 ms").arg(m_deviceName).arg(msecs));
|
||||
emit internalSetHeartbeatInterval(msecs);
|
||||
}
|
||||
|
||||
QStringList SerialPortClient::availablePorts() {
|
||||
QStringList ports;
|
||||
const auto portList = QSerialPortInfo::availablePorts();
|
||||
for (const QSerialPortInfo &info : portList) {
|
||||
QString desc = QString("%1 (%2)").arg(info.portName(), info.description());
|
||||
ports << desc;
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/5.
|
||||
//
|
||||
|
||||
#include "socketmanager.h"
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
|
||||
// 初始化静态成员变量
|
||||
SocketManager* SocketManager::m_instance = nullptr;
|
||||
QMutex SocketManager::m_mutex;
|
||||
|
||||
SocketManager* SocketManager::getInstance()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex); // 自动加锁,确保线程安全
|
||||
if (!m_instance) {
|
||||
m_instance = new SocketManager(); // 延迟初始化,首次调用时创建实例
|
||||
}
|
||||
return m_instance;
|
||||
}
|
||||
|
||||
SocketManager::SocketManager(QObject *parent) : QTcpSocket(parent)
|
||||
{
|
||||
connect(this, &QTcpSocket::connected, [=]() {
|
||||
qDebug() << "SocketManager::connected !";
|
||||
});
|
||||
|
||||
connect(this, &QTcpSocket::disconnected, [=]() {
|
||||
qDebug() << "SocketManager::disconnected !";
|
||||
});
|
||||
// 当有数据到达就调用handleReadyRead进行处理数据
|
||||
connect(this, &QTcpSocket::readyRead, this, &SocketManager::handleReadyRead);
|
||||
|
||||
}
|
||||
|
||||
SocketManager::~SocketManager()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void SocketManager::connectToServer()
|
||||
{
|
||||
// 如果未连接,就连接
|
||||
if(this->state() == QAbstractSocket::UnconnectedState){
|
||||
this->connectToHost(this->ip, this->port);
|
||||
return;
|
||||
}
|
||||
// 已连接则返回
|
||||
if(this->state() == QAbstractSocket::ConnectedState){
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SocketManager::disconnectFromServer()
|
||||
{
|
||||
this->disconnectFromHost();
|
||||
}
|
||||
|
||||
/**
|
||||
* 报文形式:先发送数据长度,再发送数据本身
|
||||
* 数据长度占4字节,大端
|
||||
* 数据本身压缩发送
|
||||
* @param filePath
|
||||
*/
|
||||
void SocketManager::sendWavFile(const QString &filePath)
|
||||
{
|
||||
if(this->state() == QAbstractSocket::ConnectedState){
|
||||
QFile file(filePath);
|
||||
if(file.open(QIODevice::ReadOnly)){
|
||||
// 压缩数据
|
||||
QByteArray compressedData = qCompress(file.readAll(), 9);
|
||||
// 发送数据长度(4字节,大端)
|
||||
quint32 totalSize = compressedData.size();
|
||||
QByteArray sizeData;
|
||||
QDataStream sizeStream(&sizeData, QIODevice::WriteOnly);
|
||||
sizeStream.setByteOrder(QDataStream::BigEndian);
|
||||
sizeStream << totalSize;
|
||||
this->write(sizeData);
|
||||
this->flush();
|
||||
|
||||
// 发送压缩数据
|
||||
this->write(compressedData);
|
||||
this->flush();
|
||||
file.close();
|
||||
}
|
||||
} else{
|
||||
qDebug() << "SocketManager::发送失败! 请检查是否连接到服务端!";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送二进制WAV数据
|
||||
* 数据长度占4字节,大端
|
||||
* 数据本身压缩发送
|
||||
* @param wavData
|
||||
*/
|
||||
void SocketManager::sendWavFile(const QByteArray &wavData)
|
||||
{
|
||||
if (this->state() == QAbstractSocket::ConnectedState) {
|
||||
if (!wavData.isEmpty()) {
|
||||
// 压缩数据(保持与文件发送相同的压缩方式)
|
||||
QByteArray compressedData = qCompress(wavData, 9);
|
||||
|
||||
// 发送数据长度(4字节大端)
|
||||
quint32 totalSize = compressedData.size();
|
||||
QByteArray sizeData;
|
||||
QDataStream sizeStream(&sizeData, QIODevice::WriteOnly);
|
||||
sizeStream.setByteOrder(QDataStream::BigEndian);
|
||||
sizeStream << totalSize;
|
||||
|
||||
// 分步发送确保可靠性
|
||||
this->write(sizeData);
|
||||
this->flush();
|
||||
|
||||
|
||||
this->write(compressedData);
|
||||
this->flush();
|
||||
qDebug() << "二进制WAV数据已发送,原始大小:" << wavData.size()
|
||||
<< "压缩后大小:" << compressedData.size();
|
||||
|
||||
} else {
|
||||
qWarning() << "尝试发送空的WAV数据";
|
||||
}
|
||||
} else {
|
||||
qDebug() << "SocketManager::发送失败! 未连接到服务端!";
|
||||
}
|
||||
}
|
||||
|
||||
void SocketManager::handleReadyRead()
|
||||
{
|
||||
receiveBuffer.append(this->readAll()); // 累积数据到缓冲区
|
||||
// 检查是否包含结束标记
|
||||
int endIndex;
|
||||
while ((endIndex = receiveBuffer.indexOf(endMarker)) != -1) {
|
||||
// 提取结束标记前的数据
|
||||
QString data = receiveBuffer.left(endIndex);
|
||||
receiveBuffer = receiveBuffer.mid(endIndex + strlen(endMarker)); // 移除已处理数据
|
||||
|
||||
// 处理数据
|
||||
// 使用动态文件名
|
||||
QFile rev(filePath + "revTest_" + QDateTime::currentDateTime().toString("yyyy-MM-dd_hh-mm-ss") +".wav");
|
||||
if (rev.open(QIODevice::WriteOnly)) {
|
||||
QJsonDocument jsonDoc = QJsonDocument::fromJson(data.toUtf8());
|
||||
// 提取JSON对象
|
||||
QJsonObject jsonObj = jsonDoc.object();
|
||||
QString response;
|
||||
// 解析response字段
|
||||
if (jsonObj.contains("response") && jsonObj["response"].isString()) {
|
||||
response = jsonObj["response"].toString();
|
||||
qDebug() << "解析到response:" << response;
|
||||
} else {
|
||||
qDebug() << "response字段缺失或类型错误";
|
||||
}
|
||||
float duration;
|
||||
if (jsonObj.contains("wav_duration") && jsonObj["wav_duration"].isDouble()) {
|
||||
duration = jsonObj["wav_duration"].toDouble();
|
||||
qDebug() << "解析到duration:" << duration;
|
||||
} else {
|
||||
qDebug() << "duration字段缺失或类型错误";
|
||||
}
|
||||
QByteArray wavData;
|
||||
// 解析wav_data_base64字段
|
||||
if (jsonObj.contains("wav_data_base64") && jsonObj["wav_data_base64"].isString()) {
|
||||
QString base64Data = jsonObj["wav_data_base64"].toString();
|
||||
wavData = QByteArray::fromBase64(base64Data.toUtf8());
|
||||
// 获取当前音频时长
|
||||
qDebug() << "音频数据大小:" << wavData.size() << "字节";
|
||||
} else {
|
||||
qDebug() << "wav_data_base64字段缺失或类型错误";
|
||||
}
|
||||
|
||||
rev.write(wavData);
|
||||
rev.close();
|
||||
// 清空缓冲区,这一步很重要,不然会导致下次接收数据时,会保存着上次接收的数据
|
||||
receiveBuffer.clear();
|
||||
// 发送信号
|
||||
emit revWavFileFinish(rev.fileName(), response, duration);
|
||||
emit revWavDataFinish(wavData);
|
||||
qDebug() << "文件接收完成并保存: " + rev.fileName();
|
||||
} else {
|
||||
qDebug() << "无法保存文件";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void SocketManager::setIp(const QString &ip)
|
||||
{
|
||||
this->ip = ip;
|
||||
}
|
||||
|
||||
QString SocketManager::getIp()
|
||||
{
|
||||
return this->ip;
|
||||
}
|
||||
|
||||
void SocketManager::setPort(qint16 port)
|
||||
{
|
||||
this->port = port;
|
||||
}
|
||||
|
||||
qint16 SocketManager::getPort()
|
||||
{
|
||||
return this->port;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/4.
|
||||
//
|
||||
|
||||
#include "websocketmanager.h"
|
||||
#include <QDebug>
|
||||
#include <QJsonObject>
|
||||
#include <utility>
|
||||
#include <QMutexLocker>
|
||||
|
||||
/// WebSocketManager
|
||||
WebSocketManager::WebSocketManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_socket(new QWebSocket) // 创建 WebSocket 对象
|
||||
, m_pingTimer(new QTimer(this))
|
||||
, m_reconnectTimer(new QTimer(this))
|
||||
, m_reconnectAttempts(0) // 重连尝试次数初始为0
|
||||
, m_isReconnectEnabled(false) // 默认不启用重连
|
||||
, m_isRequest(false) // 默认不启用自定义首次请求
|
||||
{
|
||||
// 配置 WebSocket
|
||||
m_socket->setParent(this); // 确保 socket 也在工作线程
|
||||
m_socket->setMaxAllowedIncomingFrameSize(50 * 1024 * 1024); // 单帧最大 50MB // 防止发送大数据包导致websocket断开
|
||||
m_socket->setMaxAllowedIncomingMessageSize(50 * 1024 * 1024); // 完整消息最大 50MB
|
||||
// 连接信号
|
||||
connect(m_socket, &QWebSocket::connected,
|
||||
this, &WebSocketManager::onConnected);
|
||||
connect(m_socket, &QWebSocket::disconnected,
|
||||
this, &WebSocketManager::onDisconnected);
|
||||
connect(m_socket, &QWebSocket::textMessageReceived,
|
||||
this, &WebSocketManager::onTextMessageReceived);
|
||||
connect(m_socket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::errorOccurred),
|
||||
this, &WebSocketManager::onError);
|
||||
connect(m_socket, &QWebSocket::sslErrors,
|
||||
this, &WebSocketManager::onSslErrors);
|
||||
connect(m_socket, &QWebSocket::pong,
|
||||
this, &WebSocketManager::onPong);
|
||||
// 心跳定时器
|
||||
m_pingTimer->setInterval(30000); // 30秒
|
||||
connect(m_pingTimer, &QTimer::timeout, this, &WebSocketManager::sendPing);
|
||||
|
||||
// 重连定时器
|
||||
m_reconnectTimer->setSingleShot(true);
|
||||
connect(m_reconnectTimer, &QTimer::timeout, this, &WebSocketManager::tryReconnect);
|
||||
}
|
||||
|
||||
WebSocketManager::~WebSocketManager() {
|
||||
if (m_socket) {
|
||||
m_socket->close();
|
||||
m_socket->deleteLater();
|
||||
}
|
||||
}
|
||||
bool WebSocketManager::setRequestContent(const QString& requestToken) {
|
||||
if (this->m_url.isEmpty()) {
|
||||
emit error("URL is empty!");
|
||||
return false;
|
||||
}
|
||||
m_request.setUrl(this->m_url);
|
||||
m_request.setRawHeader("Authorization", requestToken.toUtf8()); // 设置请求头(包含鉴权Token)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebSocketManager::setSocketUrl(QUrl url) {
|
||||
if (this->m_url == url) {return true;} // 如果 URL 没有变化,直接返回成功
|
||||
// 判断URL是否合法,即是否符合websocket的格式
|
||||
if (!url.isValid() || url.scheme() != "ws" && url.scheme() != "wss") {
|
||||
emit error("Invalid URL!");
|
||||
return false;
|
||||
}
|
||||
this->m_url = std::move(url); // 设置 URL
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebSocketManager::connectToServer() {
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) { // 如果已经连接,则先断开连接重新连接
|
||||
m_socket->close();
|
||||
}
|
||||
if (m_url.isEmpty()) { // 如果 URL 为空
|
||||
emit error("URL is empty!");
|
||||
return false;
|
||||
}
|
||||
emit log(QString("Connecting to %1...").arg(m_url.toString()));
|
||||
|
||||
// SSL 配置
|
||||
if (m_url.scheme() == "wss") {
|
||||
QSslConfiguration sslConfig = m_socket->sslConfiguration();
|
||||
sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone); // 开发环境
|
||||
m_socket->setSslConfiguration(sslConfig);
|
||||
}
|
||||
if (m_isRequest) { // 如果需要自定义首次请求
|
||||
m_socket->open(this->m_request); // 使用重载函数
|
||||
}
|
||||
else {
|
||||
m_socket->open(m_url);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebSocketManager::disconnectFromServer() {
|
||||
m_isReconnectEnabled = false; // 手动断开不重连
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) {
|
||||
m_socket->close(QWebSocketProtocol::CloseCodeNormal, "Client closed");
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketManager::sendText(const QString &message) {
|
||||
if (m_socket->state() == QAbstractSocket::ConnectedState) {
|
||||
m_socket->sendTextMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketManager::sendJson(const QString &type, const QJsonObject &data) {
|
||||
QJsonObject wrapper;
|
||||
wrapper["type"] = type;
|
||||
wrapper["data"] = data;
|
||||
wrapper["timestamp"] = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
sendText(QJsonDocument(wrapper).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
void WebSocketManager::sendBinary(const QByteArray &data) {
|
||||
if (m_socket->state() == QAbstractSocket::ConnectedState) {
|
||||
m_socket->sendBinaryMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
bool WebSocketManager::isConnected() const {
|
||||
return m_socket->state() == QAbstractSocket::ConnectedState;
|
||||
}
|
||||
|
||||
void WebSocketManager::setReconnectEnabled(bool enabled) {
|
||||
m_isReconnectEnabled = enabled;
|
||||
if (!enabled) {
|
||||
m_reconnectTimer->stop();
|
||||
}
|
||||
}
|
||||
void WebSocketManager::setRequestEnabled(const bool enabled) {
|
||||
m_isRequest = enabled;
|
||||
}
|
||||
|
||||
// 私有槽
|
||||
void WebSocketManager::onConnected() {
|
||||
m_reconnectAttempts = 0;
|
||||
emit log("✅ WebSocket connected");
|
||||
emit connected();
|
||||
m_pingTimer->start();
|
||||
}
|
||||
|
||||
void WebSocketManager::onDisconnected() {
|
||||
emit log("⚠️ WebSocket disconnected");
|
||||
emit disconnected();
|
||||
m_pingTimer->stop();
|
||||
|
||||
// 自动重连
|
||||
if (m_isReconnectEnabled) {
|
||||
m_reconnectTimer->start(3000); // 3秒后重试
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketManager::onTextMessageReceived(const QString &message) {
|
||||
emit textReceived(message);
|
||||
|
||||
// 自动解析 JSON
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8());
|
||||
if (doc.isObject()) {
|
||||
const QJsonObject obj = doc.object();
|
||||
const QString type = obj.value("type").toString();
|
||||
const QJsonObject data = obj.value("data").toObject();
|
||||
if (!type.isEmpty()) {
|
||||
emit jsonReceived(type, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketManager::onError(QAbstractSocket::SocketError socketError) {
|
||||
QString errorMsg;
|
||||
switch (socketError) {
|
||||
case QAbstractSocket::ConnectionRefusedError:
|
||||
errorMsg = "Connection refused";
|
||||
break;
|
||||
case QAbstractSocket::RemoteHostClosedError:
|
||||
errorMsg = "Remote host closed";
|
||||
break;
|
||||
case QAbstractSocket::HostNotFoundError:
|
||||
errorMsg = "Host not found";
|
||||
break;
|
||||
case QAbstractSocket::SocketTimeoutError:
|
||||
errorMsg = "Socket timeout";
|
||||
break;
|
||||
case QAbstractSocket::NetworkError:
|
||||
errorMsg = "Network error";
|
||||
break;
|
||||
case QAbstractSocket::SslHandshakeFailedError:
|
||||
errorMsg = "SSL handshake failed";
|
||||
break;
|
||||
default:
|
||||
errorMsg = m_socket->errorString();
|
||||
}
|
||||
emit error(QString("Socket error: %1").arg(errorMsg));
|
||||
}
|
||||
|
||||
void WebSocketManager::onSslErrors(const QList<QSslError> &errors) {
|
||||
foreach (const QSslError &err, errors) {
|
||||
emit log(QString("SSL error: %1").arg(err.errorString()));
|
||||
}
|
||||
#ifdef QT_DEBUG
|
||||
m_socket->ignoreSslErrors(); // 开发环境忽略
|
||||
#endif
|
||||
}
|
||||
|
||||
void WebSocketManager::sendPing() const {
|
||||
if (m_socket->state() == QAbstractSocket::ConnectedState) {
|
||||
m_socket->ping();
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketManager::onPong(quint64 elapsedTime, const QByteArray &) {
|
||||
emit log(QString("Pong received, latency: %1ms").arg(elapsedTime));
|
||||
}
|
||||
|
||||
void WebSocketManager::tryReconnect() {
|
||||
if (!m_isReconnectEnabled) return;
|
||||
|
||||
m_reconnectAttempts++;
|
||||
emit reconnecting(m_reconnectAttempts);
|
||||
emit log(QString("🔄 Reconnecting... (attempt %1)").arg(m_reconnectAttempts));
|
||||
|
||||
m_socket->open(m_url);
|
||||
}
|
||||
|
||||
#include <QScopedPointer>
|
||||
QMutex WebSocketClient::m_mutex;
|
||||
QScopedPointer<WebSocketClient> WebSocketClient::m_instance;
|
||||
|
||||
WebSocketClient* WebSocketClient::getInstance()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (m_instance.isNull()) {
|
||||
m_instance.reset(new WebSocketClient());
|
||||
}
|
||||
return m_instance.data();
|
||||
}
|
||||
|
||||
void WebSocketClient::destroy()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
if (!m_instance.isNull()) {
|
||||
m_instance.reset();
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient::WebSocketClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_workerThread(nullptr)
|
||||
, m_webSocketManager(nullptr)
|
||||
, m_hasAuthToken(false)
|
||||
{
|
||||
// 创建工作线程
|
||||
m_workerThread = new QThread(this);
|
||||
m_workerThread->setObjectName("WebSocketWorkerThread");
|
||||
|
||||
// 创建 WebSocketManager
|
||||
m_webSocketManager = new WebSocketManager();
|
||||
m_webSocketManager->moveToThread(m_workerThread);
|
||||
|
||||
// 连接线程结束信号
|
||||
connect(m_workerThread, &QThread::finished,
|
||||
m_webSocketManager, &QObject::deleteLater);
|
||||
|
||||
// 连接 WebSocketManager 的信号到本类的信号(转发到主线程)
|
||||
connect(m_webSocketManager, &WebSocketManager::connected,
|
||||
this, &WebSocketClient::connected);
|
||||
connect(m_webSocketManager, &WebSocketManager::disconnected,
|
||||
this, &WebSocketClient::disconnected);
|
||||
connect(m_webSocketManager, &WebSocketManager::textReceived,
|
||||
this, &WebSocketClient::textReceived);
|
||||
connect(m_webSocketManager, &WebSocketManager::jsonReceived,
|
||||
this, &WebSocketClient::jsonReceived);
|
||||
connect(m_webSocketManager, &WebSocketManager::binaryReceived,
|
||||
this, &WebSocketClient::binaryReceived);
|
||||
connect(m_webSocketManager, &WebSocketManager::error,
|
||||
this, &WebSocketClient::error);
|
||||
connect(m_webSocketManager, &WebSocketManager::log,
|
||||
this, &WebSocketClient::log);
|
||||
connect(m_webSocketManager, &WebSocketManager::reconnecting,
|
||||
this, &WebSocketClient::reconnecting);
|
||||
|
||||
// 连接本类的内部信号到 WebSocketManager 的槽(跨线程调用)
|
||||
// 注意:由于 internal* 信号是私有信号,我们需要使用 lambda 包装器
|
||||
connect(this, &WebSocketClient::internalSetUrl,
|
||||
m_webSocketManager, [this](const QUrl& url) {
|
||||
bool success = m_webSocketManager->setSocketUrl(url);
|
||||
if (!success) {
|
||||
emit error("Failed to set WebSocket URL");
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalSetAuthToken,
|
||||
m_webSocketManager, [this](const QString& token) {
|
||||
if (!token.isEmpty()) {
|
||||
bool success = m_webSocketManager->setRequestContent(token);
|
||||
if (!success) {
|
||||
emit error("Failed to set authentication token");
|
||||
}
|
||||
m_webSocketManager->setRequestEnabled(true);
|
||||
} else {
|
||||
m_webSocketManager->setRequestEnabled(false);
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalConnect,
|
||||
m_webSocketManager, &WebSocketManager::connectToServer,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalDisconnect,
|
||||
m_webSocketManager, &WebSocketManager::disconnectFromServer,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalSendText,
|
||||
m_webSocketManager, &WebSocketManager::sendText,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalSendJson,
|
||||
m_webSocketManager, &WebSocketManager::sendJson,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalSendBinary,
|
||||
m_webSocketManager, &WebSocketManager::sendBinary,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &WebSocketClient::internalSetAutoReconnect,
|
||||
m_webSocketManager, &WebSocketManager::setReconnectEnabled,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
// 启动工作线程
|
||||
m_workerThread->start();
|
||||
|
||||
qDebug() << "WebSocketClient initialized, worker thread:" << m_workerThread;
|
||||
}
|
||||
|
||||
WebSocketClient::~WebSocketClient()
|
||||
{
|
||||
qDebug() << "Shutting down WebSocketClient...";
|
||||
|
||||
// 断开连接
|
||||
disconnectFromServer();
|
||||
|
||||
// 停止工作线程
|
||||
if (m_workerThread && m_workerThread->isRunning()) {
|
||||
m_workerThread->quit();
|
||||
if (!m_workerThread->wait(3000)) {
|
||||
m_workerThread->terminate();
|
||||
m_workerThread->wait();
|
||||
}
|
||||
delete m_workerThread;
|
||||
m_workerThread = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool WebSocketClient::setConfiguration(const QUrl& url, const QString& authToken)
|
||||
{
|
||||
if (!url.isValid()) {
|
||||
emit error("Invalid URL provided");
|
||||
return false;
|
||||
}
|
||||
|
||||
QUrl oldUrl = m_url;
|
||||
m_url = url;
|
||||
m_authToken = authToken;
|
||||
m_hasAuthToken = !authToken.isEmpty();
|
||||
|
||||
// 发送到工作线程进行配置
|
||||
emit internalSetUrl(url);
|
||||
if (!authToken.isEmpty()) {
|
||||
emit internalSetAuthToken(authToken);
|
||||
}
|
||||
|
||||
// 通知配置变更
|
||||
if (oldUrl != url) {
|
||||
emit configurationChanged(oldUrl, url);
|
||||
}
|
||||
|
||||
emit log(QString("WebSocket configuration updated: %1").arg(url.toString()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebSocketClient::connectToServer()
|
||||
{
|
||||
if (!m_url.isValid()) {
|
||||
emit error("WebSocket URL not configured. Call setConfiguration() first.");
|
||||
return;
|
||||
}
|
||||
|
||||
emit log(QString("Connecting to server: %1").arg(m_url.toString()));
|
||||
emit internalConnect();
|
||||
}
|
||||
|
||||
void WebSocketClient::disconnectFromServer()
|
||||
{
|
||||
emit log("Disconnecting from server...");
|
||||
emit internalDisconnect();
|
||||
}
|
||||
|
||||
void WebSocketClient::reconnect()
|
||||
{
|
||||
if (!hasConfiguration()) {
|
||||
emit error("WebSocket not configured. Cannot reconnect.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 先断开,再连接
|
||||
disconnectFromServer();
|
||||
|
||||
// 短暂延迟后重新连接
|
||||
QTimer::singleShot(100, this, [this]() {
|
||||
emit log("Attempting to reconnect...");
|
||||
connectToServer();
|
||||
});
|
||||
}
|
||||
|
||||
void WebSocketClient::sendText(const QString& message)
|
||||
{
|
||||
if (isConnected()) {
|
||||
emit internalSendText(message);
|
||||
} else {
|
||||
emit error("Cannot send message: WebSocket is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketClient::sendJson(const QString& type, const QJsonObject& data)
|
||||
{
|
||||
if (isConnected()) {
|
||||
emit internalSendJson(type, data);
|
||||
} else {
|
||||
emit error("Cannot send JSON: WebSocket is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
void WebSocketClient::sendBinary(const QByteArray& data)
|
||||
{
|
||||
if (isConnected()) {
|
||||
emit internalSendBinary(data);
|
||||
} else {
|
||||
emit error("Cannot send binary data: WebSocket is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
bool WebSocketClient::isConnected() const
|
||||
{
|
||||
if (m_webSocketManager) {
|
||||
bool connected = false;
|
||||
// 使用阻塞调用获取连接状态
|
||||
QMetaObject::invokeMethod(const_cast<WebSocketManager*>(m_webSocketManager),
|
||||
[&connected, manager = m_webSocketManager]() {
|
||||
connected = manager->isConnected();
|
||||
},
|
||||
Qt::BlockingQueuedConnection);
|
||||
return connected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void WebSocketClient::setAutoReconnect(bool enabled)
|
||||
{
|
||||
emit log(QString("Auto reconnect %1").arg(enabled ? "enabled" : "disabled"));
|
||||
emit internalSetAutoReconnect(enabled);
|
||||
}
|
||||
|
||||
void WebSocketClient::setPingInterval(int milliseconds)
|
||||
{
|
||||
// 注意:需要在 WebSocketManager 中添加相应的方法才能支持
|
||||
// 这里暂时记录日志,提醒需要实现
|
||||
emit log(QString("setPingInterval(%1) called, but not implemented in WebSocketManager")
|
||||
.arg(milliseconds));
|
||||
Q_UNUSED(milliseconds)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
当前项目中实现了tcp, socket, websocket三种通信方式
|
||||
项目只用到了了**websocket**方式,其他两种是历史遗留(Yosuga[Qt5]所使用)
|
||||
并且websocket经过了重构,交互数据也为自定义格式
|
||||
|
||||
这边顺便提供下WebSocket类的Mermaid图,帮助理解(将代码丢给AI生成出来的图,审阅了一下还是十分准确的)
|
||||
|
||||
## 1. 类图
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class WebSocketManager {
|
||||
-QWebSocket* m_socket
|
||||
-QUrl m_url
|
||||
-QTimer* m_pingTimer
|
||||
-QTimer* m_reconnectTimer
|
||||
-int m_reconnectAttempts
|
||||
-bool m_isReconnectEnabled
|
||||
-QNetworkRequest m_request
|
||||
-bool m_isRequest
|
||||
+WebSocketManager(parent)
|
||||
~WebSocketManager()
|
||||
+setRequestContent(requestToken) bool
|
||||
+setSocketUrl(url) bool
|
||||
+connectToServer() bool
|
||||
+disconnectFromServer()
|
||||
+sendText(message)
|
||||
+sendJson(type, data)
|
||||
+sendBinary(data)
|
||||
+setReconnectEnabled(enabled)
|
||||
+setRequestEnabled(enabled)
|
||||
+isConnected() bool
|
||||
-onConnected()
|
||||
-onDisconnected()
|
||||
-onTextMessageReceived(message)
|
||||
-onError(socketError)
|
||||
-onSslErrors(errors)
|
||||
-onPong(elapsedTime, payload)
|
||||
-sendPing()
|
||||
-tryReconnect()
|
||||
-- signals --
|
||||
+connected()
|
||||
+disconnected()
|
||||
+textReceived(message)
|
||||
+jsonReceived(type, data)
|
||||
+binaryReceived(data)
|
||||
+error(errorMsg)
|
||||
+log(msg)
|
||||
+reconnecting(attempt)
|
||||
}
|
||||
|
||||
class WebSocketClient {
|
||||
-static QMutex m_mutex
|
||||
-static QScopedPointer~WebSocketClient~ m_instance
|
||||
-QThread* m_workerThread
|
||||
-WebSocketManager* m_webSocketManager
|
||||
-QUrl m_url
|
||||
-QString m_authToken
|
||||
-bool m_hasAuthToken
|
||||
+getInstance() WebSocketClient*
|
||||
+destroy()
|
||||
+setConfiguration(url, authToken) bool
|
||||
+connectToServer()
|
||||
+disconnectFromServer()
|
||||
+reconnect()
|
||||
+sendText(message)
|
||||
+sendJson(type, data)
|
||||
+sendBinary(data)
|
||||
+isConnected() bool
|
||||
+hasConfiguration() bool
|
||||
+setAutoReconnect(enabled)
|
||||
+setPingInterval(milliseconds)
|
||||
+currentUrl() QUrl
|
||||
+currentToken() QString
|
||||
+manager() WebSocketManager*
|
||||
-- signals --
|
||||
+connected()
|
||||
+disconnected()
|
||||
+textReceived(message)
|
||||
+jsonReceived(type, data)
|
||||
+binaryReceived(data)
|
||||
+error(errorMsg)
|
||||
+log(msg)
|
||||
+reconnecting(attempt)
|
||||
+configurationChanged(oldUrl, newUrl)
|
||||
-internalSetUrl(url)
|
||||
-internalSetAuthToken(token)
|
||||
-internalConnect()
|
||||
-internalDisconnect()
|
||||
-internalSendText(message)
|
||||
-internalSendJson(type, data)
|
||||
-internalSendBinary(data)
|
||||
-internalSetAutoReconnect(enabled)
|
||||
-internalSetRequestEnabled(enabled)
|
||||
}
|
||||
|
||||
class QWebSocket {
|
||||
+open(url)
|
||||
+close()
|
||||
+sendTextMessage(message)
|
||||
+sendBinaryMessage(data)
|
||||
+ping()
|
||||
+state() QAbstractSocket::SocketState
|
||||
}
|
||||
|
||||
class QThread {
|
||||
+start()
|
||||
+quit()
|
||||
+wait()
|
||||
+terminate()
|
||||
+isRunning() bool
|
||||
}
|
||||
|
||||
WebSocketClient "1" --> "1" WebSocketManager : 管理
|
||||
WebSocketClient "1" --> "1" QThread : 工作线程
|
||||
WebSocketManager "1" --> "1" QWebSocket : 封装
|
||||
WebSocketManager "1" --> "2" QTimer : 心跳/重连
|
||||
```
|
||||
|
||||
## 2. 连接时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MainThread as 主线程
|
||||
participant Client as WebSocketClient
|
||||
participant WorkerThread as 工作线程
|
||||
participant Manager as WebSocketManager
|
||||
participant Socket as QWebSocket
|
||||
participant Server as WebSocket服务器
|
||||
|
||||
MainThread->>Client: getInstance()
|
||||
Client-->>MainThread: WebSocketClient实例
|
||||
|
||||
MainThread->>Client: setConfiguration(url, token)
|
||||
Client->>WorkerThread: 创建工作线程
|
||||
Client->>Manager: 创建WebSocketManager
|
||||
Manager->>Socket: 创建QWebSocket
|
||||
Client->>Manager: 信号连接配置
|
||||
Client->>Manager: 设置URL和Token
|
||||
|
||||
MainThread->>Client: connectToServer()
|
||||
Client->>Manager: emit internalConnect()
|
||||
Manager->>Socket: open(url/request)
|
||||
Socket->>Server: WebSocket握手
|
||||
Server-->>Socket: 连接成功
|
||||
Socket->>Manager: connected()
|
||||
Manager->>Manager: 启动心跳定时器
|
||||
Manager->>Client: emit connected()
|
||||
Client->>MainThread: emit connected()
|
||||
```
|
||||
|
||||
## 3. 状态图
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 未配置
|
||||
|
||||
未配置 --> 已配置 : setConfiguration()
|
||||
已配置 --> 连接中 : connectToServer()
|
||||
|
||||
连接中 --> 已连接 : 连接成功
|
||||
连接中 --> 重连中 : 连接失败
|
||||
连接中 --> [*] : disconnectFromServer()
|
||||
|
||||
已连接 --> 已连接 : 发送/接收数据
|
||||
已连接 --> 已断开 : 连接断开
|
||||
已连接 --> [*] : disconnectFromServer()
|
||||
|
||||
已断开 --> 重连中 : 自动重连开启
|
||||
已断开 --> [*] : 自动重连关闭
|
||||
|
||||
重连中 --> 已连接 : 重连成功
|
||||
重连中 --> 重连中 : 重连失败(继续重试)
|
||||
重连中 --> [*] : disconnectFromServer()
|
||||
|
||||
state 重连中 {
|
||||
[*] --> 等待重试
|
||||
等待重试 --> 尝试连接 : 定时器触发
|
||||
尝试连接 --> 等待重试 : 连接失败
|
||||
尝试连接 --> [*] : 连接成功
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 消息发送时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as 应用程序
|
||||
participant Client as WebSocketClient
|
||||
participant Manager as WebSocketManager
|
||||
participant Socket as QWebSocket
|
||||
participant Server as WebSocket服务器
|
||||
|
||||
App->>Client: sendJson("message", data)
|
||||
alt 已连接
|
||||
Client->>Manager: emit internalSendJson("message", data)
|
||||
Manager->>Socket: sendTextMessage(json)
|
||||
Socket->>Server: 发送JSON数据
|
||||
Server-->>Socket: 响应(可选)
|
||||
Socket->>Manager: textMessageReceived()
|
||||
Manager->>Manager: 解析JSON
|
||||
Manager->>Client: emit jsonReceived()
|
||||
Client->>App: emit jsonReceived()
|
||||
else 未连接
|
||||
Client->>App: emit error("未连接")
|
||||
end
|
||||
```
|
||||
|
||||
## 5. 线程关系图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 主线程 [UI/主线程]
|
||||
direction LR
|
||||
App[应用程序]
|
||||
Client[WebSocketClient<br/>单例]
|
||||
end
|
||||
|
||||
subgraph 工作线程 [WebSocket工作线程]
|
||||
direction LR
|
||||
Manager[WebSocketManager]
|
||||
Socket[QWebSocket]
|
||||
end
|
||||
|
||||
subgraph 外部系统
|
||||
Server[WebSocket服务器]
|
||||
end
|
||||
|
||||
App -- 调用 --> Client
|
||||
Client -- 跨线程信号 --> Manager
|
||||
Manager -- Qt信号槽 --> Socket
|
||||
Socket -- TCP/WebSocket --> Server
|
||||
|
||||
Client -- 返回事件信号 --> App
|
||||
Socket -- 接收数据 --> Manager
|
||||
Manager -- 跨线程信号 --> Client
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* @brief 菜单
|
||||
* @author Misaki
|
||||
*
|
||||
* 基于Ela UI的菜单控件
|
||||
*/
|
||||
|
||||
#include <ElaMenu.h>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
#include <QPoint>
|
||||
#include <QScopedPointer> // 智能指针
|
||||
#include "Setting.h"
|
||||
#include "networkmanager.h"
|
||||
#include "socketmanager.h"
|
||||
|
||||
class Menu final : public ElaMenu
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Menu(QWidget *parent = nullptr);
|
||||
~Menu() override;
|
||||
void showMenu(const QPoint &pos);
|
||||
|
||||
signals:
|
||||
void closeMainWindow(); // 自定义关闭主窗口的信号
|
||||
void startPlay(); // 自定义开始播放的信号
|
||||
|
||||
|
||||
private:
|
||||
void createMenu();
|
||||
QAction *toggleThe; /// 切换主题(全局)
|
||||
QAction *startSingleExchangeAction; /// 开启单次对话
|
||||
QAction *startContinueExchangeAction; /// 开启连续对话
|
||||
QAction *settingsAction; /// 设置
|
||||
QAction *closeAction; /// 关闭
|
||||
|
||||
QScopedPointer<Setting> settingWindow; // 使用智能指针管理 Setting 窗口
|
||||
|
||||
private slots:
|
||||
void toggleTheme();
|
||||
|
||||
// void startExchange();
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "menu.h"
|
||||
#include "ElaTheme.h"
|
||||
#include "LAppLive2DManager.hpp"
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
|
||||
#include "TextRenderer.h"
|
||||
#include "AppCore.h"
|
||||
|
||||
Menu::Menu(QWidget *parent)
|
||||
: ElaMenu(parent)
|
||||
{
|
||||
// 设置默认主题
|
||||
eTheme->setThemeMode(ElaThemeType::Dark);
|
||||
createMenu();
|
||||
}
|
||||
|
||||
Menu::~Menu() {
|
||||
AppCore::destroy(); // 显式销毁 AppCore
|
||||
}
|
||||
|
||||
void Menu::createMenu()
|
||||
{
|
||||
toggleThe = addAction("切换主题");
|
||||
|
||||
// 单次对话功能按钮
|
||||
startSingleExchangeAction = addAction("单次对话(测试)");
|
||||
|
||||
// 连续对话功能按钮
|
||||
startContinueExchangeAction = addAction("连续对话(测试)");
|
||||
|
||||
// 添加设置按钮
|
||||
settingsAction = addAction("设置");
|
||||
|
||||
// 添加关闭按钮
|
||||
closeAction = addAction("关闭");
|
||||
|
||||
// 连接信号与槽
|
||||
|
||||
// 切换主题按钮
|
||||
connect(toggleThe, &QAction::triggered, this, [this]() {
|
||||
toggleTheme();
|
||||
});
|
||||
|
||||
// 单次对话功能
|
||||
connect(startSingleExchangeAction, &QAction::triggered, this, []() {
|
||||
qDebug() << "Start SingleExchange triggered";
|
||||
AppCore::getInstance()->SingleExchange();
|
||||
});
|
||||
// 连续对话功能 TODO: 待开发
|
||||
connect(startContinueExchangeAction, &QAction::triggered, this, []() {
|
||||
qDebug() << "Start ContinueExchange triggered";
|
||||
});
|
||||
|
||||
|
||||
// 设置按钮
|
||||
connect(settingsAction, &QAction::triggered, this, [this]() {
|
||||
qDebug() << "Settings triggered";
|
||||
AppCore::getInstance()->tryToInit();
|
||||
// 打开设置窗口
|
||||
|
||||
// 如果 Setting 窗口已经存在,则不再创建
|
||||
if (settingWindow) {
|
||||
settingWindow->show();
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态创建 Setting 窗口
|
||||
settingWindow.reset(new Setting()); // 使用智能指针管理
|
||||
|
||||
// 显示 Setting 窗口
|
||||
settingWindow->show();
|
||||
});
|
||||
|
||||
// 关闭
|
||||
connect(closeAction, &QAction::triggered, this, [this]() {
|
||||
emit closeMainWindow(); // 发射关闭信号
|
||||
});
|
||||
}
|
||||
|
||||
void Menu::showMenu(const QPoint &pos)
|
||||
{
|
||||
// 在指定位置显示菜单
|
||||
exec(pos);
|
||||
}
|
||||
|
||||
void Menu::toggleTheme()
|
||||
{
|
||||
if (eTheme->getThemeMode() == ElaThemeType::Light) {
|
||||
eTheme->setThemeMode(ElaThemeType::Dark);
|
||||
} else {
|
||||
eTheme->setThemeMode(ElaThemeType::Light);
|
||||
}
|
||||
}
|
||||
/*
|
||||
void Menu::startExchange()
|
||||
{
|
||||
// 列出所有音频输入设备
|
||||
QList<QString> devices = AudioInput::getAvailableAudioInputDevices();
|
||||
qDebug() << "可用录音设备:";
|
||||
for (const QString &device : devices) {
|
||||
qDebug() << device;
|
||||
}
|
||||
|
||||
// 设置当前录音设备(假设选择第一个设备)
|
||||
if (!devices.isEmpty()) {
|
||||
qDebug() << "选择的录音设备是: " << devices.first();
|
||||
AudioInput::getInstance()->setAudioInputDevice(devices.first());
|
||||
qDebug() << "当前录音设备: " << AudioInput::getInstance()->audioInput(); // 检查当前录音设备
|
||||
}
|
||||
|
||||
// 第一次需要主动录音来启动 信号与槽状态机(FSM)
|
||||
qDebug() << "开始录音";
|
||||
AudioInput::getInstance()->startAutoStopAudio();
|
||||
|
||||
// 检查录音状态
|
||||
if (AudioInput::getInstance()->state() == QMediaRecorder::RecordingState) {
|
||||
qDebug() << "录音已启动";
|
||||
} else {
|
||||
qDebug() << "录音启动失败";
|
||||
}
|
||||
|
||||
|
||||
// 连接信号和槽
|
||||
// 播放回答
|
||||
// 当完整接受wav文件后播放相关的wav文件
|
||||
connect(SocketManager::getInstance(), &SocketManager::revWavFileFinish, [this](const QString &filePath, const QString &response, const float duration) {
|
||||
LAppLive2DManager::GetInstance()->StartLipSync(filePath.toUtf8().constData());
|
||||
AudioOutput::getInstance()->setAudioPath(filePath);
|
||||
AudioOutput::getInstance()->playAudio();
|
||||
TextRenderer::getInstance()->addText(response, 40.0f, QColor("#FF69B4"), duration);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 开始录音
|
||||
// 当播放完成后继续开始录音
|
||||
connect(AudioOutput::getInstance(), &AudioOutput::playbackFinished, [this]() {
|
||||
qDebug() << "开始录音";
|
||||
AudioInput::getInstance()->startAutoStopAudio();
|
||||
|
||||
// 检查录音状态
|
||||
if (AudioInput::getInstance()->state() == QMediaRecorder::RecordingState) {
|
||||
qDebug() << "录音已启动";
|
||||
} else {
|
||||
qDebug() << "录音启动失败";
|
||||
}
|
||||
});
|
||||
|
||||
// 上传录音
|
||||
// 当录音完成时,发送wav文件
|
||||
connect(AudioInput::getInstance(), &AudioInput::recordingFinished_Byte, [this](const QByteArray &wavData) {
|
||||
qDebug() << "录音完成,开始上传录音文件...";
|
||||
if (wavData.isEmpty()) {
|
||||
qWarning() << "录音数据为空!";
|
||||
return;
|
||||
}
|
||||
|
||||
qDebug() << "准备发送WAV数据,大小:" << wavData.size() << "字节";
|
||||
|
||||
SocketManager::getInstance()->sendWavFile(wavData);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -0,0 +1 @@
|
||||
本目录下为右键菜单的相关代码
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/16.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <QOpenGLWidget>
|
||||
#include <QVector>
|
||||
#include <QString>
|
||||
#include <QColor>
|
||||
#include <QElapsedTimer>
|
||||
#include <QVector2D>
|
||||
#include <QFontMetrics>
|
||||
#include <QLinearGradient>
|
||||
|
||||
class TextRenderer {
|
||||
public:
|
||||
// 删除拷贝构造函数和赋值运算符
|
||||
TextRenderer(const TextRenderer&) = delete;
|
||||
void operator=(const TextRenderer&) = delete;
|
||||
// 获取单例实例
|
||||
static TextRenderer* getInstance()
|
||||
{
|
||||
if(instance == nullptr){
|
||||
instance = new TextRenderer();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
struct TextInstance {
|
||||
QString text; // 文本内容
|
||||
QVector2D basePosition; // 基础位置(Y轴)
|
||||
QColor primaryColor; // 主要文字颜色
|
||||
QColor outlineColor; // 轮廓颜色
|
||||
float duration; // 显示总时长(秒)
|
||||
qint64 startTime; // 开始显示时间(毫秒)
|
||||
bool isDropping; // 是否正在下坠
|
||||
qint64 dropStartTime; // 下坠开始时间
|
||||
float dropYVelocity; // Y轴下落速度
|
||||
float alpha; // 透明度
|
||||
QList<QPoint> charPositions; // 字符位置
|
||||
QList<int> charWidths; // 每个字符宽度
|
||||
int visibleChars; // 可见字符数
|
||||
bool flowCompleted; // 流式显示是否完成
|
||||
float holdDuration; // 实际使用的停留时间
|
||||
qint64 flowEndTime; // 流式完成时间戳
|
||||
|
||||
TextInstance() : isDropping(false), dropYVelocity(0.0f),
|
||||
alpha(1.0f), visibleChars(0),
|
||||
flowCompleted(false), holdDuration(0.5f),
|
||||
flowEndTime(0) {}
|
||||
};
|
||||
|
||||
|
||||
void setWindowSize(int w, int h);
|
||||
void addText(const QString &text, float yPos,
|
||||
const QColor &color, float duration);
|
||||
void update();
|
||||
void render();
|
||||
void setGlobalFont(const QFont &newFont);
|
||||
|
||||
/**
|
||||
* 参数建议值:<br>
|
||||
效果类型 gravity dampFactor holdDuration <br>
|
||||
柔和下落 600.0f 0.85f 1.0f <br>
|
||||
快速坠落 1200.0f 0.6f 0.3f <br>
|
||||
弹性效果 900.0f 0.75f 0.8f <br>
|
||||
真实物理模拟 980.0f 0.82f 0.5f
|
||||
*/
|
||||
void setHoldDuration(const float seconds) { defaultHoldDuration = seconds; }
|
||||
void setGravity(const float g) { gravity = g; }
|
||||
void setDampFactor(const float damp) { dampFactor = damp; }
|
||||
|
||||
// 释放单例
|
||||
static void releaseInstance() {
|
||||
if (instance) {
|
||||
delete instance;
|
||||
instance = nullptr;
|
||||
}
|
||||
}
|
||||
private:
|
||||
explicit TextRenderer(); // 构造函数私有化
|
||||
void updateFlowPositions(TextInstance &instance);
|
||||
void updateDropPositions(TextInstance &instance, float deltaTime);
|
||||
|
||||
private:
|
||||
static TextRenderer *instance;
|
||||
|
||||
QList<TextInstance> activeTexts; /// 当前显示的文字
|
||||
QElapsedTimer globalTimer; /// 全局计时器
|
||||
QFont font; /// 全局字体
|
||||
int windowWidth; /// 窗口宽度
|
||||
int windowHeight; /// 窗口高度
|
||||
qint64 lastFrameTime; /// 上一帧的时间
|
||||
|
||||
// 一些自定义参数
|
||||
float defaultHoldDuration; /// 默认停留时间(秒)
|
||||
float gravity; /// 重力加速度(像素/秒²)
|
||||
float dampFactor; /// 碰撞阻尼系数
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/16.
|
||||
//
|
||||
/**
|
||||
* 用于渲染文本显示
|
||||
*/
|
||||
|
||||
#include "TextRenderer.h"
|
||||
#include <QPainter>
|
||||
#include <QOpenGLPaintDevice>
|
||||
#include <cmath>
|
||||
#include <QRandomGenerator>
|
||||
TextRenderer *TextRenderer::instance = nullptr;
|
||||
TextRenderer::TextRenderer() : windowWidth(800), // 默认窗口宽
|
||||
windowHeight(600), // 默认窗口高
|
||||
lastFrameTime(0),
|
||||
defaultHoldDuration(0.5f), // 默认停留0.5秒
|
||||
gravity(980.0f), // 默认重力
|
||||
dampFactor(0.82f) // 默认阻尼
|
||||
{
|
||||
globalTimer.start();
|
||||
font.setFamily("Microsoft YaHei");
|
||||
font.setPixelSize(28);
|
||||
font.setWeight(QFont::Bold);
|
||||
}
|
||||
|
||||
void TextRenderer::setGlobalFont(const QFont &newFont)
|
||||
{
|
||||
font = newFont;
|
||||
font.setWeight(QFont::Bold);
|
||||
}
|
||||
|
||||
void TextRenderer::setWindowSize(int w, int h)
|
||||
{
|
||||
windowWidth = w;
|
||||
windowHeight = h;
|
||||
}
|
||||
|
||||
void TextRenderer::addText(const QString &text, float yPos,
|
||||
const QColor &color, float duration)
|
||||
{
|
||||
TextInstance instance;
|
||||
instance.text = text;
|
||||
instance.basePosition = QVector2D(0, yPos);
|
||||
instance.primaryColor = color;
|
||||
instance.outlineColor = QColor(0, 0, 0, 180);
|
||||
instance.duration = duration;
|
||||
instance.startTime = globalTimer.elapsed();
|
||||
|
||||
instance.holdDuration = defaultHoldDuration; // 应用当前全局设置
|
||||
|
||||
QFontMetrics metrics(font);
|
||||
instance.charWidths.clear();
|
||||
for (const QChar &ch : text) {
|
||||
instance.charWidths.append(metrics.horizontalAdvance(ch));
|
||||
}
|
||||
|
||||
// 初始位置计算
|
||||
updateFlowPositions(instance);
|
||||
activeTexts.append(instance);
|
||||
}
|
||||
|
||||
void TextRenderer::updateFlowPositions(TextInstance &instance)
|
||||
{
|
||||
QFontMetrics metrics(font);
|
||||
const int rightMargin = 20; // 右侧留白
|
||||
|
||||
// 计算可见部分总宽度
|
||||
int visibleWidth = 0;
|
||||
for (int i = 0; i < instance.visibleChars; ++i) {
|
||||
visibleWidth += instance.charWidths[i];
|
||||
}
|
||||
|
||||
// 动态计算起始位置
|
||||
int startX = qMin(
|
||||
windowWidth - visibleWidth - rightMargin, // 优先保证右侧空间
|
||||
(windowWidth - visibleWidth) / 2 // 次选居中显示
|
||||
);
|
||||
|
||||
// 边界保护:至少保留20px左侧边距
|
||||
startX = qMax(20, startX);
|
||||
|
||||
// 更新字符位置
|
||||
int currentX = startX;
|
||||
instance.charPositions.clear();
|
||||
|
||||
for (int i = 0; i < instance.text.size(); ++i) {
|
||||
if (i < instance.visibleChars) {
|
||||
instance.charPositions.append(QPoint(currentX, instance.basePosition.y()));
|
||||
currentX += instance.charWidths[i];
|
||||
} else {
|
||||
instance.charPositions.append(QPoint(-10000, -10000));
|
||||
}
|
||||
}
|
||||
|
||||
// 自动滚动调整:当文字溢出时整体左移
|
||||
if (currentX > windowWidth - rightMargin) {
|
||||
int overflow = currentX - (windowWidth - rightMargin);
|
||||
for (QPoint &pos : instance.charPositions) {
|
||||
if (pos.x() != -10000) {
|
||||
pos.rx() -= overflow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TextRenderer::updateDropPositions(TextInstance &instance, float deltaTime)
|
||||
{
|
||||
const float floorY = windowHeight - 30;
|
||||
|
||||
instance.dropYVelocity += gravity * deltaTime; // 使用全局重力值
|
||||
float deltaY = instance.dropYVelocity * deltaTime;
|
||||
|
||||
bool hasCollision = false;
|
||||
|
||||
for (QPoint &pos : instance.charPositions) {
|
||||
float newY = pos.y() + deltaY;
|
||||
|
||||
if (newY >= floorY) {
|
||||
newY = floorY;
|
||||
instance.dropYVelocity = -qAbs(instance.dropYVelocity) * dampFactor; // 使用全局阻尼系数
|
||||
hasCollision = true;
|
||||
pos.rx() += QRandomGenerator::global()->bounded(-3, 4);
|
||||
}
|
||||
pos.setY(newY);
|
||||
}
|
||||
|
||||
// 碰撞后处理
|
||||
if (hasCollision) {
|
||||
// 速度衰减到临界值时开始加速透明
|
||||
if (qAbs(instance.dropYVelocity) < 100.0f) {
|
||||
instance.alpha *= 0.92f; // 加快透明度衰减速度
|
||||
}
|
||||
|
||||
// 完全静止后强制移除
|
||||
if (qAbs(instance.dropYVelocity) < 5.0f) {
|
||||
instance.alpha = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// 常规透明度衰减
|
||||
instance.alpha = qMax(0.0f, instance.alpha * 0.98f);
|
||||
|
||||
// 添加随机水平扰动(只在有速度时)
|
||||
if (qAbs(instance.dropYVelocity) > 10.0f) {
|
||||
for (QPoint &pos : instance.charPositions) {
|
||||
pos.rx() += QRandomGenerator::global()->bounded(-1, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TextRenderer::update()
|
||||
{
|
||||
qint64 currentTime = globalTimer.elapsed();
|
||||
float deltaTime = (currentTime - lastFrameTime) / 1000.0f;
|
||||
lastFrameTime = currentTime;
|
||||
|
||||
auto it = activeTexts.begin();
|
||||
|
||||
while (it != activeTexts.end()) {
|
||||
TextInstance &instance = *it;
|
||||
|
||||
if (instance.isDropping) {
|
||||
updateDropPositions(instance, deltaTime); // 使用统一的deltaTime
|
||||
|
||||
// 强化消失条件:Y轴速度接近零 或 透明度低于阈值
|
||||
if ((qAbs(instance.dropYVelocity) < 5.0f && instance.alpha < 0.3f)
|
||||
|| instance.alpha < 0.01f) {
|
||||
it = activeTexts.erase(it);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 流式显示更新
|
||||
float progress = (currentTime - instance.startTime) / 1000.0f / instance.duration;
|
||||
progress = qMin(progress, 1.0f);
|
||||
|
||||
if (!instance.flowCompleted) {
|
||||
int newVisible = qMin(instance.text.size(),
|
||||
static_cast<int>(progress * instance.text.size()));
|
||||
|
||||
if (newVisible != instance.visibleChars) {
|
||||
instance.visibleChars = newVisible;
|
||||
updateFlowPositions(instance);
|
||||
}
|
||||
|
||||
// 流式显示完成检测
|
||||
if (progress >= 1.0f) {
|
||||
instance.flowCompleted = true;
|
||||
instance.flowEndTime = currentTime; // 记录完成时间
|
||||
}
|
||||
}
|
||||
// 已流式完成但未开始下坠
|
||||
else if (!instance.isDropping) {
|
||||
// 检查停留时间是否结束
|
||||
if (currentTime - instance.flowEndTime >= instance.holdDuration * 1000) {
|
||||
instance.isDropping = true;
|
||||
instance.dropStartTime = currentTime;
|
||||
|
||||
// 最终居中定位
|
||||
int totalWidth = 0;
|
||||
for (const int w : instance.charWidths) totalWidth += w;
|
||||
const int startX = (windowWidth - totalWidth) / 2;
|
||||
int currentX = startX;
|
||||
|
||||
instance.charPositions.clear();
|
||||
for (int i = 0; i < instance.text.size(); ++i) {
|
||||
instance.charPositions.append(QPoint(currentX, instance.basePosition.y()));
|
||||
currentX += instance.charWidths[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
void TextRenderer::render()
|
||||
{
|
||||
QOpenGLPaintDevice device(windowWidth, windowHeight);
|
||||
QPainter painter(&device);
|
||||
painter.setFont(font);
|
||||
painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);
|
||||
|
||||
for (const auto &instance : activeTexts) {
|
||||
QColor mainColor = instance.primaryColor;
|
||||
mainColor.setAlphaF(instance.alpha);
|
||||
QColor outlineColor = instance.outlineColor;
|
||||
outlineColor.setAlphaF(instance.alpha * 0.7f);
|
||||
|
||||
for (int i = 0; i < instance.charPositions.size(); ++i) {
|
||||
const QPoint &pos = instance.charPositions[i];
|
||||
if (pos.x() < -9999) continue; // 跳过隐藏字符
|
||||
|
||||
// 绘制轮廓
|
||||
painter.setPen(outlineColor);
|
||||
for (int dx = -1; dx <= 1; ++dx) {
|
||||
for (int dy = -1; dy <= 1; ++dy) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
painter.drawText(pos + QPoint(dx, dy), QString(instance.text[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制主体
|
||||
painter.setPen(mainColor);
|
||||
painter.drawText(pos, QString(instance.text[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/4.
|
||||
//
|
||||
|
||||
#ifndef AIRI_DESKTOPGRIL_AUDIOPAGE_H
|
||||
#define AIRI_DESKTOPGRIL_AUDIOPAGE_H
|
||||
|
||||
#include "BasePage.h"
|
||||
#include "ElaPushButton.h"
|
||||
class ElaComboBox;
|
||||
class ElaSpinBox;
|
||||
class ElaProgressBar;
|
||||
class ElaPushButton;
|
||||
class AudioPage : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit AudioPage(QWidget* parent = nullptr);
|
||||
~AudioPage() override;
|
||||
|
||||
private:
|
||||
ElaComboBox* audioInputDeviceComboBox = nullptr;
|
||||
ElaSpinBox* audioInputSpinBox = nullptr;
|
||||
ElaProgressBar* audioInputProgressBar = nullptr;
|
||||
ElaPushButton* audioAutoThresholdStartButton = nullptr;
|
||||
ElaPushButton* audioManualThresholdStartButton = nullptr;
|
||||
|
||||
ElaPushButton* testAudioPlayButton = nullptr;
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif //AIRI_DESKTOPGRIL_AUDIOPAGE_H
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/27.
|
||||
//
|
||||
|
||||
#ifndef AIRI_DESKTOPGRIL_BASEPAGE_H
|
||||
#define AIRI_DESKTOPGRIL_BASEPAGE_H
|
||||
|
||||
#include <ElaScrollPage.h>
|
||||
class QVBoxLayout;
|
||||
class BasePage : public ElaScrollPage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit BasePage(QWidget* parent = nullptr);
|
||||
~BasePage();
|
||||
|
||||
protected:
|
||||
void createCustomWidget(QString desText);
|
||||
};
|
||||
|
||||
#endif //AIRI_DESKTOPGRIL_BASEPAGE_H
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/28.
|
||||
//
|
||||
|
||||
#ifndef AIRI_DESKTOPGRIL_HOMEPAGE_H
|
||||
#define AIRI_DESKTOPGRIL_HOMEPAGE_H
|
||||
|
||||
#include "BasePage.h"
|
||||
class ElaMenu;
|
||||
class HomePage : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit HomePage(QWidget* parent = nullptr);
|
||||
~HomePage();
|
||||
Q_SIGNALS:
|
||||
Q_SIGNAL void audioNavigation();
|
||||
Q_SIGNAL void modelShopNavigation();
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif //AIRI_DESKTOPGRIL_HOMEPAGE_H
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Created by Administrator on 2025/4/1.
|
||||
//
|
||||
|
||||
/**
|
||||
* @brief 模型页面
|
||||
* 暂时只做最简单功能切换模型
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "BasePage.h"
|
||||
#include "ElaPushButton.h"
|
||||
#include "ElaLineEdit.h"
|
||||
#include "ElaComboBox.h"
|
||||
#include "ElaSlider.h"
|
||||
|
||||
#include <QUrl>
|
||||
#include <utility>
|
||||
|
||||
class ModelPage final : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit ModelPage(QWidget* parent = nullptr);
|
||||
|
||||
std::pair<QString, QString> splitPath(const QString& fullPath);
|
||||
|
||||
~ModelPage() override;
|
||||
|
||||
|
||||
private:
|
||||
// 设置当前模型
|
||||
ElaLineEdit* modelUrlEdit = nullptr; /// 模型Url 编辑框
|
||||
ElaPushButton* modelChoosePushButton = nullptr; /// 选择模型按钮
|
||||
ElaPushButton* modelUsePushButton = nullptr; /// 使用模型按钮
|
||||
ElaSlider* modelSlider = nullptr; /// 滑块(用于设置模型实时大小)
|
||||
|
||||
QUrl modelFileUrl;
|
||||
QString modelFilePathFirst;
|
||||
QString modelFilePathSecond;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/2.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include "BasePage.h"
|
||||
#include "ElaPushButton.h"
|
||||
#include "ElaLineEdit.h"
|
||||
|
||||
class ElaPushButton;
|
||||
class ElaLineEdit;
|
||||
class NetWorkPage final : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit NetWorkPage(QWidget* parent = nullptr);
|
||||
~NetWorkPage() override;
|
||||
|
||||
private:
|
||||
void initUI();
|
||||
void initWebSocketClient();
|
||||
|
||||
|
||||
|
||||
private:
|
||||
// websocket 控件
|
||||
ElaPushButton* websocketPushButton = nullptr;
|
||||
ElaLineEdit* websocketLineEdit = nullptr;
|
||||
|
||||
// 连接测试
|
||||
ElaPushButton* connectTestPushButton = nullptr;
|
||||
// 连接
|
||||
ElaPushButton* connectPushButton = nullptr;
|
||||
// 断开
|
||||
ElaPushButton* disconnectPushButton = nullptr;
|
||||
|
||||
// 发送测试按钮
|
||||
ElaPushButton* sendTestPushButton = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/30.
|
||||
//
|
||||
|
||||
#ifndef YOSUGA_RENDERPAGE_H
|
||||
#define YOSUGA_RENDERPAGE_H
|
||||
|
||||
#include "BasePage.h"
|
||||
#include "ElaPushButton.h"
|
||||
#include "ElaLineEdit.h"
|
||||
#include "ElaComboBox.h"
|
||||
class RenderPage : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit RenderPage(QWidget* parent = nullptr);
|
||||
~RenderPage();
|
||||
|
||||
|
||||
private:
|
||||
// 帧率设置
|
||||
ElaComboBox* frameRateComboBox = nullptr;
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif //YOSUGA_RENDERPAGE_H
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/21.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <ElaWidget.h>
|
||||
#include <ElaWindow.h>
|
||||
#include <ElaPushButton.h>
|
||||
#include <ElaScrollPage.h>
|
||||
#include <QStackedWidget>
|
||||
|
||||
#include "HomePage.h"
|
||||
#include "NetworkPage.h"
|
||||
#include "UISetting.h"
|
||||
#include "AudioPage.h"
|
||||
#include "RenderPage.h"
|
||||
#include "ModelPage.h"
|
||||
class Setting : public ElaWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Setting(QWidget *parent = nullptr);
|
||||
~Setting();
|
||||
|
||||
private:
|
||||
/**
|
||||
* 初始化所有页面指针
|
||||
* @author : Misaki
|
||||
*/
|
||||
void initPages();
|
||||
|
||||
/**
|
||||
* 初始化导航栏
|
||||
* @author : Misaki
|
||||
*/
|
||||
void initNavigationBar();
|
||||
|
||||
/**
|
||||
* 初始化上下文切换
|
||||
* @author : Misaki
|
||||
*/
|
||||
void initContent();
|
||||
|
||||
private slots:
|
||||
void toggleTheme();
|
||||
|
||||
private:
|
||||
// 页面指针
|
||||
HomePage *homePage;
|
||||
NetWorkPage *networkPage;
|
||||
UISetting *uiSetting;
|
||||
AudioPage *audioPage;
|
||||
RenderPage *renderPage;
|
||||
ModelPage *modelPage;
|
||||
|
||||
// 节点键值
|
||||
QString basePageKey;
|
||||
|
||||
//
|
||||
ElaPushButton *themeToggleButton;
|
||||
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/2.
|
||||
//
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "BasePage.h"
|
||||
class ElaRadioButton;
|
||||
class ElaToggleSwitch;
|
||||
class ElaComboBox;
|
||||
class UISetting : public BasePage
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit UISetting(QWidget* parent = nullptr);
|
||||
~UISetting();
|
||||
|
||||
private:
|
||||
ElaComboBox* _themeComboBox = nullptr;
|
||||
ElaToggleSwitch* _micaSwitchButton = nullptr;
|
||||
ElaToggleSwitch* _logSwitchButton = nullptr;
|
||||
ElaRadioButton* _minimumButton = nullptr;
|
||||
ElaRadioButton* _compactButton = nullptr;
|
||||
ElaRadioButton* _maximumButton = nullptr;
|
||||
ElaRadioButton* _autoButton = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/4.
|
||||
//
|
||||
|
||||
#include "AudioPage.h"
|
||||
|
||||
|
||||
#include <QHBoxLayout>
|
||||
|
||||
#include "ElaComboBox.h"
|
||||
#include "ElaPlainTextEdit.h"
|
||||
#include "ElaProgressBar.h"
|
||||
#include "ElaScrollPageArea.h"
|
||||
#include "ElaSlider.h"
|
||||
#include "ElaSpinBox.h"
|
||||
#include "ElaText.h"
|
||||
#include "ElaMessageBar.h"
|
||||
|
||||
#include "AudioInput.h"
|
||||
#include "AudioOutput.h"
|
||||
#include "TextRenderer.h"
|
||||
#include "LAppLive2DManager.hpp"
|
||||
AudioPage::AudioPage(QWidget* parent)
|
||||
: BasePage(parent)
|
||||
{
|
||||
// 预览窗口标题
|
||||
setWindowTitle("AudioPage");
|
||||
|
||||
audioInputDeviceComboBox = new ElaComboBox(this);
|
||||
audioInputDeviceComboBox->setToolTip("选择可用的录音设备");
|
||||
QStringList comboList = AudioInput::getAvailableAudioInputDevices();
|
||||
|
||||
audioInputDeviceComboBox->addItems(comboList);
|
||||
ElaScrollPageArea* comboBoxArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* comboBoxLayout = new QHBoxLayout(comboBoxArea);
|
||||
ElaText* comboBoxText = new ElaText("录音设备", this);
|
||||
comboBoxText->setTextPixelSize(15);
|
||||
comboBoxLayout->addWidget(comboBoxText);
|
||||
comboBoxLayout->addWidget(audioInputDeviceComboBox);
|
||||
comboBoxLayout->addStretch();
|
||||
comboBoxLayout->addSpacing(10);
|
||||
connect(audioInputDeviceComboBox, &ElaComboBox::currentTextChanged, [this](const QString& text) {
|
||||
AudioInput::getInstance()->setAudioInputDevice(text);
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "音频设置", "成功设置 " + text + " 为当前录音设备", 800.0, this);
|
||||
});
|
||||
|
||||
|
||||
audioInputSpinBox = new ElaSpinBox(this); // SpinBox
|
||||
audioInputSpinBox->setRange(0, 10000);
|
||||
audioInputProgressBar = new ElaProgressBar(this);
|
||||
audioInputProgressBar->setRange(0, 10000);
|
||||
// 关闭ProgressBar的百分比显示
|
||||
audioInputProgressBar->setTextVisible(false);
|
||||
// 将SpinBox和ProgressBar的数值相互绑定
|
||||
connect(audioInputSpinBox, QOverload<int>::of(&ElaSpinBox::valueChanged), [this](int value) {
|
||||
audioInputProgressBar->setValue(value);
|
||||
});
|
||||
connect(audioInputProgressBar, QOverload<int>::of(&ElaProgressBar::valueChanged), [this](int value) {
|
||||
audioInputSpinBox->setValue(value);
|
||||
});
|
||||
|
||||
// 绑定实时录音阈值到ProgressBar,同时归一到0~1000范围内
|
||||
connect(AudioInput::getInstance(), &AudioInput::rmsRealValue, [this](const qreal value) {
|
||||
audioInputProgressBar->setValue(value);
|
||||
});
|
||||
// 当计算完成最优阈值
|
||||
connect(AudioInput::getInstance(), &AudioInput::thresholdCalculated, [this](qreal value) {
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "音频设置", "自动计算出的最优阈值为:" + QString::number(value), 1000, this);
|
||||
// AudioInput会自动设置计算出的最优阈值
|
||||
});
|
||||
audioAutoThresholdStartButton = new ElaPushButton("自动最优阈值", this);
|
||||
audioAutoThresholdStartButton->setToolTip("点击后保持当前环境音5秒,自动计算出最合适的静音检测阈值");
|
||||
connect(audioAutoThresholdStartButton, &ElaPushButton::clicked, [=]() {
|
||||
AudioInput::getInstance()->startAutoThresholdClu(5000);
|
||||
qDebug("开始计算最优阈值");
|
||||
});
|
||||
audioManualThresholdStartButton = new ElaPushButton("手动设置阈值", this);
|
||||
audioManualThresholdStartButton->setToolTip("如果你觉得自动计算的不准的话");
|
||||
connect(audioManualThresholdStartButton, &ElaPushButton::clicked, [this]() {
|
||||
AudioInput::getInstance()->setSilenceThreshold(audioInputSpinBox->value());
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "音频设置", "手动设置的阈值为:" + QString::number(audioInputSpinBox->value()), 1000, this);
|
||||
});
|
||||
|
||||
ElaScrollPageArea* audioInputProgressBarArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* audioInputProgressBarLayout = new QHBoxLayout(audioInputProgressBarArea);
|
||||
ElaText* audioInputProgressBarText = new ElaText("静音检测阈值", this);
|
||||
audioInputProgressBarText->setToolTip("测试当前环境的静音阈值,用于对话中的静音检测");
|
||||
audioInputProgressBarText->setTextPixelSize(15);
|
||||
audioInputProgressBarLayout->addWidget(audioInputProgressBarText);
|
||||
audioInputProgressBarLayout->addWidget(audioInputProgressBar, 1);
|
||||
audioInputProgressBarLayout->addWidget(audioInputSpinBox);
|
||||
audioInputProgressBarLayout->addStretch(); // 添加弹性空间将后续控件推到右侧
|
||||
audioInputProgressBarLayout->addWidget(audioAutoThresholdStartButton);
|
||||
audioInputProgressBarLayout->addWidget(audioManualThresholdStartButton);
|
||||
audioInputProgressBarLayout->addStretch();
|
||||
audioInputProgressBarLayout->addSpacing(10);
|
||||
|
||||
testAudioPlayButton = new ElaPushButton("播放测试", this);
|
||||
testAudioPlayButton->setToolTip("播放一段测试音频来检测播放功能是否正常,注意观察模型嘴唇以及文字下落动画");
|
||||
ElaScrollPageArea* testAudioArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* testAudioLayout = new QHBoxLayout(testAudioArea);
|
||||
ElaText* testAudioText = new ElaText("测试", this);
|
||||
testAudioText->setTextPixelSize(15);
|
||||
testAudioLayout->addWidget(testAudioText);
|
||||
testAudioLayout->addStretch(); // 添加弹性空间将后续控件推到右侧
|
||||
testAudioLayout->addWidget(testAudioPlayButton);
|
||||
testAudioLayout->addSpacing(10);
|
||||
connect(testAudioPlayButton, &ElaPushButton::clicked, [this]() {
|
||||
const QString text = "あれアイリーじゃないよ!急にいなくなるからどこに行ったのかと思えば~";
|
||||
constexpr float duration = 6.0f; // 音频时长
|
||||
TextRenderer::getInstance()->addText(text, 40.0f, QColor("#FF69B4"), duration);
|
||||
LAppLive2DManager::GetInstance()->StartLipSync("Resources/TestFiles/test.wav");
|
||||
AudioOutput::getInstance()->playUrl(QUrl("Resources/TestFiles/test.wav"));
|
||||
});
|
||||
|
||||
|
||||
QWidget* centralWidget = new QWidget(this);
|
||||
centralWidget->setWindowTitle("音频设置");
|
||||
QVBoxLayout* centerLayout = new QVBoxLayout(centralWidget);
|
||||
centerLayout->addWidget(comboBoxArea);
|
||||
centerLayout->addWidget(audioInputProgressBarArea);
|
||||
centerLayout->addWidget(testAudioArea);
|
||||
centerLayout->addStretch();
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
addCentralWidget(centralWidget, true, true, 0);
|
||||
|
||||
}
|
||||
|
||||
AudioPage::~AudioPage()
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/27.
|
||||
//
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "BasePage.h"
|
||||
#include "ElaMenu.h"
|
||||
#include "ElaText.h"
|
||||
#include "ElaTheme.h"
|
||||
#include "ElaToolButton.h"
|
||||
|
||||
BasePage::BasePage(QWidget* parent)
|
||||
: ElaScrollPage(parent)
|
||||
{
|
||||
connect(eTheme, &ElaTheme::themeModeChanged, this, [=, this]() {
|
||||
if (!parent)
|
||||
{
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
BasePage::~BasePage()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void BasePage::createCustomWidget(QString desText)
|
||||
{
|
||||
// 顶部元素
|
||||
QWidget* customWidget = new QWidget(this);
|
||||
ElaText* subTitleText = new ElaText(this);
|
||||
subTitleText->setText("https://github.com/Liniyous/ElaWidgetTools");
|
||||
subTitleText->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
subTitleText->setTextPixelSize(11);
|
||||
|
||||
ElaToolButton* documentationButton = new ElaToolButton(this);
|
||||
documentationButton->setFixedHeight(35);
|
||||
documentationButton->setIsTransparent(false);
|
||||
documentationButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||
//_toolButton->setPopupMode(QToolButton::MenuButtonPopup);
|
||||
documentationButton->setText("Documentation");
|
||||
documentationButton->setElaIcon(ElaIconType::FileDoc);
|
||||
ElaMenu* documentationMenu = new ElaMenu(this);
|
||||
documentationMenu->addElaIconAction(ElaIconType::CardsBlank, "CardsBlank");
|
||||
documentationMenu->addElaIconAction(ElaIconType::EarthAmericas, "EarthAmericas");
|
||||
documentationButton->setMenu(documentationMenu);
|
||||
|
||||
ElaToolButton* sourceButton = new ElaToolButton(this);
|
||||
sourceButton->setFixedHeight(35);
|
||||
sourceButton->setIsTransparent(false);
|
||||
sourceButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||
sourceButton->setText("Source");
|
||||
sourceButton->setElaIcon(ElaIconType::NfcSymbol);
|
||||
ElaMenu* sourceMenu = new ElaMenu(this);
|
||||
sourceMenu->addElaIconAction(ElaIconType::FireBurner, "FireBurner");
|
||||
sourceMenu->addElaIconAction(ElaIconType::Galaxy, "Galaxy~~~~");
|
||||
sourceButton->setMenu(sourceMenu);
|
||||
|
||||
ElaToolButton* themeButton = new ElaToolButton(this);
|
||||
themeButton->setFixedSize(35, 35);
|
||||
themeButton->setIsTransparent(false);
|
||||
themeButton->setElaIcon(ElaIconType::MoonStars);
|
||||
connect(themeButton, &ElaToolButton::clicked, this, [=]() {
|
||||
eTheme->setThemeMode(eTheme->getThemeMode() == ElaThemeType::Light ? ElaThemeType::Dark : ElaThemeType::Light);
|
||||
});
|
||||
|
||||
QHBoxLayout* buttonLayout = new QHBoxLayout();
|
||||
buttonLayout->addWidget(documentationButton);
|
||||
buttonLayout->addSpacing(5);
|
||||
buttonLayout->addWidget(sourceButton);
|
||||
buttonLayout->addStretch();
|
||||
buttonLayout->addWidget(themeButton);
|
||||
buttonLayout->addSpacing(15);
|
||||
|
||||
ElaText* descText = new ElaText(this);
|
||||
descText->setText(desText);
|
||||
descText->setTextPixelSize(13);
|
||||
|
||||
QVBoxLayout* topLayout = new QVBoxLayout(customWidget);
|
||||
topLayout->setContentsMargins(0, 0, 0, 0);
|
||||
topLayout->addWidget(subTitleText);
|
||||
topLayout->addSpacing(5);
|
||||
topLayout->addLayout(buttonLayout);
|
||||
topLayout->addSpacing(5);
|
||||
topLayout->addWidget(descText);
|
||||
setCustomWidget(customWidget);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Created by Administrator on 2025/2/28.
|
||||
//
|
||||
#include "HomePage.h"
|
||||
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ElaAcrylicUrlCard.h"
|
||||
#include "ElaFlowLayout.h"
|
||||
#include "ElaImageCard.h"
|
||||
#include "ElaMenu.h"
|
||||
#include "ElaMessageBar.h"
|
||||
#include "ElaNavigationRouter.h"
|
||||
#include "ElaPopularCard.h"
|
||||
#include "ElaScrollArea.h"
|
||||
#include "ElaText.h"
|
||||
#include "ElaToolTip.h"
|
||||
HomePage::HomePage(QWidget* parent)
|
||||
: BasePage(parent)
|
||||
{
|
||||
// 预览窗口标题
|
||||
setWindowTitle("Home");
|
||||
|
||||
setTitleVisible(false);
|
||||
setContentsMargins(2, 2, 0, 0);
|
||||
// 标题卡片区域
|
||||
ElaText* desText = new ElaText("UI By ElaWidgetTools", this);
|
||||
desText->setTextPixelSize(18);
|
||||
ElaText* titleText = new ElaText("Yosuga!", this);
|
||||
titleText->setTextPixelSize(35);
|
||||
|
||||
QVBoxLayout* titleLayout = new QVBoxLayout();
|
||||
titleLayout->setContentsMargins(30, 60, 0, 0);
|
||||
titleLayout->addWidget(desText);
|
||||
titleLayout->addWidget(titleText);
|
||||
|
||||
ElaImageCard* backgroundCard = new ElaImageCard(this);
|
||||
backgroundCard->setBorderRadius(10);
|
||||
backgroundCard->setFixedHeight(400);
|
||||
backgroundCard->setMaximumAspectRatio(1.7);
|
||||
backgroundCard->setCardImage(QImage("Resources/Pic/Airi/Airi_Background.png"));
|
||||
|
||||
ElaAcrylicUrlCard* urlCard1 = new ElaAcrylicUrlCard(this);
|
||||
urlCard1->setCardPixmapSize(QSize(62, 62));
|
||||
urlCard1->setFixedSize(195, 225);
|
||||
urlCard1->setTitlePixelSize(17);
|
||||
urlCard1->setTitleSpacing(25);
|
||||
urlCard1->setSubTitleSpacing(13);
|
||||
urlCard1->setUrl("https://github.com/Misakityan/Yosuga");
|
||||
urlCard1->setCardPixmap(QPixmap("Resources/Pic/Others/img.png"));
|
||||
urlCard1->setTitle("Yosuga Github");
|
||||
urlCard1->setSubTitle("Star++!");
|
||||
ElaToolTip* urlCard1ToolTip = new ElaToolTip(urlCard1);
|
||||
urlCard1ToolTip->setToolTip("点击前往本项目GitHub");
|
||||
|
||||
ElaAcrylicUrlCard* urlCard2 = new ElaAcrylicUrlCard(this);
|
||||
urlCard2->setCardPixmapSize(QSize(62, 62));
|
||||
urlCard2->setFixedSize(195, 225);
|
||||
urlCard2->setTitlePixelSize(17);
|
||||
urlCard2->setTitleSpacing(25);
|
||||
urlCard2->setSubTitleSpacing(13);
|
||||
urlCard2->setUrl("https://space.bilibili.com/140315806");
|
||||
urlCard2->setCardPixmap(QPixmap("Resources/Pic/Others/Misaki.jpg"));
|
||||
urlCard2->setTitle("Misaki");
|
||||
urlCard2->setSubTitle("1841738040@qq.com");
|
||||
ElaToolTip* urlCard2ToolTip = new ElaToolTip(urlCard2);
|
||||
urlCard2ToolTip->setToolTip("点击前往 Misaki 的个人主页");
|
||||
|
||||
ElaScrollArea* cardScrollArea = new ElaScrollArea(this);
|
||||
cardScrollArea->setWidgetResizable(true);
|
||||
cardScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
cardScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
cardScrollArea->setIsGrabGesture(true, 0);
|
||||
cardScrollArea->setIsOverShoot(Qt::Horizontal, true);
|
||||
QWidget* cardScrollAreaWidget = new QWidget(this);
|
||||
cardScrollAreaWidget->setStyleSheet("background-color:transparent;");
|
||||
cardScrollArea->setWidget(cardScrollAreaWidget);
|
||||
QHBoxLayout* urlCardLayout = new QHBoxLayout();
|
||||
urlCardLayout->setSpacing(15);
|
||||
urlCardLayout->setContentsMargins(30, 0, 0, 6);
|
||||
urlCardLayout->addWidget(urlCard1);
|
||||
urlCardLayout->addWidget(urlCard2);
|
||||
urlCardLayout->addStretch();
|
||||
QVBoxLayout* cardScrollAreaWidgetLayout = new QVBoxLayout(cardScrollAreaWidget);
|
||||
cardScrollAreaWidgetLayout->setContentsMargins(0, 0, 0, 0);
|
||||
cardScrollAreaWidgetLayout->addStretch();
|
||||
cardScrollAreaWidgetLayout->addLayout(urlCardLayout);
|
||||
|
||||
QVBoxLayout* backgroundLayout = new QVBoxLayout(backgroundCard);
|
||||
backgroundLayout->setContentsMargins(0, 0, 0, 0);
|
||||
backgroundLayout->addLayout(titleLayout);
|
||||
backgroundLayout->addWidget(cardScrollArea);
|
||||
|
||||
// 推荐卡片
|
||||
ElaText* flowText = new ElaText("快速转到", this);
|
||||
flowText->setTextPixelSize(20);
|
||||
QHBoxLayout* flowTextLayout = new QHBoxLayout();
|
||||
flowTextLayout->setContentsMargins(33, 0, 0, 0);
|
||||
flowTextLayout->addWidget(flowText);
|
||||
// ElaFlowLayout
|
||||
// 模型商店卡片
|
||||
ElaPopularCard* ModeShopCard = new ElaPopularCard(this);
|
||||
connect(ModeShopCard, &ElaPopularCard::popularCardButtonClicked, this, [=, this]() {
|
||||
Q_EMIT modelShopNavigation();
|
||||
});
|
||||
ModeShopCard->setCardPixmap(QPixmap("Resources/Pic/Others/Live2D.png"));
|
||||
ModeShopCard->setTitle("模型设置");
|
||||
ModeShopCard->setSubTitle("属于你的Live2D模型");
|
||||
ModeShopCard->setInteractiveTips("By Misaki");
|
||||
ModeShopCard->setDetailedText("选择你喜欢的Live2D模型");
|
||||
// 音频设置卡片
|
||||
ElaPopularCard* AudioSettingCard = new ElaPopularCard(this);
|
||||
connect(AudioSettingCard, &ElaPopularCard::popularCardButtonClicked, this, [=, this]() {
|
||||
Q_EMIT audioNavigation();
|
||||
});
|
||||
AudioSettingCard->setTitle("音频设置");
|
||||
AudioSettingCard->setSubTitle("录音与播放的设置");
|
||||
AudioSettingCard->setCardPixmap(QPixmap("Resources/Pic/control/AutomationProperties.png"));
|
||||
AudioSettingCard->setInteractiveTips("By Misaki");
|
||||
AudioSettingCard->setDetailedText("自定义音频与播放的相关设定,打造最舒适的交流环境。");
|
||||
|
||||
ElaFlowLayout* flowLayout = new ElaFlowLayout(0, 5, 5);
|
||||
flowLayout->setContentsMargins(30, 0, 0, 0);
|
||||
flowLayout->setIsAnimation(true);
|
||||
flowLayout->addWidget(ModeShopCard);
|
||||
flowLayout->addWidget(AudioSettingCard);
|
||||
|
||||
|
||||
QWidget* centralWidget = new QWidget(this);
|
||||
centralWidget->setWindowTitle("Home");
|
||||
QVBoxLayout* centerVLayout = new QVBoxLayout(centralWidget);
|
||||
centerVLayout->setSpacing(0);
|
||||
centerVLayout->setContentsMargins(0, 0, 0, 0);
|
||||
centerVLayout->addWidget(backgroundCard);
|
||||
centerVLayout->addSpacing(20);
|
||||
centerVLayout->addLayout(flowTextLayout);
|
||||
centerVLayout->addSpacing(10);
|
||||
centerVLayout->addLayout(flowLayout);
|
||||
centerVLayout->addStretch();
|
||||
addCentralWidget(centralWidget);
|
||||
}
|
||||
|
||||
HomePage::~HomePage()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// Created by Administrator on 2025/4/1.
|
||||
//
|
||||
#include "ModelPage.h"
|
||||
|
||||
#include <QFileDialog>
|
||||
#include <QHBoxLayout>
|
||||
#include "ElaMessageBar.h"
|
||||
#include "ElaScrollPageArea.h"
|
||||
#include "ElaText.h"
|
||||
#include <QtConcurrent>
|
||||
#include <QOpenGLContext>
|
||||
#include <QOffscreenSurface>
|
||||
#include <QFutureWatcher>
|
||||
#include "LAppLive2DManager.hpp"
|
||||
|
||||
ModelPage::ModelPage(QWidget *parent)
|
||||
: BasePage(parent) {
|
||||
// 预览窗口标题
|
||||
setWindowTitle("ModelPage");
|
||||
|
||||
|
||||
modelUrlEdit = new ElaLineEdit(this); // 模型Url Edit
|
||||
modelUrlEdit->setFixedWidth(300);
|
||||
modelUrlEdit->setPlaceholderText("用于显示当前的模型Url");
|
||||
|
||||
modelChoosePushButton = new ElaPushButton("选择模型", this);
|
||||
modelChoosePushButton->setToolTip("选择.model3.json结尾的文件");
|
||||
|
||||
modelUsePushButton = new ElaPushButton("使用模型", this);
|
||||
modelUsePushButton->setToolTip("使用选择的模型或Url对应的模型");
|
||||
|
||||
ElaScrollPageArea *modelSetArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout *modelSetLayout = new QHBoxLayout(modelSetArea);
|
||||
ElaText *modelSetText = new ElaText("模型设置", this);
|
||||
modelSetText->setTextPixelSize(15);
|
||||
modelSetLayout->addWidget(modelSetText);
|
||||
modelSetLayout->addWidget(modelUrlEdit);
|
||||
modelSetLayout->addStretch();
|
||||
modelSetLayout->addWidget(modelChoosePushButton);
|
||||
modelSetLayout->addWidget(modelUsePushButton);
|
||||
modelSetLayout->addSpacing(10);
|
||||
|
||||
// 创建滑块控件和标签
|
||||
ElaText *modelSizeText = new ElaText("模型大小比例设置", this);
|
||||
modelSizeText->setToolTip("实时调整模型大小");
|
||||
modelSizeText->setTextPixelSize(15);
|
||||
modelSlider = new ElaSlider(this); // 滑块(用于设置模型实时大小)
|
||||
modelSlider->setRange(0, 99); // 设置范围
|
||||
modelSlider->setValue(85); // 设置默认值
|
||||
modelSlider->setOrientation(Qt::Horizontal); // 水平方向
|
||||
// 创建独立的区域容器
|
||||
ElaScrollPageArea *modelSliderArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout *modelSliderLayout = new QHBoxLayout(modelSliderArea);
|
||||
|
||||
// 添加到布局(加一个标签显示数值)
|
||||
ElaText *modelSliderValueText = new ElaText("85%", this);
|
||||
modelSliderValueText->setTextPixelSize(14);
|
||||
connect(modelSlider, &ElaSlider::valueChanged, this, [modelSliderValueText](const int value){
|
||||
modelSliderValueText->setText(QString("%1%").arg(value + 1));
|
||||
LAppLive2DManager::GetInstance()->ModelSizeChange(100 - value); // 实时更新模型大小
|
||||
});
|
||||
modelSliderLayout->addWidget(modelSizeText);
|
||||
modelSliderLayout->addWidget(modelSlider);
|
||||
modelSliderLayout->addWidget(modelSliderValueText);
|
||||
modelSliderLayout->addStretch(); // 让内容靠左
|
||||
|
||||
connect(modelChoosePushButton, &ElaPushButton::clicked, this, [this]() {
|
||||
// 创建对话框对象(使用 heap 分配,由 Qt 对象树管理内存)
|
||||
auto *fileDialog = new QFileDialog(this);
|
||||
fileDialog->setWindowTitle("选择模型文件");
|
||||
fileDialog->setNameFilter("Live2D Model (*.model3.json)");
|
||||
|
||||
// 设置初始目录
|
||||
const QString exeDir = QCoreApplication::applicationDirPath(); // 获取当前exe所在目录的本地路径
|
||||
fileDialog->setDirectory(exeDir); // 设置初始目录为当前 exe 所在目录
|
||||
|
||||
// 连接信号:当用户选中文件并点击打开时
|
||||
connect(fileDialog, &QFileDialog::fileSelected, this, [this, fileDialog](const QString &file) {
|
||||
modelFileUrl = QUrl::fromLocalFile(file);
|
||||
if (!modelFileUrl.isEmpty()) {
|
||||
const QString t = modelFileUrl.toLocalFile();
|
||||
const std::pair<QString, QString> path = this->splitPath(t);
|
||||
this->modelFilePathFirst = path.first;
|
||||
this->modelFilePathSecond = path.second;
|
||||
this->modelUrlEdit->setText(t);
|
||||
ElaMessageBar::success(ElaMessageBarType::BottomRight, "模型设置", "模型选择成功", 2000, this);
|
||||
}
|
||||
// 用完即弃,自动清理内存
|
||||
fileDialog->deleteLater();
|
||||
});
|
||||
// 处理取消的情况(防止内存泄漏)
|
||||
connect(fileDialog, &QFileDialog::rejected, fileDialog, &QObject::deleteLater);
|
||||
// 显示对话框(非阻塞,不会卡住主界面)
|
||||
fileDialog->open();
|
||||
});
|
||||
connect(modelUsePushButton, &ElaPushButton::clicked, this, [this]() {
|
||||
// 模型使用
|
||||
if (modelFileUrl.isEmpty()) {
|
||||
ElaMessageBar::information(ElaMessageBarType::BottomRight, "模型设置", "似乎并没有选择模型", 800.0, this);
|
||||
return;
|
||||
}
|
||||
// UI 状态设置为加载中
|
||||
modelUsePushButton->setEnabled(false); // 禁用使用按钮
|
||||
modelUsePushButton->setText("加载中"); // 修改按钮文本
|
||||
modelChoosePushButton->setEnabled(false); // 禁用选择按钮
|
||||
|
||||
// 获取路径字符串 (必须按值传递给lambda)
|
||||
std::string dir = this->modelFilePathFirst.toStdString();
|
||||
std::string filename = this->modelFilePathSecond.toStdString();
|
||||
|
||||
// 启动异步任务
|
||||
QFuture<LAppModel *> future = QtConcurrent::run([dir, filename]() -> LAppModel * {
|
||||
// 以下代码在子线程执行
|
||||
// 创建临时 OpenGL 上下文
|
||||
auto *context = new QOpenGLContext();
|
||||
// 关键点:设置与全局共享上下文共享 (这样主线程才能看到纹理)
|
||||
context->setShareContext(QOpenGLContext::globalShareContext());
|
||||
if (!context->create()) {
|
||||
delete context;
|
||||
return nullptr;
|
||||
}
|
||||
// 创建离屏表面 (因为子线程没有窗口,需要一个假的绘制表面)
|
||||
auto *surface = new QOffscreenSurface();
|
||||
surface->setFormat(context->format());
|
||||
surface->create();
|
||||
// 绑定上下文
|
||||
if (!context->makeCurrent(surface)) {
|
||||
delete surface;
|
||||
delete context;
|
||||
return nullptr;
|
||||
}
|
||||
// 执行真正的耗时加载
|
||||
// 调用在 Manager 里新写的函数
|
||||
LAppModel *model = LAppLive2DManager::GetInstance()->LoadModelInstance(dir, filename);
|
||||
// 清理子线程资源
|
||||
context->doneCurrent();
|
||||
delete surface;
|
||||
delete context;
|
||||
|
||||
return model;
|
||||
});
|
||||
|
||||
// 监控任务结束
|
||||
auto *watcher = new QFutureWatcher<LAppModel *>();
|
||||
connect(watcher, &QFutureWatcher<LAppModel *>::finished, this, [this, watcher]() {
|
||||
// 下面的代码在主线程中执行
|
||||
LAppModel *newModel = watcher->result();
|
||||
if (newModel) {
|
||||
// 调用挂载函数,瞬间完成切换
|
||||
LAppLive2DManager::GetInstance()->MountLoadedModel(newModel);
|
||||
ElaMessageBar::success(ElaMessageBarType::BottomRight, "成功", "模型加载完成", 2000, this);
|
||||
} else {
|
||||
ElaMessageBar::error(ElaMessageBarType::BottomRight, "错误", "模型加载失败 (OpenGL环境异常)", 2000, this);
|
||||
}
|
||||
// 恢复 UI
|
||||
modelUsePushButton->setEnabled(true);
|
||||
modelUsePushButton->setText("使用模型");
|
||||
modelChoosePushButton->setEnabled(true);
|
||||
watcher->deleteLater();
|
||||
});
|
||||
// 开始监控
|
||||
watcher->setFuture(future);
|
||||
});
|
||||
|
||||
|
||||
QWidget *centralWidget = new QWidget(this);
|
||||
centralWidget->setWindowTitle("模型设置");
|
||||
QVBoxLayout *centerLayout = new QVBoxLayout(centralWidget);
|
||||
centerLayout->addWidget(modelSetArea);
|
||||
centerLayout->addWidget(modelSliderArea);
|
||||
centerLayout->addStretch();
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
addCentralWidget(centralWidget, true, true, 0);
|
||||
}
|
||||
|
||||
// 返回 pair<目录路径, 文件名>
|
||||
std::pair<QString, QString> ModelPage::splitPath(const QString &fullPath) {
|
||||
const QFileInfo fileInfo(fullPath);
|
||||
|
||||
// 获取目录部分(自动处理末尾斜杠)
|
||||
QString dirPath = fileInfo.dir().absolutePath() + "/";
|
||||
|
||||
// 获取文件名部分(如果是目录则返回空)
|
||||
QString fileName = fileInfo.fileName();
|
||||
|
||||
return {dirPath, fileName};
|
||||
}
|
||||
|
||||
ModelPage::~ModelPage() {
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/2.
|
||||
//
|
||||
|
||||
#include "NetworkPage.h"
|
||||
#include <QHBoxLayout>
|
||||
#include "ElaScrollPageArea.h"
|
||||
#include "ElaText.h"
|
||||
#include "ElaMessageBar.h"
|
||||
#include "websocketmanager.h"
|
||||
#include "NetWorkDO.h"
|
||||
#include <QFile>
|
||||
|
||||
NetWorkPage::NetWorkPage(QWidget* parent)
|
||||
: BasePage(parent)
|
||||
{
|
||||
// 预览窗口标题
|
||||
setWindowTitle("NetworkPage");
|
||||
|
||||
this->initUI(); // 初始化UI
|
||||
this->initWebSocketClient(); // 初始化websocket客户端(主要是相关的信号与槽)
|
||||
}
|
||||
|
||||
NetWorkPage::~NetWorkPage()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void NetWorkPage::initUI() {
|
||||
// websocket UI
|
||||
websocketPushButton = new ElaPushButton("设定",this);
|
||||
websocketPushButton->setToolTip("设定服务端WebSocket地址");
|
||||
websocketLineEdit = new ElaLineEdit(this);
|
||||
websocketLineEdit->setPlaceholderText("请输入服务端WebSocket地址");
|
||||
websocketLineEdit->setFixedWidth(300); // 设置websocketLineEdit框的宽度
|
||||
|
||||
ElaScrollPageArea* websocketToggleSwitchArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* websocketToggleSwitchLayout = new QHBoxLayout(websocketToggleSwitchArea);
|
||||
ElaText* websocketToggleSwitchText = new ElaText("服务端WebSocket地址:", this);
|
||||
websocketToggleSwitchText->setTextPixelSize(15);
|
||||
websocketToggleSwitchLayout->addWidget(websocketToggleSwitchText);
|
||||
websocketToggleSwitchLayout->addWidget(websocketLineEdit);
|
||||
websocketToggleSwitchLayout->addStretch();
|
||||
websocketToggleSwitchLayout->addWidget(websocketPushButton);
|
||||
websocketToggleSwitchLayout->addSpacing(10);
|
||||
// 连通测试按钮
|
||||
connectTestPushButton = new ElaPushButton("连通测试",this);
|
||||
connectTestPushButton->setToolTip("测试与服务器连通性(如果成功连通会自动连上服务器)");
|
||||
connectPushButton = new ElaPushButton("连接",this);
|
||||
disconnectPushButton = new ElaPushButton("断开",this);
|
||||
sendTestPushButton = new ElaPushButton("发送测试",this);
|
||||
ElaScrollPageArea* connectTestArea = new ElaScrollPageArea(this); // 创建一个滚动页面
|
||||
QHBoxLayout* connectTestLayout = new QHBoxLayout(connectTestArea);
|
||||
connectTestLayout->addWidget(connectTestPushButton); // 将连通测试按钮添加到布局中
|
||||
connectTestLayout->addStretch(); // 添加一个空格
|
||||
connectTestLayout->addWidget(sendTestPushButton); // 将发送测试按钮添加到布局中
|
||||
connectTestLayout->addWidget(disconnectPushButton); // 将断开按钮添加到布局中
|
||||
connectTestLayout->addWidget(connectPushButton); // 将连接按钮添加到布局中
|
||||
connectTestLayout->addSpacing(10);
|
||||
// 添加到布局
|
||||
QWidget* centralWidget = new QWidget(this); // 中心部件
|
||||
centralWidget->setWindowTitle("连接设置"); // 中心部件标题
|
||||
QVBoxLayout* centerLayout = new QVBoxLayout(centralWidget); // 中心部件布局
|
||||
centerLayout->addWidget(websocketToggleSwitchArea); // 将websocketToggleSwitchArea添加到布局中
|
||||
centerLayout->addWidget(connectTestArea); // 将connectTestArea添加到布局中
|
||||
|
||||
centerLayout->addStretch();
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0); // 设置布局的边距
|
||||
addCentralWidget(centralWidget, true, true, 0); // 添加中心部件
|
||||
}
|
||||
|
||||
void NetWorkPage::initWebSocketClient() {
|
||||
auto* client = WebSocketClient::getInstance(); // 获取单例实例(设置一个默认地址)
|
||||
auto* netDO = NetworkDO::getInstance();
|
||||
// 注入:将底层发送能力赋予 NetworkDO
|
||||
netDO->registerSender([client](const QString& type, const QJsonObject& data){
|
||||
client->sendJson(type, data);
|
||||
});
|
||||
// 监听:底层收到数据 -> NetworkDO 解析
|
||||
connect(client, &WebSocketClient::jsonReceived,
|
||||
netDO, &NetworkDO::onDataReceived);
|
||||
// 连接成功的处理
|
||||
connect(client, &WebSocketClient::connected, this, [this]() {
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "WebSocket", "连接成功", 800.0, this);
|
||||
});
|
||||
// 连接失败的处理
|
||||
connect(client, &WebSocketClient::error, this, [this](const QString& errorMsg) {
|
||||
ElaMessageBar::error(ElaMessageBarType::TopLeft, "WebSocket错误", errorMsg, 1500.0, this);
|
||||
});
|
||||
// 断开连接的处理
|
||||
connect(client, &WebSocketClient::disconnected, this, [this]() {
|
||||
ElaMessageBar::information(ElaMessageBarType::BottomRight, "WebSocket", "连接已断开", 800.0, this);
|
||||
});
|
||||
// 接收数据处理
|
||||
connect(client, &WebSocketClient::jsonReceived, this, [](const QString &type, const QJsonObject &data) {
|
||||
qDebug() << "Received JSON data: " << type << " " << data;
|
||||
});
|
||||
|
||||
connect(websocketPushButton, &ElaPushButton::clicked, this, [this, client]() { // 设置服务端websocket地址
|
||||
const QUrl url(websocketLineEdit->text().trimmed()); // 从LineEdit中获取服务端websocket地址
|
||||
// 初始化客户端
|
||||
if (client->setConfiguration(url)) {
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "连接设置",
|
||||
QString("服务器地址已设置为: %1").arg(url.toString()), 800.0, this);
|
||||
return;
|
||||
}
|
||||
ElaMessageBar::warning(ElaMessageBarType::TopLeft, "连接设置",
|
||||
QString("服务器地址存在问题"), 800.0, this);
|
||||
});
|
||||
|
||||
connect(connectTestPushButton, &ElaPushButton::clicked, this, [this, client]() {
|
||||
if (client->isConnected()) {
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "连通测试",
|
||||
"当前已连通", 800.0, this);
|
||||
return;
|
||||
}
|
||||
client->connectToServer(); // 连接
|
||||
// 使用定时器延迟检查连接状态
|
||||
QTimer::singleShot(1000, this, [this, client]() {
|
||||
if (!client->isConnected()) {
|
||||
ElaMessageBar::warning(ElaMessageBarType::TopLeft, "连通测试",
|
||||
"无法连接到服务器,请检查地址和服务器状态", 1500.0, this);
|
||||
return;
|
||||
}
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "连通测试",
|
||||
"连通测试成功", 800.0, this);
|
||||
});
|
||||
});
|
||||
connect(connectPushButton, &ElaPushButton::clicked, this, [this, client]() {
|
||||
if (client->isConnected()) {
|
||||
ElaMessageBar::information(ElaMessageBarType::TopRight, "连接状态",
|
||||
"已连接,无需重复连接", 800.0, this);
|
||||
return;
|
||||
}
|
||||
const QString urlStr = websocketLineEdit->text().trimmed();
|
||||
if (urlStr.isEmpty()) {
|
||||
ElaMessageBar::warning(ElaMessageBarType::TopLeft, "连接",
|
||||
"请先设置服务器地址", 800.0, this);
|
||||
return;
|
||||
}
|
||||
// 确保使用正确的地址
|
||||
client->setConfiguration(QUrl(urlStr));
|
||||
client->connectToServer();
|
||||
// 连接结果会在 connected/error 信号中处理
|
||||
ElaMessageBar::information(ElaMessageBarType::TopRight, "连接",
|
||||
"正在连接服务器...", 800.0, this);
|
||||
});
|
||||
connect(disconnectPushButton, &ElaPushButton::clicked, this, [this, client]() {
|
||||
if (!client->isConnected()) {
|
||||
ElaMessageBar::information(ElaMessageBarType::BottomRight, "断开连接",
|
||||
"当前未连接", 800.0, this);
|
||||
return;
|
||||
}
|
||||
client->disconnectFromServer();
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "断开连接",
|
||||
"已断开连接", 800.0, this);
|
||||
});
|
||||
connect(sendTestPushButton, &ElaPushButton::clicked, this, [this, netDO, client]() {
|
||||
if (!client->isConnected()) {
|
||||
ElaMessageBar::information(ElaMessageBarType::BottomRight, "断开连接",
|
||||
"当前未连接", 800.0, this);
|
||||
return;
|
||||
}
|
||||
// 创建数据包
|
||||
QFile test_wav_file("Resources/TestFiles/test.wav");
|
||||
if (!test_wav_file.open(QIODevice::ReadOnly)) {
|
||||
qDebug() << "Failed to open test.wav";
|
||||
ElaMessageBar::warning(ElaMessageBarType::TopLeft, "发送测试",
|
||||
"无法打开测试音频文件", 1500.0, this);
|
||||
return;
|
||||
}
|
||||
QByteArray wavData = test_wav_file.readAll();
|
||||
QString base64Str = QString::fromLatin1(wavData.toBase64());
|
||||
AudioDataTransferObject packet;
|
||||
packet.setData("Owner", "client")
|
||||
.setData("isStream", true)
|
||||
.setData("sequence", 42)
|
||||
.setData("text", "Hello World")
|
||||
.setData("data", base64Str); // 填入测试音频数据
|
||||
netDO->sendPacket(packet);
|
||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "发送测试",
|
||||
"已成功发送数据包", 1000.0, this);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/30.
|
||||
//
|
||||
|
||||
#include "RenderPage.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QtWidgets>
|
||||
#include "ElaComboBox.h"
|
||||
#include "ElaMessageBar.h"
|
||||
#include "ElaScrollPageArea.h"
|
||||
#include "ElaText.h"
|
||||
|
||||
#include "GLCore.h"
|
||||
#include "AppContext.h"
|
||||
RenderPage::RenderPage(QWidget* parent)
|
||||
: BasePage(parent)
|
||||
{
|
||||
// 预览窗口标题
|
||||
setWindowTitle("RenderPage");
|
||||
|
||||
frameRateComboBox = new ElaComboBox(this);
|
||||
QStringList frameRateComboList = GLCore::getFrameRateList();
|
||||
frameRateComboBox->addItems(frameRateComboList);
|
||||
ElaScrollPageArea* frameRateComboBoxArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* frameRateComboBoxLayout = new QHBoxLayout(frameRateComboBoxArea);
|
||||
ElaText* frameRateComboBoxText = new ElaText("帧率设置", this);
|
||||
frameRateComboBoxText->setTextPixelSize(15);
|
||||
frameRateComboBoxLayout->addWidget(frameRateComboBoxText);
|
||||
frameRateComboBoxLayout->addStretch();
|
||||
frameRateComboBoxLayout->addWidget(frameRateComboBox);
|
||||
frameRateComboBoxLayout->addSpacing(10);
|
||||
connect(frameRateComboBox, &ElaComboBox::currentTextChanged, this, [this](const QString& text) {
|
||||
AppContext::GetGLCore()->setFrameRate(GLCore::getFrameRateMap().value(text));
|
||||
});
|
||||
|
||||
|
||||
QWidget* centralWidget = new QWidget(this);
|
||||
centralWidget->setWindowTitle("渲染设置");
|
||||
QVBoxLayout* centerLayout = new QVBoxLayout(centralWidget);
|
||||
centerLayout->addWidget(frameRateComboBoxArea);
|
||||
centerLayout->addStretch();
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
addCentralWidget(centralWidget, true, true, 0);
|
||||
|
||||
}
|
||||
|
||||
RenderPage::~RenderPage()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// Created by Administrator on 2025/1/21.
|
||||
//
|
||||
|
||||
|
||||
#include <ElaTheme.h>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSvgRenderer>
|
||||
#include <QPainter>
|
||||
|
||||
|
||||
#include "Setting.h"
|
||||
|
||||
#include "socketmanager.h"
|
||||
Setting::Setting(QWidget *parent)
|
||||
: ElaWindow(parent)
|
||||
{
|
||||
// 设置窗口标题
|
||||
setWindowTitle("设置");
|
||||
// 设置窗口图标
|
||||
setWindowIcon(QIcon("Resources/Pic/Airi/Airi_s.svg"));
|
||||
// 初始化窗口
|
||||
resize(1000, 740);
|
||||
// 禁用窗口缩放按钮
|
||||
setWindowButtonFlag(ElaAppBarType::MaximizeButtonHint, false); // 隐藏最大化按钮
|
||||
setWindowButtonFlag(ElaAppBarType::MinimizeButtonHint, false); // 隐藏最小化按钮
|
||||
// 移动窗口到屏幕中心
|
||||
this->moveToCenter();
|
||||
|
||||
this->setWindowFlag(Qt::Tool); // 隐藏应用程序图标
|
||||
this->setWindowFlag(Qt::WindowStaysOnTopHint); // 默认设置窗口始终在顶部
|
||||
// 设置用户信息卡
|
||||
this->setUserInfoCardTitle("Yosuga");
|
||||
this->setUserInfoCardSubTitle("联系维度的桥梁!");
|
||||
// 加载 SVG 图片
|
||||
QSvgRenderer renderer(QString("Resources/Pic/Airi/Airi.svg"));
|
||||
// 创建 QPixmap 并绘制 SVG
|
||||
QPixmap pixmap(64, 64);
|
||||
pixmap.fill(Qt::transparent); // 设置透明背景
|
||||
QPainter painter(&pixmap);
|
||||
renderer.render(&painter);
|
||||
this->setUserInfoCardPixmap(pixmap);
|
||||
|
||||
// 初始化页面
|
||||
initPages();
|
||||
|
||||
// 创建导航栏和内容区域
|
||||
initNavigationBar();
|
||||
|
||||
// 创建上下文
|
||||
initContent();
|
||||
|
||||
// 设置初始主题
|
||||
eTheme->setThemeMode(ElaThemeType::Dark);
|
||||
}
|
||||
|
||||
Setting::~Setting()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void Setting::initPages()
|
||||
{
|
||||
homePage = new HomePage(this);
|
||||
networkPage = new NetWorkPage(this);
|
||||
uiSetting = new UISetting(this);
|
||||
audioPage = new AudioPage(this);
|
||||
renderPage = new RenderPage(this);
|
||||
modelPage = new ModelPage(this);
|
||||
}
|
||||
|
||||
void Setting::initNavigationBar()
|
||||
{
|
||||
// 添加主页节点(顶级节点)
|
||||
addPageNode("主页", homePage, ElaIconType::House);
|
||||
|
||||
// 添加模型商店节点
|
||||
addPageNode("模型设置", modelPage, ElaIconType::Shop);
|
||||
|
||||
// 添加网络连接设置节点
|
||||
addPageNode("连接设置", networkPage, ElaIconType::NetworkWired);
|
||||
// 添加音频设置节点
|
||||
addPageNode("音频设置", audioPage, ElaIconType::MusicNote);
|
||||
// 添加渲染设置节点
|
||||
addPageNode("渲染设置", renderPage, ElaIconType::ArrowsRotate);
|
||||
|
||||
QString uiSettingKey;
|
||||
addFooterNode("UI设置", uiSetting, uiSettingKey, 0, ElaIconType::GearComplex);
|
||||
}
|
||||
|
||||
void Setting::initContent()
|
||||
{
|
||||
connect(homePage, &HomePage::modelShopNavigation, this, [&](){
|
||||
this->navigation(modelPage->property("ElaPageKey").toString());
|
||||
});
|
||||
connect(homePage, &HomePage::audioNavigation, this, [&](){
|
||||
this->navigation(audioPage->property("ElaPageKey").toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void Setting::toggleTheme()
|
||||
{
|
||||
if (eTheme->getThemeMode() == ElaThemeType::Light) {
|
||||
eTheme->setThemeMode(ElaThemeType::Dark);
|
||||
} else {
|
||||
eTheme->setThemeMode(ElaThemeType::Light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// Created by Administrator on 2025/3/2.
|
||||
//
|
||||
#include "UISetting.h"
|
||||
|
||||
|
||||
#include <QDebug>
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ElaApplication.h"
|
||||
#include "ElaComboBox.h"
|
||||
#include "ElaLog.h"
|
||||
#include "ElaRadioButton.h"
|
||||
#include "ElaScrollPageArea.h"
|
||||
#include "ElaText.h"
|
||||
#include "ElaTheme.h"
|
||||
#include "ElaToggleSwitch.h"
|
||||
#include "ElaWindow.h"
|
||||
UISetting::UISetting(QWidget* parent)
|
||||
: BasePage(parent)
|
||||
{
|
||||
// 预览窗口标题
|
||||
ElaWindow* window = dynamic_cast<ElaWindow*>(parent);
|
||||
setWindowTitle("Setting");
|
||||
|
||||
ElaText* themeText = new ElaText("主题设置", this);
|
||||
themeText->setWordWrap(false);
|
||||
themeText->setTextPixelSize(18);
|
||||
|
||||
_themeComboBox = new ElaComboBox(this);
|
||||
_themeComboBox->addItem("日间模式");
|
||||
_themeComboBox->addItem("夜间模式");
|
||||
ElaScrollPageArea* themeSwitchArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* themeSwitchLayout = new QHBoxLayout(themeSwitchArea);
|
||||
ElaText* themeSwitchText = new ElaText("主题切换", this);
|
||||
themeSwitchText->setWordWrap(false);
|
||||
themeSwitchText->setTextPixelSize(15);
|
||||
themeSwitchLayout->addWidget(themeSwitchText);
|
||||
themeSwitchLayout->addStretch();
|
||||
themeSwitchLayout->addWidget(_themeComboBox);
|
||||
connect(_themeComboBox, QOverload<int>::of(&ElaComboBox::currentIndexChanged), this, [=](int index) {
|
||||
if (index == 0)
|
||||
{
|
||||
eTheme->setThemeMode(ElaThemeType::Light);
|
||||
}
|
||||
else
|
||||
{
|
||||
eTheme->setThemeMode(ElaThemeType::Dark);
|
||||
}
|
||||
});
|
||||
connect(eTheme, &ElaTheme::themeModeChanged, this, [=, this](ElaThemeType::ThemeMode themeMode) {
|
||||
_themeComboBox->blockSignals(true);
|
||||
if (themeMode == ElaThemeType::Light)
|
||||
{
|
||||
_themeComboBox->setCurrentIndex(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
_themeComboBox->setCurrentIndex(1);
|
||||
}
|
||||
_themeComboBox->blockSignals(false);
|
||||
});
|
||||
|
||||
ElaText* helperText = new ElaText("应用程序设置", this);
|
||||
helperText->setWordWrap(false);
|
||||
helperText->setTextPixelSize(18);
|
||||
|
||||
_micaSwitchButton = new ElaToggleSwitch(this);
|
||||
ElaScrollPageArea* micaSwitchArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* micaSwitchLayout = new QHBoxLayout(micaSwitchArea);
|
||||
ElaText* micaSwitchText = new ElaText("启用云母效果", this);
|
||||
micaSwitchText->setWordWrap(false);
|
||||
micaSwitchText->setTextPixelSize(15);
|
||||
micaSwitchLayout->addWidget(micaSwitchText);
|
||||
micaSwitchLayout->addStretch();
|
||||
micaSwitchLayout->addWidget(_micaSwitchButton);
|
||||
connect(_micaSwitchButton, &ElaToggleSwitch::toggled, this, [=](bool checked) {
|
||||
eApp->setIsEnableMica(checked);
|
||||
});
|
||||
|
||||
_logSwitchButton = new ElaToggleSwitch(this);
|
||||
ElaScrollPageArea* logSwitchArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* logSwitchLayout = new QHBoxLayout(logSwitchArea);
|
||||
ElaText* logSwitchText = new ElaText("启用日志功能", this);
|
||||
logSwitchText->setWordWrap(false);
|
||||
logSwitchText->setTextPixelSize(15);
|
||||
logSwitchLayout->addWidget(logSwitchText);
|
||||
logSwitchLayout->addStretch();
|
||||
logSwitchLayout->addWidget(_logSwitchButton);
|
||||
connect(_logSwitchButton, &ElaToggleSwitch::toggled, this, [=](bool checked) {
|
||||
ElaLog::getInstance()->initMessageLog(checked);
|
||||
if (checked)
|
||||
{
|
||||
qDebug() << "日志已启用!";
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "日志已关闭!";
|
||||
}
|
||||
});
|
||||
|
||||
_minimumButton = new ElaRadioButton("Minimum", this);
|
||||
_compactButton = new ElaRadioButton("Compact", this);
|
||||
_maximumButton = new ElaRadioButton("Maximum", this);
|
||||
_autoButton = new ElaRadioButton("Auto", this);
|
||||
_autoButton->setChecked(true);
|
||||
ElaScrollPageArea* displayModeArea = new ElaScrollPageArea(this);
|
||||
QHBoxLayout* displayModeLayout = new QHBoxLayout(displayModeArea);
|
||||
ElaText* displayModeText = new ElaText("导航栏模式选择", this);
|
||||
displayModeText->setWordWrap(false);
|
||||
displayModeText->setTextPixelSize(15);
|
||||
displayModeLayout->addWidget(displayModeText);
|
||||
displayModeLayout->addStretch();
|
||||
displayModeLayout->addWidget(_minimumButton);
|
||||
displayModeLayout->addWidget(_compactButton);
|
||||
displayModeLayout->addWidget(_maximumButton);
|
||||
displayModeLayout->addWidget(_autoButton);
|
||||
connect(_minimumButton, &ElaRadioButton::toggled, this, [=](bool checked) {
|
||||
if (checked)
|
||||
{
|
||||
window->setNavigationBarDisplayMode(ElaNavigationType::Minimal);
|
||||
}
|
||||
});
|
||||
connect(_compactButton, &ElaRadioButton::toggled, this, [=](bool checked) {
|
||||
if (checked)
|
||||
{
|
||||
window->setNavigationBarDisplayMode(ElaNavigationType::Compact);
|
||||
}
|
||||
});
|
||||
connect(_maximumButton, &ElaRadioButton::toggled, this, [=](bool checked) {
|
||||
if (checked)
|
||||
{
|
||||
window->setNavigationBarDisplayMode(ElaNavigationType::Maximal);
|
||||
}
|
||||
});
|
||||
connect(_autoButton, &ElaRadioButton::toggled, this, [=](bool checked) {
|
||||
if (checked)
|
||||
{
|
||||
window->setNavigationBarDisplayMode(ElaNavigationType::Auto);
|
||||
}
|
||||
});
|
||||
|
||||
QWidget* centralWidget = new QWidget(this);
|
||||
centralWidget->setWindowTitle("UI设置");
|
||||
QVBoxLayout* centerLayout = new QVBoxLayout(centralWidget);
|
||||
centerLayout->addSpacing(30);
|
||||
centerLayout->addWidget(themeText);
|
||||
centerLayout->addSpacing(10);
|
||||
centerLayout->addWidget(themeSwitchArea);
|
||||
centerLayout->addSpacing(15);
|
||||
centerLayout->addWidget(helperText);
|
||||
centerLayout->addSpacing(10);
|
||||
centerLayout->addWidget(logSwitchArea);
|
||||
centerLayout->addWidget(micaSwitchArea);
|
||||
centerLayout->addWidget(displayModeArea);
|
||||
centerLayout->addStretch();
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
addCentralWidget(centralWidget, true, true, 0);
|
||||
}
|
||||
|
||||
UISetting::~UISetting()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
设置界面,UI使用Ela UI
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
/**
|
||||
* 屏幕截图与系统信息获取工具类
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QSize>
|
||||
#include <QScreen>
|
||||
#include <QPixmap>
|
||||
#include <QBuffer>
|
||||
#include <QGuiApplication>
|
||||
#include <QWindow>
|
||||
#include <QCursor>
|
||||
#include <QSysInfo>
|
||||
class ScreenHelper
|
||||
{
|
||||
public:
|
||||
// 系统信息struct
|
||||
struct SystemInfo {
|
||||
QString osType; // 例如: "windows", "linux", "macos"
|
||||
QString osVersion; // 例如: "Windows 11 (10.0)", "Ubuntu 22.04"
|
||||
QString displayServer; // 例如: "windows", "cocoa", "xcb" (X11), "wayland"
|
||||
bool isWayland; // 专门标记是否为 Wayland
|
||||
};
|
||||
|
||||
// 截图结果struct
|
||||
struct ScreenshotResult {
|
||||
bool success; // 是否成功
|
||||
QString base64Data; // 图片的Base64字符串 (PNG格式)
|
||||
int width; // 图片宽度
|
||||
int height; // 图片高度
|
||||
QString screenName; // 屏幕名称
|
||||
QString errorMsg; // 如果失败,返回错误信息
|
||||
};
|
||||
public:
|
||||
/**
|
||||
* @brief 获取当前焦点屏幕的全屏截图并转换为Base64 \n
|
||||
* 判定逻辑:优先取有焦点的窗口所在屏幕,若无,取鼠标所在屏幕
|
||||
*/
|
||||
static ScreenshotResult captureFocusedScreen();
|
||||
|
||||
/**
|
||||
* @brief 获取当前操作系统和显示服务信息
|
||||
*/
|
||||
static SystemInfo getSystemInfo();
|
||||
|
||||
private:
|
||||
// 私有构造,禁止实例化
|
||||
ScreenHelper() = default;
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// Created by misaki on 2026/1/26.
|
||||
//
|
||||
|
||||
/**
|
||||
* cobs.hpp
|
||||
* 所谓COBS,即Consistent Overhead Byte Stuffing(持续开销字节填充)
|
||||
* 是一种将字节包编码成不包含值为零的字节(0x00)形式的方法。
|
||||
* 输入的字节包可以包含从 0x00 到 0xFF 的全部范围内的字节。
|
||||
* COBS 编码的数据包保证生成字节范围 0x01 到 0xFF 的数据包。
|
||||
* 因此,在通信协议中,数据包边界可以用 0x00 字节可靠地界定。
|
||||
*
|
||||
* 在Yosuga项目当中,COBS编码被用于解决Yosuga与嵌入式设备使用串口收发数据时出现的粘包问题。
|
||||
* 之所以使用COBS编码而不是常用的字符填充法,这是因为字符填充法会使得数据包的大小无法确定,并且往往会使得数据包变得更大。
|
||||
*
|
||||
* 本模块为COBS的C++实现,而在Yosuga_embedded当中,则使用了cobs的C实现。
|
||||
*
|
||||
* C++20
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
namespace cobs {
|
||||
|
||||
// 状态码
|
||||
enum class [[nodiscard]] Status : uint8_t {
|
||||
OK = 0x00,
|
||||
NULL_POINTER = 0x01,
|
||||
OUT_BUFFER_OVERFLOW = 0x02,
|
||||
ZERO_BYTE_IN_INPUT = 0x04, // 仅 decode
|
||||
INPUT_TOO_SHORT = 0x08 // 仅 decode
|
||||
};
|
||||
|
||||
// 结果结构体
|
||||
struct [[nodiscard]] EncodeResult {
|
||||
size_t out_len = 0;
|
||||
Status status = Status::OK;
|
||||
};
|
||||
|
||||
struct [[nodiscard]] DecodeResult {
|
||||
size_t out_len = 0;
|
||||
Status status = Status::OK;
|
||||
};
|
||||
|
||||
// 缓冲区大小计算
|
||||
constexpr size_t encode_dst_len_max(const size_t src_len) noexcept {
|
||||
return (src_len == 0) ? 1 : (src_len + (src_len + 253) / 254);
|
||||
}
|
||||
|
||||
constexpr size_t decode_dst_len_max(const size_t src_len) noexcept {
|
||||
return (src_len == 0) ? 0 : (src_len - 1);
|
||||
}
|
||||
|
||||
constexpr size_t encode_src_offset(const size_t src_len) noexcept {
|
||||
return (src_len + 253) / 254;
|
||||
}
|
||||
|
||||
// 底层核心实现
|
||||
inline EncodeResult encode_core(std::span<uint8_t> dst, const std::span<const uint8_t> src) noexcept {
|
||||
EncodeResult result;
|
||||
if (dst.empty() || src.empty()) {
|
||||
result.status = Status::NULL_POINTER;
|
||||
return result;
|
||||
}
|
||||
|
||||
const uint8_t* src_read_ptr = src.data();
|
||||
const uint8_t* src_end_ptr = src_read_ptr + src.size();
|
||||
uint8_t* dst_start_ptr = dst.data();
|
||||
const uint8_t* dst_end_ptr = dst_start_ptr + dst.size();
|
||||
uint8_t* dst_code_write_ptr = dst_start_ptr;
|
||||
uint8_t* dst_write_ptr = dst_code_write_ptr + 1;
|
||||
uint8_t search_len = 1;
|
||||
|
||||
if (src.empty()) {
|
||||
*dst_code_write_ptr = search_len;
|
||||
result.out_len = 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
if (dst_write_ptr >= dst_end_ptr) {
|
||||
result.status = Status::OUT_BUFFER_OVERFLOW;
|
||||
break;
|
||||
}
|
||||
|
||||
const uint8_t src_byte = *src_read_ptr++;
|
||||
if (src_byte == 0) {
|
||||
*dst_code_write_ptr = search_len;
|
||||
dst_code_write_ptr = dst_write_ptr++;
|
||||
search_len = 1;
|
||||
if (src_read_ptr >= src_end_ptr) break;
|
||||
} else {
|
||||
*dst_write_ptr++ = src_byte;
|
||||
search_len++;
|
||||
if (src_read_ptr >= src_end_ptr) break;
|
||||
if (search_len == 0xFF) {
|
||||
*dst_code_write_ptr = search_len;
|
||||
dst_code_write_ptr = dst_write_ptr++;
|
||||
search_len = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dst_code_write_ptr >= dst_end_ptr) {
|
||||
result.status = Status::OUT_BUFFER_OVERFLOW;
|
||||
} else {
|
||||
*dst_code_write_ptr = search_len;
|
||||
}
|
||||
|
||||
result.out_len = static_cast<size_t>(dst_write_ptr - dst_start_ptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
inline DecodeResult decode_core(std::span<uint8_t> dst, const std::span<const uint8_t> src) noexcept {
|
||||
DecodeResult result;
|
||||
if (dst.empty() || src.empty()) {
|
||||
result.status = Status::NULL_POINTER;
|
||||
return result;
|
||||
}
|
||||
|
||||
const uint8_t* src_read_ptr = src.data();
|
||||
const uint8_t* src_end_ptr = src_read_ptr + src.size();
|
||||
uint8_t* dst_start_ptr = dst.data();
|
||||
uint8_t* dst_end_ptr = dst_start_ptr + dst.size();
|
||||
uint8_t* dst_write_ptr = dst_start_ptr;
|
||||
|
||||
if (src.empty()) {
|
||||
return result; // out_len = 0, status = OK
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
uint8_t len_code = *src_read_ptr++;
|
||||
if (len_code == 0) {
|
||||
result.status = Status::ZERO_BYTE_IN_INPUT;
|
||||
break;
|
||||
}
|
||||
len_code--;
|
||||
|
||||
auto remaining = static_cast<size_t>(src_end_ptr - src_read_ptr);
|
||||
if (len_code > remaining) {
|
||||
result.status = Status::INPUT_TOO_SHORT;
|
||||
len_code = static_cast<uint8_t>(remaining);
|
||||
}
|
||||
|
||||
remaining = static_cast<size_t>(dst_end_ptr - dst_write_ptr);
|
||||
if (len_code > remaining) {
|
||||
result.status = Status::OUT_BUFFER_OVERFLOW;
|
||||
len_code = static_cast<uint8_t>(remaining);
|
||||
}
|
||||
|
||||
for (uint8_t i = len_code; i != 0; i--) {
|
||||
const uint8_t src_byte = *src_read_ptr++;
|
||||
if (src_byte == 0) {
|
||||
result.status = Status::ZERO_BYTE_IN_INPUT;
|
||||
}
|
||||
*dst_write_ptr++ = src_byte;
|
||||
}
|
||||
|
||||
if (src_read_ptr >= src_end_ptr) break;
|
||||
if (len_code != 0xFE) {
|
||||
if (dst_write_ptr >= dst_end_ptr) {
|
||||
result.status = Status::OUT_BUFFER_OVERFLOW;
|
||||
break;
|
||||
}
|
||||
*dst_write_ptr++ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
result.out_len = static_cast<size_t>(dst_write_ptr - dst_start_ptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 便捷接口(std::vector)
|
||||
inline EncodeResult encode(std::vector<uint8_t>& dst, const std::span<const uint8_t> src) noexcept {
|
||||
dst.resize(encode_dst_len_max(src.size()));
|
||||
const auto result = encode_core(dst, src);
|
||||
dst.resize(result.out_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
inline DecodeResult decode(std::vector<uint8_t>& dst, const std::span<const uint8_t> src) noexcept {
|
||||
dst.resize(decode_dst_len_max(src.size()));
|
||||
const auto result = decode_core(dst, src);
|
||||
dst.resize(result.out_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 便捷接口(std::string)
|
||||
inline EncodeResult encode(std::string& dst, const std::string_view src) noexcept {
|
||||
dst.resize(encode_dst_len_max(src.size()));
|
||||
const auto result = encode_core(
|
||||
std::span<uint8_t>(reinterpret_cast<uint8_t*>(dst.data()), dst.size()),
|
||||
std::span<const uint8_t>(reinterpret_cast<const uint8_t*>(src.data()), src.size())
|
||||
);
|
||||
dst.resize(result.out_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
inline DecodeResult decode(std::string& dst, const std::span<const uint8_t> src) noexcept {
|
||||
std::vector<uint8_t> temp;
|
||||
const auto result = decode(temp, src);
|
||||
if (result.status == Status::OK) {
|
||||
dst.assign(reinterpret_cast<const char*>(temp.data()), temp.size());
|
||||
}
|
||||
return {result.out_len, result.status};
|
||||
}
|
||||
|
||||
// 类型安全辅助函数
|
||||
template <typename T>
|
||||
requires std::is_trivially_copyable_v<T>
|
||||
inline EncodeResult encode(std::vector<uint8_t>& dst, const T& obj) noexcept {
|
||||
return encode(dst, std::span<const uint8_t>(
|
||||
reinterpret_cast<const uint8_t*>(&obj), sizeof(T)
|
||||
));
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
requires std::is_trivially_copyable_v<T>
|
||||
inline DecodeResult decode(T& obj, const std::span<const uint8_t> src) noexcept {
|
||||
return decode_core(std::span<uint8_t>(
|
||||
reinterpret_cast<uint8_t*>(&obj), sizeof(T)
|
||||
), src);
|
||||
}
|
||||
|
||||
} // namespace cobs
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// Created by misaki on 2026/2/1.
|
||||
//
|
||||
#include <QDebug>
|
||||
#include "ScreenHelperUtil.hpp"
|
||||
ScreenHelper::ScreenshotResult ScreenHelper::captureFocusedScreen()
|
||||
{
|
||||
ScreenHelper::ScreenshotResult result;
|
||||
result.success = false;
|
||||
// 获取目标屏幕
|
||||
QScreen *targetScreen = nullptr;
|
||||
|
||||
// 首先尝试获取当前应用程序拥有焦点的窗口所在的屏幕
|
||||
QWindow *focusWindow = QGuiApplication::focusWindow();
|
||||
if (focusWindow) {
|
||||
targetScreen = focusWindow->screen();
|
||||
}
|
||||
// 如果没有窗口焦点或者窗口还没显示,获取鼠标光标所在的屏幕
|
||||
if (!targetScreen) {
|
||||
targetScreen = QGuiApplication::screenAt(QCursor::pos());
|
||||
}
|
||||
|
||||
// 如果以上都失败,回退到主屏幕
|
||||
if (!targetScreen) {
|
||||
targetScreen = QGuiApplication::primaryScreen();
|
||||
}
|
||||
|
||||
if (!targetScreen) {
|
||||
result.errorMsg = "Critical Error: No detectible screen found.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取屏幕基本信息
|
||||
result.screenName = targetScreen->name();
|
||||
|
||||
// 执行截图
|
||||
// grabWindow(0) 表示截取整个屏幕
|
||||
// 注:在 Wayland 上,这可能需要系统权限或会弹出确认框,或者在某些安全策略下返回黑色图像
|
||||
QPixmap pixmap = targetScreen->grabWindow(0);
|
||||
|
||||
if (pixmap.isNull()) {
|
||||
result.errorMsg = "Failed to grab screen content (Permission denied or System restriction).";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.width = pixmap.width();
|
||||
result.height = pixmap.height();
|
||||
|
||||
// 转换为 Base64
|
||||
QByteArray byteArray;
|
||||
QBuffer buffer(&byteArray);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
// 保存为 PNG 格式,质量默认即可
|
||||
if (pixmap.save(&buffer, "PNG")) {
|
||||
result.base64Data = QString::fromLatin1(byteArray.toBase64());
|
||||
result.success = true;
|
||||
} else {
|
||||
result.errorMsg = "Failed to encode image to PNG buffer.";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
ScreenHelper::SystemInfo ScreenHelper::getSystemInfo()
|
||||
{
|
||||
ScreenHelper::SystemInfo info;
|
||||
// 获取操作系统类型
|
||||
info.osType = QSysInfo::productType();
|
||||
|
||||
// 获取详细版本 (例如 Windows 10/11, Ubuntu 20.04)
|
||||
// prettyProductName() 通常能区分 Win10 和 Win11
|
||||
info.osVersion = QSysInfo::prettyProductName();
|
||||
|
||||
// 获取显示服务器类型 (Platform Plugin)
|
||||
// 这里的返回值通常是 QPA 插件的名字
|
||||
// Windows -> "windows"
|
||||
// macOS -> "cocoa"
|
||||
// Linux X11 -> "xcb"
|
||||
// Linux Wayland -> "wayland"
|
||||
QString platformName = QGuiApplication::platformName();
|
||||
info.displayServer = platformName;
|
||||
|
||||
// 专门判断 Wayland
|
||||
info.isWayland = (platformName == "wayland");
|
||||
// 针对 Linux 做更细致的显示名称优化
|
||||
if (platformName == "xcb") {
|
||||
info.displayServer = "X11 (xcb)";
|
||||
} else if (platformName == "wayland") {
|
||||
info.displayServer = "Wayland";
|
||||
}
|
||||
return info;
|
||||
}
|
||||
Reference in New Issue
Block a user