智能宠物监护系统:基于多模态感知的宠物情绪与位置监控平台
摘要:本项目设计了一种融合物联网技术与人工智能的宠物智能监护系统。通过GPS定位模块实时追踪宠物位置,结合对讲机模块采集环境音频,利用AI大模型进行情绪状态分析,最终通过Web平台为宠物主人提供可视化监护服务。系统有效解决了宠物走失预警和情绪健康监测两大核心痛点。
系统流程图如下:

项目背景概要:随着宠物经济的快速发展,宠物主对爱宠的健康监护和安全保障需求日益增长。传统宠物定位设备功能单一,无法满足情绪监测和智能交互的需求。本项目设计了一款集成GPS定位、双向对讲、AI情绪分析的智能宠物监护系统,通过多模态数据融合(位置、声音、运动),结合AI大模型和Flask后端服务,实现宠物情绪实时监测、异常行为预警及人宠智能交互,有效提升宠物监护的智能化水平。
创新点:
-
实现实时定位(GPS+北斗双模)
-
通过声纹分析识别宠物情绪状态
-
建立宠物情绪健康数据库
-
提供异常行为智能预警机制
技术实现:
整个项目在硬件架构上可清晰划分为上位机和下位机两大核心部分,二者通过串口通信实现双向数据交互。上位机采用 Python 作为主要开发语言,充分利用其丰富的数据处理库和便捷的 GUI 开发能力,负责数据的深度分析、可视化展示以及与用户的交互界面设计。下位机则基于 STM32F103C8T6 微控制器平台,使用 C 语言进行开发,凭借其高效的执行效率和对硬件的精确控制能力,承担起数据采集、预处理以及与 GPS 模块的直接通信任务。
在硬件连接方面,STM32F103C8T6 微控制器通过 UART 接口与 GPS 模块建立通信链路,按照特定的波特率接收 GPS 模块输出的 NMEA-0183 格式报文。这些报文包含了丰富的导航信息,如位置坐标、速度、时间等。下位机程序需要对这些原始报文进行解析,提取出其中有用的位置数据,如纬度、经度、海拔高度等信息。
gps模块的图片如下:

