标签: mediaDevices

  • 【实用教程】在网页里集成语音输入:1. 在浏览器里完成录音

    【实用教程】在网页里集成语音输入:1. 在浏览器里完成录音

    继续写实用教程。接下来准备讲解一下如何在网页里集成语音识别,语音输入的功能。也就是俗称的 STT(Speech to Text)。这个功能可以很好的鼓励用户输入更多的内容,而且考虑到现在大家用移动设备、一体机的很多,麦克风也早已普及,所以我建议大家都了解一下相关操作。不知道要写多长,长的话会分成几篇,慢慢连载。

    计划内容:

    1. 基础概念
    2. 使用 MediaRecorder 完成录音
    3. STT 的几个方案:
      • OpenAI Whisper API
      • CloudFlare Workers AI
      • 腾讯云
      • WASM
    4. 细节处理
    5. 总结

    基础概念

    好的,闲言少叙,我们来看语音输入需要用到哪些技术。

    1. 首先,我们要想办法把用户的声音录下来。
    2. 接下来,就是把录音文件发给某个模型来解析。这一步的选择很多,我会介绍几个我用过的服务。其实浏览器自己也有 SpeechRecognition API,但是我测试的结果并不可用,也不可控,所以就放弃了。
    3. 然后接收识别结果,显示出来,就可以了。

    有些系统自带语音输入,不过质量可能不好;而且录音文件中所携带的用户情绪等信息在将来大模型发展后,也有更多的可探索空间。所以我觉得这个功能还是值得学习使用的。

    使用 MediaRecorder 完成录音

    浏览器提供 MediaRecorder 帮我们完成录音工作。它的用法很简单,相信大家看一眼文档就能学会。我觉得值得注意的点只有几个:

    1. iOS Safari 和基于 Chromium 的 Edge / Chrome 支持的文件格式不同,需要处理
    2. 使用时需要用户许可,所以我们要处理用户拒绝的状态
    3. Safari 需要指定 timeslice

    接下来我们看代码(基于 Nuxt3,即 Vue3 + TS)。首先,我们要使用 navigator.mediaDevices 捕获音视频流,然后才能进行录制。

    // 因为这里我们只需要录音,所以只要 audio 就可以
    const constraints = { audio: true };
    // 这里的 stream 最好是全局或者组件内可访问的状态。因为录音结束我们要及时释放它,否则可能会一直都有在录音的提示
    let stream: MediaStream | null = null;
    // 需要根据浏览器类型确定音频文件 mimeType
    const supportedMimeType = import.meta.client && isSafari() ? 'audio/mp4' : 'audio/webm;codecs=opus';
    let mediaRecorder: MediaRecorder | null = null;
    
    // 各种初始化,比如声音、画波形图等
    isStarting.value = true;
    
    try {    
      stream = await navigator.mediaDevices.getUserMedia(constraints);
      mediaRecorder = new MediaRecorder(stream, {
        mimeType: supportedMimeType,
      });
    } catch (e) {
      isStarting.value = false;
      // 判断错误类型,如果是用户主动的 Permission denied,就请用户自己去开开关;否则可能无法处理,就显示不支持好了
      startingError.value = (e as Error).message === 'Permission denied'
        ? 'You have denied access to your microphone. To use this feature, please enable it in your browser settings and try again. Thank you!'
        : 'Sorry, your browser doesn\'t support voice input.';
      return;
    }

    接下来,如果用户许可我们使用录音设备,就可以开始录音了。这里其实我省掉了选择不同输入设备的选项,大多数时候不重要,用默认即可。感兴趣有需求的同学可以自己摸索。

    // 录音正式开始时,我们可以在这里处理一些状态,以更新显示状态
    mediaRecorder.addEventListener('start', async function () {
      isRecordingStarted.value = true;
      isRecording.value = true;
      isStarting.value = false;
      startTime = Date.now();
      startCountingTime();
      emit('update:isWorking', true);
    });
    // 录音数据有变化,记录到 recordedChunks
    mediaRecorder.addEventListener('dataavailable', function (event: BlobEvent) {
      if (event.data.size > 0) {
        recordedChunks.push(event.data);
      }
    });
    // 录音停止,即调用 `mediaRecorder.stop()` 之后,处理语音文字转换,或者用户主动放弃(丢掉录音)等
    mediaRecorder.addEventListener('stop', async function () {
      isRecording.value = false;
      // 把录音转换成文件,可以下载
      audioBlob.value = new Blob(recordedChunks, {
        type: supportedMimeType,
      });
      // 释放 stream,这一步很重要,录音提示不消除,很影响用户体验
      stream?.getTracks().forEach(track => track.stop());
      stream = null;
      audioDuration += Date.now() - startTime;
    
      // 这是我的一个标记,用来标记用户点击的是“取消”还是“转换”
      if (isBreak) {
        isBreak = false;
        return;
      }
    
      // 考虑到用户可能使用移动设备,移动网络情况不稳定,所以不能只请求一次,要隔一段时间多尝试几次
      retryCount = 0;
      let error = 'init';
      while (retryCount < autoRetry && error) {
        error = (await speechToText(audioDuration)) || '';
        if (error) {
          retryCount++;
          if (retryCount < autoRetry) {
            await sleep(1000 + (retryCount - 1) * 5000); // 1s, 6s, 11s
          }
        }
      }
      if (error) {
        convertingError.value = error;
      }
    });
    
    // 启动录音。在 Safari 里我们必须写明 `timeslice` 参数,否则可能在 stop 的时候还没记录数据。我觉得这是个 bug
    mediaRecorder.start(1000);

    小结

    基本上,这样就可以完成录音部分的功能,配合文件下载做一个录音机习作也是可以的。算算字数也不少了,本文就先到这里,下次介绍使用大模型 API 进行文字转换的功能。

    如果你对网页开发、AI 产品开发、录音播放等有兴趣或问题,欢迎留言与我讨论。