用 Vue 写一个录音组件

先写一个 Vue 组件,用于录音功能。以下是一个简单的录音组件示例:

<template>
  <div class="button-container">
    <button @mousedown="startRecording" @mouseup="stopRecording" @mouseleave="stopRecording" class="red-round-button"></button>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

const mediaRecorder = ref<MediaRecorder | null>(null);
  const audioChunks = ref<Blob[]>([]);


async function startRecording() {
  if (!navigator.mediaDevices || !window.MediaRecorder) {
    alert('Media Devices API or MediaRecorder API not supported in this browser.');
    return;
  }

  const audioConstraints = {
    audio: {
      sampleRate: 16000,
      channelCount: 1,
      volume: 1.0
    }
  };

  try {
    const stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
    mediaRecorder.value = new MediaRecorder(stream);
    audioChunks.value = [];

    mediaRecorder.value.ondataavailable = (event) => {
      audioChunks.value.push(event.data);
    };

    mediaRecorder.value.start();
  } catch (error) {
    console.error('Error accessing the microphone', error);
  }
}

function stopRecording() {
  if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
    mediaRecorder.value.stop();
    mediaRecorder.value.onstop = async () => {
      const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
      const audioUrl = URL.createObjectURL(audioBlob);
      const audio = new Audio(audioUrl);
      await audio.play();
    };
  }
}

onMounted(() => {
  // You can perform actions after the component has been mounted
});

onUnmounted(() => {
  // Perform cleanup tasks, such as stopping the media recorder if it's still active
  if (mediaRecorder.value) {
    mediaRecorder.value.stop();
  }
});
</script>

<style scoped>
.button-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh; /* This assumes you want to center the button vertically in the viewport */
}

.red-round-button {
  background-color: red;
  border: none;
  color: white;
  padding: 10px 20px;
  border-radius: 50%; /* This creates the round shape */
  cursor: pointer;
  outline: none;
  font-size: 16px;
  /* Adjust width and height to make the button round, they must be equal */
  width: 50px;
  height: 50px;
  /* Optional: Add some transition for interactions */
  transition: background-color 0.3s;
}

.red-round-button:hover {
  background-color: darkred;
}
</style>

注意

  1. 浏览器只支持以默认 sample rate 以及 webm 格式录制音频,经 Chrome/Firefox 测试上面传入的 sampleRate 其实无效
  2. 参考 Supported Audio Constraints in getUserMedia()Recording Audio in the Browser Using the MediaStream Recorder API,Chrome 的默认格式是 webm/opus,Firefox 的默认格式是 ogg/opus,采样率都是 48000 Hz
  3. 这里可以查看当前浏览器支持的 constraint:MediaDevices: getSupportedConstraints() method
  4. 录音格式经过测试,macOS 端浏览器支持 audio/webm,iOS 端浏览器支持 audio/mp4,参考 MediaRecorder: isTypeSupported() static method

封装成 streamlit 组件

项目结构可以参考模板库

在封装过程中,我们需要做以下几点改动:

  1. 给组件加上 streamlit 相关依赖
  2. 将录音数据的 Blob 转换为 base64 编码,以便传回 Python 函数。
  3. 修改组件的样式和属性,以适应 Streamlit 的使用场景。
<template>
  <div class="button-container">
    <button @mousedown="startRecording" @mouseup="stopRecording" @mouseleave="stopRecording" class="record-button" :style="dynamicStyles"></button>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted , computed } from 'vue';
import { Streamlit } from "streamlit-component-lib"
import { useStreamlit } from "./streamlit"

const props = defineProps(['args'])

const dynamicStyles = computed(() => {
  return {
    width: props.args.width,
    height: props.args.height,
  };
});

useStreamlit();

const mediaRecorder = ref(null);
const audioChunks = ref([]);

async function startRecording() {
  if (!navigator.mediaDevices || !window.MediaRecorder) {
    alert('Media Devices API or MediaRecorder API not supported in this browser.');
    return;
  }

  try {
    const stream = await navigator.mediaDevices.getUserMedia({audio: true});
    mediaRecorder.value = new MediaRecorder(stream);
    audioChunks.value = [];

    mediaRecorder.value.ondataavailable = (event) => {
      audioChunks.value.push(event.data);
    };

    mediaRecorder.value.start();
  } catch (error) {
    console.error('Error accessing the microphone', error);
  }
}

function stopRecording() {
  if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
    mediaRecorder.value.stop();
    mediaRecorder.value.onstop = async function() {
      const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
      const reader = new FileReader();
      reader.onload = function(event) {
        if (event.target && event.target.result) {
          const dataUrl = event.target.result;
          const base64 = dataUrl.split(',')[1]; // Extract the base64 part
          Streamlit.setComponentValue(base64);
        } else {
          console.log('Failed to read Blob as base64');
        }
      };
      reader.readAsDataURL(audioBlob);
    };
    
  }
}

onMounted(() => {
  // You can perform actions after the component has been mounted
});

onUnmounted(() => {
  // Perform cleanup tasks, such as stopping the media recorder if it's still active
  if (mediaRecorder.value) {
    mediaRecorder.value.stop();
  }
});
</script>

<style scoped>
.button-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh; /* center the button vertically in the viewport */
}

.record-button {
  background-color: rgb(255, 100, 100);
  border: none;
  color: white;
  padding: 10px 20px;
  border-radius: 50%; /* This creates the round shape */
  cursor: pointer;
  outline: none;
  font-size: 16px;
  width: 80px;
  height: 80px;
  /* Optional: Add some transition for interactions */
  transition: background-color 0.3s;
}

.record-button:active {
  background-color: darkred;
}
</style>

Python 端的封装与使用

在 Python 端,我们需要创建一个 Streamlit 应用,用于接收和处理前端传递的 base64 编码数据。以下是 Python 端的代码示例:

import base64
import os
import streamlit.components.v1 as components

_RELEASE = True

if not _RELEASE:
    _component_func = components.declare_component(
        "record_button",
        url="http://localhost:5173",
    )
else:
    parent_dir = os.path.dirname(os.path.abspath(__file__))
    build_dir = os.path.join(parent_dir, "frontend/dist")
    _component_func = components.declare_component(
        "record_button",
        path=build_dir
    )


def record_button(size, key=None):
    component_value = _component_func(size=size, key=key, default=None)
    if component_value:
        component_value = base64.b64decode(component_value)
    return component_value
  1. 修改 _RELEASE 参数,调试时可以设为 False ,使用本地 npm run dev 来调试。发布时先运行 npm run build 打包 frontend,然后将_RELEASE 设为 True
  2. 修改代码中对应的组件名称、返回默认值
  3. 使用 vite 打包时需要在 tsconfig.json 中加上 "compilerOptions.baseUrl": ".",以及在 vite.config.ts 中加上 `base: ‘./’,不然路径会有问题,参考 how-to-view-a-component-in-release-mode-using-vite

打包与发布

最后,我们将这个 Streamlit 组件打包并发布到 PyPI,以便其他用户可以轻松地安装和使用。打包和发布的过程涉及到一些配置和命令,具体步骤可以参考 Streamlit 官方文档(Streamlit Document - Publish a Component)和 Python 打包用户指南(Python Packaging User Guide)。

在发布之前,确保修改 MANIFEST.in 文件,将前端构建文件夹包含在内。同时,根据你的组件信息修改 pyproject.toml 文件。

python3 -m build # 打包
python3 -m twine upload --repository testpypi dist/* # 上传

注意文件夹名称一致,否则 pip 安装成功后 import 时也会出现 Module Name Not Found的错误

参考

社区有一些类似的组件可以参考