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

系统流程图如下:

项目背景概要:随着宠物经济的快速发展,宠物主对爱宠的健康监护和安全保障需求日益增长。传统宠物定位设备功能单一,无法满足情绪监测和智能交互的需求。本项目设计了一款集成GPS定位、双向对讲、AI情绪分析的智能宠物监护系统,通过多模态数据融合(位置、声音、运动),结合AI大模型Flask后端服务,实现宠物情绪实时监测、异常行为预警及人宠智能交互,有效提升宠物监护的智能化水平。

      创新点:

  1. 实现实时定位(GPS+北斗双模)

  2. 通过声纹分析识别宠物情绪状态

  3. 建立宠物情绪健康数据库

  4. 提供异常行为智能预警机制

技术实现:

整个项目在硬件架构上可清晰划分为上位机和下位机两大核心部分,二者通过串口通信实现双向数据交互。上位机采用 Python 作为主要开发语言,充分利用其丰富的数据处理库和便捷的 GUI 开发能力,负责数据的深度分析、可视化展示以及与用户的交互界面设计。下位机则基于 STM32F103C8T6 微控制器平台,使用 C 语言进行开发,凭借其高效的执行效率和对硬件的精确控制能力,承担起数据采集、预处理以及与 GPS 模块的直接通信任务。

在硬件连接方面,STM32F103C8T6 微控制器通过 UART 接口与 GPS 模块建立通信链路,按照特定的波特率接收 GPS 模块输出的 NMEA-0183 格式报文。这些报文包含了丰富的导航信息,如位置坐标、速度、时间等。下位机程序需要对这些原始报文进行解析,提取出其中有用的位置数据,如纬度、经度、海拔高度等信息。

gps模块的图片如下:

下位机的 GPS 报文处理流程主要包括以下几个关键步骤:

  1. 数据接收:配置 STM32 的 UART 外设,通过中断方式实时接收 GPS 模块发送的数据流。
  2. 报文识别:通过识别特定的起始符(如 "$")和结束符(如 "\r\n")来定位完整的 NMEA 报文。
  3. 数据解析:根据不同类型的 NMEA 语句,按照其特定的字段格式解析出所需的位置信息。
  4. 数据校验:通过计算校验和(Checksum)来验证接收到的报文是否完整、准确。
  5. 数据存储与转发:将解析后的有效位置数据存储在指定的数据结构中,并通过串口发送给上位机进行进一步处理。

 下位机部分代码:

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 构建了一个交互式地图界面,主要实现流程如下:

  1. 地图初始化:加载腾讯地图 SDK,设置初始地图中心点和缩放级别,配置地图控件和样式。

  2. 位置标记:设计了一个定制化的定位图标,采用醒目的颜色和动态效果,以确保在地图上能够直观地识别当前位置。

  3. 实时更新机制:前端通过 WebSocket 建立与后端的长连接,实时接收位置更新数据。每当接收到新的经纬度信息时,系统会平滑地移动定位图标至新位置,同时记录历史轨迹并以不同颜色进行显示。

  4. 轨迹回放功能:实现了历史轨迹的存储和回放功能,用户可以选择特定时间段查看移动路径,支持速度调节和暂停功能。

  5. 地图交互增强:添加了缩放控制、图层切换、位置搜索等功能,提升用户体验。同时实现了地图自适应屏幕大小的响应式设计,确保在不同设备上都能获得良好的显示效果。

<!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>

 效果展示:
        

对讲机语音分析功能实现

系统集成了对讲机模块的语音采集和分析功能,实现了从声音信息获取到情绪分析的完整流程:

  1. 音频采集与传输:通过 WebRTC 技术直接在浏览器中捕获对讲机发送的音频流,将其转换为特定格式后传输至后端服务器。

  2. 音频处理:后端接收到音频数据后,进行格式转换、降噪和特征提取等预处理操作,将原始音频转换为适合 AI 模型处理的格式。

  3. 情绪分析:将处理后的音频特征输入到大语言模型中,利用模型的情感分析能力对语音内容进行解析,识别出说话者的情绪状态(如愤怒、喜悦、悲伤、焦虑等)。

  4. 结果可视化:前端通过动态图表、颜色编码和文字描述等方式,直观地展示情绪分析结果。同时,系统支持情绪变化趋势分析和历史记录查询功能。

  5. 实时反馈机制:当检测到特定情绪状态(如紧急、危险)时,系统会触发相应的提醒机制,通过声音、弹窗等方式通知用户。

<!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>

效果展示:

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

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