个人每周博客:语音模块集成开发总结

本周工作概述

本周的主要工作集中在语音模块的集成开发上,目标是为应用添加语音录制、上传及分析功能,以支持语音面试场景。这项功能旨在提升用户体验,特别是在面试或交流场景中,通过语音内容和情感分析为用户提供更全面的反馈。以下是对开发过程、核心代码以及实现功能的详细总结。

功能框架

文件系统
在这里插入图片描述
语音处理
在这里插入图片描述

开发内容与技术实现
  1. 前端语音录制功能
    在前端部分,我实现了语音录制功能,使用了 WebRTC 技术通过 navigator.mediaDevices.getUserMedia 获取麦克风权限,并结合 MediaRecorder API 实现录音功能。录音完成后,数据会以 MP3 格式保存为 Blob 对象,并转换为 File 对象添加到文件列表中,供后续上传。代码中还包括了录音时长计时、3分钟自动停止以及用户界面提示等功能,确保用户体验流畅。
 async startRecording() {
      try {
        // 请求麦克风权限
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

        // 创建MediaRecorder实例
        this.mediaRecorder = new MediaRecorder(stream);

        // 存储录音数据
        const audioChunks = [];

        // 监听数据可用事件
        this.mediaRecorder.ondataavailable = (event) => {
          if (event.data.size > 0) {
            audioChunks.push(event.data);
          }
        };

        // 监听录音停止事件
        this.mediaRecorder.onstop = () => {
          // 将录音数据合并为一个Blob
          const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' });

          // 创建一个唯一的文件名
          const fileName = `voice_${new Date().getTime()}.mp3`;

          // 创建File对象
          const audioFile = new File([audioBlob], fileName, { type: 'audio/mp3' });

          // 将录音文件添加到文件列表
          this.processedFiles.push({
            name: fileName,
            size: audioBlob.size,
            type: 'audio/mp3',
            raw: audioFile
          });

          // 关闭所有轨道
          stream.getTracks().forEach(track => track.stop());

          this.$message.success('录音已完成并添加到文件列表');
        };

        // 开始录音
        this.mediaRecorder.start();

        // 显示正在录音的提示
        this.$message({
          message: '正在录音中,点击停止按钮结束录音',
          type: 'warning',
          duration: 0,
          showClose: true,
          center: true,
          customClass: 'recording-message'
        });

        // 创建停止录音按钮
        const h = this.$createElement;
        this.recordingMessageBox = this.$msgbox({
          title: '录音中',
          message: h('div', null, [
            h('p', { style: 'text-align: center' }, '正在录音,请对着麦克风说话'),
            h('div', { style: 'text-align: center; margin-top: 20px' }, [
              h('span', { style: 'color: #f56c6c; margin-right: 10px' }, '录音时长: '),
              h('span', { class: 'recording-timer', ref: 'timer' }, '00:00')
            ])
          ]),
          showCancelButton: false,
          confirmButtonText: '停止录音',
          beforeClose: (action, instance, done) => {
            if (action === 'confirm') {
              // 停止录音
              if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
                this.mediaRecorder.stop();
              }
              // 清除计时器
              if (this.recordingTimer) {
                clearInterval(this.recordingTimer);
                this.recordingTimer = null;
              }
              // 关闭所有消息
              this.$message.closeAll();
            }
            done();
          }
        });

        // 开始计时
        let seconds = 0;
        this.recordingTimer = setInterval(() => {
          seconds++;
          const minutes = Math.floor(seconds / 60);
          const remainingSeconds = seconds % 60;
          const timeString = `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;

          // 更新计时器显示
          const timerElements = document.getElementsByClassName('recording-timer');
          if (timerElements && timerElements.length > 0) {
            timerElements[0].textContent = timeString;
          }

          // 如果录音超过3分钟,自动停止
          if (seconds >= 180) {
            if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
              this.mediaRecorder.stop();
            }
            clearInterval(this.recordingTimer);
            this.recordingTimer = null;
            this.recordingMessageBox.close();
            this.$message.info('录音已达到最大时长(3分钟),已自动停止');
          }
        }, 1000);
      } catch (error) {
        console.error('录音失败:', error);
        this.$message.error('无法访问麦克风,请确保已授予麦克风权限');
      }
    }
  }
}

通过上述代码,前端实现了从权限请求到录音保存的全流程功能。

  1. 后端语音文件处理与上传
    在后端部分,语音文件与其他类型的文件一起通过 MultipartFile 形式上传。我设计了一个统一的接口 /sendMessageWithPoll,通过 fileMessageId 参数将文件与对应的消息绑定,确保文件上传与消息内容的一致性。

` @PostMapping(“/sendMessageWithPoll”)
public GlobalResult sendMessage(
@RequestParam(value = “chatRequest”) String chatRequestStr,
@RequestPart(value = “files”, required = false) List files,
@RequestParam(value = “fileMessageId”, required = false) String fileMessageId)
throws JsonProcessingException {
// 手动解析JSON字符串
ObjectMapper objectMapper = new ObjectMapper();
ChatRequest chatRequest = objectMapper.readValue(chatRequestStr, ChatRequest.class);
List messageList = chatRequest.getMessageList();

    List<MessageLocalDto> collect = messageList.stream().map((item) -> {
        MessageLocalDto messageLocalDto = new MessageLocalDto(item);
        //把文件和所属message绑定
        if (fileMessageId != null && !fileMessageId.isEmpty()) {
            if (Objects.equals(messageLocalDto.getMessageId(), fileMessageId)) {
                messageLocalDto.setUploadFiles(files);
            }
        }

        return messageLocalDto;
    }).collect(Collectors.toList());
    List<MessageLocal> messageLocals = chatService.convertMessageListDto(collect);
    Interviewer interviewer = chatRequest.getInterviewer();
    // 参数验证
    if (messageLocals == null || messageLocals.isEmpty()
            || (messageLocals.get(messageList.size() - 1).getContent().getText().isEmpty() && messageLocals.get(messageList.size() - 1).getContent().getFiles().isEmpty())) {
        throw new ServiceException("缺少发送信息");
    }
    if (interviewer == null) {
        throw new ServiceException("未设置面试官");
    }
    Long idUser = UserUtils.getCurrentUserByToken().getIdUser();
    // 异步处理消息

    String messageId = String.valueOf(UUID.randomUUID());
    try {
        chatService.sendMessageToInterviewer(
                messageLocals,
                interviewer,
                idUser,
                messageId,
                output -> {
                    // 将每个输出添加到队列

                    MessageQueueUtil.addMessage(output);
                    // System.out.println("add queue: "+ output.getText());
                });
    } catch (Exception e) {
        e.printStackTrace();
        MessageQueueUtil.addMessage(new ChatOutput("系统错误: " + e.getMessage()));
    }
    return GlobalResultGenerator.genSuccessStringDataResult(messageId);
}`   

上传后,系统会使用统一逻辑处理上传文件,根据文件类型进行区分处理,语音文件(MP3格式)会被单独提取并送入分析流程。

  1. 语音内容与情感分析
    针对语音文件,我实现了 analyzeAudio 方法,利用 qwen-audio-turbo-latest 多模态模型进行语音转录和情感分析。模型不仅能准确识别语音内容,还能分析面试者的情感状态(如紧张或自信),为语音面试场景提供重要参考。分析结果会以结构化文本形式返回,包含语音内容和情感分析两部分。
public MessageFileDto analyzeFileInfo(FileInfo fileInfo){
        if(fileInfo.getType() == "mp3"){
            return analyzeAudio(fileInfo);
        }else{
            return  analyzeFile(fileInfo);
        }
    }
    
 public MessageFileDto analyzeAudio(FileInfo fileInfo) {
        StringBuilder combinedText = new StringBuilder();
        
        // 1. 处理语音内容
        if (fileInfo != null && fileInfo.getUrl() != null) {
            try {
                String fullPath = new File(UPLOAD_DIR, fileInfo.getUrl()).getAbsolutePath();
                String audioAnalysis = audioAnalysisUtil.analyzeInterviewAudio(fullPath);
                combinedText.append(audioAnalysis).append("\n\n");
            } catch (Exception e) {
                e.printStackTrace();
                combinedText.append("<语音分析失败>\n\n");
            }
        }
        MessageFileDto messageFileDto = new MessageFileDto(fileInfo);
        messageFileDto.setText(combinedText.toString().trim());
        return messageFileDto;
    }
    
public String analyzeInterviewAudio(String audioFilePath) throws ApiException, NoApiKeyException, UploadFileException {
        // 构建系统提示词 - 以面试官角度分析
        String systemPrompt = "你是一名专业的面试官,请分析以下面试者的语音回答。"
                + "你需要完成以下任务:"
                + "1. 准确转录语音内容,用<语音内容></语音内容>标签包裹"
                + "2. 分析面试者的情感状态(是否紧张、自信等),用<情感分析></情感分析>标签包裹"
                + "请确保输出格式严格遵循上述要求";

        MultiModalConversation conv = new MultiModalConversation();
        
        // 构建用户消息
        MultiModalMessage userMessage = MultiModalMessage.builder()
                .role(Role.USER.getValue())
                .content(Arrays.asList(
                        createAudioContent(audioFilePath),
                        createTextContent("请分析这段面试回答")
                ))
                .build();
        
        // 构建系统消息
        MultiModalMessage systemMessage = MultiModalMessage.builder()
                .role(Role.SYSTEM.getValue())
                .content(Arrays.asList(createTextContent(systemPrompt)))
                .build();

        MultiModalConversationParam param = MultiModalConversationParam.builder()
                .model("qwen-audio-turbo-latest")
                .apiKey(apiKey)  // 注入API Key
                .messages(Arrays.asList(systemMessage, userMessage))
                .build();

        MultiModalConversationResult result = conv.call(param);
        return result.getOutput().getChoices().get(0).getMessage().getContent().toString();
    }

通过上述实现,语音分析功能能够为用户提供有价值的反馈。

成果展示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上传文件后,通过智能体的多ai协作可以进行语音内容和情感分析,并将结果输入到思考模型中生成面试问题:
面试官接收到的语音分析内容:
在这里插入图片描述

面试官在接收语音分析后的思考内容:
在这里插入图片描述

总结与展望

本周的语音模块集成工作顺利完成,从前端录音到后端处理与分析,整体功能链路已打通。下一步计划完成面试时的评价模块,同时开始着手实现利用mcp实现多个模块的ai调用连通


Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