Compare commits

..

1 Commits

Binary file not shown.

@ -1,37 +1,30 @@
<!-- 音频录制组件模板 -->
<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>
@ -39,11 +32,9 @@
</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>
@ -53,11 +44,9 @@
</div>
</div>
<!-- 录音控制按钮区域 -->
<!-- 录音控制按钮 -->
<div class="recorder-controls">
<!-- 按钮切换动画 -->
<transition name="button-switch" mode="out-in">
<!-- 开始录音按钮仅在未录音且未处理时显示 -->
<el-button
v-if="!isRecording && !isProcessing"
type="primary"
@ -74,7 +63,6 @@
<div class="button-glow"></div>
</el-button>
<!-- 停止录音按钮仅在录音时显示 -->
<el-button
v-else-if="isRecording"
type="danger"
@ -89,7 +77,6 @@
<span>停止录音</span>
</el-button>
<!-- 处理中按钮显示加载状态 -->
<el-button
v-else
size="large"
@ -102,49 +89,39 @@
</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"
@ -155,17 +132,14 @@
</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>
@ -177,23 +151,19 @@
</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>
删除
@ -201,9 +171,7 @@
</div>
</div>
<!-- 音频播放器容器 -->
<div class="audio-player-container">
<!-- HTML5音频播放器 -->
<audio
ref="audioPlayerRef"
controls
@ -213,21 +181,17 @@
您的浏览器不支持音频播放
</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"
@ -243,51 +207,48 @@
</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 // MediaRecorder
let audioChunks = [] //
let recordingTimer = null //
let audioContext = null // Web Audio API
let analyser = null //
let microphone = null //
let animationId = null // ID
//
//
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 settings = ref({
duration: 5, // 5
autoStop: true //
duration: 5, // 5
autoStop: true
})
//
//
const statusTitle = computed(() => {
if (isProcessing.value) return '处理中'
if (isRecording.value) return '录音中'
@ -302,171 +263,166 @@ const statusText = computed(() => {
return '点击开始录音按钮进行音频识别'
})
//
//
const initRecorder = async () => {
try {
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000, // 16kHz
channelCount: 1, //
echoCancellation: true, //
noiseSuppression: true //
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
})
// MediaRecorder使WebM
// MediaRecorder
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 // FFT
analyser.fftSize = 256
// 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) // URL
const url = URL.createObjectURL(audioBlob)
recordedAudio.value = {
blob: audioBlob, // Blob
url: url, // URL
duration: recordingTime.value, //
size: audioBlob.size //
blob: audioBlob,
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) // URL
recordedAudio.value = null //
URL.revokeObjectURL(recordedAudio.value.url)
recordedAudio.value = null
}
recordingTime.value = 0 //
recordingTime.value = 0
}
//
//
const submitRecording = async () => {
if (!recordedAudio.value) return
isSubmitting.value = true
try {
// BlobArrayBuffer
// 使Web Audio API
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
@ -475,15 +431,13 @@ 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)
@ -491,7 +445,6 @@ const startVisualization = () => {
let barHeight
let x = 0
//
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i] / 255 * canvas.height
@ -502,64 +455,58 @@ const startVisualization = () => {
}
}
draw() //
draw()
}
//
//
const stopVisualization = () => {
if (animationId) {
cancelAnimationFrame(animationId) //
animationId = null // ID
cancelAnimationFrame(animationId)
animationId = null
}
}
// :
//
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,30 +1,23 @@
<!-- 音频录制组件模板 -->
<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>
@ -32,17 +25,15 @@
</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>
@ -50,11 +41,9 @@
</div>
</div>
<!-- 录音控制按钮区域 -->
<!-- 录音控制按钮 -->
<div class="recorder-controls">
<!-- 按钮切换动画 -->
<transition name="button-switch" mode="out-in">
<!-- 开始录音按钮仅在未录音时显示 -->
<el-button
v-if="!isRecording"
type="primary"
@ -71,7 +60,6 @@
<div class="button-glow"></div>
</el-button>
<!-- 停止录音按钮仅在录音时显示 -->
<el-button
v-else
type="danger"
@ -89,20 +77,16 @@
</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"
@ -117,10 +101,8 @@
</el-select>
</div>
<!-- 自动停止设置项 -->
<div class="setting-item">
<label class="setting-label">自动停止</label>
<!-- 自动停止开关录音时禁用 -->
<el-switch
v-model="settings.autoStop"
:disabled="isRecording"
@ -132,23 +114,19 @@
</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>
删除
@ -156,9 +134,7 @@
</div>
</div>
<!-- 音频播放器容器 -->
<div class="audio-player-container">
<!-- HTML5音频播放器 -->
<audio
ref="audioPlayerRef"
controls
@ -169,14 +145,11 @@
</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>
@ -189,49 +162,46 @@
</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 // MediaRecorder
let audioChunks = [] //
let recordingTimer = null //
let audioContext = null // Web Audio API
let analyser = null //
let microphone = null //
let animationId = null // ID
//
//
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 settings = ref({
duration: 5, // 5
autoStop: true //
duration: 5, // 5
autoStop: true
})
//
//
const statusTitle = computed(() => {
if (isRecording.value) return '录音中'
if (isReady.value) return '就绪'
@ -244,47 +214,46 @@ const statusDescription = computed(() => {
return '点击开始录音按钮进行音频识别'
})
//
//
const initRecorder = async () => {
try {
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000, // 16kHz
channelCount: 1, //
echoCancellation: true, //
noiseSuppression: true //
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
})
// MediaRecorder使WebM
// MediaRecorder
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 // FFT
analyser.fftSize = 256
// 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) {
@ -293,236 +262,226 @@ 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 // 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) // URL
const url = URL.createObjectURL(audioBlob)
recordedAudio.value = {
blob: audioBlob, // Blob
url: url, // URL
duration: recordingTime.value, //
size: audioBlob.size //
blob: audioBlob,
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) // URL
recordedAudio.value = null //
URL.revokeObjectURL(recordedAudio.value.url)
recordedAudio.value = null
}
recordingTime.value = 0 //
recordingTime.value = 0
}
//
//
const submitRecording = async (audioBlob) => {
if (!audioBlob) return
isSubmitting.value = true //
isSubmitting.value = true
try {
// BlobArrayBuffer
// 使Web Audio API
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') // 2D
const bufferLength = analyser.frequencyBinCount //
const dataArray = new Uint8Array(bufferLength) //
const ctx = canvas.getContext('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 // ID
cancelAnimationFrame(animationId)
animationId = null
}
}
// :
//
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;
@ -530,7 +489,6 @@ onUnmounted(() => {
position: relative;
}
/* 状态指示器圆形样式 */
.status-indicator {
width: 120px;
height: 120px;
@ -546,14 +504,12 @@ 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);
@ -561,13 +517,11 @@ 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);
@ -577,36 +531,31 @@ 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%;
@ -616,12 +565,10 @@ onUnmounted(() => {
transition: opacity 0.3s ease;
}
/* 录音中动画圈激活样式 */
.recording-ring.active {
opacity: 1;
}
/* 动画圈基础样式 */
.ring-outer,
.ring-middle,
.ring-inner {
@ -633,14 +580,12 @@ 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;
@ -648,7 +593,6 @@ onUnmounted(() => {
animation-delay: 0.5s;
}
/* 内圈样式 */
.ring-inner {
width: 120px;
height: 120px;

@ -1,7 +1,5 @@
<!-- 音频上传组件模板 -->
<template>
<div class="audio-upload">
<!-- Element Plus上传组件支持拖拽上传 -->
<el-upload
ref="uploadRef"
class="upload-dragger"
@ -18,41 +16,33 @@
: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> 音频文件到此处
@ -67,12 +57,10 @@
</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"
@ -80,10 +68,8 @@
: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>
@ -96,24 +82,20 @@
</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
@ -124,14 +106,11 @@
</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>
@ -143,59 +122,55 @@
</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')
@ -205,56 +180,54 @@ const beforeUpload = (file) => {
//
createAudioPreview(file)
//
uploading.value = true
uploadProgress.value = 0
return true
}
//
//
const createAudioPreview = (file) => {
const url = URL.createObjectURL(file) // URL
const url = URL.createObjectURL(file)
audioPreview.value = {
name: file.name, //
size: file.size, //
url: url // URL
name: file.name,
size: file.size,
url: url
}
}
//
//
const clearPreview = () => {
if (audioPreview.value) {
URL.revokeObjectURL(audioPreview.value.url) // URL
audioPreview.value = null //
URL.revokeObjectURL(audioPreview.value.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)
@ -263,17 +236,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,24 +1,17 @@
<!-- 音频识别历史记录列表组件模板 -->
<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"
@ -26,15 +19,12 @@
>
{{ 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'" />
@ -42,34 +32,28 @@
</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="{
@ -80,9 +64,8 @@
</div>
</div>
</div>
<!-- 分页组件当历史记录数量超过页面大小时显示 -->
<!-- 分页如果历史记录很多 -->
<div v-if="history.length > pageSize" class="pagination">
<!-- Element Plus分页组件 -->
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
@ -96,9 +79,7 @@
</template>
<script setup>
// Vue 3 Composition API
import { ref, computed } from 'vue'
// Element Plus
import {
Document,
Upload,
@ -107,91 +88,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 '刚刚' // 1
return '刚刚'
} else if (diffInSeconds < 3600) {
return `${Math.floor(diffInSeconds / 60)}分钟前` // 1
return `${Math.floor(diffInSeconds / 60)}分钟前`
} else if (diffInSeconds < 86400) {
return `${Math.floor(diffInSeconds / 3600)}小时前` // 24
return `${Math.floor(diffInSeconds / 3600)}小时前`
} else {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString().slice(0, 5) // 24
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString().slice(0, 5)
}
}
//
//
const formatDuration = (seconds) => {
if (!seconds) return 'N/A' // N/A
if (!seconds) return 'N/A'
if (seconds < 60) {
return `${seconds.toFixed(1)}s` // 1
return `${seconds.toFixed(1)}s`
}
const minutes = Math.floor(seconds / 60) //
const remainingSeconds = seconds % 60 //
return `${minutes}m${remainingSeconds.toFixed(1)}s` // ms
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m${remainingSeconds.toFixed(1)}s`
}
//
//
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,27 +1,20 @@
<!-- 音频预测结果展示组件模板 -->
<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>
分享
@ -29,28 +22,20 @@
</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"
@ -60,7 +45,6 @@
stroke="rgba(255, 255, 255, 0.2)"
stroke-width="8"
/>
<!-- 进度圆环 -->
<circle
class="ring-progress"
cx="50"
@ -74,7 +58,6 @@
: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"/>
@ -82,23 +65,19 @@
</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>
@ -109,7 +88,6 @@
</div>
</div>
<!-- 置信度信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><DataAnalysis /></el-icon>
@ -120,7 +98,6 @@
</div>
</div>
<!-- 预测时间信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><Timer /></el-icon>
@ -131,7 +108,6 @@
</div>
</div>
<!-- 音频时长信息项 -->
<div class="detail-item">
<div class="detail-icon">
<el-icon><Headphone /></el-icon>
@ -144,54 +120,45 @@
</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="{
@ -207,7 +174,6 @@
</div>
</div>
<!-- 无结果状态显示 -->
<div v-else class="no-result">
<div class="empty-state">
<div class="empty-icon">
@ -220,54 +186,47 @@
</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 // null
default: null
}
})
//
const chartContainer = ref(null) // ECharts
const showChart = ref(false) //
let chartInstance = null // ECharts
//
const chartContainer = ref(null)
const showChart = ref(false)
let chartInstance = null
//
//
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',
@ -282,7 +241,6 @@ const formatTime = (timestamp) => {
}
}
//
const formatDuration = (seconds) => {
if (!seconds && seconds !== 0) return 'N/A'
@ -291,45 +249,41 @@ const formatDuration = (seconds) => {
if (isNaN(duration)) return 'N/A'
if (duration < 60) {
return `${duration.toFixed(1)}` // 1
return `${duration.toFixed(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: '类别置信度分布',
@ -337,36 +291,34 @@ 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,
@ -374,14 +326,13 @@ 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
URL.revokeObjectURL(url)
ElMessage.success('结果导出成功')
} catch (error) {
@ -389,18 +340,15 @@ 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(() => {
@ -409,20 +357,18 @@ const shareResult = () => {
}
}
//
watch(() => props.result, () => {
if (props.result) {
nextTick(() => {
initChart() // DOM
initChart()
})
}
}, { immediate: true })
//
onMounted(() => {
if (props.result) {
nextTick(() => {
initChart() // DOM
initChart()
})
}
})

@ -0,0 +1,135 @@
#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;
}

@ -0,0 +1,28 @@
#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;
};

@ -4,13 +4,6 @@
#include <QVBoxLayout>
#include <QDebug>
/**
* @brief MapWidget
* @param parent
*
* QQuickWidgetQML
*
*/
MapWidget::MapWidget(QWidget *parent)
: QWidget(parent),
m_quickWidget(nullptr),
@ -35,23 +28,12 @@ MapWidget::MapWidget(QWidget *parent)
setLayout(layout);
}
/**
* @brief MapWidget
*
*
*/
MapWidget::~MapWidget()
{
// 清除目标标记
clearTargetMarkers();
}
/**
* @brief
*
* QML
* QML
*/
void MapWidget::initializeMap()
{
// 设置默认位置 (默认为长沙)
@ -89,13 +71,6 @@ void MapWidget::initializeMap()
}
}
/**
* @brief
* @param position
*
*
* QML
*/
void MapWidget::setUAVPosition(const QGeoCoordinate &position)
{
m_currentPosition = position;
@ -108,13 +83,6 @@ void MapWidget::setUAVPosition(const QGeoCoordinate &position)
m_quickWidget->rootContext()->setContextProperty("uavTrajectory", QVariant::fromValue(m_trajectory));
}
/**
* @brief
* @param trajectory
*
*
* QML
*/
void MapWidget::setUAVTrajectory(const QVector<QGeoCoordinate> &trajectory)
{
// 创建一个空的GeoPath然后添加坐标
@ -125,24 +93,12 @@ void MapWidget::setUAVTrajectory(const QVector<QGeoCoordinate> &trajectory)
m_quickWidget->rootContext()->setContextProperty("uavTrajectory", QVariant::fromValue(m_trajectory));
}
/**
* @brief
*
*
*/
void MapWidget::clearTrajectory()
{
m_trajectory = QGeoPath();
m_quickWidget->rootContext()->setContextProperty("uavTrajectory", QVariant::fromValue(m_trajectory));
}
/**
* @brief
* @param center
*
* QML
*
*/
void MapWidget::setMapCenter(const QGeoCoordinate &center)
{
if (!m_mapItem) {
@ -160,13 +116,6 @@ void MapWidget::setMapCenter(const QGeoCoordinate &center)
}
}
/**
* @brief
* @param zoomLevel
*
* QML
*
*/
void MapWidget::setZoomLevel(double zoomLevel)
{
if (!m_mapItem) {
@ -184,12 +133,6 @@ void MapWidget::setZoomLevel(double zoomLevel)
}
}
/**
* @brief
*
*
* QML
*/
void MapWidget::toggleMapType()
{
if (m_rootObject) {
@ -209,12 +152,6 @@ void MapWidget::toggleMapType()
}
}
/**
* @brief
* @return "normal""satellite"
*
* QML
*/
QString MapWidget::getCurrentMapType() const
{
if (m_rootObject) {
@ -223,15 +160,6 @@ QString MapWidget::getCurrentMapType() const
return "normal";
}
/**
* @brief
* @param position
* @param distance
* @param uncertainty
*
*
* QML
*/
void MapWidget::addTargetMarker(const QGeoCoordinate &position, double distance, double uncertainty)
{
if (!m_mapItem || !m_rootObject) {
@ -267,12 +195,6 @@ void MapWidget::addTargetMarker(const QGeoCoordinate &position, double distance,
}
}
/**
* @brief
*
* QML
*
*/
void MapWidget::clearTargetMarkers()
{
if (!m_rootObject) {
@ -293,12 +215,6 @@ void MapWidget::clearTargetMarkers()
}
}
/**
* @brief
* @return
*
*
*/
QGeoCoordinate MapWidget::getCurrentPosition() const
{
return m_currentPosition;

@ -1,99 +1,60 @@
#include "QtCameraCapture.h"
/**
* @brief QtCameraCapture
* @param parent
*
* QAbstractVideoSurface
*
*/
QtCameraCapture::QtCameraCapture(QObject *parent) : QAbstractVideoSurface(parent)
{
}
/**
* @brief
* @param handleType 使
* @return
*
*
* - ARGB32
* - RGB322432
* - BGROpenCV
* - YUV
* - Y8Y16
* - JPEGRAW
*/
QList<QVideoFrame::PixelFormat> QtCameraCapture::supportedPixelFormats(QAbstractVideoBuffer::HandleType handleType) const
{
Q_UNUSED(handleType);
return QList<QVideoFrame::PixelFormat>()
<< QVideoFrame::Format_ARGB32
<< QVideoFrame::Format_ARGB32_Premultiplied
<< QVideoFrame::Format_RGB32
<< QVideoFrame::Format_RGB24
<< QVideoFrame::Format_RGB565
<< QVideoFrame::Format_RGB555
<< QVideoFrame::Format_ARGB8565_Premultiplied
<< QVideoFrame::Format_BGRA32
<< QVideoFrame::Format_BGRA32_Premultiplied
<< QVideoFrame::Format_BGR32
<< QVideoFrame::Format_BGR24
<< QVideoFrame::Format_BGR565
<< QVideoFrame::Format_BGR555
<< QVideoFrame::Format_BGRA5658_Premultiplied
<< QVideoFrame::Format_AYUV444
<< QVideoFrame::Format_AYUV444_Premultiplied
<< QVideoFrame::Format_YUV444
<< QVideoFrame::Format_YUV420P
<< QVideoFrame::Format_YV12
<< QVideoFrame::Format_UYVY
<< QVideoFrame::Format_YUYV
<< QVideoFrame::Format_NV12
<< QVideoFrame::Format_NV21
<< QVideoFrame::Format_IMC1
<< QVideoFrame::Format_IMC2
<< QVideoFrame::Format_IMC3
<< QVideoFrame::Format_IMC4
<< QVideoFrame::Format_Y8
<< QVideoFrame::Format_Y16
<< QVideoFrame::Format_Jpeg
<< QVideoFrame::Format_CameraRaw
<< QVideoFrame::Format_AdobeDng;
}
/**
* @brief
* @param frame
* @return truefalse
*
*
*
* 1.
* 2.
* 3.
* 4. QImage
* 5. frameAvailable
* 6.
*/
bool QtCameraCapture::present(const QVideoFrame &frame)
{
if (frame.isValid()) {
// 克隆帧数据以避免数据竞争问题
QVideoFrame cloneFrame(frame);
// 将帧数据映射到内存,以只读方式访问
cloneFrame.map(QAbstractVideoBuffer::ReadOnly);
// 将视频帧转换为QImage格式
const QImage image(cloneFrame.bits(),
cloneFrame.width(),
cloneFrame.height(),
QVideoFrame::imageFormatFromPixelFormat(cloneFrame.pixelFormat()));
// 发送信号通知其他组件有新帧可用
emit frameAvailable(image);
// 解除内存映射
cloneFrame.unmap();
return true;
}
return false;
}
#include "QtCameraCapture.h"
QtCameraCapture::QtCameraCapture(QObject *parent) : QAbstractVideoSurface(parent)
{
}
QList<QVideoFrame::PixelFormat> QtCameraCapture::supportedPixelFormats(QAbstractVideoBuffer::HandleType handleType) const
{
Q_UNUSED(handleType);
return QList<QVideoFrame::PixelFormat>()
<< QVideoFrame::Format_ARGB32
<< QVideoFrame::Format_ARGB32_Premultiplied
<< QVideoFrame::Format_RGB32
<< QVideoFrame::Format_RGB24
<< QVideoFrame::Format_RGB565
<< QVideoFrame::Format_RGB555
<< QVideoFrame::Format_ARGB8565_Premultiplied
<< QVideoFrame::Format_BGRA32
<< QVideoFrame::Format_BGRA32_Premultiplied
<< QVideoFrame::Format_BGR32
<< QVideoFrame::Format_BGR24
<< QVideoFrame::Format_BGR565
<< QVideoFrame::Format_BGR555
<< QVideoFrame::Format_BGRA5658_Premultiplied
<< QVideoFrame::Format_AYUV444
<< QVideoFrame::Format_AYUV444_Premultiplied
<< QVideoFrame::Format_YUV444
<< QVideoFrame::Format_YUV420P
<< QVideoFrame::Format_YV12
<< QVideoFrame::Format_UYVY
<< QVideoFrame::Format_YUYV
<< QVideoFrame::Format_NV12
<< QVideoFrame::Format_NV21
<< QVideoFrame::Format_IMC1
<< QVideoFrame::Format_IMC2
<< QVideoFrame::Format_IMC3
<< QVideoFrame::Format_IMC4
<< QVideoFrame::Format_Y8
<< QVideoFrame::Format_Y16
<< QVideoFrame::Format_Jpeg
<< QVideoFrame::Format_CameraRaw
<< QVideoFrame::Format_AdobeDng;
}
bool QtCameraCapture::present(const QVideoFrame &frame)
{
if (frame.isValid()) {
QVideoFrame cloneFrame(frame);
cloneFrame.map(QAbstractVideoBuffer::ReadOnly);
const QImage image(cloneFrame.bits(),
cloneFrame.width(),
cloneFrame.height(),
QVideoFrame::imageFormatFromPixelFormat(cloneFrame.pixelFormat()));
emit frameAvailable(image);
cloneFrame.unmap();
return true;
}
return false;
}

@ -0,0 +1,70 @@
#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

@ -0,0 +1,36 @@
#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);
}
);

@ -12,12 +12,6 @@ extern "C" {
#define LOCAL_RECORD_FLASH_ELAPSE 500
/**
* @brief OpenGL
*
* YUV
* 44
*/
static const GLfloat vertices[] = {
// vertex coordinate
-1.0f, -1.0f,
@ -31,12 +25,6 @@ static const GLfloat vertices[] = {
1.0f, 0.0f
};
/**
* @brief VLKVideoWidget
*
* OpenGLUI
*
*/
VLKVideoWidget::VLKVideoWidget(QWidget *parent)
: QOpenGLWidget(parent)
, m_textureUniformY(0), m_textureUniformU(0), m_textureUniformV(0)
@ -78,11 +66,6 @@ VLKVideoWidget::VLKVideoWidget(QWidget *parent)
m_fontNoSignal.setLetterSpacing(QFont::AbsoluteSpacing, 0);
}
/**
* @brief VLKVideoWidget
*
* OpenGL
*/
VLKVideoWidget::~VLKVideoWidget()
{
makeCurrent();
@ -109,11 +92,6 @@ VLKVideoWidget::~VLKVideoWidget()
}
}
/**
* @brief OpenGL
*
*
*/
void VLKVideoWidget::initializeGL()
{
m_mutex.lock();
@ -124,12 +102,6 @@ void VLKVideoWidget::initializeGL()
m_mutex.unlock();
}
/**
* @brief
*
*
* uniform
*/
bool VLKVideoWidget::InitShader()
{
// intialize openGL (function of QOpenGLFunctions)
@ -175,11 +147,6 @@ bool VLKVideoWidget::InitShader()
return true;
}
/**
* @brief
*
* YUV
*/
bool VLKVideoWidget::InitTexture()
{
if (m_TextureIDY != 0)
@ -218,11 +185,6 @@ bool VLKVideoWidget::InitTexture()
return 0;
}
/**
* @brief OpenGL
*
* YUVUI
*/
void VLKVideoWidget::paintGL()
{
QPainter painter;
@ -262,12 +224,6 @@ void VLKVideoWidget::paintGL()
painter.end();
}
/**
* @brief YUV
*
* 使OpenGLYUV420P
*
*/
void VLKVideoWidget::RenderYUV()
{
if (m_iPixelW <= 0 || m_iPixelH <= 0) {
@ -326,11 +282,6 @@ void VLKVideoWidget::RenderYUV()
m_mutex.unlock();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::DrawDirectionOperateImg(QPainter& painter)
{
if (!m_bLButtonPressed || m_ptBegin == m_ptEnd)
@ -352,11 +303,6 @@ void VLKVideoWidget::DrawDirectionOperateImg(QPainter& painter)
painter.restore();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::DrawLocalRecordFlash(QPainter& painter)
{
if (m_iLocalRecordingFlashTimer != 0 && m_bFlashFlag)
@ -367,11 +313,6 @@ void VLKVideoWidget::DrawLocalRecordFlash(QPainter& painter)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::DrawTelemetryInfo(QPainter& painter)
{
painter.save();
@ -383,11 +324,6 @@ void VLKVideoWidget::DrawTelemetryInfo(QPainter& painter)
painter.restore();
}
/**
* @brief
*
* "No signal"
*/
void VLKVideoWidget::DrawVideoStatus(QPainter& painter)
{
painter.save();
@ -403,11 +339,6 @@ void VLKVideoWidget::DrawVideoStatus(QPainter& painter)
painter.restore();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::resizeGL(int width, int height)
{
Q_UNUSED(width);
@ -420,22 +351,12 @@ void VLKVideoWidget::resizeGL(int width, int height)
}
}
/**
* @brief
*
* 使
*/
void VLKVideoWidget::Init(int width, int height)
{
Q_UNUSED(width);
Q_UNUSED(height);
}
/**
* @brief
*
* FFmpegAVFrameYUV
*/
void VLKVideoWidget::Repaint(const AVFrame *frame)
{
if (!frame)
@ -488,21 +409,11 @@ void VLKVideoWidget::Repaint(const AVFrame *frame)
update();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::EndOfFile()
{
emit SignalEndOfFile();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::onSlotEndOfFile()
{
Clear();
@ -510,11 +421,6 @@ void VLKVideoWidget::onSlotEndOfFile()
ShowLocalRecordFlash(false);
}
/**
* @brief
*
* YUVOpenGL
*/
void VLKVideoWidget::Clear()
{
m_mutex.lock();
@ -536,11 +442,6 @@ void VLKVideoWidget::Clear()
update();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::dragEnterEvent(QDragEnterEvent *event)
{
if (event->source() == this)
@ -549,11 +450,6 @@ void VLKVideoWidget::dragEnterEvent(QDragEnterEvent *event)
event->accept();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::dropEvent(QDropEvent *event)
{
Q_UNUSED(event);
@ -564,11 +460,6 @@ void VLKVideoWidget::dropEvent(QDropEvent *event)
*/
}
/**
* @brief
*
*
*/
void VLKVideoWidget::contextMenuEvent(QContextMenuEvent * event)
{
Q_UNUSED(event);
@ -576,21 +467,11 @@ void VLKVideoWidget::contextMenuEvent(QContextMenuEvent * event)
// m_menu->show();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::on_SlotContextMenu(QAction* action)
{
Q_UNUSED(action);
}
/**
* @brief
*
*
*/
void VLKVideoWidget::mouseDoubleClickEvent(QMouseEvent *event)
{
// check if the device has track capability
@ -608,11 +489,6 @@ void VLKVideoWidget::mouseDoubleClickEvent(QMouseEvent *event)
VLK_TrackTargetPositionEx(&param, iImgPosX, iImgPosY, m_iPixelW, m_iPixelH);
}
/**
* @brief
*
*
*/
void VLKVideoWidget::wheelEvent(QWheelEvent* event)
{
if (event->delta() > 0) // wheel is moving forward
@ -639,11 +515,6 @@ void VLKVideoWidget::wheelEvent(QWheelEvent* event)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
@ -664,11 +535,6 @@ void VLKVideoWidget::mousePressEvent(QMouseEvent *event)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::mouseMoveEvent(QMouseEvent *event)
{
if (m_bLButtonPressed)
@ -678,11 +544,6 @@ void VLKVideoWidget::mouseMoveEvent(QMouseEvent *event)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::mouseReleaseEvent(QMouseEvent *event)
{
Q_UNUSED(event);
@ -707,11 +568,6 @@ void VLKVideoWidget::mouseReleaseEvent(QMouseEvent *event)
update();
}
/**
* @brief
*
*
*/
void VLKVideoWidget::timerEvent(QTimerEvent *event)
{
if (event->timerId() == m_nStopZoomTimerID)
@ -752,11 +608,6 @@ void VLKVideoWidget::timerEvent(QTimerEvent *event)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::ShowLocalRecordFlash(bool bShow)
{
if (bShow)
@ -775,11 +626,6 @@ void VLKVideoWidget::ShowLocalRecordFlash(bool bShow)
}
}
/**
* @brief
*
*
*/
void VLKVideoWidget::setStandardSize(int width, int height)
{
if (width > 0 && height > 0) {
@ -788,11 +634,6 @@ void VLKVideoWidget::setStandardSize(int width, int height)
}
}
/**
* @brief
*
*
*/
QRect VLKVideoWidget::calculateAspectRatioRect() const
{
QRect result = rect();

@ -11,24 +11,6 @@
struct AVFrame;
class Widget;
/**
* @brief VLK
*
* OpenGLYUV420P
* QOpenGLWidgetQOpenGLFunctions
*
*
* - 使OpenGLYUV420P
* -
* -
* - UI
* -
* -
* - 线
*
*
*/
class VLKVideoWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
Q_OBJECT

@ -1,519 +1,367 @@
#include "VideoObjNetwork.h"
#include "VLKVideoWidget.h"
#include <chrono>
/**
* @brief
*
* FFmpeg
*/
bool CVideoObjNetwork::m_bInit = false;
/**
* @brief
* @param r AVRational
* @return
*
* FFmpeg
*/
static double r2d(AVRational r)
{
return r.den == 0 ? 0 : (double)r.num / (double)r.den;
}
/**
* @brief CVideoObjNetwork
*
*
*/
CVideoObjNetwork::CVideoObjNetwork()
: m_pAVFmtContext(NULL) // FFmpeg格式上下文
, m_iVideoStreamIndex(-1) // 视频流索引
, m_iAudioStreamIndex(-1) // 音频流索引
, m_iWidth(0), m_iHeight(0) // 视频宽度和高度
, m_pThread(NULL) // 解码线程指针
, m_cbFunc(NULL) // 回调函数指针
, m_lUserParam(0) // 用户参数
, m_bExit(false) // 退出标志
, m_pCodecContext(NULL) // 解码器上下文
, m_pVideoWidget(NULL) // 视频显示组件
{
}
/**
* @brief CVideoObjNetwork
*
*
*/
CVideoObjNetwork::~CVideoObjNetwork()
{
Close();
}
/**
* @brief FFmpeg
* @param ptr
* @param level
* @param fmt
* @param vl
*
* FFmpegQt
*/
void ffmpeg_log_callback(void* ptr, int level, const char* fmt, va_list vl)
{
// va_start(vl, fmt);
QString strResult = QString::vasprintf(fmt, vl);
// va_end(vl);
qDebug() << strResult;
}
/**
* @brief
* @param strURL URL
* @param pVideoWidget
* @return truefalse
*
* FFmpeg线
*/
bool CVideoObjNetwork::Open(const std::string& strURL, VLKVideoWidget* pVideoWidget)
{
if (!m_bInit)
{
// av_log_set_callback(ffmpeg_log_callback);
// initialize ffmpeg
av_register_all();
avformat_network_init();
m_bInit = true;
}
Close();
m_strURL = strURL;
m_pVideoWidget = pVideoWidget;
m_bExit = false;
m_pThread = new std::thread(ThreadFunc, this);
return true;
}
/**
* @brief
* @return truefalse
*
* 线
*/
bool CVideoObjNetwork::IsOpen()
{
return m_pThread != NULL;
}
/**
* @brief
* @param pVideoDataCB
* @param lUserParam
*
*
*/
void CVideoObjNetwork::SetDataCallback(VideoDataCallback pVideoDataCB, long lUserParam)
{
m_cbFunc = pVideoDataCB;
m_lUserParam = lUserParam;
}
/**
* @brief
*
* 线
*/
void CVideoObjNetwork::Close()
{
if (m_pThread != NULL)
{
m_bExit = true;
m_pThread->join();
delete m_pThread;
m_pThread = NULL;
}
CloseDemux();
CloseDecoder();
}
/**
* @brief
* @param strURL URL
* @return truefalse
*
* 使FFmpeg
* 退
*/
bool CVideoObjNetwork::OpenDemux(const std::string& strURL)
{
AVDictionary *opts = NULL;
// av_dict_set(&opts, "rtsp_transport", "tcp", 0);
// av_dict_set(&opts, "max_delay", "500", 0);
// av_dict_set(&opts, "stimeout", "5000", 0);
m_pAVFmtContext = avformat_alloc_context();
AVIOInterruptCB cb = { interrupt_callback, this };
m_pAVFmtContext->interrupt_callback = cb;
m_pAVFmtContext->flags |= AVFMT_FLAG_NONBLOCK;
AVInputFormat* inputformat = /*av_find_input_format("rtsp")*/NULL;
int re = avformat_open_input(
&m_pAVFmtContext,
strURL.c_str(),
inputformat,
&opts);
if (re != 0)
{
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("open url %s failed! %s\n", strURL.c_str(), buf);
if (re != AVERROR_EXIT)
{
}
return false;
}
qDebug("open %s success !\n", strURL.c_str());
re = avformat_find_stream_info(m_pAVFmtContext, 0);
// std::string stdstrURL = strURL.toStdString();
// av_dump_format(m_pAVFmtContext, 0, stdstrURL.c_str(), 0);
// find video stream
m_iVideoStreamIndex = av_find_best_stream(m_pAVFmtContext, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
if (m_iVideoStreamIndex < 0)
{
qCritical("could not find video stream !!!!!!!!!\n");
return false;
}
AVStream *as = m_pAVFmtContext->streams[m_iVideoStreamIndex];
m_iWidth = as->codecpar->width;
m_iHeight = as->codecpar->height;
qDebug("video info codec_id: %d, pixel format: %d, width: %d, height: %d, video fps: %lf\n",
as->codecpar->codec_id, as->codecpar->format,
as->codecpar->width, as->codecpar->height,
r2d(as->avg_frame_rate));
// find audio stream
/*m_iAudioStreamIndex = av_find_best_stream(m_pAVFmtContext, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
as = m_pAVFmtContext->streams[m_iAudioStreamIndex];
m_iSampleRate = as->codecpar->sample_rate;
m_iChannels = as->codecpar->channels;
qDebug("audio info codec_id: %d, format: %d, sample_rate: %d, channels: %d, frame_size: %d",
as->codecpar->codec_id, as->codecpar->format,
as->codecpar->sample_rate, as->codecpar->channels,
as->codecpar->frame_size);*/
return true;
}
/**
* @brief
*
* FFmpeg
*/
void CVideoObjNetwork::CloseDemux()
{
StopLocalRecord();
if (m_pAVFmtContext != NULL)
{
avformat_close_input(&m_pAVFmtContext);
}
m_iVideoStreamIndex = -1;
m_iAudioStreamIndex = -1;
}
/**
* @brief FFmpeg
* @param para CVideoObjNetwork
* @return 10
*
* FFmpeg
* m_bExittrue1
*/
int CVideoObjNetwork::interrupt_callback(void* para)
{
// qDebug() << __FUNCTION__;
CVideoObjNetwork* pThis = (CVideoObjNetwork*)para;
if (NULL == pThis)
{
return 0;
}
if (pThis->m_bExit)
{
return 1;
}
return 0;
}
/**
* @brief 线
* @param pThis CVideoObjNetwork
*
* 线OnThreadFunc
*/
void CVideoObjNetwork::ThreadFunc(CVideoObjNetwork* pThis)
{
if (pThis != NULL)
{
pThis->OnThreadFunc();
}
}
/**
* @brief 线
*
*
* 1.
* 2.
* 3.
* 4.
*/
void CVideoObjNetwork::OnThreadFunc()
{
while (!m_bExit)
{
if (!OpenDemux(m_strURL))
{
CloseDemux();
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
continue;
}
AVStream *as = m_pAVFmtContext->streams[m_iVideoStreamIndex];
if (!OpenDecoder(as->codecpar))
{
CloseDecoder();
CloseDemux();
continue;
}
ReadPacketLoop();
CloseDecoder();
CloseDemux();
}
}
/**
* @brief
*
*
*
*/
void CVideoObjNetwork::ReadPacketLoop()
{
while (!m_bExit)
{
AVPacket *pkt = av_packet_alloc();
int re = av_read_frame(m_pAVFmtContext, pkt);
if (re != 0)
{
av_packet_free(&pkt);
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("av_read_frame error: %s\n", buf);
break;
}
// convert pts timebase and dts timebase to millisecond timebase
pkt->pts = pkt->pts*(1000 * (r2d(m_pAVFmtContext->streams[pkt->stream_index]->time_base)));
pkt->dts = pkt->dts*(1000 * (r2d(m_pAVFmtContext->streams[pkt->stream_index]->time_base)));
if (pkt->pts < 0)
{
qCritical("bad pts %ld, throw this packet away\n", pkt->pts);
av_packet_free(&pkt);
continue;
}
// send video package to decoder
if (pkt->stream_index == m_iVideoStreamIndex)
{
Send2Decode(pkt);
}
else
{
}
av_packet_free(&pkt);
}
}
/**
* @brief
* @param pkt
*
*
*/
void CVideoObjNetwork::WriteLocalRecord(const AVPacket* pkt)
{
Q_UNUSED(pkt);
}
/**
* @brief
*
* FFmpeg
*/
void CVideoObjNetwork::Clear()
{
m_mutex.lock();
if (m_pAVFmtContext != NULL)
{
avformat_flush(m_pAVFmtContext);
}
m_mutex.unlock();
}
/**
* @brief
* @return true
*
*
*/
bool CVideoObjNetwork::StartLocalRecord()
{
return true;
}
/**
* @brief
*
*
*/
void CVideoObjNetwork::StopLocalRecord()
{
}
/**
* @brief
*
*
*/
void CVideoObjNetwork::Capture()
{
}
/**
* @brief
* @param para
* @return truefalse
*
*
* 线
*/
bool CVideoObjNetwork::OpenDecoder(const AVCodecParameters *para)
{
AVCodec *vcodec = avcodec_find_decoder(para->codec_id);
if (!vcodec)
{
qCritical("can't find the codec id = %d\n", para->codec_id);
return false;
}
m_pCodecContext = avcodec_alloc_context3(vcodec);
avcodec_parameters_to_context(m_pCodecContext, para);
m_pCodecContext->thread_count = 1/*QThread::idealThreadCount()*/;
int re = avcodec_open2(m_pCodecContext, 0, 0);
if (re != 0)
{
avcodec_free_context(&m_pCodecContext);
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("avcodec_open2 failed! : %s\n", buf);
return false;
}
return true;
}
/**
* @brief
*
*
*/
void CVideoObjNetwork::CloseDecoder()
{
if (m_pCodecContext)
{
avcodec_close(m_pCodecContext);
avcodec_free_context(&m_pCodecContext);
}
}
/**
* @brief
* @param pkt
*
*
*
*/
void CVideoObjNetwork::Send2Decode(const AVPacket *pkt)
{
if (!pkt || pkt->size <= 0 || !pkt->data || !m_pCodecContext)
{
return;
}
int re = avcodec_send_packet(m_pCodecContext, pkt);
if (re != 0)
{
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("avcodec_send_packet failed! : %s\n", buf);
}
while (!m_bExit)
{
AVFrame *frame = av_frame_alloc();
int re = avcodec_receive_frame(m_pCodecContext, frame);
if (re != 0)
{
av_frame_free(&frame);
break;
}
// display video
Send2Display(frame);
av_frame_free(&frame);
}
}
/**
* @brief
* @param frame
*
*
*/
void CVideoObjNetwork::Send2Display(const AVFrame* frame)
{
if (!frame || frame->data[0] == NULL || frame->linesize[0] == 0)
{
return;
}
if (m_pVideoWidget != NULL)
{
m_pVideoWidget->Repaint(frame);
}
}
#include "VideoObjNetwork.h"
#include "VLKVideoWidget.h"
#include <chrono>
bool CVideoObjNetwork::m_bInit = false;
static double r2d(AVRational r)
{
return r.den == 0 ? 0 : (double)r.num / (double)r.den;
}
CVideoObjNetwork::CVideoObjNetwork()
: m_pAVFmtContext(NULL)
, m_iVideoStreamIndex(-1)
, m_iAudioStreamIndex(-1)
, m_iWidth(0), m_iHeight(0)
, m_pThread(NULL)
, m_cbFunc(NULL)
, m_lUserParam(0)
, m_bExit(false)
, m_pCodecContext(NULL)
, m_pVideoWidget(NULL)
{
}
CVideoObjNetwork::~CVideoObjNetwork()
{
Close();
}
void ffmpeg_log_callback(void* ptr, int level, const char* fmt, va_list vl)
{
// va_start(vl, fmt);
QString strResult = QString::vasprintf(fmt, vl);
// va_end(vl);
qDebug() << strResult;
}
bool CVideoObjNetwork::Open(const std::string& strURL, VLKVideoWidget* pVideoWidget)
{
if (!m_bInit)
{
// av_log_set_callback(ffmpeg_log_callback);
// initialize ffmpeg
av_register_all();
avformat_network_init();
m_bInit = true;
}
Close();
m_strURL = strURL;
m_pVideoWidget = pVideoWidget;
m_bExit = false;
m_pThread = new std::thread(ThreadFunc, this);
return true;
}
bool CVideoObjNetwork::IsOpen()
{
return m_pThread != NULL;
}
void CVideoObjNetwork::SetDataCallback(VideoDataCallback pVideoDataCB, long lUserParam)
{
m_cbFunc = pVideoDataCB;
m_lUserParam = lUserParam;
}
void CVideoObjNetwork::Close()
{
if (m_pThread != NULL)
{
m_bExit = true;
m_pThread->join();
delete m_pThread;
m_pThread = NULL;
}
CloseDemux();
CloseDecoder();
}
bool CVideoObjNetwork::OpenDemux(const std::string& strURL)
{
AVDictionary *opts = NULL;
// av_dict_set(&opts, "rtsp_transport", "tcp", 0);
// av_dict_set(&opts, "max_delay", "500", 0);
// av_dict_set(&opts, "stimeout", "5000", 0);
m_pAVFmtContext = avformat_alloc_context();
AVIOInterruptCB cb = { interrupt_callback, this };
m_pAVFmtContext->interrupt_callback = cb;
m_pAVFmtContext->flags |= AVFMT_FLAG_NONBLOCK;
AVInputFormat* inputformat = /*av_find_input_format("rtsp")*/NULL;
int re = avformat_open_input(
&m_pAVFmtContext,
strURL.c_str(),
inputformat,
&opts);
if (re != 0)
{
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("open url %s failed! %s\n", strURL.c_str(), buf);
if (re != AVERROR_EXIT)
{
}
return false;
}
qDebug("open %s success !\n", strURL.c_str());
re = avformat_find_stream_info(m_pAVFmtContext, 0);
// std::string stdstrURL = strURL.toStdString();
// av_dump_format(m_pAVFmtContext, 0, stdstrURL.c_str(), 0);
// find video stream
m_iVideoStreamIndex = av_find_best_stream(m_pAVFmtContext, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
if (m_iVideoStreamIndex < 0)
{
qCritical("could not find video stream !!!!!!!!!\n");
return false;
}
AVStream *as = m_pAVFmtContext->streams[m_iVideoStreamIndex];
m_iWidth = as->codecpar->width;
m_iHeight = as->codecpar->height;
qDebug("video info codec_id: %d, pixel format: %d, width: %d, height: %d, video fps: %lf\n",
as->codecpar->codec_id, as->codecpar->format,
as->codecpar->width, as->codecpar->height,
r2d(as->avg_frame_rate));
// find audio stream
/*m_iAudioStreamIndex = av_find_best_stream(m_pAVFmtContext, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
as = m_pAVFmtContext->streams[m_iAudioStreamIndex];
m_iSampleRate = as->codecpar->sample_rate;
m_iChannels = as->codecpar->channels;
qDebug("audio info codec_id: %d, format: %d, sample_rate: %d, channels: %d, frame_size: %d",
as->codecpar->codec_id, as->codecpar->format,
as->codecpar->sample_rate, as->codecpar->channels,
as->codecpar->frame_size);*/
return true;
}
void CVideoObjNetwork::CloseDemux()
{
StopLocalRecord();
if (m_pAVFmtContext != NULL)
{
avformat_close_input(&m_pAVFmtContext);
}
m_iVideoStreamIndex = -1;
m_iAudioStreamIndex = -1;
}
int CVideoObjNetwork::interrupt_callback(void* para)
{
// qDebug() << __FUNCTION__;
CVideoObjNetwork* pThis = (CVideoObjNetwork*)para;
if (NULL == pThis)
{
return 0;
}
if (pThis->m_bExit)
{
return 1;
}
return 0;
}
void CVideoObjNetwork::ThreadFunc(CVideoObjNetwork* pThis)
{
if (pThis != NULL)
{
pThis->OnThreadFunc();
}
}
void CVideoObjNetwork::OnThreadFunc()
{
while (!m_bExit)
{
if (!OpenDemux(m_strURL))
{
CloseDemux();
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
continue;
}
AVStream *as = m_pAVFmtContext->streams[m_iVideoStreamIndex];
if (!OpenDecoder(as->codecpar))
{
CloseDecoder();
CloseDemux();
continue;
}
ReadPacketLoop();
CloseDecoder();
CloseDemux();
}
}
void CVideoObjNetwork::ReadPacketLoop()
{
while (!m_bExit)
{
AVPacket *pkt = av_packet_alloc();
int re = av_read_frame(m_pAVFmtContext, pkt);
if (re != 0)
{
av_packet_free(&pkt);
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("av_read_frame error: %s\n", buf);
break;
}
// convert pts timebase and dts timebase to millisecond timebase
pkt->pts = pkt->pts*(1000 * (r2d(m_pAVFmtContext->streams[pkt->stream_index]->time_base)));
pkt->dts = pkt->dts*(1000 * (r2d(m_pAVFmtContext->streams[pkt->stream_index]->time_base)));
if (pkt->pts < 0)
{
qCritical("bad pts %ld, throw this packet away\n", pkt->pts);
av_packet_free(&pkt);
continue;
}
// send video package to decoder
if (pkt->stream_index == m_iVideoStreamIndex)
{
Send2Decode(pkt);
}
else
{
}
av_packet_free(&pkt);
}
}
void CVideoObjNetwork::WriteLocalRecord(const AVPacket* pkt)
{
Q_UNUSED(pkt);
}
void CVideoObjNetwork::Clear()
{
m_mutex.lock();
if (m_pAVFmtContext != NULL)
{
avformat_flush(m_pAVFmtContext);
}
m_mutex.unlock();
}
bool CVideoObjNetwork::StartLocalRecord()
{
return true;
}
void CVideoObjNetwork::StopLocalRecord()
{
}
void CVideoObjNetwork::Capture()
{
}
bool CVideoObjNetwork::OpenDecoder(const AVCodecParameters *para)
{
AVCodec *vcodec = avcodec_find_decoder(para->codec_id);
if (!vcodec)
{
qCritical("can't find the codec id = %d\n", para->codec_id);
return false;
}
m_pCodecContext = avcodec_alloc_context3(vcodec);
avcodec_parameters_to_context(m_pCodecContext, para);
m_pCodecContext->thread_count = 1/*QThread::idealThreadCount()*/;
int re = avcodec_open2(m_pCodecContext, 0, 0);
if (re != 0)
{
avcodec_free_context(&m_pCodecContext);
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("avcodec_open2 failed! : %s\n", buf);
return false;
}
return true;
}
void CVideoObjNetwork::CloseDecoder()
{
if (m_pCodecContext)
{
avcodec_close(m_pCodecContext);
avcodec_free_context(&m_pCodecContext);
}
}
void CVideoObjNetwork::Send2Decode(const AVPacket *pkt)
{
if (!pkt || pkt->size <= 0 || !pkt->data || !m_pCodecContext)
{
return;
}
int re = avcodec_send_packet(m_pCodecContext, pkt);
if (re != 0)
{
char buf[1024] = { 0 };
av_strerror(re, buf, sizeof(buf) - 1);
qCritical("avcodec_send_packet failed! : %s\n", buf);
}
while (!m_bExit)
{
AVFrame *frame = av_frame_alloc();
int re = avcodec_receive_frame(m_pCodecContext, frame);
if (re != 0)
{
av_frame_free(&frame);
break;
}
// display video
Send2Display(frame);
av_frame_free(&frame);
}
}
void CVideoObjNetwork::Send2Display(const AVFrame* frame)
{
if (!frame || frame->data[0] == NULL || frame->linesize[0] == 0)
{
return;
}
if (m_pVideoWidget != NULL)
{
m_pVideoWidget->Repaint(frame);
}
}

@ -0,0 +1,66 @@
#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;
};

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

Loading…
Cancel
Save