下位机的 GPS 报文处理流程主要包括以下几个关键步骤:
- 数据接收:配置 STM32 的 UART 外设,通过中断方式实时接收 GPS 模块发送的数据流。
- 报文识别:通过识别特定的起始符(如 "$")和结束符(如 "\r\n")来定位完整的 NMEA 报文。
- 数据解析:根据不同类型的 NMEA 语句,按照其特定的字段格式解析出所需的位置信息。
- 数据校验:通过计算校验和(Checksum)来验证接收到的报文是否完整、准确。
- 数据存储与转发:将解析后的有效位置数据存储在指定的数据结构中,并通过串口发送给上位机进行进一步处理。
下位机部分代码:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1)
{
if(gps_rx_buffer[gps_rx_index] == '\n') // 检测到帧结束
{
gps_rx_buffer[gps_rx_index] = '\0'; // 终止字符串
if(gps_rx_index > 5 && gps_rx_buffer[0] == '$') // 基本有效性检查
{
gps_data_ready = 1;
}
gps_rx_index = 0;
}
else
{
gps_rx_index = (gps_rx_index + 1) % GPS_BUFFER_SIZE;
}
HAL_UART_Receive_IT(&huart1, (uint8_t*)&gps_rx_buffer[gps_rx_index], 1);
}
}
// 校验和验证
int validate_checksum(const char* sentence)
{
char checksum = 0;
const char* p = sentence + 1; // 跳过$符号
char received_checksum[3] = {0};
// 计算校验和
while(*p && *p != '*')
{
checksum ^= *p;
p++;
}
if(*p == '*')
{
strncpy(received_checksum, p+1, 2);
uint8_t expected = strtol(received_checksum, NULL, 16);
return (checksum == expected);
}
return 0;
}
// 解析NMEA语句
void GPS_ParseNMEA(const char* nmea)
{
char field[2] = {0}; // 存储N/S和E/W
if(strstr(nmea, "$GPRMC"))
{
char status;
float speed, course;
float raw_lat, raw_lon; // 临时存储原始值
// 修改sscanf捕获原始值
int parsed = sscanf(nmea,
"$GPRMC,%9[^,],%c,%f,%c,%f,%c,%f,%f,%6[^,]",
current_gps.utc_time,
&status,
&raw_lat, // 纬度
&field[0], // N/S
&raw_lon, // 原始经度
&field[1], // E/W
&speed,
&course,
current_gps.date);
if(parsed != 9) return;
// 纬度转换
int lat_deg = (int)(raw_lat / 100);
float lat_min = raw_lat - (lat_deg * 100.0f);
current_gps.latitude = lat_deg + (lat_min / 60.0f);
if(field[0] == 'S') current_gps.latitude *= -1;
// 经度转换
int lon_deg = (int)(raw_lon / 100);
float lon_min = raw_lon - (lon_deg * 100.0f);
current_gps.longitude = lon_deg + (lon_min / 60.0f);
if(field[1] == 'W') current_gps.longitude *= -1;
current_gps.fix_status = (status == 'A') ? 1 : 0;
}
else if(strstr(nmea, "$GPGGA"))
{
uint8_t fix_quality, satellites;
float raw_lat, raw_lon, hdop, altitude;
char lat_dir, lon_dir, alt_unit;
// 修改sscanf捕获原始值
int parsed = sscanf(nmea,
"$GPGGA,%9[^,],%f,%c,%f,%c,%hhu,%hhu,%f,%f,%c",
current_gps.utc_time,
&raw_lat, // 原始纬度
&lat_dir, // N/S
&raw_lon, // 原始经度
&lon_dir, // E/W
&fix_quality,
&satellites,
&hdop,
&altitude,
&alt_unit);
if(parsed < 8) return; // 至少需要8个有效字段
// 纬度转换
int lat_deg = (int)(raw_lat / 100);
float lat_min = raw_lat - (lat_deg * 100.0f);
current_gps.latitude = lat_deg + (lat_min / 60.0f);
if(lat_dir == 'S') current_gps.latitude *= -1;
// 经度转换
int lon_deg = (int)(raw_lon / 100);
float lon_min = raw_lon - (lon_deg * 100.0f);
current_gps.longitude = lon_deg + (lon_min / 60.0f);
if(lon_dir == 'W') current_gps.longitude *= -1;
current_gps.fix_status = (fix_quality >= 1) ? 1 : 0;
current_gps.satellites = satellites;
}
}
// 转换:将度分格式转换为十进制度
float convert_dm_to_dd(float dm, char direction) {
int deg = (int)(dm / 100);
float min = dm - deg * 100;
float dd = deg + min / 60;
return (direction == 'S' || direction == 'W') ? -dd : dd;
}
上位机系统实现过程
上位机系统作为整个项目的核心交互界面,承担着数据处理、地图可视化和用户交互的重要职责。其实现过程融合了多种先进技术,为用户提供了直观、高效的操作体验。
上位机与下位机信息传输的部分代码如下,该模块需要注意的是他需要实时的收发,所以需要用到多线程技术,还要注意处理数据存储,要防止数据在收发过程中出现丢包覆盖等问题,从而影响数据的准确性。
import serial
import time
import threading
# 定义全局GPS读取器对象
gps_reader = None
class GPSReader:
def __init__(self):
self.lock = threading.Lock() # 线程安全锁
self.current_lat = 0.0 # 当前纬度
self.current_lng = 0.0 # 当前经度
self.ser = None # 串口对象
self.running = False # 运行标志
def start(self):
if self.running:
return
self.running = True
# 启动独立线程处理串口数据
threading.Thread(target=self._read_loop, daemon=True).start()
def _read_loop(self):
try:
# 初始化串口
self.ser = serial.Serial(
port="COM24", # 需要修改成自己的端口号
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1,
write_timeout=0.1,
exclusive=True
)
print(f"成功连接串口: {self.ser.name}")
recv_buffer = bytearray()
while self.running:
if self.ser.in_waiting > 0:
chunk = self.ser.read(self.ser.in_waiting)
recv_buffer.extend(chunk)
# 按换行符分割报文
while b'\n' in recv_buffer:
line_end = recv_buffer.find(b'\n')
line = recv_buffer[:line_end].decode('utf-8', errors='ignore').strip()
recv_buffer = recv_buffer[line_end + 1:]
if line:
try:
# 解析经纬度
parts = line.split(', ')
lat = float(parts[0].split(':')[1])
lng = float(parts[1].split(':')[1])
# 更新当前位置
with self.lock:
self.current_lat = lat
self.current_lng = lng
#print(f"更新位置:纬度 {lat}, 经度 {lng}")
except (IndexError, ValueError):
print("无效报文格式,无法解析经纬度")
time.sleep(0.001)
except serial.SerialException as e:
print(f"串口连接失败: {e}")
finally:
self.close()
def get_current(self):
with self.lock:
if self.current_lat is None or self.current_lng is None:
raise Exception("GPS未获取到有效位置")
return float(self.current_lat), float(self.current_lng)
#return 0.000,0.000
def close(self):
if self.ser and self.ser.is_open:
self.ser.close()
self.running = False
print("串口已关闭")
地图定位功能实现
上位机通过串口与下位机建立稳定通信,实时接收经纬度信息。这些原始数据通过后端服务进行解析和格式转换后,以 WebSocket 或 HTTP 接口的形式传输至前端页面。前端页面基于腾讯地图 API 构建了一个交互式地图界面,主要实现流程如下:
-
地图初始化:加载腾讯地图 SDK,设置初始地图中心点和缩放级别,配置地图控件和样式。
-
位置标记:设计了一个定制化的定位图标,采用醒目的颜色和动态效果,以确保在地图上能够直观地识别当前位置。
-
实时更新机制:前端通过 WebSocket 建立与后端的长连接,实时接收位置更新数据。每当接收到新的经纬度信息时,系统会平滑地移动定位图标至新位置,同时记录历史轨迹并以不同颜色进行显示。
-
轨迹回放功能:实现了历史轨迹的存储和回放功能,用户可以选择特定时间段查看移动路径,支持速度调节和暂停功能。
-
地图交互增强:添加了缩放控制、图层切换、位置搜索等功能,提升用户体验。同时实现了地图自适应屏幕大小的响应式设计,确保在不同设备上都能获得良好的显示效果。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>定位功能</title>
<style>
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
background: #f0f2f5;
}
/* 地图容器样式 */
#map {
height: calc(100vh - 140px);
border-radius: 8px;
margin: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: all 0.3s ease;
}
/* 位置信息展示栏 */
.location-info {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
padding: 16px 24px;
box-shadow: 0 -4px 16px rgba(0,0,0,0.08);
backdrop-filter: blur(8px);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
z-index: 1000;
}
/* 信息卡片样式 */
.info-card {
background: #ffffff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(24,144,255,0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.info-card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 16px rgba(24,144,255,0.15);
}
.info-label {
color: #666;
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.info-label svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.info-value {
color: #1890ff;
font-size: 18px;
font-weight: 600;
font-family: 'Roboto Mono', monospace;
letter-spacing: -0.5px;
}
.update-time {
color: #666;
font-size: 12px;
text-align: center;
margin-top: 12px;
opacity: 0.8;
}
/* 加载状态 */
.loading {
position: relative;
opacity: 0.8;
}
.loading::after {
content: "";
position: absolute;
right: -24px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 3px solid rgba(24,144,255,0.2);
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 错误状态 */
.error-status {
background: #fff1f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
/* 响应式布局 */
@media (max-width: 768px) {
#map {
margin: 10px;
height: calc(100vh - 180px);
}
.location-info {
grid-template-columns: 1fr;
padding: 12px;
gap: 12px;
}
.info-card {
padding: 12px;
}
.info-value {
font-size: 16px;
}
}
@keyframes spin {
0% { transform: translateY(-50%) rotate(0deg); }
100% { transform: translateY(-50%) rotate(360deg); }
}
</style>
<script src="https://map.qq.com/api/gljs?v=1.exp&key=4KMBZ-Q6GL4-MBZUV-K5CQP-ETTW3-TXBZ5"></script>
<!-- Material Icons -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<div id="map"></div>
<div class="location-info">
<div class="info-card">
<div class="info-label">
<svg viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
纬度
</div>
<div class="info-value" id="latitude">-</div>
</div>
<div class="info-card">
<div class="info-label">
<svg viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
经度
</div>
<div class="info-value" id="longitude">-</div>
</div>
<div class="info-card">
<div class="info-label">
<svg viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
更新时间
</div>
<div class="info-value" id="update-time">-</div>
</div>
</div>
<script>
// 初始化地图
let map = null;
let currentMarker = null;
function initMap(lat, lng) {
map = new TMap.Map('map', {
center: new TMap.LatLng(lat, lng),
zoom: 16,
pitch: 45, // 俯仰角度
rotation: 0 // 地图旋转角度
});
// 创建初始标记
currentMarker = new TMap.MultiMarker({
map: map,
styles: {
'active': new TMap.MarkerStyle({
width: 40,
height: 40,
anchor: { x: 20, y: 40 },
src: 'https://assets.website-files.com/5d3e1f2d5b6f9c329a59c5a6/5d4f5a8d5c3d3a5a8ecfc579_location-pin.svg'
})
},
geometries: [{
id: 'current',
styleId: 'active',
position: new TMap.LatLng(lat, lng)
}]
});
}
// 状态管理
const state = {
lastUpdate: null,
isUpdating: false,
errorCount: 0
};
// 更新位置信息
async function updateLocation() {
if (state.isUpdating) return;
try {
state.isUpdating = true;
const response = await fetch('http://127.0.0.1:5000/api/location');
const data = await response.json();
// 确保数据结构正确
// const lat = data.latitude || data.lat; // 兼容不同字段名
// const lng = data.longitude || data.lng; // 兼容不同字段名
const lat = data.lat;
const lng = data.lng;
const timestamp = data.timestamp ? new Date(data.timestamp * 1000) : new Date();
// 更新显示
document.getElementById('latitude').textContent = lat.toFixed(6);
document.getElementById('longitude').textContent = lng.toFixed(6);
document.getElementById('update-time').textContent = timestamp.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// 显示加载状态
document.querySelectorAll('.info-value').forEach(el => {
el.classList.add('loading');
});
if (!response.ok) {
throw new Error(`HTTP错误 ${response.status}`);
}
if (data.error) {
throw new Error(data.error);
}
// 更新地图标记
updateMapPosition(lat, lng);
// 重置错误计数
state.errorCount = 0;
} catch (error) {
console.error('更新:', error);
handleUpdateError(!error);
// state.errorCount++;
if (state.errorCount > 3) {
showCriticalError();
state.errorCount = 0;
}
}
finally {
state.isUpdating = false;
document.querySelectorAll('.info-value').forEach(el => {
el.classList.remove('loading');
});
}
}
// 更新地图位置
function updateMapPosition(lat, lng) {
// 更新标记位置
map.setCenter(new TMap.LatLng(lat, lng));
currentMarker.setGeometries([{
id: 'current',
position: new TMap.LatLng(lat, lng)
}]);
// 添加轨迹线
if (state.lastPosition) {
new TMap.Polyline({
map: map,
geometries: [
new TMap.LatLng(state.lastPosition.lat, state.lastPosition.lng),
new TMap.LatLng(lat, lng)
],
color: '#1890ff',
width: 3
});
}
state.lastPosition = { lat, lng };
}
// 错误处理
function handleUpdateError(error) {
// 更新显示错误状态
document.querySelectorAll('.info-card').forEach(card => {
card.classList.add('error-status');
});
document.getElementById('update-time').textContent =
`${new Date().toLocaleTimeString()} (更新)`;
// 5秒后清除错误状态
setTimeout(() => {
document.querySelectorAll('.info-card').forEach(card => {
card.classList.remove('error-status');
});
}, 5000);
}
// 严重错误处理
function showCriticalError() {
const alertBox = document.createElement('div');
alertBox.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #ff4d4f;
color: white;
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 2000;
animation: slideIn 0.3s ease;
`;
alertBox.textContent = '无法获取位置信息,请检查网络连接';
document.body.appendChild(alertBox);
setTimeout(() => {
alertBox.style.animation = 'slideOut 0.3s ease';
setTimeout(() => alertBox.remove(), 300);
}, 5000);
}
// 初始化地图
initMap(26.908802, 106.397502);
// 首次更新
updateLocation();
// 设置定时更新(1秒间隔)
setInterval(updateLocation, 1000);
// 窗口尺寸变化时重置地图
window.addEventListener('resize', () => {
map.trigger('resize');
});
</script>
</body>
</html>
效果展示:

对讲机语音分析功能实现
系统集成了对讲机模块的语音采集和分析功能,实现了从声音信息获取到情绪分析的完整流程:
-
音频采集与传输:通过 WebRTC 技术直接在浏览器中捕获对讲机发送的音频流,将其转换为特定格式后传输至后端服务器。
-
音频处理:后端接收到音频数据后,进行格式转换、降噪和特征提取等预处理操作,将原始音频转换为适合 AI 模型处理的格式。
-
情绪分析:将处理后的音频特征输入到大语言模型中,利用模型的情感分析能力对语音内容进行解析,识别出说话者的情绪状态(如愤怒、喜悦、悲伤、焦虑等)。
-
结果可视化:前端通过动态图表、颜色编码和文字描述等方式,直观地展示情绪分析结果。同时,系统支持情绪变化趋势分析和历史记录查询功能。
-
实时反馈机制:当检测到特定情绪状态(如紧急、危险)时,系统会触发相应的提醒机制,通过声音、弹窗等方式通知用户。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>通讯分析功能</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary: #FF6B6B;
--secondary: #4ECDC4;
--background: #f9f9f9;
}
body {
background: var(--background);
min-height: 100vh;
font-family: 'Helvetica Neue', sans-serif;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
width: 90%;
max-width: 800px;
padding: 2rem;
position: relative;
overflow: hidden;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.visualizer {
height: 150px;
background: rgba(78, 205, 196, 0.1);
border-radius: 15px;
margin: 2rem 0;
position: relative;
}
#waveform {
width: 100%;
height: 100%;
}
.control-btn {
width: 120px;
height: 120px;
border: none;
border-radius: 50%;
background: var(--primary);
color: white;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 8px 20px rgba(255,107,107,0.3);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid var(--primary);
animation: ripple 1.5s infinite;
}
@keyframes ripple {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.5); opacity: 0; }
}
.result-card {
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(245,245,245,0.9));
border-radius: 15px;
padding: 2rem;
margin-top: 2rem;
min-height: 200px;
backdrop-filter: blur(10px);
position: relative;
}
.emotion-icon {
font-size: 4.5rem;
transition: all 0.5s ease;
display: inline-block;
}
.status-text {
color: var(--primary);
font-size: 1.4rem;
font-weight: 600;
margin: 1rem 0;
}
.analysis-progress {
height: 4px;
background: rgba(78, 205, 196, 0.3);
width: 100%;
border-radius: 2px;
margin: 1rem 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--secondary);
width: 0;
transition: width 0.3s ease;
}
.permission-alert {
position: fixed;
top: 20px;
right: 20px;
background: #fff;
padding: 1rem;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐾 通讯情绪分析 🐕</h1>
<p>通过声音了解您的爱宠心情</p>
</div>
<div class="visualizer">
<canvas id="waveform"></canvas>
</div>
<div class="text-center">
<button class="control-btn" id="controlBtn">
<i class="fas fa-microphone">点击</i>
</button>
</div>
<div class="analysis-progress">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="result-card">
<div class="text-center">
<div class="emotion-icon" id="emotionIcon">🎵</div>
<div class="status-text" id="statusText">点击麦克风开始分析</div>
<div id="analysisResult"></div>
</div>
</div>
</div>
<div class="permission-alert" id="permissionAlert">
<i class="fas fa-exclamation-triangle"></i> 需要麦克风权限才能使用此功能
</div>
<script>
let audioContext;
let source;
let scriptProcessor;
let isRecording = false;
let animationFrameId;
let audioBuffer = [];
const canvas = document.getElementById('waveform');
const ctx = canvas.getContext('2d');
const rootStyles = getComputedStyle(document.documentElement);
// 音频参数配置
const SAMPLE_RATE = 16000;
const CHANNELS = 1;
const BUFFER_SIZE = 4096;
const RECORD_INTERVAL = 1000; // 1秒发送一次
const emotionMap = {
happy: { icon: '😄', color: '#4CAF50', text: '开心快乐' },
warning: { icon: '😰', color: '#FF9800', text: '焦虑不安' },
angry: { icon: '😠', color: '#F44336', text: '生气警惕' },
sad: { icon: '😌', color: '#2196F3', text: '伤心难过' }
};
// 初始化音频设备
async function initAudio() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: CHANNELS,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
setupAudioProcessing(stream);
hidePermissionAlert();
} catch (err) {
showPermissionAlert();
throw new Error('麦克风访问被拒绝');
}
}
// 设置音频处理流程
function setupAudioProcessing(stream) {
// 清理之前的资源
if (audioContext) audioContext.close();
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: SAMPLE_RATE
});
// 创建音频分析器
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
// 创建音频源
source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
// 设置ScriptProcessorNode采集原始数据
scriptProcessor = audioContext.createScriptProcessor(
BUFFER_SIZE,
CHANNELS,
CHANNELS
);
// 实时数据采集回调
scriptProcessor.onaudioprocess = (audioProcessingEvent) => {
if (!isRecording) return;
const inputBuffer = audioProcessingEvent.inputBuffer;
const channelData = inputBuffer.getChannelData(0);
// 将PCM数据存入缓冲区
audioBuffer.push(new Float32Array(channelData));
};
// 连接处理节点
source.connect(scriptProcessor);
scriptProcessor.connect(audioContext.destination);
// 启动定时发送任务
setInterval(sendAudioData, RECORD_INTERVAL);
// 启动波形动画
startWaveformAnimation(analyser);
isRecording = true;
document.getElementById('controlBtn').innerHTML = '<i class="fas fa-stop"></i>';
updateStatus('正在分析宠物声音...', 'analyzing');
}
// 生成WAV文件头
function generateWavHeader(dataLength) {
const header = new ArrayBuffer(44);
const view = new DataView(header);
// RIFF标识符
writeString(view, 0, 'RIFF');
// 文件长度
view.setUint32(4, 36 + dataLength, true);
// WAVE格式
writeString(view, 8, 'WAVE');
// fmt子块
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmt块长度
view.setUint16(20, 1, true); // PCM格式
view.setUint16(22, CHANNELS, true); // 声道数
view.setUint32(24, SAMPLE_RATE, true); // 采样率
view.setUint32(28, SAMPLE_RATE * 2, true); // 字节率
view.setUint16(32, 2, true); // 块对齐
view.setUint16(34, 16, true); // 位深
// data标识符
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true); // 数据长度
return view;
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// 发送音频数据
async function sendAudioData() {
if (!isRecording || audioBuffer.length === 0) return;
// 合并缓冲区中的PCM数据
const totalLength = audioBuffer.reduce((acc, cur) => acc + cur.length, 0);
const wavData = new Int16Array(totalLength);
let offset = 0;
audioBuffer.forEach(buffer => {
buffer.forEach(sample => {
wavData[offset++] = sample * 0x7FFF;
});
});
audioBuffer = []; // 清空缓冲区
// 创建带头的WAV Blob
const header = generateWavHeader(wavData.length * 2);
const wavBlob = new Blob(
[header, wavData],
{ type: 'audio/wav' }
);
// 创建表单数据
const formData = new FormData();
formData.append('audio', wavBlob, `recording_${Date.now()}.wav`);
try {
updateProgress(30);
const response = await fetch('/analyze_audio', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '服务器错误');
}
const result = await response.json();
updateEmotionDisplay(result.emotion);
updateProgress(100);
setTimeout(() => updateProgress(0), 1000);
} catch (error) {
console.error('分析失败:', error);
updateStatus(`传输错误: ${error.message}`, 'error');
}
}
// 启动波形动画绘制
function startWaveformAnimation(analyser) {
// 取消之前的动画
cancelAnimationFrame(animationFrameId);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
if (!isRecording) {
// 停止时清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
// 绘制前清除上一帧
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
analyser.getByteTimeDomainData(dataArray);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = rootStyles.getPropertyValue('--secondary');
const sliceWidth = canvas.width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
// 递归请求下一帧动画
animationFrameId = requestAnimationFrame(draw);
}
// 启动第一帧动画
animationFrameId = requestAnimationFrame(draw);
}
// 更新情绪显示
function updateEmotionDisplay(emotion) {
const config = emotionMap[emotion] || emotionMap.normal;
document.getElementById('emotionIcon').textContent = config.icon;
document.getElementById('emotionIcon').style.color = config.color;
document.getElementById('statusText').textContent = config.text;
document.getElementById('analysisResult').innerHTML = `
<p style="color: ${config.color}">
<i class="fas fa-comment"></i>
${getAdviceText(emotion)}
</p>
`;
}
// 获取建议文本
function getAdviceText(emotion) {
const adviceMap = {
happy: "可以继续和宠物互动玩耍",
warning: "建议给予安抚和安全感",
angry: "请保持距离避免刺激",
sad: "给予关心与陪伴"
};
return adviceMap[emotion] || "请观察其他行为表现";
}
// 停止分析
function stopAnalysis() {
if (!isRecording) return;
isRecording = false;
audioBuffer = [];
// 断开音频节点
scriptProcessor?.disconnect();
source?.disconnect();
audioContext?.close();
// 终止波形动画
cancelAnimationFrame(animationFrameId);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新界面状态
document.getElementById('controlBtn').innerHTML = '<i class="fas fa-microphone"></i>';
updateStatus('分析已停止', 'normal');
}
// 控制按钮点击事件
document.getElementById('controlBtn').addEventListener('click', async () => {
if (isRecording) {
stopAnalysis();
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
await initAudio();
}
});
// 更新进度条
function updateProgress(percentage) {
document.getElementById('progressBar').style.width = `${percentage}%`;
}
// 显示/隐藏权限提示
function showPermissionAlert() {
const alert = document.getElementById('permissionAlert');
alert.style.display = 'block';
setTimeout(() => alert.style.display = 'none', 5000);
}
function hidePermissionAlert() {
document.getElementById('permissionAlert').style.display = 'none';
}
// 更新状态文本
function updateStatus(text, type) {
const statusElem = document.getElementById('statusText');
statusElem.textContent = text;
statusElem.style.color = {
analyzing: rootStyles.getPropertyValue('--secondary'),
error: '#f44336',
normal: rootStyles.getPropertyValue('--primary')
}[type];
}
</script>
</body>
</html>
效果展示:

还有很多部分的代码我也不一一上传了,包括实现的效果也仅为部分效果,本次主要是作为分享,有需要的话也可以联系我,我给你们链接。
更多推荐
所有评论(0)