Compare commits

..

20 Commits

Binary file not shown.

@ -1,135 +0,0 @@
#include "FFVideoFormatConvert.h"
#include "VideoObjNetwork.h"
CFFVideoFormatConvert::CFFVideoFormatConvert(void)
: m_img_convert_ctx(NULL)
, m_pFrame(NULL)
, m_pBuffer(NULL), m_uBufferSize(0)
, m_iWidth(0), m_iHeight(0)
, m_pImage(NULL)
{
}
CFFVideoFormatConvert::~CFFVideoFormatConvert(void)
{
Close();
}
void CFFVideoFormatConvert::Close()
{
if (m_img_convert_ctx)
{
sws_freeContext(m_img_convert_ctx);
m_img_convert_ctx = NULL;
}
if (m_pFrame)
{
av_frame_free(&m_pFrame);
m_pFrame = NULL;
}
if (m_pBuffer)
{
av_free(m_pBuffer);
m_pBuffer = NULL;
m_uBufferSize = 0;
}
if (m_pImage != NULL)
{
delete m_pImage;
m_pImage = NULL;
}
m_iWidth = 0;
m_iHeight = 0;
}
bool CFFVideoFormatConvert::RGB32toYUV420P(const QImage* pIn, AVFrame** pOut)
{
// reinitialize object if image width or height changed
if (pIn->width() != m_iWidth || pIn->height() != m_iHeight)
{
Close();
}
if (m_pBuffer == NULL)
{
m_iWidth = pIn->width();
m_iHeight = pIn->height();
m_uBufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, pIn->width(), pIn->height(), 1);
m_pBuffer = (uint8_t *) av_malloc(m_uBufferSize);
}
if (m_pFrame == NULL)
{
m_pFrame = av_frame_alloc();
m_pFrame->width = pIn->width();
m_pFrame->height = pIn->height();
av_image_fill_arrays(m_pFrame->data, m_pFrame->linesize,
m_pBuffer, AV_PIX_FMT_YUV420P, pIn->width(), pIn->height(), 1);
}
if (m_img_convert_ctx == NULL)
{
m_img_convert_ctx = sws_getContext(pIn->width(), pIn->height(),
AV_PIX_FMT_RGB32,
pIn->width(), pIn->height(),
AV_PIX_FMT_YUV420P, SWS_BICUBIC, NULL, NULL, NULL);
}
const uint8_t *const srcSlice[] = { pIn->bits() };
const int srcStride[] = { pIn->bytesPerLine()};
sws_scale(m_img_convert_ctx,
srcSlice,
srcStride, 0, pIn->height(),
m_pFrame->data,
m_pFrame->linesize);
*pOut = m_pFrame;
return true;
}
bool CFFVideoFormatConvert::YUV420P2RGB32(const AVFrame* pIn, QImage** pOut)
{
// reinitialize object if image width or height changed
if (pIn->width != m_iWidth || pIn->height != m_iHeight)
{
Close();
}
if (m_pBuffer == NULL)
{
m_iWidth = pIn->width;
m_iHeight = pIn->height;
m_uBufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGB32, pIn->width, pIn->height, 1);
m_pBuffer = (uint8_t *)av_malloc(m_uBufferSize);
}
if (m_pFrame == NULL)
{
m_pFrame = av_frame_alloc();
av_image_fill_arrays(m_pFrame->data, m_pFrame->linesize,
m_pBuffer, AV_PIX_FMT_RGB32, pIn->width, pIn->height, 1);
}
if (m_img_convert_ctx == NULL)
{
m_img_convert_ctx = sws_getContext(pIn->width, pIn->height,
AV_PIX_FMT_YUV420P,
pIn->width, pIn->height,
AV_PIX_FMT_RGB32, SWS_BICUBIC, NULL, NULL, NULL);
}
sws_scale(m_img_convert_ctx,
(uint8_t const * const *)pIn->data,
pIn->linesize, 0, pIn->height,
m_pFrame->data,
m_pFrame->linesize);
*pOut = new QImage((uchar *)m_pFrame->data[0], m_iWidth, m_iHeight, QImage::Format_RGB32);
return true;
}

@ -1,28 +0,0 @@
#pragma once
#include <QImage>
struct AVFrame;
struct SwsContext;
class CFFVideoFormatConvert
{
public:
CFFVideoFormatConvert(void);
~CFFVideoFormatConvert(void);
bool RGB32toYUV420P(const QImage* pIn, AVFrame** pOut);
bool YUV420P2RGB32(const AVFrame* pIn, QImage** pOut);
private:
void Close();
private:
SwsContext* m_img_convert_ctx;
AVFrame* m_pFrame;
uint8_t* m_pBuffer;
uint m_uBufferSize;
int m_iWidth;
int m_iHeight;
QImage* m_pImage;
};

@ -1,70 +0,0 @@
#ifndef QTCAMERACAPTURE_H
#define QTCAMERACAPTURE_H
#include <QObject>
#include <QAbstractVideoSurface>
#include <QDebug>
class QtCameraCapture : public QAbstractVideoSurface
{
Q_OBJECT
public:
enum PixelFormat {
Format_Invalid,
Format_ARGB32,
Format_ARGB32_Premultiplied,
Format_RGB32,
Format_RGB24,
Format_RGB565,
Format_RGB555,
Format_ARGB8565_Premultiplied,
Format_BGRA32,
Format_BGRA32_Premultiplied,
Format_BGR32,
Format_BGR24,
Format_BGR565,
Format_BGR555,
Format_BGRA5658_Premultiplied,
Format_AYUV444,
Format_AYUV444_Premultiplied,
Format_YUV444,
Format_YUV420P,
Format_YV12,
Format_UYVY,
Format_YUYV,
Format_NV12,
Format_NV21,
Format_IMC1,
Format_IMC2,
Format_IMC3,
Format_IMC4,
Format_Y8,
Format_Y16,
Format_Jpeg,
Format_CameraRaw,
Format_AdobeDng,
#ifndef Q_QDOC
NPixelFormats,
#endif
Format_User = 1000
};
Q_ENUM(PixelFormat)
explicit QtCameraCapture(QObject *parent = 0);
QList<QVideoFrame::PixelFormat> supportedPixelFormats(
QAbstractVideoBuffer::HandleType handleType = QAbstractVideoBuffer::NoHandle) const;
bool present(const QVideoFrame &frame) override;
signals:
void frameAvailable(QImage frame);
};
#endif // QTCAMERACAPTURE_H

@ -1,36 +0,0 @@
#pragma once
#define GET_STR(x) #x
#define A_VER 3
#define T_VER 4
// vertex shader
const char *vString = GET_STR(
attribute vec4 vertexIn;
attribute vec2 textureIn;
varying vec2 textureOut;
void main(void)
{
gl_Position = vertexIn;
textureOut = textureIn;
}
);
// texture shader
const char *tString = GET_STR(
varying vec2 textureOut;
uniform sampler2D tex_y;
uniform sampler2D tex_u;
uniform sampler2D tex_v;
void main(void)
{
vec3 yuv;
vec3 rgb;
yuv.x = texture2D(tex_y, textureOut).r;
yuv.y = texture2D(tex_u, textureOut).r - 0.5;
yuv.z = texture2D(tex_v, textureOut).r - 0.5;
rgb = mat3(1.0, 1.0, 1.0,
0.0, -0.39465, 2.03211,
1.13983, -0.58060, 0.0) * yuv;
gl_FragColor = vec4(rgb, 1.0);
}
);

@ -1,66 +0,0 @@
#pragma once
#include <string>
#include <mutex>
#include <thread>
extern "C"
{
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include "libavutil/avutil.h"
#include "libswscale/swscale.h"
#include "libavutil/imgutils.h"
};
typedef void (*VideoDataCallback)(int iEncode, int iWidth, int iHeight, const char* pData, long lLen, long lPTS, void* pUserParam);
class VLKVideoWidget;
class CVideoObjNetwork
{
public:
CVideoObjNetwork();
virtual ~CVideoObjNetwork();
virtual bool Open(const std::string& strURL, VLKVideoWidget* pVideoWidget);
virtual bool IsOpen();
void SetDataCallback(VideoDataCallback pVideoDataCB, long lUserParam);
virtual void Clear();
virtual void Close();
virtual bool StartLocalRecord();
virtual void StopLocalRecord();
virtual void Capture();
private:
static void ThreadFunc(CVideoObjNetwork* pThis);
virtual void OnThreadFunc();
bool OpenDemux(const std::string& strURL);
void CloseDemux();
void WriteLocalRecord(const AVPacket* pkt);
static int interrupt_callback(void* para);
void ReadPacketLoop();
bool OpenDecoder(const AVCodecParameters *para);
void Send2Decode(const AVPacket* pkt);
void Send2Display(const AVFrame* frame);
void CloseDecoder();
private:
static bool m_bInit;
std::mutex m_mutex;
std::string m_strURL;
VLKVideoWidget* m_pVideoWidget;
AVFormatContext* m_pAVFmtContext;
int m_iVideoStreamIndex;
int m_iAudioStreamIndex;
int m_iWidth;
int m_iHeight;
std::thread* m_pThread;
bool m_bExit;
VideoDataCallback m_cbFunc;
long m_lUserParam;
AVCodecContext* m_pCodecContext;
};

@ -1,27 +0,0 @@
#ifndef IMAGEPREVIEWDIALOG_H
#define IMAGEPREVIEWDIALOG_H
#include <QDialog>
#include <QLabel>
#include <QVBoxLayout>
#include <QPushButton>
#include <QScrollArea>
class ImagePreviewDialog : public QDialog
{
Q_OBJECT
public:
explicit ImagePreviewDialog(const QString &imagePath, QWidget *parent = nullptr);
~ImagePreviewDialog();
private:
QLabel *m_imageLabel;
QScrollArea *m_scrollArea;
QPushButton *m_closeButton;
void setupUi();
void loadImage(const QString &imagePath);
};
#endif // IMAGEPREVIEWDIALOG_H

@ -1,28 +0,0 @@
#include "widget.h"
#include <QApplication>
#include <QDebug>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
// print SDK Version
qDebug() << "ViewLink SDK Version: " << GetSDKVersion();
// initialize SDK
VLK_Init();
Widget w;
w.show();
int ret = a.exec();
// diconnect all
VLK_Disconnect();
// uninitialize SDK
VLK_UnInit();
return ret;
}

