1. 稍微重构了一下项目代码结构,使其更加合理
2. 重构了音频播放类,简化其接口,并支持流式wav音频播放 3. 增加了对流式音频数据的处理类 4. 增加了对GUI自动化操作的处理类
This commit is contained in:
+19
-15
@@ -23,18 +23,20 @@ file(GLOB_RECURSE LAppLive2D
|
|||||||
)
|
)
|
||||||
file(GLOB_RECURSE YosugaSrc
|
file(GLOB_RECURSE YosugaSrc
|
||||||
CONFIGURE_DEPENDS
|
CONFIGURE_DEPENDS
|
||||||
"src/AudioHandle/Src/*.cpp"
|
"src/Handle/AudioHandle/Src/*.cpp"
|
||||||
"src/AudioHandle/Inc/*.h"
|
"src/Handle/AudioHandle/Inc/*.h"
|
||||||
"src/Menu/Src/*.cpp"
|
"src/Handle/NetWorkHandle/Src/*.cpp"
|
||||||
"src/Menu/Inc/*.h"
|
"src/Handle/NetWorkHandle/Inc/*.h"
|
||||||
|
"src/Handle/DataObjectHandle/Src/*.cpp"
|
||||||
|
"src/Handle/DataObjectHandle/Inc/*.h"
|
||||||
|
"src/UI/Menu/Src/*.cpp"
|
||||||
|
"src/UI/Menu/Inc/*.h"
|
||||||
"src/DAO/Inc/*.h"
|
"src/DAO/Inc/*.h"
|
||||||
"src/DAO/Src/*.cpp"
|
"src/DAO/Src/*.cpp"
|
||||||
"src/NetWorkHandle/Src/*.cpp"
|
"src/UI/Setting/Src/*.cpp"
|
||||||
"src/NetWorkHandle/Inc/*.h"
|
"src/UI/Setting/Inc/*.h"
|
||||||
"src/Setting/Src/*.cpp"
|
"src/UI/Render/TextRender/Src/*.cpp"
|
||||||
"src/Setting/Inc/*.h"
|
"src/UI/Render/TextRender/Inc/*.h"
|
||||||
"src/Render/TextRender/Src/*.cpp"
|
|
||||||
"src/Render/TextRender/Inc/*.h"
|
|
||||||
"src/Core/Src/*.cpp"
|
"src/Core/Src/*.cpp"
|
||||||
"src/Core/Inc/*.h"
|
"src/Core/Inc/*.h"
|
||||||
"src/Utils/Inc/*.hpp"
|
"src/Utils/Inc/*.hpp"
|
||||||
@@ -192,11 +194,13 @@ target_include_directories(${PROJECT_NAME}
|
|||||||
3rdparty/Live2D/Src/Core/include
|
3rdparty/Live2D/Src/Core/include
|
||||||
3rdparty/Live2D/Src/stb
|
3rdparty/Live2D/Src/stb
|
||||||
3rdparty/Live2D/Src/LAppLive2D/Inc
|
3rdparty/Live2D/Src/LAppLive2D/Inc
|
||||||
src/AudioHandle/Inc
|
3rdparty/autogui-cpp/src
|
||||||
src/Menu/Inc
|
src/Handle/AudioHandle/Inc
|
||||||
src/NetWorkHandle/Inc
|
src/Handle/NetWorkHandle/Inc
|
||||||
src/Setting/Inc
|
src/Handle/DataObjectHandle/Inc
|
||||||
src/Render/TextRender/Inc
|
src/UI/Menu/Inc
|
||||||
|
src/UI/Setting/Inc
|
||||||
|
src/UI/Render/TextRender/Inc
|
||||||
src/Core/Inc
|
src/Core/Inc
|
||||||
src/DAO/Inc
|
src/DAO/Inc
|
||||||
src/Utils/Inc
|
src/Utils/Inc
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by Administrator on 2025/1/17.
|
|
||||||
//
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QObject>
|
|
||||||
#include <QMediaPlayer> // 音频播放模块
|
|
||||||
#include <QAudioOutput> // QMediaPlayer 的音量控制组件
|
|
||||||
#include <QAudioSink> // 音频输出组件, 用于原始数据播放
|
|
||||||
#include <QUrl>
|
|
||||||
#include <QBuffer>
|
|
||||||
#include <QScopedPointer>
|
|
||||||
#include <QMutex>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief 音频播放模块
|
|
||||||
* @author Misaki
|
|
||||||
* 单例类
|
|
||||||
* 本模块重新基于Qt6重构
|
|
||||||
* 实现的功能
|
|
||||||
* 1. 设定传入的音频文件路径
|
|
||||||
* 2. 根据音频文件路径播放音频
|
|
||||||
* 将上面的两个函数封装成一个槽函数,以及设定一个对应的信号
|
|
||||||
*/
|
|
||||||
class AudioOutput : public QObject
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
Q_DISABLE_COPY(AudioOutput) // 禁用拷贝
|
|
||||||
private:
|
|
||||||
/**
|
|
||||||
* 构造函数私有化
|
|
||||||
* @param parent
|
|
||||||
*/
|
|
||||||
explicit AudioOutput(QObject *parent = nullptr); // 并不将本模块挂在对象树当中,因为本模块为单例类,内存自行管理
|
|
||||||
|
|
||||||
static QScopedPointer<AudioOutput> instance; // 单例类
|
|
||||||
static QMutex mutex;
|
|
||||||
public:
|
|
||||||
static AudioOutput *getInstance();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 析构函数
|
|
||||||
*/
|
|
||||||
~AudioOutput() override;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 播放来自文件的音频
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
void playAudio(const QUrl& url) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 播放内存中的WAV字节流数据
|
|
||||||
* @param wavData 完整的WAV格式字节流
|
|
||||||
*/
|
|
||||||
void playFromByteArray(const QByteArray &wavData);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取WAV音频格式
|
|
||||||
* @param wavData
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
QAudioFormat getWavFormat(const QByteArray &wavData) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 暂停播放音频
|
|
||||||
*/
|
|
||||||
void pauseAudio() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止播放音频
|
|
||||||
*/
|
|
||||||
void stopAudio() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置播放速度
|
|
||||||
* @param speed
|
|
||||||
*/
|
|
||||||
void setPlaySpeed(double speed) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放速度
|
|
||||||
* @return double
|
|
||||||
*/
|
|
||||||
[[nodiscard]] double getPlaySpeed() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置播放音量
|
|
||||||
* @param volume
|
|
||||||
*/
|
|
||||||
void setPlayVolume(int volume) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放音量
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
[[nodiscard]] int getPlayVolume() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前播放位置(毫秒)
|
|
||||||
* @return qint64
|
|
||||||
*/
|
|
||||||
[[nodiscard]] qint64 getPlayPosition() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取媒体总时长(毫秒)
|
|
||||||
* @return qint64
|
|
||||||
*/
|
|
||||||
[[nodiscard]] qint64 getMediaDuration() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据当前播放位置,返回播放进度百分比
|
|
||||||
* @return double
|
|
||||||
*/
|
|
||||||
[[nodiscard]] double getPlayProgress() const;
|
|
||||||
|
|
||||||
void setAudioFormat(int sampleRate, int channels, QAudioFormat::SampleFormat sampleType);
|
|
||||||
|
|
||||||
void setAudioFormat(const QAudioFormat &format_);
|
|
||||||
|
|
||||||
[[nodiscard]] QAudioFormat getAudioFormat() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放状态
|
|
||||||
* QMediaPlayer::StoppedState、QMediaPlayer::PlayingState、QMediaPlayer::PausedState
|
|
||||||
* @return QMediaPlayer::State
|
|
||||||
*/
|
|
||||||
[[nodiscard]] QMediaPlayer::MediaStatus getState() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误类型
|
|
||||||
* NoError,
|
|
||||||
ResourceError,
|
|
||||||
FormatError,
|
|
||||||
NetworkError,
|
|
||||||
AccessDeniedError
|
|
||||||
* @return QMediaPlayer::Error
|
|
||||||
*/
|
|
||||||
[[nodiscard]] QMediaPlayer::Error getError() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误描述
|
|
||||||
* @return QString
|
|
||||||
*/
|
|
||||||
[[nodiscard]] QString getErrorString() const;
|
|
||||||
|
|
||||||
signals:
|
|
||||||
void playbackFinished(); // 播放完成信号
|
|
||||||
|
|
||||||
private:
|
|
||||||
QMediaPlayer *mediaPlayer; /// <!音频播放核心组件
|
|
||||||
QAudioOutput *audioOutput; /// <!音量和设备控制
|
|
||||||
QAudioSink *audioSink; /// <!字节流播放组件
|
|
||||||
QAudioFormat format; /// <!字节流播放时所用音频格式
|
|
||||||
QBuffer *audioBuffer; /// <!存储内存数据
|
|
||||||
};
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by Administrator on 2025/1/17.
|
|
||||||
//
|
|
||||||
|
|
||||||
#include "AudioOutput.h"
|
|
||||||
#include <QMediaDevices>
|
|
||||||
#include <QDataStream>
|
|
||||||
|
|
||||||
QScopedPointer<AudioOutput> AudioOutput::instance; // 使用QScopedPointer去管理单例,自动析构
|
|
||||||
QMutex AudioOutput::mutex;
|
|
||||||
|
|
||||||
AudioOutput *AudioOutput::getInstance()
|
|
||||||
{
|
|
||||||
// 懒汉式(单线程播放,无需考虑加锁)
|
|
||||||
if (instance.isNull()) { // 若未访问
|
|
||||||
QMutexLocker locker(&mutex);
|
|
||||||
if (instance.isNull()) {
|
|
||||||
// 使用reset初始化
|
|
||||||
instance.reset(new AudioOutput());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance.data(); // 返回单例实例
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioOutput::AudioOutput(QObject *parent) : QObject(parent), mediaPlayer(nullptr), audioOutput(nullptr), audioSink(nullptr), audioBuffer(nullptr)
|
|
||||||
{
|
|
||||||
audioBuffer = new QBuffer(this); // 初始化缓冲区
|
|
||||||
// 初始化 QMediaPlayer 用于文件播放
|
|
||||||
mediaPlayer = new QMediaPlayer(this);
|
|
||||||
// 初始化QAudioOutput 用于控制 QMediaPlayer 的音量与设备
|
|
||||||
audioOutput = new QAudioOutput(QMediaDevices::defaultAudioOutput(), this);
|
|
||||||
mediaPlayer->setAudioOutput(audioOutput); // 将 QMediaPlayer 与 QAudioOutput 关联起来
|
|
||||||
|
|
||||||
// 监听 QMediaPlayer 状态变化
|
|
||||||
connect(mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus state) {
|
|
||||||
if (state == QMediaPlayer::EndOfMedia) {
|
|
||||||
emit playbackFinished(); // 触发播放完成信号
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化 默认的QAudioSink 和 QBuffer 用于字节流播放
|
|
||||||
format.setSampleRate(44100); // 采样率
|
|
||||||
format.setChannelCount(2); // 播放通道数
|
|
||||||
format.setSampleFormat(QAudioFormat::Int16); // 采样格式
|
|
||||||
audioSink = new QAudioSink(QMediaDevices::defaultAudioOutput(), format, this);
|
|
||||||
audioBuffer = new QBuffer(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
AudioOutput::~AudioOutput()
|
|
||||||
{
|
|
||||||
if (mediaPlayer->playbackState() != QMediaPlayer::StoppedState) {
|
|
||||||
mediaPlayer->stop();
|
|
||||||
}
|
|
||||||
if (audioSink->state() != QAudio::StoppedState) {
|
|
||||||
audioSink->stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioOutput::playAudio(const QUrl& url) const {
|
|
||||||
mediaPlayer->setSource(url);
|
|
||||||
mediaPlayer->play();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从字节数组中播放音频
|
|
||||||
* @param wavData
|
|
||||||
*/
|
|
||||||
void AudioOutput::playFromByteArray(const QByteArray &wavData) {
|
|
||||||
// 确保数据有效
|
|
||||||
if (wavData.isEmpty()) {
|
|
||||||
qWarning() << "尝试播放空音频数据";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 停止 QMediaPlayer 播放,防止冲突
|
|
||||||
if (mediaPlayer->playbackState() != QMediaPlayer::StoppedState) {
|
|
||||||
mediaPlayer->stop();
|
|
||||||
}
|
|
||||||
// 停止并释放旧的 QAudioSink 这里因为QAudioSink 不支持运行时修改播放音频格式,因此只能这么做
|
|
||||||
if (audioSink) {
|
|
||||||
audioSink->stop();
|
|
||||||
delete audioSink;
|
|
||||||
audioSink = nullptr;
|
|
||||||
}
|
|
||||||
// 解析新的音频格式
|
|
||||||
const QAudioFormat newFormat = getWavFormat(wavData);
|
|
||||||
if (!newFormat.isValid()) {
|
|
||||||
qWarning() << "音频格式解析失败,停止播放。";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 创建一个新的 QAudioSink 实例
|
|
||||||
audioSink = new QAudioSink(QMediaDevices::defaultAudioOutput(), newFormat, this);
|
|
||||||
// 准备 QBuffer:只包含音频原始数据 (跳过 44 字节的 WAV 头部)
|
|
||||||
const QByteArray rawAudioData = wavData.mid(44);
|
|
||||||
|
|
||||||
// 重置缓冲区
|
|
||||||
audioBuffer->close();
|
|
||||||
audioBuffer->setData(rawAudioData);
|
|
||||||
audioBuffer->open(QIODevice::ReadOnly);
|
|
||||||
|
|
||||||
// 监听 QAudioSink 状态变化以触发 playbackFinished 信号
|
|
||||||
connect(audioSink, &QAudioSink::stateChanged, this, [this](const QAudio::State state) {
|
|
||||||
if (state == QAudio::StoppedState) {
|
|
||||||
if (audioSink->error() == QAudio::NoError) {
|
|
||||||
emit playbackFinished();
|
|
||||||
} else {
|
|
||||||
qWarning() << "QAudioSink 播放错误:" << audioSink->error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 将 QBuffer (QIODevice) 传递给 QAudioSink,并开始播放
|
|
||||||
audioSink->start(audioBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief 从完整的 WAV 字节流中解析 QAudioFormat
|
|
||||||
* @param wavData 完整的 WAV 文件字节流
|
|
||||||
* @return QAudioFormat 如果解析成功,返回正确的格式;否则返回默认格式
|
|
||||||
*/
|
|
||||||
QAudioFormat AudioOutput::getWavFormat(const QByteArray &wavData) const {
|
|
||||||
QAudioFormat format_;
|
|
||||||
|
|
||||||
// 完整的 WAV 文件至少有 44 字节的头部
|
|
||||||
if (wavData.size() < 44) {
|
|
||||||
qWarning() << "WAV 数据太短,无法解析头部";
|
|
||||||
return this->format; // 返回一个默认格式
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 QDataStream 以小端模式读取二进制数据
|
|
||||||
QDataStream stream(wavData);
|
|
||||||
stream.setByteOrder(QDataStream::LittleEndian);
|
|
||||||
|
|
||||||
// 跳过 RIFF 和 FORMAT 块 (共 20 字节)
|
|
||||||
stream.skipRawData(20);
|
|
||||||
|
|
||||||
// 读取声道数 (2 bytes)
|
|
||||||
quint16 channels = 0;
|
|
||||||
stream >> channels;
|
|
||||||
format_.setChannelCount(channels);
|
|
||||||
|
|
||||||
// 读取采样率 (4 bytes)
|
|
||||||
quint32 sampleRate = 0;
|
|
||||||
stream >> sampleRate;
|
|
||||||
format_.setSampleRate(sampleRate);
|
|
||||||
|
|
||||||
// 跳过 ByteRate 和 BlockAlign (共 6 字节)
|
|
||||||
stream.skipRawData(6);
|
|
||||||
|
|
||||||
// 读取位深 (2 bytes)
|
|
||||||
quint16 bitsPerSample = 0;
|
|
||||||
stream >> bitsPerSample;
|
|
||||||
|
|
||||||
// 根据位深设置 QAudioFormat 的 SampleFormat
|
|
||||||
if (bitsPerSample == 8) {
|
|
||||||
format_.setSampleFormat(QAudioFormat::UInt8);
|
|
||||||
} else if (bitsPerSample == 16) {
|
|
||||||
format_.setSampleFormat(QAudioFormat::Int16);
|
|
||||||
} else if (bitsPerSample == 32) {
|
|
||||||
format_.setSampleFormat(QAudioFormat::Float); // 32位通常是浮点数
|
|
||||||
} else {
|
|
||||||
qWarning() << "不支持的位深:" << bitsPerSample;
|
|
||||||
return this->format; // 返回一个默认格式
|
|
||||||
}
|
|
||||||
|
|
||||||
qDebug() << "WAV Format - Rate:" << sampleRate << ", Channels:" << channels << ", Bits:" << bitsPerSample;
|
|
||||||
return format_;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 暂停播放
|
|
||||||
*/
|
|
||||||
void AudioOutput::pauseAudio() const {
|
|
||||||
mediaPlayer->pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止播放
|
|
||||||
*/
|
|
||||||
void AudioOutput::stopAudio() const {
|
|
||||||
mediaPlayer->stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置播放速度
|
|
||||||
* @param speed
|
|
||||||
*/
|
|
||||||
void AudioOutput::setPlaySpeed(const double speed) const {
|
|
||||||
mediaPlayer->setPlaybackRate(speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放速度
|
|
||||||
* @return double
|
|
||||||
*/
|
|
||||||
double AudioOutput::getPlaySpeed() const {
|
|
||||||
return mediaPlayer->playbackRate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置播放音量
|
|
||||||
* @param volume
|
|
||||||
*/
|
|
||||||
void AudioOutput::setPlayVolume(const int volume) const {
|
|
||||||
// 将 0-100 转换为 0.0 - 1.0
|
|
||||||
const qreal newVolume = static_cast<qreal>(volume) / 100.0;
|
|
||||||
audioOutput->setVolume(static_cast<float>(newVolume));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放音量
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
int AudioOutput::getPlayVolume() const {
|
|
||||||
// 将 0.0 - 1.0 转换为 0-100
|
|
||||||
return qRound(audioOutput->volume() * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前播放位置(毫秒)
|
|
||||||
* @return qint64
|
|
||||||
*/
|
|
||||||
qint64 AudioOutput::getPlayPosition() const {
|
|
||||||
// 检查 QMediaPlayer 是否处于播放状态,且当前是否有源在播放
|
|
||||||
if (mediaPlayer->playbackState() == QMediaPlayer::PlayingState) {
|
|
||||||
return mediaPlayer->position();
|
|
||||||
}
|
|
||||||
// 否则返回 0
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取音频时长(毫秒)
|
|
||||||
* @return qint64
|
|
||||||
*/
|
|
||||||
qint64 AudioOutput::getMediaDuration() const {
|
|
||||||
if(mediaPlayer->playbackState() == QMediaPlayer::PlayingState){
|
|
||||||
return mediaPlayer->duration();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据当前播放位置,返回播放进度百分比
|
|
||||||
* @return double
|
|
||||||
*/
|
|
||||||
double AudioOutput::getPlayProgress() const {
|
|
||||||
if(mediaPlayer->playbackState() != QMediaPlayer::PlayingState){
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return static_cast<double>(this->getPlayPosition()) / static_cast<double>(this->getMediaDuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioOutput::setAudioFormat(const int sampleRate, const int channels, const QAudioFormat::SampleFormat sampleType) {
|
|
||||||
this->format.setSampleRate(sampleRate);
|
|
||||||
this->format.setChannelCount(channels);
|
|
||||||
this->format.setSampleFormat(sampleType);
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioOutput::setAudioFormat(const QAudioFormat &format_) {
|
|
||||||
this->format = format_;
|
|
||||||
}
|
|
||||||
|
|
||||||
QAudioFormat AudioOutput::getAudioFormat() const {
|
|
||||||
return this->format;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取播放状态
|
|
||||||
* QMediaPlayer::StoppedState、QMediaPlayer::PlayingState、QMediaPlayer::PausedState
|
|
||||||
* @return QMediaPlayer::State
|
|
||||||
*/
|
|
||||||
QMediaPlayer::MediaStatus AudioOutput::getState() const {
|
|
||||||
return mediaPlayer->mediaStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误类型
|
|
||||||
* NoError,
|
|
||||||
ResourceError,
|
|
||||||
FormatError,
|
|
||||||
NetworkError,
|
|
||||||
AccessDeniedError,
|
|
||||||
ServiceMissingError,
|
|
||||||
MediaIsPlaylist
|
|
||||||
* @return QMediaPlayer::Error
|
|
||||||
*/
|
|
||||||
QMediaPlayer::Error AudioOutput::getError() const {
|
|
||||||
return mediaPlayer->error();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误描述
|
|
||||||
* @return QString
|
|
||||||
*/
|
|
||||||
QString AudioOutput::getErrorString() const {
|
|
||||||
return mediaPlayer->errorString();
|
|
||||||
}
|
|
||||||
@@ -19,17 +19,17 @@ class QJsonObject;
|
|||||||
class AudioDataTransferObject final : public DataTransferObjectBase{
|
class AudioDataTransferObject final : public DataTransferObjectBase{
|
||||||
public:
|
public:
|
||||||
// 构造函数(带默认值)
|
// 构造函数(带默认值)
|
||||||
explicit AudioDataTransferObject(const QString& owner = "client",
|
explicit AudioDataTransferObject(QString owner = "client",
|
||||||
bool isStream = false,
|
bool isStream = false,
|
||||||
bool isStart = false,
|
bool isStart = false,
|
||||||
bool isEnd = false,
|
bool isEnd = false,
|
||||||
int sequence = 0,
|
int sequence = 0,
|
||||||
const QByteArray& data = {},
|
QByteArray data = {},
|
||||||
int sampleRate = 16000,
|
int sampleRate = 16000,
|
||||||
int channelCount = 1,
|
int channelCount = 1,
|
||||||
int bitDepth = 16,
|
int bitDepth = 16,
|
||||||
double duration = 0.0,
|
double duration = 0.0,
|
||||||
const QString& text = "");
|
QString text = "");
|
||||||
// 静态工厂方法
|
// 静态工厂方法
|
||||||
static AudioDataTransferObject fromJson(const QJsonObject& json);
|
static AudioDataTransferObject fromJson(const QJsonObject& json);
|
||||||
|
|
||||||
|
|||||||
@@ -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; /// 滚动方向
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
#include "DataTransferObjectBase.h"
|
#include "DataTransferObjectBase.h"
|
||||||
#include "AudioDataTransferObject.h"
|
#include "AudioDataTransferObject.h"
|
||||||
|
#include "AutoAgentDataObject.h"
|
||||||
/**
|
/**
|
||||||
* NetworkDO
|
* NetworkDO
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +55,8 @@ public:
|
|||||||
signals:
|
signals:
|
||||||
// 业务接收信号
|
// 业务接收信号
|
||||||
void audioPacketReceived(const AudioDataTransferObject& packet); // 音频数据准备完成信号
|
void audioPacketReceived(const AudioDataTransferObject& packet); // 音频数据准备完成信号
|
||||||
|
void autoAgentPacketReceived(const AutoAgentDataObject& packet); // 自动代理数据包接收信号
|
||||||
|
|
||||||
void errorOccurred(const QString& errorMsg); // 错误信号
|
void errorOccurred(const QString& errorMsg); // 错误信号
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
|
|||||||
@@ -3,29 +3,30 @@
|
|||||||
//
|
//
|
||||||
#include "AudioDataTransferObject.h"
|
#include "AudioDataTransferObject.h"
|
||||||
#include <QJsonValue>
|
#include <QJsonValue>
|
||||||
|
#include <utility>
|
||||||
// 构造函数实现(初始化列表)
|
// 构造函数实现(初始化列表)
|
||||||
AudioDataTransferObject::AudioDataTransferObject(const QString& owner,
|
AudioDataTransferObject::AudioDataTransferObject(QString owner,
|
||||||
bool isStream,
|
bool isStream,
|
||||||
bool isStart,
|
bool isStart,
|
||||||
bool isEnd,
|
bool isEnd,
|
||||||
int sequence,
|
int sequence,
|
||||||
const QByteArray& data,
|
QByteArray data,
|
||||||
int sampleRate,
|
int sampleRate,
|
||||||
int channelCount,
|
int channelCount,
|
||||||
int bitDepth,
|
int bitDepth,
|
||||||
double duration,
|
double duration,
|
||||||
const QString& text)
|
QString text)
|
||||||
: m_owner(owner)
|
: m_owner(std::move(owner))
|
||||||
, m_isStream(isStream)
|
, m_isStream(isStream)
|
||||||
, m_isStart(isStart)
|
, m_isStart(isStart)
|
||||||
, m_isEnd(isEnd)
|
, m_isEnd(isEnd)
|
||||||
, m_sequence(sequence)
|
, m_sequence(sequence)
|
||||||
, m_data(data)
|
, m_data(std::move(data))
|
||||||
, m_sampleRate(sampleRate)
|
, m_sampleRate(sampleRate)
|
||||||
, m_channelCount(channelCount)
|
, m_channelCount(channelCount)
|
||||||
, m_bitDepth(bitDepth)
|
, m_bitDepth(bitDepth)
|
||||||
, m_duration(duration)
|
, m_duration(duration)
|
||||||
, m_text(text) {
|
, m_text(std::move(text)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态工厂方法:从 JSON 反序列化
|
// 静态工厂方法:从 JSON 反序列化
|
||||||
@@ -86,7 +87,7 @@ AudioDataTransferObject& AudioDataTransferObject::setData(const QString& key,
|
|||||||
} else if (key == "sequence") {
|
} else if (key == "sequence") {
|
||||||
m_sequence = value.toInt();
|
m_sequence = value.toInt();
|
||||||
} else if (key == "data") {
|
} else if (key == "data") {
|
||||||
// 这里要求已是 base64 字符串
|
// 这里要求传入的是 base64 字符串
|
||||||
m_data = QByteArray::fromBase64(value.toString().toUtf8());
|
m_data = QByteArray::fromBase64(value.toString().toUtf8());
|
||||||
} else if (key == "sampleRate") {
|
} else if (key == "sampleRate") {
|
||||||
m_sampleRate = value.toInt();
|
m_sampleRate = value.toInt();
|
||||||
@@ -98,7 +99,9 @@ AudioDataTransferObject& AudioDataTransferObject::setData(const QString& key,
|
|||||||
m_duration = value.toDouble();
|
m_duration = value.toDouble();
|
||||||
} else if (key == "text") {
|
} else if (key == "text") {
|
||||||
m_text = value.toString();
|
m_text = value.toString();
|
||||||
|
} else {
|
||||||
|
qWarning() << "Unknown key or invalid value type:" << key << value;
|
||||||
}
|
}
|
||||||
// 如果 key 不存在,默认忽略
|
|
||||||
return *this; // 返回自身引用,支持链式调用
|
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;
|
||||||
|
}
|
||||||
@@ -69,8 +69,8 @@ void NetworkDO::onDataReceived(const QString& type, const QJsonObject& data)
|
|||||||
if (type == "audio_data") {
|
if (type == "audio_data") {
|
||||||
emit audioPacketReceived(AudioDataTransferObject::fromJson(data)); // 构造并发送音频对象
|
emit audioPacketReceived(AudioDataTransferObject::fromJson(data)); // 构造并发送音频对象
|
||||||
}
|
}
|
||||||
else if (type == "control_data") {
|
else if (type == "auto_agent") {
|
||||||
|
emit autoAgentPacketReceived(AutoAgentDataObject::fromJson(data));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
qWarning() << "[NetworkDO] Received unknown type:" << type;
|
qWarning() << "[NetworkDO] Received unknown type:" << type;
|
||||||
|
|||||||
@@ -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,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,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,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()) { // 如果是开始包
|
||||||
|
AudioOutput::getInstance()->startStream(packet.sampleRate(), packet.channelCount(), packet.bitDepth());; // 播放开始
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 否则播放即可
|
||||||
|
AudioOutput::getInstance()->pushStreamData(packet.audioData());
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// Created by misaki on 2026/1/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
#include "AutoAgentHandle.h"
|
||||||
|
#include "NetWorkDO.h"
|
||||||
|
#include <SimpleAutoGUI.h> // 引入 AutoGUI 头文件
|
||||||
|
// 初始化静态成员
|
||||||
|
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) {
|
||||||
|
if (packet.getAction() == "click") { // 单击
|
||||||
|
qDebug() << "Click: " << packet.getX1() << ", " << packet.getY1();
|
||||||
|
AutoGUI::moveTo(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::moveTo(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::moveTo(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(), 0.8);
|
||||||
|
}
|
||||||
|
// TODO: 快捷键,输入文本,滚动待实现
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
### 本模块负责将服务端返回的数据对象做解析并执行相应的动作
|
||||||
+2
@@ -20,6 +20,8 @@ WebSocketManager::WebSocketManager(QObject *parent)
|
|||||||
{
|
{
|
||||||
// 配置 WebSocket
|
// 配置 WebSocket
|
||||||
m_socket->setParent(this); // 确保 socket 也在工作线程
|
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,
|
connect(m_socket, &QWebSocket::connected,
|
||||||
this, &WebSocketManager::onConnected);
|
this, &WebSocketManager::onConnected);
|
||||||
@@ -110,7 +110,7 @@ AudioPage::AudioPage(QWidget* parent)
|
|||||||
constexpr float duration = 6.0f; // 音频时长
|
constexpr float duration = 6.0f; // 音频时长
|
||||||
TextRenderer::getInstance()->addText(text, 40.0f, QColor("#FF69B4"), duration);
|
TextRenderer::getInstance()->addText(text, 40.0f, QColor("#FF69B4"), duration);
|
||||||
LAppLive2DManager::GetInstance()->StartLipSync("Resources/TestFiles/test.wav");
|
LAppLive2DManager::GetInstance()->StartLipSync("Resources/TestFiles/test.wav");
|
||||||
AudioOutput::getInstance()->playAudio(QUrl("Resources/TestFiles/test.wav"));
|
AudioOutput::getInstance()->playUrl(QUrl("Resources/TestFiles/test.wav"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
#include "ElaMessageBar.h"
|
#include "ElaMessageBar.h"
|
||||||
#include "websocketmanager.h"
|
#include "websocketmanager.h"
|
||||||
#include "NetWorkDO.h"
|
#include "NetWorkDO.h"
|
||||||
|
#include <QFile>
|
||||||
|
#include "AudioDataHandle.h"
|
||||||
|
|
||||||
NetWorkPage::NetWorkPage(QWidget* parent)
|
NetWorkPage::NetWorkPage(QWidget* parent)
|
||||||
: BasePage(parent)
|
: BasePage(parent)
|
||||||
@@ -71,6 +73,7 @@ void NetWorkPage::initUI() {
|
|||||||
void NetWorkPage::initWebSocketClient() {
|
void NetWorkPage::initWebSocketClient() {
|
||||||
auto* client = WebSocketClient::getInstance(); // 获取单例实例(设置一个默认地址)
|
auto* client = WebSocketClient::getInstance(); // 获取单例实例(设置一个默认地址)
|
||||||
auto* netDO = NetworkDO::getInstance();
|
auto* netDO = NetworkDO::getInstance();
|
||||||
|
AudioDataHandle::getInstance(); // 初始化音频处理模块
|
||||||
// 注入:将底层发送能力赋予 NetworkDO
|
// 注入:将底层发送能力赋予 NetworkDO
|
||||||
netDO->registerSender([client](const QString& type, const QJsonObject& data){
|
netDO->registerSender([client](const QString& type, const QJsonObject& data){
|
||||||
client->sendJson(type, data);
|
client->sendJson(type, data);
|
||||||
@@ -78,7 +81,6 @@ void NetWorkPage::initWebSocketClient() {
|
|||||||
// 监听:底层收到数据 -> NetworkDO 解析
|
// 监听:底层收到数据 -> NetworkDO 解析
|
||||||
connect(client, &WebSocketClient::jsonReceived,
|
connect(client, &WebSocketClient::jsonReceived,
|
||||||
netDO, &NetworkDO::onDataReceived);
|
netDO, &NetworkDO::onDataReceived);
|
||||||
|
|
||||||
// 连接成功的处理
|
// 连接成功的处理
|
||||||
connect(client, &WebSocketClient::connected, this, [this]() {
|
connect(client, &WebSocketClient::connected, this, [this]() {
|
||||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "WebSocket", "连接成功", 800.0, this);
|
ElaMessageBar::success(ElaMessageBarType::TopRight, "WebSocket", "连接成功", 800.0, this);
|
||||||
@@ -162,12 +164,21 @@ void NetWorkPage::initWebSocketClient() {
|
|||||||
return;
|
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;
|
AudioDataTransferObject packet;
|
||||||
packet.setData("Owner", "client")
|
packet.setData("Owner", "client")
|
||||||
.setData("isStream", true)
|
.setData("isStream", true)
|
||||||
.setData("sequence", 42)
|
.setData("sequence", 42)
|
||||||
.setData("text", "Hello World")
|
.setData("text", "Hello World")
|
||||||
.setData("data", "SGVsbG8gV29ybGQ="); // 填入音频数据 (Hello World的base64)
|
.setData("data", base64Str); // 填入测试音频数据
|
||||||
netDO->sendPacket(packet);
|
netDO->sendPacket(packet);
|
||||||
ElaMessageBar::success(ElaMessageBarType::TopRight, "发送测试",
|
ElaMessageBar::success(ElaMessageBarType::TopRight, "发送测试",
|
||||||
"已成功发送数据包", 1000.0, this);
|
"已成功发送数据包", 1000.0, this);
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
* 之所以使用COBS编码而不是常用的字符填充法,这是因为字符填充法会使得数据包的大小无法确定,并且往往会使得数据包变得更大。
|
* 之所以使用COBS编码而不是常用的字符填充法,这是因为字符填充法会使得数据包的大小无法确定,并且往往会使得数据包变得更大。
|
||||||
*
|
*
|
||||||
* 本模块为COBS的C++实现,而在Yosuga_embedded当中,则使用了cobs的C实现。
|
* 本模块为COBS的C++实现,而在Yosuga_embedded当中,则使用了cobs的C实现。
|
||||||
|
*
|
||||||
|
* C++20
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|||||||
Reference in New Issue
Block a user