用 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>
注意
- 浏览器只支持以默认 sample rate 以及 webm 格式录制音频,经 Chrome/Firefox 测试上面传入的 sampleRate 其实无效
- 参考 Supported Audio Constraints in getUserMedia() 与 Recording Audio in the Browser Using the MediaStream Recorder API,Chrome 的默认格式是 webm/opus,Firefox 的默认格式是 ogg/opus,采样率都是 48000 Hz
- 这里可以查看当前浏览器支持的 constraint:MediaDevices: getSupportedConstraints() method
- 录音格式经过测试,macOS 端浏览器支持
audio/webm
,iOS 端浏览器支持audio/mp4
,参考 MediaRecorder: isTypeSupported() static method
封装成 streamlit 组件
项目结构可以参考模板库
- 官方模板库:component-template
- vue:streamlit-component-template-vue
- vue+vite:Streamlit Component Vue Vite Template
在封装过程中,我们需要做以下几点改动:
- 给组件加上 streamlit 相关依赖
- 将录音数据的 Blob 转换为 base64 编码,以便传回 Python 函数。
- 修改组件的样式和属性,以适应 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
- 修改
_RELEASE
参数,调试时可以设为False
,使用本地npm run dev
来调试。发布时先运行npm run build
打包 frontend,然后将_RELEASE
设为True
- 修改代码中对应的组件名称、返回默认值
- 使用 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的错误
参考
社区有一些类似的组件可以参考
- Components - audio
- Github - Streamlit Audio Recorder:Star 最多的一个,但是按钮太多了,且无法定制界面
- Github - streamlit-audiorecorder:界面相对简单
- Github - Streamlit Mic Recorder:带语音识别功能