@ -1,247 +0,0 @@
#ifndef WIDGET_H
#define WIDGET_H
#include <QWidget>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimer>
#include <QLabel>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QGraphicsItem>
#include <QGeoCoordinate>
#include <QPushButton>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netinet/in.h>
#include "ViewLink.h"
#include "VideoObjNetwork.h"
#include "VideoObjUSBCamera.h"
#include "../identification system/src/image_processor.h"
#include "../identification system/src/zhipu_api.h"
// 替换无人机连接类使用workproject下的MAVLinkClient
#include "../workproject/include/mavlink_client.h"
#include "MapWidget.h"
namespace Ui {
class Widget;
}
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = 0);
~Widget();
private slots:
void on_btnConnectTCP_clicked();
void on_btnConnctSerialPort_clicked();
private:
static int VLK_ConnStatusCallback(int iConnStatus, const char* szMessage, int iMsgLen, void* pUserParam);
static int VLK_DevStatusCallback(int iType, const char* szBuffer, int iBufLen, void* pUserParam);
signals:
void SignalConnectionStatus(int iConnStatus, const QString& strMessage);
void SignalDeviceModel(VLK_DEV_MODEL model);
void SignalDeviceConfig(VLK_DEV_CONFIG config);
void SignalDeviceTelemetry(VLK_DEV_TELEMETRY telemetry);
private slots:
void onSlotConnectionStatus(int iConnStatus, const QString& strMessage);
void onSlotDeviceModel(VLK_DEV_MODEL model);
void onSlotDeviceConfig(VLK_DEV_CONFIG config);
void onSlotDeviceTelemetry(VLK_DEV_TELEMETRY telemetry);
void on_btnUp_pressed();
void on_btnUp_released();
void on_btnLeft_pressed();
void on_btnLeft_released();
void on_btnHome_clicked();
void on_btnRight_pressed();
void on_btnRight_released();
void on_btnDown_pressed();
void on_btnDown_released();
void on_cmbImageSensor_activated(int index);
void on_cmbIRColor_activated(int index);
void on_checkBoxPIP_clicked(bool checked);
void on_btnOpenNetworkVideo_clicked();
void on_btnOpenUSBVideo_clicked();
void on_btnZoomIn_pressed();
void on_btnZoomIn_released();
void on_btnZoomOut_pressed();
void on_btnZoomOut_released();
void on_btnGimbalTakePhoto_clicked();
void on_btnStartRecord_clicked();
void on_btnStopRecord_clicked();
void on_btnIdentifyTarget_clicked();
void on_btnClearIdentification_clicked();
void on_sliderConfidence_valueChanged(int value);
// u6dfbu52a0u9884u5904u7406u590du9009u6846u72b6u6001u53d8u5316u7684u69fdu51fdu6570
void on_checkBoxPreprocess_stateChanged(int state);
// 无人机控制相关槽函数
void on_btnConnectUAV_clicked();
void on_btnArmDisarm_clicked();
void on_btnTakeoff_clicked();
void on_btnLand_clicked();
void on_btnRTL_clicked();
void on_btnMode_clicked();
// 添加切换地图类型的槽函数
void on_btnSwitchMapType_clicked();
// 添加声源定位相关槽函数
void on_btnStartSoundLocator_clicked();
void on_btnStopSoundLocator_clicked();
void onSoundDataReceived();
void updateSoundVisualization(double x, double y, double strength, double angle);
// 添加显示目标在地图上的槽函数
void on_btnShowTargetsOnMap_clicked();
private:
// initialize UI control
void InitUI();
void initTcpConnect();
void initSerialConnect();
// 更新UI状态
void updateUIState();
// 保存检测到的目标和距离数据
void saveDetectionDataToFile();
// 构建发送给大模型的上下文信息
QString buildDetectionContext();
// MAVLink回调函数
void handleHeartbeat(const mavlink_heartbeat_t& heartbeat);
void handleSystemStatus(const mavlink_sys_status_t& status);
void handleAttitude(const mavlink_attitude_t& attitude);
void handlePosition(const mavlink_global_position_int_t& position);
void handleGPS(const mavlink_gps_raw_int_t& gps);
// 发送MAVLink命令
bool sendMavlinkCommand(uint16_t command, float param1 = 0, float param2 = 0,
float param3 = 0, float param4 = 0, float param5 = 0,
float param6 = 0, float param7 = 0);
// 声源定位相关方法
void fetchSoundLocatorData();
void initSoundVisualization();
// 计算目标地理位置
QGeoCoordinate calculateTargetPosition(double distance, double gimbalYaw, double gimbalPitch);
// 在地图上显示目标
void showDetectedTargetsOnMap();
// 添加单个目标到地图
void addTargetToMap(const DetectedObject& target);
// 计算目标位置估计的不确定性(米)
double calculateUncertainty(double distance);
// TCP控制相关方法
bool connectToMoveControlTCP();
void disconnectFromMoveControlTCP();
bool sendMoveCommand(double x, double y);
void moveTowardSoundSource(double angle);
private:
Ui::Widget *ui;
CVideoObjNetwork m_VideoObjNetwork;
CVideoObjUSBCamera m_VideoObjUSBCamera;
ImageProcessor* m_pImageProcessor;
ZhipuAPI* m_pZhipuAPI;
std::vector<DetectedObject> m_detectedObjects;
QString m_lastCapturedImagePath;
float m_confidenceThreshold;
// 目标距离相关成员
QLabel* m_labelDistanceValue;
QString m_detectionLogFile;
QString m_lastDetectionTime;
float m_lastLaserDistance;
bool m_distanceEstimationEnabled;
// 地图目标标记相关成员
bool m_targetsOnMap; // 标记目标是否已显示在地图上
double m_lastGimbalYaw; // 上次吊舱偏航角
double m_lastGimbalPitch; // 上次吊舱俯仰角
QPushButton* m_btnShowTargetsOnMap; // 显示目标按钮
// 替换为MAVLinkClient
MAVLinkClient *m_mavlinkClient;
MapWidget *m_mapWidget;
bool m_isUAVArmed;
// 保存当前飞行数据
mavlink_heartbeat_t m_heartbeat;
mavlink_sys_status_t m_sysStatus;
mavlink_attitude_t m_attitude;
mavlink_global_position_int_t m_position;
mavlink_gps_raw_int_t m_gps;
bool m_isMAVConnected;
// 声音定位相关成员
QNetworkAccessManager* m_soundNetworkManager;
QTimer* m_soundDataTimer;
bool m_isSoundLocatorRunning;
QString m_soundLocatorIP;
int m_soundLocatorPort;
QGraphicsScene* m_soundScene;
QGraphicsView* m_soundView;
QGraphicsEllipseItem* m_soundSourceItem;
QGraphicsLineItem* m_soundDirectionLine;
QGraphicsTextItem* m_soundInfoText;
QGraphicsEllipseItem* m_soundDetectorItem;
double m_soundX;
double m_soundY;
double m_soundStrength;
double m_soundAngle;
// TCP移动控制相关成员
int m_moveControlSocket;
struct sockaddr_in m_moveControlAddr;
bool m_moveControlConnected;
QString m_moveControlIP;
int m_moveControlPort;
bool m_lastSoundProcessed; // 标记上一次声音是否已处理
// GPS状态标签
QLabel* m_labelGPSValue;
QLabel* m_labelSatellitesValue;
QLabel* m_labelHDOPValue;
QLabel* m_labelVDOPValue;
};
#endif // WIDGET_H

@ -1,344 +0,0 @@
<template>
<div class="app-container">
<header class="app-header">
<h1>声源定位系统</h1>
<div class="system-status">
<el-tag :type="connectionStatus ? 'success' : 'danger'">
{{ connectionStatus ? '已连接到数据源' : '未连接到数据源' }}
</el-tag>
</div>
</header>
<main class="main-content">
<div class="control-panel">
<h2>控制面板</h2>
<div class="control-buttons">
<el-button type="primary" @click="startMonitoring" :disabled="isMonitoring">
开始监听
</el-button>
<el-button type="danger" @click="stopMonitoring" :disabled="!isMonitoring">
停止监听
</el-button>
</div>
<div class="source-info">
<h3>声源信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="X 坐标">{{ sourceData.X.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="Y 坐标">{{ sourceData.Y.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="强度">{{ sourceData.strength.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="角度">{{ sourceData.angle.toFixed(2) }}°</el-descriptions-item>
</el-descriptions>
</div>
</div>
<div class="visualization-panel">
<h2>声源定位可视化</h2>
<div ref="chartContainer" class="chart-container"></div>
</div>
</main>
<footer class="app-footer">
<p>© 2025 声源定位系统. 基于 K210 麦克风阵列.</p>
</footer>
</div>
</template>
<script>
import axios from 'axios';
import * as echarts from 'echarts';
export default {
name: 'App',
data() {
return {
sourceData: {
X: 0.0,
Y: 0.0,
strength: 0.0,
angle: 0.0,
},
chart: null,
connectionStatus: false,
isMonitoring: false,
pollingInterval: null,
API_BASE_URL: 'http://127.0.0.1:5000', //
};
},
mounted() {
this.initChart();
this.checkConnection();
},
beforeUnmount() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
}
if (this.chart) {
this.chart.dispose();
}
},
methods: {
checkConnection() {
axios.get(`${this.API_BASE_URL}/data`)
.then(() => {
this.connectionStatus = true;
})
.catch(() => {
this.connectionStatus = false;
});
},
initChart() {
const chartDom = this.$refs.chartContainer;
this.chart = echarts.init(chartDom);
this.updateChart();
//
window.addEventListener('resize', () => {
this.chart.resize();
});
},
updateChart() {
const { X, Y, strength, angle } = this.sourceData;
// 线 ()
const angleRad = (angle * Math.PI) / 180;
const directionLength = 10;
const dirX = directionLength * Math.sin(angleRad);
const dirY = directionLength * Math.cos(angleRad);
const option = {
title: {
text: '实时声源定位地图',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: function(params) {
if (params.seriesIndex === 0) {
return `声源位置:<br/>X: ${X.toFixed(2)}<br/>Y: ${Y.toFixed(2)}<br/>强度: ${strength.toFixed(2)}<br/>角度: ${angle.toFixed(2)}°`;
}
return '';
}
},
legend: {
data: ['声源位置', '方向'],
bottom: 10
},
grid: {
top: 80,
left: 50,
right: 50,
bottom: 60
},
xAxis: {
type: 'value',
min: -15,
max: 15,
name: 'X 坐标',
nameLocation: 'center',
nameGap: 30,
axisLine: {
show: true,
onZero: true
},
splitLine: {
show: true,
lineStyle: {
type: 'dashed'
}
}
},
yAxis: {
type: 'value',
min: -15,
max: 15,
name: 'Y 坐标',
nameLocation: 'center',
nameGap: 30,
axisLine: {
show: true,
onZero: true
},
splitLine: {
show: true,
lineStyle: {
type: 'dashed'
}
}
},
series: [
{
name: '声源位置',
type: 'scatter',
symbolSize: Math.max(10, strength * 5),
data: [[X, Y]],
itemStyle: {
color: '#F56C6C'
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(245, 108, 108, 0.5)'
}
},
label: {
show: true,
position: 'top',
formatter: `强度: ${strength.toFixed(2)}\n角度: ${angle.toFixed(2)}°`
},
z: 10
},
{
name: '方向',
type: 'line',
data: [[0, 0], [dirX, dirY]],
lineStyle: {
width: 2,
color: '#409EFF'
},
symbol: ['circle', 'arrow'],
symbolSize: [5, 12],
label: {
show: false
},
z: 5
},
{
name: '探测器',
type: 'scatter',
data: [[0, 0]],
symbolSize: 10,
itemStyle: {
color: '#67C23A'
},
label: {
show: true,
position: 'bottom',
formatter: '探测器'
},
z: 8
}
]
};
this.chart.setOption(option);
},
startMonitoring() {
this.isMonitoring = true;
// 500ms
this.pollingInterval = setInterval(() => {
this.fetchSourceData();
}, 500);
},
stopMonitoring() {
this.isMonitoring = false;
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
},
fetchSourceData() {
axios.get(`${this.API_BASE_URL}/data`)
.then(response => {
this.sourceData = response.data;
this.connectionStatus = true;
this.updateChart();
})
.catch(error => {
console.error('获取声源数据失败:', error);
this.connectionStatus = false;
});
}
}
}
</script>
<style>
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f7fa;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
}
.app-header {
background-color: #304156;
color: white;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin: 0;
font-size: 1.6rem;
}
.main-content {
flex: 1;
display: flex;
padding: 1.5rem;
gap: 1.5rem;
}
.control-panel {
flex: 1;
background-color: white;
border-radius: 4px;
padding: 1.5rem;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.visualization-panel {
flex: 2;
background-color: white;
border-radius: 4px;
padding: 1.5rem;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.chart-container {
height: 500px;
width: 100%;
}
.control-buttons {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
.app-footer {
background-color: #304156;
color: #a7b0bc;
text-align: center;
padding: 1rem;
font-size: 0.9rem;
}
h2, h3 {
margin-top: 0;
color: #303133;
border-bottom: 1px solid #ebeef5;
padding-bottom: 0.5rem;
}
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
.chart-container {
height: 350px;
}
}
</style>

@ -1 +0,0 @@
Subproject commit 39c8188929657d71dfecbac4288025765589c300

@ -1,421 +1,162 @@
# 声源定位系统 - 开发板与PC端配合工作流程
# 声源定位系统 - 整合版
## 📋 项目概述
## 系统概述
本项目实现了一个基于麦克风阵列的声源定位系统,采用开发板(K210)与PC端服务器协同工作的架构。系统能够实时检测枪声并进行精确的声源定位适用于战场感知、安防监控等场景
这是一个完整的声源定位系统包含开发板端和PC端服务器支持多种可视化界面
### 🎯 系统特点
## 文件结构
- **高精度定位**基于TDOA算法的麦克风阵列定位
- **智能识别**PC端强大的枪声识别算法
- **实时响应**:定位与识别并行处理
- **可靠通信**WiFi网络下的稳定数据传输
- **可视化界面**:实时显示定位结果和系统状态
```
back-code/
├── development_board.py # 开发板端主程序 (K210)
├── pc_server.py # PC端服务器主程序 (整合版)
└── README.md # 本文件
front-code/
└── sound-vue-frontend/ # Vue Web前端 (可选)
```
## 功能特性
### PC端服务器 (pc_server.py)
- ✅ **TCP通信**: 接收开发板音频和定位数据
- ✅ **音频识别**: 集成audio-classification进行枪声检测
- ✅ **实时可视化**: 使用matplotlib显示声源位置
- ✅ **HTTP API**: 提供RESTful API接口
- ✅ **性能监控**: 实时统计和性能分析
- ✅ **卡尔曼滤波**: 定位数据平滑处理
### 开发板端 (development_board.py)
- ✅ **音频采集**: 麦克风阵列录音
- ✅ **声源定位**: 实时计算声源位置
- ✅ **WiFi通信**: 与PC端数据交换
- ✅ **模式切换**: 录音模式 ↔ 定位模式
- ✅ **异常处理**: 完善的错误恢复机制
### Web前端 (可选)
- ✅ **实时显示**: 基于ECharts的可视化
- ✅ **响应式设计**: 支持移动端访问
- ✅ **数据监控**: 实时状态显示
## 快速开始
### 1. 安装依赖
## 🏗️ 系统架构
```bash
# PC端依赖
pip install numpy matplotlib flask flask-cors configparser
# 可选Web前端依赖
cd front-code/sound-vue-frontend
npm install
```
┌─────────────────────────────────────────────────────────────┐
│ 开发板端 (K210) │
├─────────────────────────────────────────────────────────────┤
│ 硬件层: │
│ • 麦克风阵列 (4通道) │
│ • WiFi模块 (ESP8285) │
│ • LED指示灯、蜂鸣器 │
├─────────────────────────────────────────────────────────────┤
│ 软件层: │
│ • 音频采集与预处理 │
│ • 声源定位算法 (TDOA) │
│ • 网络通信管理 │
│ • 系统状态监控 │
└─────────────────────────────────────────────────────────────┘
┌─────────┴─────────┐
│ WiFi网络通信 │
└─────────┬─────────┘
┌─────────────────────────────────────────────────────────────┐
│ PC端服务器 │
├─────────────────────────────────────────────────────────────┤
│ 功能模块: │
│ • 音频识别引擎 (枪声检测) │
│ • 数据可视化界面 (matplotlib) │
│ • Web API服务 (Flask) │
│ • 数据存储与分析 │
└─────────────────────────────────────────────────────────────┘
### 2. 启动PC端服务器
```bash
cd back-code
python pc_server.py
```
## 🔧 硬件配置
### 开发板端硬件
- **主控芯片**: K210 (双核64位RISC-V)
- **麦克风阵列**: 4通道I2S接口
- **网络模块**: ESP8285 WiFi模块
- **存储**: 16MB Flash + 8MB PSRAM
- **接口**: UART、I2S、GPIO、PWM
### 引脚配置
```python
# 麦克风阵列引脚
mic_i2s_d0 = 23 # 数据通道0
mic_i2s_d1 = 22 # 数据通道1
mic_i2s_d2 = 21 # 数据通道2
mic_i2s_d3 = 20 # 数据通道3
mic_i2s_ws = 19 # 字选择
mic_i2s_sclk = 18 # 时钟
# 其他硬件
led_pin = 12 # LED指示灯
buzzer_pin = 13 # 蜂鸣器
wifi_en_pin = 8 # WiFi使能控制
服务器启动后会显示:
- matplotlib可视化界面
- HTTP API服务 (http://localhost:5000)
### 3. 启动开发板端
将`development_board.py`上传到K210开发板并运行。
### 4. 可选启动Web前端
```bash
cd front-code/sound-vue-frontend
npm run serve
```
## 🌐 网络配置
访问 http://localhost:8080 查看Web界面。
### 开发板端网络设置
```python
# WiFi连接配置
wifi_ssid = "junzekeki"
wifi_password = "234567890l"
## HTTP API接口
# PC端服务器地址
pc_ip = "192.168.1.100"
pc_port_audio = 12346 # 音频数据传输端口
pc_port_cmd = 12347 # 指令控制端口
pc_port_location = 12348 # 定位数据传输端口
### 获取定位数据
```
GET http://localhost:5000/data
```
### PC端服务器配置
```python
# 服务器监听配置
host = "0.0.0.0" # 监听所有网络接口
port_audio = 12346 # 音频数据接收端口
port_cmd = 12347 # 指令发送端口
port_location = 12348 # 定位数据接收端口
响应示例:
```json
{
"X": 1.23,
"Y": -0.45,
"strength": 2.5,
"angle": 90.0,
"timestamp": 1640995200.123,
"confidence": 0.85
}
```
## 🔄 详细工作流程
### 阶段1: 系统初始化
#### 开发板端初始化流程
1. **硬件初始化**
- 初始化麦克风阵列I2S接口
- 配置GPIO引脚LED、蜂鸣器、WiFi使能
- 初始化定时器和中断
2. **网络连接**
- 启用WiFi模块
- 连接到指定WiFi网络
- 建立3个Socket连接
* `audio_socket`: 发送音频数据
* `cmd_socket`: 接收PC端指令
* `location_socket`: 发送定位数据
3. **系统启动**
- 启动性能监控模块
- 启动心跳机制30秒间隔
- 初始化音频缓冲区和映射队列
- 切换到录音模式
#### PC端初始化流程
1. **服务器启动**
- 创建3个Socket服务器
- 初始化音频识别模块
- 启动matplotlib可视化界面
- 初始化Flask Web API
2. **等待连接**
- 监听开发板连接请求
- 建立数据通信通道
- 启动数据处理线程
### 阶段2: 录音监听模式
#### 开发板端工作流程
1. **音频采集**
- 从麦克风阵列获取音频数据
- 应用增益控制和噪声抑制
- 将音频数据转换为标准格式
2. **数据传输**
- 将音频数据通过`audio_socket`发送给PC端
- 更新性能统计(发送包数、数据量等)
3. **指令监听**
- 非阻塞检查`cmd_socket`是否有PC端指令
- 处理模式切换指令START_LOCATION、STOP_LOCATION等
#### PC端工作流程
1. **音频接收**
- 从`audio_socket`接收音频数据
- 将数据添加到音频处理器缓冲区
2. **枪声识别**
- 当缓冲区达到处理阈值时进行识别
- 使用预训练的音频分类模型
- 计算枪声检测置信度
3. **模式切换**
- 当检测到枪声时,发送"START_LOCATION"指令
- 切换到定位模式进行精确定位
### 阶段3: 定位识别模式(核心流程)
#### 开发板端定位流程
1. **音频缓冲**
- 持续录音并添加到定位音频缓冲区
- 缓冲区大小0.5秒音频数据
- 当缓冲区满时触发处理
2. **声源定位**
- 对缓冲的音频数据进行预处理
- 计算各麦克风间的时延差TDOA
- 使用最小二乘法求解声源位置
- 应用卡尔曼滤波平滑定位结果
3. **映射存储**
- 将定位结果和对应音频存储为映射关系
- 映射结构:
```python
{
'location_data': LocationData对象,
'audio_data': 音频数据列表,
'timestamp': 时间戳,
'processed': False
}
```
4. **识别请求**
- 构建识别请求:`RECOGNITION_REQUEST:timestamp:data_size`
- 通过`audio_socket`发送音频数据给PC端
- 清空音频缓冲区,准备下一轮
#### PC端识别流程
1. **请求处理**
- 检测识别请求标识
- 解析请求头获取时间戳和数据大小
- 接收指定大小的音频数据
2. **枪声识别**
- 将音频数据转换为numpy数组
- 检查音频质量(信噪比、能量等)
- 使用音频分类模型进行识别
- 计算识别置信度
3. **结果返回**
- 构建识别结果:`RECOGNITION_RESULT:timestamp:is_gunshot:confidence`
- 通过`cmd_socket`发送给开发板
### 阶段4: 结果处理与输出
#### 开发板端结果处理
1. **结果接收**
- 从`cmd_socket`接收识别结果
- 解析时间戳、枪声标识、置信度
2. **时间戳匹配**
- 在映射队列中查找时间戳最接近的定位数据
- 匹配条件时间差小于1秒
- 标记匹配的映射为已处理
3. **条件输出**
- 如果识别结果为枪声:
* 提取对应的定位坐标
* 通过`location_socket`发送给PC端
* 记录日志和性能统计
- 如果识别结果不是枪声:
* 忽略该定位数据
* 继续监听下一轮
4. **资源清理**
- 移除已处理的映射关系
- 清理过期的识别结果超过5秒
- 维护映射队列大小最大20个
#### PC端数据处理
1. **定位数据接收**
- 从`location_socket`接收定位数据
- 解析坐标、强度、角度等信息
2. **数据后处理**
- 应用卡尔曼滤波平滑轨迹
- 异常值检测和剔除
- 数据平滑和插值
3. **可视化更新**
- 更新matplotlib实时图表
- 显示枪声位置、轨迹、统计信息
- 更新Web API数据接口
4. **数据存储**
- 将定位数据添加到历史记录
- 更新性能统计和系统状态
- 生成分析报告
### 阶段5: 模式切换与维护
#### 动态模式切换
1. **录音→定位模式**
- 触发条件PC端检测到枪声
- 切换指令:`START_LOCATION`
- 开发板响应:重置定位缓冲区,开始定位流程
2. **定位→录音模式**
- 触发条件PC端发送停止指令或超时
- 切换指令:`STOP_LOCATION`
- 开发板响应:清理定位资源,回到录音模式
#### 系统维护
1. **心跳机制**
- 开发板每30秒发送心跳包
- 包含系统状态、内存使用、错误统计
- PC端监控连接状态和系统健康
2. **错误恢复**
- 网络断开自动重连
- 硬件故障检测和恢复
- 异常状态处理和日志记录
3. **性能监控**
- 实时监控CPU、内存使用率
- 统计数据传输量和延迟
- 生成性能报告和告警
## 📊 数据格式规范
### 音频数据格式
```python
# 音频参数
sample_rate = 16000 # 采样率 16kHz
channels = 1 # 单声道
format = "int16" # 16位整数格式
chunk_size = 1024 # 数据块大小
### 获取系统状态
```
GET http://localhost:5000/status
```
### 定位数据格式
```python
# 定位数据结构
LocationData {
x: float, # X坐标 (米)
y: float, # Y坐标 (米)
strength: float, # 信号强度 (0-1)
angle: float, # 方位角 (度)
timestamp: float, # 时间戳
confidence: float, # 置信度 (0-1)
quality: float, # 定位质量 (0-1)
noise_level: float # 噪声水平 (0-1)
}
### 获取性能统计
```
GET http://localhost:5000/stats
```
## 配置说明
系统使用`config.ini`文件进行配置,主要配置项:
```ini
[NETWORK]
host = 0.0.0.0
port_audio = 12346
port_cmd = 12347
port_location = 12348
[HTTP_API]
enabled = True
host = 0.0.0.0
port = 5000
cors_enabled = True
[RECOGNITION]
gunshot_threshold = 0.7
recognition_interval = 3.0
```
### 通信协议格式
```python
# 识别请求
"RECOGNITION_REQUEST:timestamp:data_size"
## 使用场景
### 场景1仅使用matplotlib界面
直接运行`pc_server.py`系统会自动显示matplotlib可视化界面。
# 识别结果
"RECOGNITION_RESULT:timestamp:is_gunshot:confidence"
### 场景2仅使用Web界面
1. 启动`pc_server.py` (提供HTTP API)
2. 启动Vue前端
3. 通过浏览器访问Web界面
# 定位数据
"x,y,strength,angle"
### 场景3同时使用两种界面
两个界面可以同时运行,数据实时同步。
# 心跳数据
"HEARTBEAT:timestamp:mode:status:memory"
## 故障排除
### 1. Flask模块未找到
```bash
pip install flask flask-cors
```
## 🎯 系统优势
### 1. 准确性优势
- **分离式处理**开发板专注定位PC端专注识别
- **时间戳匹配**:精确关联定位数据和识别结果
- **多重验证**:音频质量检查、置信度阈值、异常值检测
### 2. 实时性优势
- **并行处理**:定位和识别同时进行
- **非阻塞通信**Socket超时机制避免阻塞
- **缓冲优化**:合理的缓冲区大小和清理策略
### 3. 可靠性优势
- **自动重连**:网络断开自动恢复
- **错误处理**:完善的异常捕获和恢复机制
- **状态监控**:实时监控系统健康状态
### 4. 扩展性优势
- **模块化设计**:各功能模块独立,易于升级
- **配置灵活**:支持动态配置参数
- **接口标准化**:标准化的数据格式和通信协议
## 🔍 应用场景
### 1. 战场感知
- 实时检测枪声位置
- 威胁源定位和追踪
- 战场态势分析
### 2. 安防监控
- 枪声检测和报警
- 安全区域监控
- 事件记录和分析
### 3. 训练模拟
- 射击训练评估
- 战术演练分析
- 性能数据统计
### 4. 城市安全
- 公共安全监控
- 应急响应支持
- 犯罪预防分析
## 📈 性能指标
### 定位精度
- **角度精度**: ±2° (在10米距离)
- **距离精度**: ±0.5米 (在10米距离)
- **响应时间**: <100ms
### 识别性能
- **检测准确率**: >95%
- **误报率**: <2%
- **漏报率**: <3%
### 系统性能
- **最大检测距离**: 50米
- **工作温度**: -20°C ~ +70°C
- **连续工作时间**: >24小时
- **网络延迟**: <50ms
## 🛠️ 部署说明
### 开发板端部署
1. 将代码烧录到K210开发板
2. 配置WiFi网络参数
3. 连接麦克风阵列硬件
4. 启动系统并检查连接状态
### PC端部署
1. 安装Python依赖包
2. 配置服务器网络参数
3. 启动音频识别服务
4. 运行可视化界面
### 网络配置
1. 确保开发板和PC在同一WiFi网络
2. 检查防火墙设置
3. 验证端口连通性
4. 测试数据传输
## 📝 注意事项
1. **硬件连接**确保麦克风阵列正确连接检查I2S信号质量
2. **网络稳定**使用稳定的WiFi网络避免频繁断开
3. **电源供应**开发板需要稳定的5V电源供应
4. **环境噪声**:避免强电磁干扰和机械振动
5. **定期维护**:定期检查系统状态和清理日志文件
---
**版本**: 3.0.0
**作者**: 声源定位系统开发团队
**日期**: 2025年
**许可证**: MIT License
### 2. 端口被占用
修改`config.ini`中的端口配置。
### 3. 开发板连接失败
检查网络配置和开发板IP地址。
## 版本历史
- **v2.0.0**: 整合Flask API支持多种可视化界面
- **v1.0.0**: 基础声源定位功能
## 技术支持
如有问题,请检查日志文件或联系开发团队。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -30,6 +30,42 @@
<el-descriptions-item label="角度">{{ sourceData.angle.toFixed(2) }}°</el-descriptions-item>
</el-descriptions>
</div>
<div class="gunshot-records">
<h3>枪声记录统计</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="总记录数">{{ recordsStats.total_records || 0 }}</el-descriptions-item>
<el-descriptions-item label="枪声记录数">{{ recordsStats.gunshot_records || 0 }}</el-descriptions-item>
<el-descriptions-item label="非枪声记录数">{{ recordsStats.non_gunshot_records || 0 }}</el-descriptions-item>
<el-descriptions-item label="枪声比例">{{ ((recordsStats.gunshot_ratio || 0) * 100).toFixed(2) }}%</el-descriptions-item>
</el-descriptions>
</div>
<div class="gunshot-list">
<h3>最近枪声记录</h3>
<el-table :data="gunshotRecords" style="width: 100%" max-height="200">
<el-table-column prop="timestamp" label="时间" width="120">
<template #default="scope">
{{ formatTimestamp(scope.row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="location.x" label="X坐标" width="80">
<template #default="scope">
{{ scope.row.location.x.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="location.y" label="Y坐标" width="80">
<template #default="scope">
{{ scope.row.location.y.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="gunshot_confidence" label="置信度" width="80">
<template #default="scope">
{{ (scope.row.gunshot_confidence * 100).toFixed(1) }}%
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="visualization-panel">
@ -63,11 +99,20 @@ export default {
isMonitoring: false,
pollingInterval: null,
API_BASE_URL: 'http://127.0.0.1:5000', //
recordsStats: {
total_records: 0,
gunshot_records: 0,
non_gunshot_records: 0,
gunshot_ratio: 0
},
gunshotRecords: []
};
},
mounted() {
this.initChart();
this.checkConnection();
this.fetchRecordsStats();
this.fetchGunshotRecords();
},
beforeUnmount() {
if (this.pollingInterval) {
@ -230,6 +275,8 @@ export default {
// 500ms
this.pollingInterval = setInterval(() => {
this.fetchSourceData();
this.fetchRecordsStats();
this.fetchGunshotRecords();
}, 500);
},
stopMonitoring() {
@ -251,6 +298,28 @@ export default {
console.error('获取声源数据失败:', error);
this.connectionStatus = false;
});
},
fetchRecordsStats() {
axios.get(`${this.API_BASE_URL}/location_records_stats`)
.then(response => {
this.recordsStats = response.data;
})
.catch(error => {
console.error('获取记录统计失败:', error);
});
},
fetchGunshotRecords() {
axios.get(`${this.API_BASE_URL}/gunshot_records`)
.then(response => {
this.gunshotRecords = response.data.gunshot_records || [];
})
.catch(error => {
console.error('获取枪声记录失败:', error);
});
},
formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString();
}
}
}
@ -296,6 +365,8 @@ export default {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-height: 80vh;
overflow-y: auto;
}
.visualization-panel {
@ -317,6 +388,10 @@ export default {
margin: 1rem 0;
}
.gunshot-records, .gunshot-list {
margin-top: 1rem;
}
.app-footer {
background-color: #304156;
color: #a7b0bc;

@ -1,30 +1,37 @@
<!-- 音频录制组件模板 -->
<template>
<div class="audio-recorder">
<div class="recorder-content">
<!-- 录音状态显示 -->
<!-- 录音状态显示区域 -->
<div class="recorder-status">
<!-- 状态指示器根据录音状态动态切换样式 -->
<div class="status-indicator" :class="{
active: isRecording,
ready: isReady,
processing: isProcessing
}">
<div class="status-bg"></div>
<!-- 图标切换动画 -->
<transition name="icon-switch" mode="out-in">
<!-- 处理中状态图标 -->
<el-icon class="status-icon processing" v-if="isProcessing" key="processing">
<Loading />
</el-icon>
<!-- 录音中状态图标 -->
<el-icon class="status-icon recording" v-else-if="isRecording" key="recording">
<Microphone />
</el-icon>
<!-- 就绪状态图标 -->
<el-icon class="status-icon ready" v-else-if="isReady" key="ready">
<Microphone />
</el-icon>
<!-- 默认状态图标 -->
<el-icon class="status-icon default" v-else key="default">
<MicrophoneOne />
</el-icon>
</transition>
<!-- 录音波纹效果 -->
<!-- 录音波纹动画效果仅在录音时显示 -->
<div v-if="isRecording" class="recording-waves">
<div class="wave wave-1"></div>
<div class="wave wave-2"></div>
@ -32,9 +39,11 @@
</div>
</div>
<!-- 状态文本信息显示区域 -->
<div class="status-text">
<h3 class="status-title">{{ statusTitle }}</h3>
<p class="primary-text">{{ statusText }}</p>
<!-- 录音时长显示仅在录音时显示 -->
<transition name="slide-down">
<p class="secondary-text" v-if="recordingTime > 0">
<el-icon><Timer /></el-icon>
@ -44,9 +53,11 @@
</div>
</div>
<!-- 录音控制按钮 -->
<!-- 录音控制按钮区域 -->
<div class="recorder-controls">
<!-- 按钮切换动画 -->
<transition name="button-switch" mode="out-in">
<!-- 开始录音按钮仅在未录音且未处理时显示 -->
<el-button
v-if="!isRecording && !isProcessing"
type="primary"
@ -63,6 +74,7 @@
<div class="button-glow"></div>
</el-button>
<!-- 停止录音按钮仅在录音时显示 -->
<el-button
v-else-if="isRecording"
type="danger"
@ -77,6 +89,7 @@
<span>停止录音</span>
</el-button>
<!-- 处理中按钮显示加载状态 -->
<el-button
v-else
size="large"
@ -89,39 +102,49 @@
</transition>
</div>
<!-- 录音设置 -->
<!-- 录音设置区域 -->
<div class="recorder-settings">
<!-- 设置区域标题 -->
<div class="settings-header">
<el-icon><Setting /></el-icon>
<span>录音设置</span>
</div>
<!-- 录音设置表单 -->
<el-form :model="settings" label-width="80px" size="small" class="settings-form">
<!-- 录音时长设置项 -->
<el-form-item label="录音时长">
<!-- 录音时长选择器录音时禁用 -->
<el-select v-model="settings.duration" :disabled="isRecording" class="duration-select">
<!-- 3秒选项 -->
<el-option label="3秒" :value="3">
<div class="option-content">
<span>3</span>
<el-tag size="small" type="info">快速</el-tag>
</div>
</el-option>
<!-- 5秒选项推荐 -->
<el-option label="5秒" :value="5">
<div class="option-content">
<span>5</span>
<el-tag size="small" type="success">推荐</el-tag>
</div>
</el-option>
<!-- 10秒选项 -->
<el-option label="10秒" :value="10">
<div class="option-content">
<span>10</span>
<el-tag size="small" type="warning">详细</el-tag>
</div>
</el-option>
<!-- 15秒和30秒选项 -->
<el-option label="15秒" :value="15" />
<el-option label="30秒" :value="30" />
</el-select>
</el-form-item>
<!-- 自动停止设置项 -->
<el-form-item label="自动停止">
<!-- 自动停止开关录音时禁用 -->
<el-switch
v-model="settings.autoStop"
:disabled="isRecording"
@ -132,14 +155,17 @@
</el-form>
</div>
<!-- 音频可视化 -->
<!-- 音频可视化区域仅在录音时显示 -->
<transition name="fade">
<div v-if="isRecording" class="audio-visualizer">
<!-- 可视化区域标题 -->
<div class="visualizer-header">
<el-icon><DataAnalysis /></el-icon>
<span>实时音频波形</span>
</div>
<!-- 波形画布 -->
<canvas ref="canvasRef" class="visualizer-canvas"></canvas>
<!-- 音量信息显示 -->
<div class="visualizer-info">
<div class="info-item">
<span class="label">音量:</span>
@ -151,19 +177,23 @@
</div>
</transition>
<!-- 录音预览 -->
<!-- 录音预览区域有录音时显示 -->
<transition name="slide-up">
<div v-if="recordedAudio" class="audio-preview">
<!-- 预览区域头部 -->
<div class="preview-header">
<div class="preview-title">
<el-icon class="preview-icon"><Headphone /></el-icon>
<span>录音预览</span>
</div>
<!-- 预览操作按钮组 -->
<div class="preview-actions">
<!-- 播放录音按钮 -->
<el-button type="text" size="small" @click="playRecording" class="action-button">
<el-icon><VideoPlay /></el-icon>
播放
</el-button>
<!-- 删除录音按钮 -->
<el-button type="text" size="small" @click="clearRecording" class="action-button danger">
<el-icon><Delete /></el-icon>
删除
@ -171,7 +201,9 @@
</div>
</div>
<!-- 音频播放器容器 -->
<div class="audio-player-container">
<!-- HTML5音频播放器 -->
<audio
ref="audioPlayerRef"
controls
@ -181,17 +213,21 @@
您的浏览器不支持音频播放
</audio>
</div>
<!-- 录音信息显示区域 -->
<div class="preview-info">
<!-- 录音时长信息 -->
<div class="info-item">
<el-icon><Timer /></el-icon>
<span>时长: {{ formatTime(recordedAudio.duration) }}</span>
</div>
<!-- 文件大小信息 -->
<div class="info-item">
<el-icon><DataBoard /></el-icon>
<span>大小: {{ formatFileSize(recordedAudio.size) }}</span>
</div>
</div>
<!-- 识别录音按钮 -->
<el-button
type="primary"
@click="submitRecording"
@ -207,48 +243,51 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Element Plus
import { ElMessage } from 'element-plus'
// API
import { apiService } from '../utils/api'
// Props
//
const props = defineProps({
disabled: {
type: Boolean,
default: false
default: false //
}
})
// Emits
//
const emit = defineEmits(['record-success', 'record-error'])
//
const isRecording = ref(false)
const isReady = ref(false)
const isSubmitting = ref(false)
const isProcessing = ref(false)
const recordingTime = ref(0)
const recordedAudio = ref(null)
const canvasRef = ref()
const audioPlayerRef = ref()
const volume = ref(0)
//
let mediaRecorder = null
let audioChunks = []
let recordingTimer = null
let audioContext = null
let analyser = null
let microphone = null
let animationId = null
//
//
const isRecording = ref(false) //
const isReady = ref(false) //
const isSubmitting = ref(false) //
const isProcessing = ref(false) //
const recordingTime = ref(0) //
const recordedAudio = ref(null) //
const canvasRef = ref() //
const audioPlayerRef = ref() //
const volume = ref(0) //
//
let mediaRecorder = null // MediaRecorder
let audioChunks = [] //
let recordingTimer = null //
let audioContext = null // Web Audio API
let analyser = null //
let microphone = null //
let animationId = null // ID
//
const settings = ref({
duration: 5, // 5
autoStop: true
duration: 5, // 5
autoStop: true //
})
//
//
const statusTitle = computed(() => {
if (isProcessing.value) return '处理中'
if (isRecording.value) return '录音中'
@ -263,166 +302,171 @@ const statusText = computed(() => {
return '点击开始录音按钮进行音频识别'
})
//
//
const initRecorder = async () => {
try {
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
sampleRate: 16000, // 16kHz
channelCount: 1, //
echoCancellation: true, //
noiseSuppression: true //
}
})
// MediaRecorder
// MediaRecorder使WebM
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
//
// Web Audio API
audioContext = new (window.AudioContext || window.webkitAudioContext)()
analyser = audioContext.createAnalyser()
microphone = audioContext.createMediaStreamSource(stream)
microphone.connect(analyser)
analyser.fftSize = 256
analyser.fftSize = 256 // FFT
//
// MediaRecorder
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
audioChunks.push(event.data) //
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
createAudioPreview(audioBlob)
audioChunks = []
createAudioPreview(audioBlob) //
audioChunks = [] //
}
isReady.value = true
ElMessage.success('麦克风初始化成功')
} catch (error) {
//
console.error('麦克风初始化失败:', error)
ElMessage.error('无法访问麦克风,请检查权限设置')
}
}
//
//
const startRecording = () => {
// MediaRecorder
if (!mediaRecorder || mediaRecorder.state !== 'inactive') return
audioChunks = []
recordingTime.value = 0
isRecording.value = true
audioChunks = [] //
recordingTime.value = 0 //
isRecording.value = true //
mediaRecorder.start()
mediaRecorder.start() //
//
//
recordingTimer = setInterval(() => {
recordingTime.value++
//
//
if (settings.value.autoStop && recordingTime.value >= settings.value.duration) {
stopRecording()
}
}, 1000)
//
//
startVisualization()
ElMessage.info('开始录音')
}
//
//
const stopRecording = () => {
// MediaRecorder
if (!mediaRecorder || mediaRecorder.state !== 'recording') return
isRecording.value = false
mediaRecorder.stop()
isRecording.value = false //
mediaRecorder.stop() //
//
//
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
//
//
stopVisualization()
ElMessage.success('录音完成')
}
//
//
const createAudioPreview = (audioBlob) => {
const url = URL.createObjectURL(audioBlob)
const url = URL.createObjectURL(audioBlob) // URL
recordedAudio.value = {
blob: audioBlob,
url: url,
duration: recordingTime.value,
size: audioBlob.size
blob: audioBlob, // Blob
url: url, // URL
duration: recordingTime.value, //
size: audioBlob.size //
}
}
//
//
const playRecording = () => {
if (audioPlayerRef.value) {
audioPlayerRef.value.play()
audioPlayerRef.value.play() //
}
}
//
//
const clearRecording = () => {
if (recordedAudio.value) {
URL.revokeObjectURL(recordedAudio.value.url)
recordedAudio.value = null
URL.revokeObjectURL(recordedAudio.value.url) // URL
recordedAudio.value = null //
}
recordingTime.value = 0
recordingTime.value = 0 //
}
//
//
const submitRecording = async () => {
if (!recordedAudio.value) return
isSubmitting.value = true
try {
// 使Web Audio API
// BlobArrayBuffer
const arrayBuffer = await recordedAudio.value.blob.arrayBuffer()
//
//
const tempAudioContext = new (window.AudioContext || window.webkitAudioContext)()
const audioBuffer = await tempAudioContext.decodeAudioData(arrayBuffer)
//
//
const channelData = audioBuffer.getChannelData(0)
const audioData = Array.from(channelData)
//
//
await tempAudioContext.close()
// API
const response = await apiService.predictAudioData({
audio_data: audioData,
sample_rate: audioBuffer.sampleRate
})
if (response.data.status === 'success') {
emit('record-success', response.data.result)
clearRecording()
emit('record-success', response.data.result) //
clearRecording() //
} else {
throw new Error(response.data.message)
}
} catch (error) {
emit('record-error', error.message)
emit('record-error', error.message) //
} finally {
isSubmitting.value = false
isSubmitting.value = false //
}
}
//
//
const startVisualization = () => {
if (!canvasRef.value || !analyser) return
@ -431,13 +475,15 @@ const startVisualization = () => {
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
//
const draw = () => {
if (!isRecording.value) return
animationId = requestAnimationFrame(draw)
animationId = requestAnimationFrame(draw) //
analyser.getByteFrequencyData(dataArray)
analyser.getByteFrequencyData(dataArray) //
//
ctx.fillStyle = 'rgb(255, 255, 255)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
@ -445,6 +491,7 @@ const startVisualization = () => {
let barHeight
let x = 0
//
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i] / 255 * canvas.height
@ -455,58 +502,64 @@ const startVisualization = () => {
}
}
draw()
draw() //
}
//
//
const stopVisualization = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
cancelAnimationFrame(animationId) //
animationId = null // ID
}
}
//
// :
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
const mins = Math.floor(seconds / 60) //
const secs = seconds % 60 //
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
//
//
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const i = Math.floor(Math.log(bytes) / Math.log(k)) //
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
//
//
//
onMounted(() => {
initRecorder()
})
//
//
onUnmounted(() => {
//
if (recordingTimer) {
clearInterval(recordingTimer)
}
//
if (animationId) {
cancelAnimationFrame(animationId)
}
//
if (mediaRecorder && mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach(track => track.stop())
}
//
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
//
clearRecording()
})
</script>

@ -1,23 +1,30 @@
<!-- 音频录制组件模板 -->
<template>
<div class="audio-recorder">
<div class="recorder-content">
<!-- 录音状态显示 -->
<!-- 录音状态显示区域 -->
<div class="recorder-status">
<!-- 状态指示器包装器 -->
<div class="status-indicator-wrapper">
<!-- 状态指示器根据录音状态动态切换样式 -->
<div class="status-indicator" :class="{ active: isRecording, ready: isReady, disabled: disabled }">
<!-- 图标切换动画 -->
<transition name="icon-switch" mode="out-in">
<!-- 录音中状态图标 -->
<el-icon class="status-icon recording" v-if="isRecording" key="recording">
<Loading />
</el-icon>
<!-- 就绪状态图标 -->
<el-icon class="status-icon ready" v-else-if="isReady && !disabled" key="ready">
<Microphone />
</el-icon>
<!-- 禁用状态图标 -->
<el-icon class="status-icon disabled" v-else key="disabled">
<MicrophoneFilled />
</el-icon>
</transition>
<!-- 录音状态动画圈 -->
<!-- 录音状态动画圈录音时显示脉冲效果 -->
<div class="recording-ring" :class="{ active: isRecording }">
<div class="ring-outer"></div>
<div class="ring-middle"></div>
@ -25,15 +32,17 @@
</div>
</div>
<!-- 音频可视化波形 -->
<!-- 音频可视化波形显示区域仅在录音时显示 -->
<div v-if="isRecording" class="waveform-container">
<canvas ref="canvasRef" class="waveform-canvas"></canvas>
</div>
</div>
<!-- 状态文本信息显示区域 -->
<div class="status-text">
<h3 class="status-title">{{ statusTitle }}</h3>
<p class="status-description">{{ statusDescription }}</p>
<!-- 录音时长显示仅在录音时显示 -->
<div v-if="recordingTime > 0" class="recording-time">
<el-icon class="time-icon"><Timer /></el-icon>
<span class="time-text">{{ formatTime(recordingTime) }}</span>
@ -41,9 +50,11 @@
</div>
</div>
<!-- 录音控制按钮 -->
<!-- 录音控制按钮区域 -->
<div class="recorder-controls">
<!-- 按钮切换动画 -->
<transition name="button-switch" mode="out-in">
<!-- 开始录音按钮仅在未录音时显示 -->
<el-button
v-if="!isRecording"
type="primary"
@ -60,6 +71,7 @@
<div class="button-glow"></div>
</el-button>
<!-- 停止录音按钮仅在录音时显示 -->
<el-button
v-else
type="danger"
@ -77,16 +89,20 @@
</transition>
</div>
<!-- 录音设置 -->
<!-- 录音设置区域 -->
<div class="recorder-settings">
<!-- 设置区域标题 -->
<div class="settings-header">
<el-icon class="settings-icon"><Setting /></el-icon>
<span>录音设置</span>
</div>
<!-- 设置选项网格布局 -->
<div class="settings-grid">
<!-- 录音时长设置项 -->
<div class="setting-item">
<label class="setting-label">录音时长</label>
<!-- 录音时长选择器录音时禁用 -->
<el-select
v-model="settings.duration"
:disabled="isRecording"
@ -101,8 +117,10 @@
</el-select>
</div>
<!-- 自动停止设置项 -->
<div class="setting-item">
<label class="setting-label">自动停止</label>
<!-- 自动停止开关录音时禁用 -->
<el-switch
v-model="settings.autoStop"
:disabled="isRecording"
@ -114,19 +132,23 @@
</div>
</div>
<!-- 录音预览 -->
<!-- 录音预览区域有录音时显示 -->
<transition name="slide-up">
<div v-if="recordedAudio" class="audio-preview">
<!-- 预览区域头部 -->
<div class="preview-header">
<div class="preview-title">
<el-icon class="preview-icon"><Headphone /></el-icon>
<span>录音预览</span>
</div>
<!-- 预览操作按钮组 -->
<div class="preview-actions">
<!-- 播放录音按钮 -->
<el-button type="text" size="small" @click="playRecording" class="action-button play">
<el-icon><VideoPlay /></el-icon>
播放
</el-button>
<!-- 删除录音按钮 -->
<el-button type="text" size="small" @click="clearRecording" class="action-button delete">
<el-icon><Delete /></el-icon>
删除
@ -134,7 +156,9 @@
</div>
</div>
<!-- 音频播放器容器 -->
<div class="audio-player-container">
<!-- HTML5音频播放器 -->
<audio
ref="audioPlayerRef"
controls
@ -145,11 +169,14 @@
</audio>
</div>
<!-- 录音信息显示区域 -->
<div class="preview-info">
<!-- 录音时长信息 -->
<div class="info-item">
<el-icon><Timer /></el-icon>
<span>{{ formatTime(recordedAudio.duration) }}</span>
</div>
<!-- 文件大小信息 -->
<div class="info-item">
<el-icon><DataBoard /></el-icon>
<span>{{ formatFileSize(recordedAudio.size) }}</span>
@ -162,46 +189,49 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Element Plus
import { ElMessage } from 'element-plus'
// API
import { apiService } from '../utils/api'
// Props
//
const props = defineProps({
disabled: {
type: Boolean,
default: false
default: false //
}
})
// Emits
//
const emit = defineEmits(['record-success', 'record-error'])
//
const isRecording = ref(false)
const isReady = ref(false)
const isSubmitting = ref(false)
const recordingTime = ref(0)
const recordedAudio = ref(null)
const canvasRef = ref()
const audioPlayerRef = ref()
//
let mediaRecorder = null
let audioChunks = []
let recordingTimer = null
let audioContext = null
let analyser = null
let microphone = null
let animationId = null
//
//
const isRecording = ref(false) //
const isReady = ref(false) //
const isSubmitting = ref(false) //
const recordingTime = ref(0) //
const recordedAudio = ref(null) //
const canvasRef = ref() //
const audioPlayerRef = ref() //
//
let mediaRecorder = null // MediaRecorder
let audioChunks = [] //
let recordingTimer = null //
let audioContext = null // Web Audio API
let analyser = null //
let microphone = null //
let animationId = null // ID
//
const settings = ref({
duration: 5, // 5
autoStop: true
duration: 5, // 5
autoStop: true //
})
//
//
const statusTitle = computed(() => {
if (isRecording.value) return '录音中'
if (isReady.value) return '就绪'
@ -214,46 +244,47 @@ const statusDescription = computed(() => {
return '点击开始录音按钮进行音频识别'
})
//
//
const initRecorder = async () => {
try {
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
sampleRate: 16000, // 16kHz
channelCount: 1, //
echoCancellation: true, //
noiseSuppression: true //
}
})
// MediaRecorder
// MediaRecorder使WebM
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
//
// Web Audio API
audioContext = new (window.AudioContext || window.webkitAudioContext)()
analyser = audioContext.createAnalyser()
microphone = audioContext.createMediaStreamSource(stream)
microphone.connect(analyser)
analyser.fftSize = 256
analyser.fftSize = 256 // FFT
//
// MediaRecorder
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
audioChunks.push(event.data) //
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
createAudioPreview(audioBlob)
submitRecording(audioBlob)
audioChunks = []
createAudioPreview(audioBlob) //
submitRecording(audioBlob) //
audioChunks = [] //
}
isReady.value = true
isReady.value = true //
ElMessage.success('麦克风初始化成功')
} catch (error) {
@ -262,226 +293,236 @@ const initRecorder = async () => {
}
}
//
//
const startRecording = () => {
if (!mediaRecorder || mediaRecorder.state !== 'inactive') return
audioChunks = []
recordingTime.value = 0
isRecording.value = true
audioChunks = [] //
recordingTime.value = 0 //
isRecording.value = true //
mediaRecorder.start()
mediaRecorder.start() //
//
//
recordingTimer = setInterval(() => {
recordingTime.value++
//
//
if (settings.value.autoStop && recordingTime.value >= settings.value.duration) {
stopRecording()
}
}, 1000)
//
//
startVisualization()
ElMessage.info('开始录音')
}
//
//
const stopRecording = () => {
if (!mediaRecorder || mediaRecorder.state !== 'recording') return
isRecording.value = false
mediaRecorder.stop()
isRecording.value = false // false
mediaRecorder.stop() //
//
//
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
//
//
stopVisualization()
ElMessage.success('录音完成')
}
//
//
const createAudioPreview = (audioBlob) => {
const url = URL.createObjectURL(audioBlob)
const url = URL.createObjectURL(audioBlob) // URL
recordedAudio.value = {
blob: audioBlob,
url: url,
duration: recordingTime.value,
size: audioBlob.size
blob: audioBlob, // Blob
url: url, // URL
duration: recordingTime.value, //
size: audioBlob.size //
}
}
//
//
const playRecording = () => {
if (audioPlayerRef.value) {
audioPlayerRef.value.play()
audioPlayerRef.value.play() //
}
}
//
//
const clearRecording = () => {
if (recordedAudio.value) {
URL.revokeObjectURL(recordedAudio.value.url)
recordedAudio.value = null
URL.revokeObjectURL(recordedAudio.value.url) // URL
recordedAudio.value = null //
}
recordingTime.value = 0
recordingTime.value = 0 //
}
//
//
const submitRecording = async (audioBlob) => {
if (!audioBlob) return
isSubmitting.value = true
isSubmitting.value = true //
try {
// 使Web Audio API
// BlobArrayBuffer
const arrayBuffer = await audioBlob.arrayBuffer()
//
//
const tempAudioContext = new (window.AudioContext || window.webkitAudioContext)()
const audioBuffer = await tempAudioContext.decodeAudioData(arrayBuffer)
//
//
const channelData = audioBuffer.getChannelData(0)
const audioData = Array.from(channelData)
//
//
await tempAudioContext.close()
// API
const response = await apiService.predictAudioData({
audio_data: audioData,
sample_rate: audioBuffer.sampleRate
audio_data: audioData, //
sample_rate: audioBuffer.sampleRate //
})
if (response.data.status === 'success') {
emit('record-success', response.data.result)
emit('record-success', response.data.result) //
} else {
throw new Error(response.data.message)
throw new Error(response.data.message) //
}
} catch (error) {
emit('record-error', error.message)
emit('record-error', error.message) //
} finally {
isSubmitting.value = false
isSubmitting.value = false //
}
}
//
//
const startVisualization = () => {
if (!canvasRef.value || !analyser) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
const ctx = canvas.getContext('2d') // 2D
const bufferLength = analyser.frequencyBinCount //
const dataArray = new Uint8Array(bufferLength) //
const draw = () => {
if (!isRecording.value) return
animationId = requestAnimationFrame(draw)
animationId = requestAnimationFrame(draw) //
analyser.getByteFrequencyData(dataArray)
analyser.getByteFrequencyData(dataArray) //
//
//
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const barWidth = (canvas.width / bufferLength) * 2.5
const barWidth = (canvas.width / bufferLength) * 2.5 //
let barHeight
let x = 0
//
for (let i = 0; i < bufferLength; i++) {
barHeight = (dataArray[i] / 255) * canvas.height * 0.8
barHeight = (dataArray[i] / 255) * canvas.height * 0.8 //
//
//
const gradient = ctx.createLinearGradient(0, canvas.height - barHeight, 0, canvas.height)
gradient.addColorStop(0, 'rgba(64, 158, 255, 0.8)')
gradient.addColorStop(1, 'rgba(82, 196, 26, 0.8)')
gradient.addColorStop(0, 'rgba(64, 158, 255, 0.8)') //
gradient.addColorStop(1, 'rgba(82, 196, 26, 0.8)') // 绿
ctx.fillStyle = gradient
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight) //
x += barWidth + 1
x += barWidth + 1 //
}
}
draw()
}
//
//
const stopVisualization = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
cancelAnimationFrame(animationId) //
animationId = null // ID
}
}
//
// :
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
const mins = Math.floor(seconds / 60) //
const secs = seconds % 60 //
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
//
//
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const sizes = ['B', 'KB', 'MB', 'GB'] //
const i = Math.floor(Math.log(bytes) / Math.log(k)) //
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
//
//
onMounted(() => {
initRecorder()
initRecorder() //
})
//
//
onUnmounted(() => {
//
if (recordingTimer) {
clearInterval(recordingTimer)
}
//
if (animationId) {
cancelAnimationFrame(animationId)
}
//
if (mediaRecorder && mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach(track => track.stop())
}
//
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
//
clearRecording()
})
</script>
<style scoped>
/* 音频录制组件整体样式 */
.audio-recorder {
width: 100%;
}
/* 录制内容区域样式 */
.recorder-content {
text-align: center;
}
/* 录音状态样式 */
/* 录音状态显示区域样式 */
.recorder-status {
margin-bottom: 40px;
}
/* 状态指示器包装器样式 */
.status-indicator-wrapper {
display: flex;
flex-direction: column;
@ -489,6 +530,7 @@ onUnmounted(() => {
position: relative;
}
/* 状态指示器圆形样式 */
.status-indicator {
width: 120px;
height: 120px;
@ -504,12 +546,14 @@ onUnmounted(() => {
backdrop-filter: blur(10px);
}
/* 就绪状态指示器样式 */
.status-indicator.ready {
border-color: rgba(82, 196, 26, 0.6);
background: rgba(82, 196, 26, 0.1);
box-shadow: 0 0 30px rgba(82, 196, 26, 0.3);
}
/* 录音中状态指示器样式 */
.status-indicator.active {
border-color: rgba(64, 158, 255, 0.8);
background: rgba(64, 158, 255, 0.1);
@ -517,11 +561,13 @@ onUnmounted(() => {
animation: recording-pulse 2s ease-in-out infinite;
}
/* 禁用状态指示器样式 */
.status-indicator.disabled {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
}
/* 录音脉冲动画关键帧 */
@keyframes recording-pulse {
0%, 100% {
transform: scale(1);
@ -531,31 +577,36 @@ onUnmounted(() => {
}
}
/* 状态图标样式 */
.status-icon {
font-size: 3.5rem;
transition: all 0.3s ease;
z-index: 2;
}
/* 就绪状态图标样式 */
.status-icon.ready {
color: #52c41a;
}
/* 录音中状态图标样式 */
.status-icon.recording {
color: #409eff;
animation: rotating 2s linear infinite;
}
/* 禁用状态图标样式 */
.status-icon.disabled {
color: rgba(255, 255, 255, 0.4);
}
/* 旋转动画关键帧 */
@keyframes rotating {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 录音动画圈 */
/* 录音动画圈样式 */
.recording-ring {
position: absolute;
top: 50%;
@ -565,10 +616,12 @@ onUnmounted(() => {
transition: opacity 0.3s ease;
}
/* 录音中动画圈激活样式 */
.recording-ring.active {
opacity: 1;
}
/* 动画圈基础样式 */
.ring-outer,
.ring-middle,
.ring-inner {
@ -580,12 +633,14 @@ onUnmounted(() => {
transform: translate(-50%, -50%);
}
/* 外圈样式 */
.ring-outer {
width: 160px;
height: 160px;
animation: ring-pulse 2s ease-in-out infinite;
}
/* 中圈样式 */
.ring-middle {
width: 140px;
height: 140px;
@ -593,6 +648,7 @@ onUnmounted(() => {
animation-delay: 0.5s;
}
/* 内圈样式 */
.ring-inner {
width: 120px;
height: 120px;

@ -1,5 +1,7 @@
<!-- 音频上传组件模板 -->
<template>
<div class="audio-upload">
<!-- Element Plus上传组件支持拖拽上传 -->
<el-upload
ref="uploadRef"
class="upload-dragger"
@ -16,33 +18,41 @@
:show-file-list="false"
>
<div class="upload-content">
<!-- 上传图标和动画 -->
<!-- 上传图标和动画区域 -->
<div class="upload-icon-wrapper">
<!-- 图标切换动画 -->
<transition name="icon-switch" mode="out-in">
<!-- 上传成功图标 -->
<el-icon class="upload-icon success" v-if="uploadSuccess" key="success">
<Check />
</el-icon>
<!-- 上传中图标 -->
<el-icon class="upload-icon uploading" v-else-if="uploading" key="uploading">
<Loading />
</el-icon>
<!-- 默认上传图标 -->
<el-icon class="upload-icon default" v-else key="default">
<Upload />
</el-icon>
</transition>
<!-- 图标发光效果 -->
<div class="icon-glow" :class="{ active: uploading || uploadSuccess }"></div>
</div>
<!-- 上传文本 -->
<!-- 上传文本信息区域 -->
<div class="upload-text">
<transition name="fade" mode="out-in">
<!-- 上传成功状态文本 -->
<div v-if="uploadSuccess" key="success" class="success-message">
<p class="primary-text success"> 文件上传成功</p>
<p class="secondary-text">正在进行音频识别...</p>
</div>
<!-- 上传中状态文本 -->
<div v-else-if="uploading" key="uploading" class="uploading-message">
<p class="primary-text uploading">正在上传并识别中...</p>
<p class="secondary-text">请稍等模型正在分析您的音频</p>
</div>
<!-- 默认状态文本 -->
<div v-else key="default" class="default-message">
<p class="primary-text">
<span class="highlight">点击</span> <span class="highlight">拖拽</span> 音频文件到此处
@ -57,10 +67,12 @@
</transition>
</div>
<!-- 上传进度 -->
<!-- 上传进度显示区域 -->
<transition name="slide-down">
<div v-if="uploading" class="progress-container">
<!-- 进度条容器 -->
<div class="progress-wrapper">
<!-- Element Plus进度条组件 -->
<el-progress
:percentage="uploadProgress"
:stroke-width="8"
@ -68,8 +80,10 @@
:color="progressColor"
class="custom-progress"
/>
<!-- 进度指示器 -->
<div class="progress-indicator">
<span class="progress-text">{{ uploadProgress }}%</span>
<!-- 进度动画点 -->
<div class="progress-dots">
<span class="dot"></span>
<span class="dot"></span>
@ -82,20 +96,24 @@
</div>
</el-upload>
<!-- 音频预览 -->
<!-- 音频预览区域 -->
<transition name="slide-up">
<div v-if="audioPreview" class="audio-preview">
<!-- 预览区域头部 -->
<div class="preview-header">
<div class="preview-title">
<el-icon class="preview-icon"><Headphone /></el-icon>
<span>音频预览</span>
</div>
<!-- 关闭预览按钮 -->
<el-button type="text" size="small" @click="clearPreview" class="close-button">
<el-icon><Close /></el-icon>
</el-button>
</div>
<!-- 音频播放器容器 -->
<div class="audio-player-container">
<!-- HTML5音频播放器 -->
<audio
ref="audioPlayerRef"
controls
@ -106,11 +124,14 @@
</audio>
</div>
<!-- 音频信息显示区域 -->
<div class="audio-info">
<!-- 文件名信息 -->
<div class="info-item">
<el-icon><Document /></el-icon>
<span>{{ audioPreview.name }}</span>
</div>
<!-- 文件大小信息 -->
<div class="info-item">
<el-icon><DataBoard /></el-icon>
<span>{{ formatFileSize(audioPreview.size) }}</span>
@ -122,55 +143,59 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed } from 'vue'
// Element Plus
import { ElMessage } from 'element-plus'
// Props
//
const props = defineProps({
disabled: {
type: Boolean,
default: false
default: false //
}
})
// Emits
//
const emit = defineEmits(['upload-success', 'upload-error'])
//
const uploadRef = ref()
const audioPlayerRef = ref()
const uploading = ref(false)
const uploadSuccess = ref(false)
const uploadProgress = ref(0)
const audioPreview = ref(null)
//
const uploadRef = ref() //
const audioPlayerRef = ref() //
const uploading = ref(false) //
const uploadSuccess = ref(false) //
const uploadProgress = ref(0) //
const audioPreview = ref(null) //
//
//
const progressColor = computed(() => {
if (uploadProgress.value < 30) return '#409eff'
if (uploadProgress.value < 70) return '#e6a23c'
return '#67c23a'
//
if (uploadProgress.value < 30) return '#409eff' //
if (uploadProgress.value < 70) return '#e6a23c' //
return '#67c23a' // 绿
})
//
const uploadUrl = '/api/upload'
//
const uploadUrl = '/api/upload' //
const uploadHeaders = {
// Content-Type boundary
}
const uploadData = {}
const uploadData = {} //
//
//
const beforeUpload = (file) => {
//
const allowedTypes = ['audio/wav', 'audio/mpeg', 'audio/flac', 'audio/m4a', 'audio/ogg', 'audio/aac']
const fileExtension = file.name.split('.').pop().toLowerCase()
const allowedExtensions = ['wav', 'mp3', 'flac', 'm4a', 'ogg', 'aac']
//
if (!allowedTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
ElMessage.error('不支持的文件格式,请上传音频文件')
return false
}
// (50MB)
// (50MB)
const maxSize = 50 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过50MB')
@ -180,54 +205,56 @@ const beforeUpload = (file) => {
//
createAudioPreview(file)
//
uploading.value = true
uploadProgress.value = 0
return true
}
//
//
const createAudioPreview = (file) => {
const url = URL.createObjectURL(file)
const url = URL.createObjectURL(file) // URL
audioPreview.value = {
name: file.name,
size: file.size,
url: url
name: file.name, //
size: file.size, //
url: url // URL
}
}
//
//
const clearPreview = () => {
if (audioPreview.value) {
URL.revokeObjectURL(audioPreview.value.url)
audioPreview.value = null
URL.revokeObjectURL(audioPreview.value.url) // URL
audioPreview.value = null //
}
}
//
//
const handleProgress = (event) => {
uploadProgress.value = Math.round(event.percent)
uploadProgress.value = Math.round(event.percent) //
}
//
//
const handleSuccess = (response, file) => {
uploading.value = false
uploadProgress.value = 100
if (response.status === 'success') {
emit('upload-success', response.result, file.name)
emit('upload-success', response.result, file.name) //
ElMessage.success('音频上传并识别成功')
} else {
emit('upload-error', response.message)
emit('upload-error', response.message) //
ElMessage.error(`识别失败: ${response.message}`)
}
}
//
//
const handleError = (error, file) => {
uploading.value = false
uploadProgress.value = 0
//
let errorMessage = '上传失败'
try {
const errorData = JSON.parse(error.message)
@ -236,17 +263,17 @@ const handleError = (error, file) => {
errorMessage = error.message || errorMessage
}
emit('upload-error', errorMessage)
emit('upload-error', errorMessage) //
ElMessage.error(`上传失败: ${errorMessage}`)
}
//
//
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const i = Math.floor(Math.log(bytes) / Math.log(k)) //
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

@ -1,17 +1,24 @@
<!-- 音频识别历史记录列表组件模板 -->
<template>
<div class="history-list">
<!-- 空状态显示当没有历史记录时显示 -->
<div v-if="history.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>暂无识别历史</p>
</div>
<!-- 历史记录列表有记录时显示 -->
<div v-else class="history-items">
<!-- 遍历分页后的历史记录项 -->
<div
v-for="(item, index) in paginatedHistory"
:key="index"
class="history-item"
@click="selectItem(item)"
> <div class="item-header">
> <!-- 历史记录项头部信息 -->
<div class="item-header">
<!-- 识别结果标题区域 -->
<div class="item-title">
<!-- 识别结果标签根据置信度显示不同颜色 -->
<el-tag
:type="getResultType(item.confidence || item.score)"
size="small"
@ -19,12 +26,15 @@
>
{{ item.predicted_class || item.label }}
</el-tag>
<!-- 置信度百分比显示 -->
<span class="confidence">
{{ ((item.confidence || item.score) * 100).toFixed(1) }}%
</span>
</div>
<!-- 元数据信息区域 -->
<div class="item-meta">
<!-- 来源标识上传或录音 -->
<span class="source-badge" :class="item.source">
<el-icon>
<Upload v-if="item.source === 'upload'" />
@ -32,28 +42,34 @@
</el-icon>
{{ item.source === 'upload' ? '上传' : '录音' }}
</span>
<!-- 时间戳显示 -->
<span class="timestamp">{{ formatTime(item.timestamp) }}</span>
</div>
</div>
<!-- 详细信息区域 -->
<div class="item-details">
<!-- 文件名信息如果有 -->
<div v-if="item.filename" class="detail-row">
<el-icon><Document /></el-icon>
<span>{{ truncateFilename(item.filename) }}</span>
</div>
<!-- 音频时长信息如果有 -->
<div v-if="item.audio_info" class="detail-row">
<el-icon><Timer /></el-icon>
<span>{{ formatDuration(item.audio_info.duration) }}</span>
</div>
<!-- 预测耗时信息 -->
<div class="detail-row">
<el-icon><Cpu /></el-icon>
<span>{{ item.prediction_time?.toFixed(3) }}s</span>
</div>
</div>
<!-- 置信度进度条 -->
<!-- 置信度可视化进度条 -->
<div class="confidence-bar">
<!-- 置信度填充条根据置信度值动态调整宽度和颜色 -->
<div
class="confidence-fill"
:style="{
@ -64,8 +80,9 @@
</div>
</div>
</div>
<!-- 分页如果历史记录很多 -->
<!-- 分页组件当历史记录数量超过页面大小时显示 -->
<div v-if="history.length > pageSize" class="pagination">
<!-- Element Plus分页组件 -->
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
@ -79,7 +96,9 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed } from 'vue'
// Element Plus
import {
Document,
Upload,
@ -88,91 +107,91 @@ import {
Cpu
} from '@element-plus/icons-vue'
// Props
//
const props = defineProps({
history: {
type: Array,
default: () => []
default: () => [] //
}
})
// Emits
//
const emit = defineEmits(['select-item'])
//
const currentPage = ref(1)
const pageSize = ref(10)
//
const currentPage = ref(1) //
const pageSize = ref(10) //
//
//
const paginatedHistory = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return props.history.slice(start, end)
const start = (currentPage.value - 1) * pageSize.value //
const end = start + pageSize.value //
return props.history.slice(start, end) //
})
//
//
const getResultType = (score) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'danger'
if (score >= 0.8) return 'success' // 绿
if (score >= 0.6) return 'warning' //
return 'danger' //
}
//
//
const getConfidenceColor = (score) => {
if (score >= 0.8) return '#67c23a'
if (score >= 0.6) return '#e6a23c'
return '#f56c6c'
if (score >= 0.8) return '#67c23a' // 绿
if (score >= 0.6) return '#e6a23c' //
return '#f56c6c' //
}
//
//
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diffInSeconds = Math.floor((now - date) / 1000)
const diffInSeconds = Math.floor((now - date) / 1000) //
if (diffInSeconds < 60) {
return '刚刚'
return '刚刚' // 1
} else if (diffInSeconds < 3600) {
return `${Math.floor(diffInSeconds / 60)}分钟前`
return `${Math.floor(diffInSeconds / 60)}分钟前` // 1
} else if (diffInSeconds < 86400) {
return `${Math.floor(diffInSeconds / 3600)}小时前`
return `${Math.floor(diffInSeconds / 3600)}小时前` // 24
} else {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString().slice(0, 5)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString().slice(0, 5) // 24
}
}
//
//
const formatDuration = (seconds) => {
if (!seconds) return 'N/A'
if (!seconds) return 'N/A' // N/A
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
return `${seconds.toFixed(1)}s` // 1
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m${remainingSeconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60) //
const remainingSeconds = seconds % 60 //
return `${minutes}m${remainingSeconds.toFixed(1)}s` // ms
}
//
//
const truncateFilename = (filename, maxLength = 20) => {
if (!filename || filename.length <= maxLength) return filename
if (!filename || filename.length <= maxLength) return filename //
const extension = filename.split('.').pop()
const nameWithoutExt = filename.slice(0, -(extension.length + 1))
const truncatedName = nameWithoutExt.slice(0, maxLength - extension.length - 4) + '...'
const extension = filename.split('.').pop() //
const nameWithoutExt = filename.slice(0, -(extension.length + 1)) //
const truncatedName = nameWithoutExt.slice(0, maxLength - extension.length - 4) + '...' //
return truncatedName + '.' + extension
return truncatedName + '.' + extension //
}
//
//
const selectItem = (item) => {
emit('select-item', item)
emit('select-item', item) //
}
//
//
const handlePageChange = (page) => {
currentPage.value = page
currentPage.value = page //
}
</script>

@ -1,20 +1,27 @@
<!-- 音频预测结果展示组件模板 -->
<template>
<div class="prediction-result" v-if="result">
<!-- 主要预测结果卡片 -->
<!-- 主要预测结果展示卡片 -->
<div class="main-result-card">
<!-- 结果卡片头部区域 -->
<div class="result-header">
<!-- 结果图标 -->
<div class="result-icon">
<el-icon><TrophyBase /></el-icon>
</div>
<!-- 结果标题和描述 -->
<div class="result-title">
<h3>识别结果</h3>
<p>深度学习模型分析完成</p>
</div>
<!-- 操作按钮区域 -->
<div class="result-actions">
<!-- 导出结果按钮 -->
<el-button type="primary" size="small" @click="exportResult" class="action-btn">
<template #icon><el-icon><Download /></el-icon></template>
导出
</el-button>
<!-- 分享结果按钮 -->
<el-button size="small" @click="shareResult" class="action-btn">
<template #icon><el-icon><Share /></el-icon></template>
分享
@ -22,20 +29,28 @@
</div>
</div>
<!-- 主要预测结果展示 -->
<!-- 主要预测结果展示区域 -->
<div class="main-prediction">
<!-- 预测结果徽章 -->
<div class="prediction-badge">
<!-- 徽章图标 -->
<div class="badge-icon">
<el-icon><Star /></el-icon>
</div>
<!-- 徽章内容区域 -->
<div class="badge-content">
<!-- 预测类别名称 -->
<div class="predicted-class">{{ result.predicted_class || result.label }}</div>
<!-- 置信度分数显示 -->
<div class="confidence-score">
置信度: <span class="confidence-value">{{ ((result.confidence || result.score) * 100).toFixed(2) }}%</span>
</div>
</div>
<!-- 置信度环形进度条 -->
<div class="confidence-ring">
<!-- SVG环形进度图 -->
<svg class="ring-svg" viewBox="0 0 100 100">
<!-- 背景圆环 -->
<circle
class="ring-background"
cx="50"
@ -45,6 +60,7 @@
stroke="rgba(255, 255, 255, 0.2)"
stroke-width="8"
/>
<!-- 进度圆环 -->
<circle
class="ring-progress"
cx="50"
@ -58,6 +74,7 @@
:stroke-dashoffset="strokeDashoffset"
transform="rotate(-90 50 50)"
/>
<!-- 渐变定义 -->
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#52c41a"/>
@ -65,19 +82,23 @@
</linearGradient>
</defs>
</svg>
<!-- 环形中心文本显示 -->
<div class="ring-text">{{ Math.round((result.confidence || result.score) * 100) }}%</div>
</div>
</div>
</div>
</div>
<!-- 详细信息卡片 -->
<!-- 预测结果详细信息卡片 -->
<div class="details-card">
<!-- 详细信息卡片头部 -->
<div class="details-header">
<el-icon><InfoFilled /></el-icon>
<span>详细信息</span>
</div>
<!-- 详细信息网格布局 -->
<div class="details-grid">
<!-- 预测类别信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><Flag /></el-icon>
@ -88,6 +109,7 @@
</div>
</div>
<!-- 置信度信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><DataAnalysis /></el-icon>
@ -98,6 +120,7 @@
</div>
</div>
<!-- 预测时间信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><Timer /></el-icon>
@ -108,6 +131,7 @@
</div>
</div>
<!-- 音频时长信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><Headphone /></el-icon>
@ -120,45 +144,54 @@
</div>
</div>
<!-- 概率分布图表 -->
<!-- 所有类别概率分布展示卡片 -->
<div v-if="result.all_probabilities" class="probability-card">
<!-- 概率分布卡片头部 -->
<div class="probability-header">
<el-icon><PieChart /></el-icon>
<span>所有类别概率分布</span>
<!-- 视图切换按钮 -->
<el-button type="text" size="small" @click="toggleChartView" class="toggle-view">
<el-icon><Switch /></el-icon>
{{ showChart ? '列表视图' : '图表视图' }}
</el-button>
</div>
<!-- 图表视图 -->
<!-- ECharts图表视图 -->
<transition name="fade">
<div v-if="showChart" class="chart-container">
<div ref="chartContainer" class="echarts-chart"></div>
</div>
</transition>
<!-- 列表视图 -->
<!-- 概率列表视图 -->
<transition name="fade">
<div v-if="!showChart" class="probability-list">
<!-- 遍历排序后的概率数据 -->
<div
v-for="(prob, className) in sortedProbabilities"
:key="className"
class="probability-item"
:class="{ active: className === (result.predicted_class || result.label) }"
>
<!-- 概率信息显示区域 -->
<div class="prob-info">
<!-- 类别名称和最高标识 -->
<div class="class-name">
<span class="name-text">{{ className }}</span>
<!-- 最高概率标识标签 -->
<el-tag v-if="className === (result.predicted_class || result.label)"
size="small" type="success" class="winner-tag">
<el-icon><Trophy /></el-icon>
最高
</el-tag>
</div>
<!-- 概率百分比值 -->
<div class="prob-value">{{ (prob * 100).toFixed(2) }}%</div>
</div> <div class="prob-bar-container">
</div> <!-- 概率进度条容器 -->
<div class="prob-bar-container">
<div class="prob-bar">
<!-- 概率填充条根据概率值动态调整宽度和颜色 -->
<div
class="prob-fill"
:style="{
@ -174,6 +207,7 @@
</div>
</div>
<!-- 无结果状态显示 -->
<div v-else class="no-result">
<div class="empty-state">
<div class="empty-icon">
@ -186,47 +220,54 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
// Element Plus
import { ElMessage } from 'element-plus'
// ECharts
import * as echarts from 'echarts'
//
const props = defineProps({
result: {
type: Object,
default: null
default: null // null
}
})
//
const chartContainer = ref(null)
const showChart = ref(false)
let chartInstance = null
//
const chartContainer = ref(null) // ECharts
const showChart = ref(false) //
let chartInstance = null // ECharts
//
//
const circumference = computed(() => 2 * Math.PI * 40) // r=40
// SVGstroke-dashoffset
const strokeDashoffset = computed(() => {
if (!props.result) return circumference.value
const confidence = props.result.confidence || props.result.score || 0
return circumference.value - (confidence * circumference.value)
return circumference.value - (confidence * circumference.value) //
})
//
const sortedProbabilities = computed(() => {
if (!props.result?.all_probabilities) return {}
const entries = Object.entries(props.result.all_probabilities)
entries.sort((a, b) => b[1] - a[1]) //
return Object.fromEntries(entries)
return Object.fromEntries(entries) //
})
//
//
const formatTime = (timestamp) => {
if (!timestamp) return 'N/A'
try {
if (typeof timestamp === 'string') {
return timestamp
return timestamp //
}
//
return new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
@ -241,6 +282,7 @@ const formatTime = (timestamp) => {
}
}
//
const formatDuration = (seconds) => {
if (!seconds && seconds !== 0) return 'N/A'
@ -249,41 +291,45 @@ const formatDuration = (seconds) => {
if (isNaN(duration)) return 'N/A'
if (duration < 60) {
return `${duration.toFixed(1)}`
return `${duration.toFixed(1)}` // 1
}
const minutes = Math.floor(duration / 60)
const remainingSeconds = (duration % 60).toFixed(1)
return `${minutes}${remainingSeconds}`
const minutes = Math.floor(duration / 60) //
const remainingSeconds = (duration % 60).toFixed(1) //
return `${minutes}${remainingSeconds}` //
} catch (error) {
console.error('时长格式化错误:', error)
return 'N/A'
}
}
//
const getProgressColor = (prob, isWinner = false) => {
if (isWinner) {
return 'linear-gradient(135deg, #52c41a, #73d13d)'
return 'linear-gradient(135deg, #52c41a, #73d13d)' // 绿
}
if (prob > 0.7) return 'linear-gradient(135deg, #52c41a, #73d13d)'
if (prob > 0.4) return 'linear-gradient(135deg, #fadb14, #ffec3d)'
if (prob > 0.2) return 'linear-gradient(135deg, #fa8c16, #ffa940)'
return 'linear-gradient(135deg, #ff4d4f, #ff7875)'
if (prob > 0.7) return 'linear-gradient(135deg, #52c41a, #73d13d)' // 绿
if (prob > 0.4) return 'linear-gradient(135deg, #fadb14, #ffec3d)' //
if (prob > 0.2) return 'linear-gradient(135deg, #fa8c16, #ffa940)' //
return 'linear-gradient(135deg, #ff4d4f, #ff7875)' //
}
// ECharts
const initChart = () => {
if (!props.result?.all_probabilities || !chartContainer.value) return
if (chartInstance) {
chartInstance.dispose()
chartInstance.dispose() //
}
chartInstance = echarts.init(chartContainer.value)
chartInstance = echarts.init(chartContainer.value) //
//
const data = Object.entries(props.result.all_probabilities)
.map(([name, value]) => ({ name, value: (value * 100).toFixed(2) }))
.sort((a, b) => b.value - a.value)
.map(([name, value]) => ({ name, value: (value * 100).toFixed(2) })) //
.sort((a, b) => b.value - a.value) //
// ECharts
const option = {
title: {
text: '类别置信度分布',
@ -291,34 +337,36 @@ const initChart = () => {
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c}%'
formatter: '{a} <br/>{b}: {c}%' //
},
legend: {
orient: 'vertical',
left: 'left'
left: 'left' //
},
series: [
{
name: '置信度',
type: 'pie',
radius: '50%',
type: 'pie', //
radius: '50%', //
data: data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
shadowColor: 'rgba(0, 0, 0, 0.5)' //
}
}
}
]
}
chartInstance.setOption(option)
chartInstance.setOption(option) //
}
// JSON
const exportResult = () => {
try {
//
const exportData = {
predicted_class: props.result.predicted_class,
confidence: props.result.confidence,
@ -326,13 +374,14 @@ const exportResult = () => {
all_probabilities: props.result.all_probabilities
}
// JSON
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `prediction_result_${Date.now()}.json`
a.download = `prediction_result_${Date.now()}.json` //
a.click()
URL.revokeObjectURL(url)
URL.revokeObjectURL(url) // URL
ElMessage.success('结果导出成功')
} catch (error) {
@ -340,15 +389,18 @@ const exportResult = () => {
}
}
//
const shareResult = () => {
const shareText = `音频分类结果: ${props.result.predicted_class} (置信度: ${(props.result.confidence * 100).toFixed(2)}%)`
if (navigator.share) {
// 使API
navigator.share({
title: '音频分类结果',
text: shareText
})
} else {
//
navigator.clipboard.writeText(shareText).then(() => {
ElMessage.success('结果已复制到剪贴板')
}).catch(() => {
@ -357,18 +409,20 @@ const shareResult = () => {
}
}
//
watch(() => props.result, () => {
if (props.result) {
nextTick(() => {
initChart()
initChart() // DOM
})
}
}, { immediate: true })
//
onMounted(() => {
if (props.result) {
nextTick(() => {
initChart()
initChart() // DOM
})
}
})

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save