Appearance
语音功能全流程
在一些聊天软件中,语音是必备的功能,语音的工作模式:录制-->存储成文件-->上传-->下载播放,存储可以使用阿里云 OSS,录音需要权限。浏览器出于安全和隐私的考虑,在使用麦克风之前必须获得用户的明确授权。这通常通过 navigator.mediaDevices.getUserMedia() 方法来实现。
前端代码
需安装模块
sh
pnpm add js-audio-recorderpnpm add js-audio-recorder组件代码
vue
<template>
<div class="voice-message-container">
<!-- 聊天消息区域 -->
<div class="message-list">
<div v-if="messages.length === 0" class="no-message">
暂无消息,请按住下方按钮录制第一条语音
</div>
<!-- 循环渲染消息 -->
<div v-for="msg in messages" :key="msg.id" class="message-item">
<!-- 修复播放,添加 @click 事件,并动态绑定 class -->
<div
class="audio-player"
@click="playAudio(msg)"
:class="{ playing: msg.id === currentlyPlayingId }"
>
<!-- 修复播放,动态显示播放图标 -->
<span class="play-icon">{{
msg.id === currentlyPlayingId ? "❚❚" : "▶"
}}</span>
<span class="duration">{{ msg.duration.toFixed(1) }} "</span>
</div>
</div>
</div>
<!-- 录音控制区域 -->
<div class="recorder-footer">
<div
class="record-button"
@mousedown="startRecording"
@mouseup="stopAndSend"
@touchstart.prevent="startRecording"
@touchend.prevent="stopAndSend"
@mouseleave="cancelRecording"
:class="{ recording: isRecording }"
>
{{ buttonText }}
</div>
</div>
<!-- 录音时的悬浮提示 -->
<div v-if="isRecording" class="recording-indicator">
<div class="icon">🎤</div>
<div class="text">正在录音... {{ recordTime }}s</div>
<div class="cancel-tip">松开手指,发送语音</div>
<div class="cancel-tip">滑出按钮区域,取消发送</div>
</div>
<!-- 核心修复,使用一个全局的、隐藏的 audio 标签来处理所有播放逻辑 -->
<audio
ref="audioPlayerRef"
@ended="onPlayEnded"
@pause="onPlayEnded"
></audio>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import Recorder from "js-audio-recorder";
const recorder = ref(null);
const isRecording = ref(false);
const recordTime = ref(0);
const timer = ref(null);
const messages = ref([]);
const audioPlayerRef = ref(null);
const currentlyPlayingId = ref(null);
const buttonText = computed(() => {
return isRecording.value ? "松开 发送" : "按住 说话";
});
const initRecorder = () => {
Recorder.getPermission()
.then(() => {
console.log("已获取麦克风权限");
recorder.value = new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
});
})
.catch((error) => {
console.error("获取麦克风权限失败:", error);
alert("无法获取麦克风权限,请检查浏览器设置。");
});
};
const startRecording = () => {
if (!recorder.value) {
alert("录音设备初始化失败!");
return;
}
if (isRecording.value) return;
recorder.value
.start()
.then(() => {
isRecording.value = true;
timer.value = setInterval(() => {
recordTime.value++;
}, 1000);
})
.catch((error) => {
console.error("开始录音失败:", error);
});
};
const stopAndSend = () => {
if (!isRecording.value) return;
console.log("停止录音,准备发送...");
isRecording.value = false;
clearInterval(timer.value);
const audioBlob = recorder.value.getWAVBlob();
const duration = recorder.value.duration;
if (duration < 1) {
console.log("录音时间太短");
recordTime.value = 0;
return;
}
uploadAudio(audioBlob, duration);
recordTime.value = 0;
};
const cancelRecording = () => {
if (!isRecording.value) return;
recorder.value.stop();
isRecording.value = false;
clearInterval(timer.value);
recordTime.value = 0;
console.log("录音已取消");
};
const uploadAudio = (audioBlob, duration) => {
const formData = new FormData();
formData.append("audioFile", audioBlob, "voice.wav");
fetch("http://localhost:3000/api/upload-voice", {
method: "POST",
body: formData,
})
.then((response) => {
if (!response.ok) throw new Error("网络响应错误");
return response.json();
})
.then((data) => {
if (data.success) {
console.log("上传成功! URL:", data.fileUrl);
const newMessage = {
id: new Date().getTime(),
url: data.fileUrl,
duration: duration,
};
messages.value.push(newMessage);
} else {
throw new Error(data.message || "上传失败");
}
})
.catch((error) => {
console.error("上传过程中发生错误:", error);
alert("语音发送失败,请稍后再试。");
});
};
const playAudio = (msg) => {
const player = audioPlayerRef.value;
if (!player) return;
// 如果点击的是正在播放的消息,则暂停
if (currentlyPlayingId.value === msg.id) {
player.pause();
currentlyPlayingId.value = null;
} else {
// 否则,播放新的音频
player.src = msg.url;
player.play().catch((e) => console.error("播放失败:", e));
currentlyPlayingId.value = msg.id;
}
};
const onPlayEnded = () => {
currentlyPlayingId.value = null;
};
onMounted(() => {
initRecorder();
});
</script>
<style scoped>
.voice-message-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
max-width: 400px;
margin: 0 auto;
background-color: #f0f2f5;
border: 1px solid #dcdcdc;
}
.message-list {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
}
.no-message {
text-align: center;
color: #888;
margin-top: 40px;
}
.message-item {
display: flex;
justify-content: flex-end;
margin-bottom: 15px;
}
.audio-player {
background-color: #95ec69;
padding: 10px 15px;
border-radius: 12px;
display: flex;
align-items: center;
cursor: pointer;
color: #000;
min-width: 80px;
transition: background-color 0.3s;
}
/* 正在播放时的样式 */
.audio-player.playing {
background-color: #72c84a;
}
/* 隐藏原生的audio播放器,只用它的功能 */
audio {
display: none;
}
.play-icon {
margin-right: 10px;
font-size: 16px;
}
.duration {
font-size: 14px;
}
.recorder-footer {
height: 60px;
border-top: 1px solid #dcdcdc;
background-color: #f7f7f7;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
}
.record-button {
flex-grow: 1;
height: 40px;
line-height: 40px;
text-align: center;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.record-button.recording {
background-color: #c7c7c7;
}
.recording-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
z-index: 100;
}
.recording-indicator .icon {
font-size: 40px;
}
.recording-indicator .text {
margin: 10px 0;
}
.recording-indicator .cancel-tip {
font-size: 12px;
color: #eee;
}
</style><template>
<div class="voice-message-container">
<!-- 聊天消息区域 -->
<div class="message-list">
<div v-if="messages.length === 0" class="no-message">
暂无消息,请按住下方按钮录制第一条语音
</div>
<!-- 循环渲染消息 -->
<div v-for="msg in messages" :key="msg.id" class="message-item">
<!-- 修复播放,添加 @click 事件,并动态绑定 class -->
<div
class="audio-player"
@click="playAudio(msg)"
:class="{ playing: msg.id === currentlyPlayingId }"
>
<!-- 修复播放,动态显示播放图标 -->
<span class="play-icon">{{
msg.id === currentlyPlayingId ? "❚❚" : "▶"
}}</span>
<span class="duration">{{ msg.duration.toFixed(1) }} "</span>
</div>
</div>
</div>
<!-- 录音控制区域 -->
<div class="recorder-footer">
<div
class="record-button"
@mousedown="startRecording"
@mouseup="stopAndSend"
@touchstart.prevent="startRecording"
@touchend.prevent="stopAndSend"
@mouseleave="cancelRecording"
:class="{ recording: isRecording }"
>
{{ buttonText }}
</div>
</div>
<!-- 录音时的悬浮提示 -->
<div v-if="isRecording" class="recording-indicator">
<div class="icon">🎤</div>
<div class="text">正在录音... {{ recordTime }}s</div>
<div class="cancel-tip">松开手指,发送语音</div>
<div class="cancel-tip">滑出按钮区域,取消发送</div>
</div>
<!-- 核心修复,使用一个全局的、隐藏的 audio 标签来处理所有播放逻辑 -->
<audio
ref="audioPlayerRef"
@ended="onPlayEnded"
@pause="onPlayEnded"
></audio>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import Recorder from "js-audio-recorder";
const recorder = ref(null);
const isRecording = ref(false);
const recordTime = ref(0);
const timer = ref(null);
const messages = ref([]);
const audioPlayerRef = ref(null);
const currentlyPlayingId = ref(null);
const buttonText = computed(() => {
return isRecording.value ? "松开 发送" : "按住 说话";
});
const initRecorder = () => {
Recorder.getPermission()
.then(() => {
console.log("已获取麦克风权限");
recorder.value = new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
});
})
.catch((error) => {
console.error("获取麦克风权限失败:", error);
alert("无法获取麦克风权限,请检查浏览器设置。");
});
};
const startRecording = () => {
if (!recorder.value) {
alert("录音设备初始化失败!");
return;
}
if (isRecording.value) return;
recorder.value
.start()
.then(() => {
isRecording.value = true;
timer.value = setInterval(() => {
recordTime.value++;
}, 1000);
})
.catch((error) => {
console.error("开始录音失败:", error);
});
};
const stopAndSend = () => {
if (!isRecording.value) return;
console.log("停止录音,准备发送...");
isRecording.value = false;
clearInterval(timer.value);
const audioBlob = recorder.value.getWAVBlob();
const duration = recorder.value.duration;
if (duration < 1) {
console.log("录音时间太短");
recordTime.value = 0;
return;
}
uploadAudio(audioBlob, duration);
recordTime.value = 0;
};
const cancelRecording = () => {
if (!isRecording.value) return;
recorder.value.stop();
isRecording.value = false;
clearInterval(timer.value);
recordTime.value = 0;
console.log("录音已取消");
};
const uploadAudio = (audioBlob, duration) => {
const formData = new FormData();
formData.append("audioFile", audioBlob, "voice.wav");
fetch("http://localhost:3000/api/upload-voice", {
method: "POST",
body: formData,
})
.then((response) => {
if (!response.ok) throw new Error("网络响应错误");
return response.json();
})
.then((data) => {
if (data.success) {
console.log("上传成功! URL:", data.fileUrl);
const newMessage = {
id: new Date().getTime(),
url: data.fileUrl,
duration: duration,
};
messages.value.push(newMessage);
} else {
throw new Error(data.message || "上传失败");
}
})
.catch((error) => {
console.error("上传过程中发生错误:", error);
alert("语音发送失败,请稍后再试。");
});
};
const playAudio = (msg) => {
const player = audioPlayerRef.value;
if (!player) return;
// 如果点击的是正在播放的消息,则暂停
if (currentlyPlayingId.value === msg.id) {
player.pause();
currentlyPlayingId.value = null;
} else {
// 否则,播放新的音频
player.src = msg.url;
player.play().catch((e) => console.error("播放失败:", e));
currentlyPlayingId.value = msg.id;
}
};
const onPlayEnded = () => {
currentlyPlayingId.value = null;
};
onMounted(() => {
initRecorder();
});
</script>
<style scoped>
.voice-message-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
max-width: 400px;
margin: 0 auto;
background-color: #f0f2f5;
border: 1px solid #dcdcdc;
}
.message-list {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
}
.no-message {
text-align: center;
color: #888;
margin-top: 40px;
}
.message-item {
display: flex;
justify-content: flex-end;
margin-bottom: 15px;
}
.audio-player {
background-color: #95ec69;
padding: 10px 15px;
border-radius: 12px;
display: flex;
align-items: center;
cursor: pointer;
color: #000;
min-width: 80px;
transition: background-color 0.3s;
}
/* 正在播放时的样式 */
.audio-player.playing {
background-color: #72c84a;
}
/* 隐藏原生的audio播放器,只用它的功能 */
audio {
display: none;
}
.play-icon {
margin-right: 10px;
font-size: 16px;
}
.duration {
font-size: 14px;
}
.recorder-footer {
height: 60px;
border-top: 1px solid #dcdcdc;
background-color: #f7f7f7;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
}
.record-button {
flex-grow: 1;
height: 40px;
line-height: 40px;
text-align: center;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.record-button.recording {
background-color: #c7c7c7;
}
.recording-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
z-index: 100;
}
.recording-indicator .icon {
font-size: 40px;
}
.recording-indicator .text {
margin: 10px 0;
}
.recording-indicator .cancel-tip {
font-size: 12px;
color: #eee;
}
</style>后端代码
- 需安装下面模块
sh
npm install express multer ali-oss cors dotenvnpm install express multer ali-oss cors dotenv配置环境变量
# .env 文件
# 服务器运行端口
PORT=3000
# --- 你的阿里云 OSS 配置 ---
OSS_ACCESS_KEY_ID=你的AccessKeyId
OSS_ACCESS_KEY_SECRET=你的AccessKeySecret
OSS_BUCKET=你的Bucket名称
OSS_REGION=你的Bucket所在地域代码 (例如:oss-cn-shanghai)# .env 文件
# 服务器运行端口
PORT=3000
# --- 你的阿里云 OSS 配置 ---
OSS_ACCESS_KEY_ID=你的AccessKeyId
OSS_ACCESS_KEY_SECRET=你的AccessKeySecret
OSS_BUCKET=你的Bucket名称
OSS_REGION=你的Bucket所在地域代码 (例如:oss-cn-shanghai)server.js文件
js
// server.js
// 1. 引入依赖
require("dotenv").config(); // 首先加载环境变量
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const OSS = require("ali-oss");
const path = require("path");
// 2. 初始化
const app = express();
const PORT = process.env. PORT || 3000;
// 3. 配置中间件
app.use(cors()); // 允许所有来源的跨域请求,在生产环境中应配置得更严格
app.use(express.json());
// 配置 multer: 我们不把文件存到本地磁盘,而是直接在内存中处理
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// 配置阿里云 OSS 客户端
const client = new OSS({
region: process.env. OSS_REGION,
accessKeyId: process.env. OSS_ACCESS_KEY_ID,
accessKeySecret: process.env. OSS_ACCESS_KEY_SECRET,
bucket: process.env. OSS_BUCKET,
});
console.log("OSS 客户端配置成功,使用 Bucket:", process.env. OSS_BUCKET);
// 4. 创建文件上传路由
// upload.single('audioFile') 表示处理名为 'audioFile' 的单个文件上传
app.post("/api/upload-voice", upload.single("audioFile"), async (req, res) => {
if (!req.file) {
return res
.status(400)
.json({ success: false, message: "没有检测到音频文件" });
}
try {
// 为文件生成一个独一无二的名字,避免覆盖
// 格式: voice-messages/1678886400000-random123.wav
const fileName = `voice-messages/${Date.now()}-${Math.floor(
Math.random() * 1000
)}${path.extname(req.file.originalname) || ".wav"}`;
console.log(`准备上传文件到 OSS: ${fileName}`);
// 使用 client.put 进行上传
// 第一个参数是上传到 OSS 后的文件名(包含路径)
// 第二个参数是文件内容,req.file.buffer 就是 multer 存放在内存中的文件数据
const result = await client.put(fileName, req.file.buffer);
console.log("上传成功, OSS 返回结果:", result);
// 【重要】确保你的 Bucket 读写权限为 "公共读",否则 URL 无法直接访问
// result.url 就是上传后文件的公开访问地址
res.status(200).json({
success: true,
message: "上传成功",
fileUrl: result.url,
});
} catch (error) {
console.error("上传到 OSS 时发生错误:", error);
res.status(500).json({ success: false, message: "服务器内部错误" });
}
});
// 5. 启动服务器
app.listen(PORT, () => {
console.log(`语音服务已启动,正在监听 http://localhost:${PORT}`);
});// server.js
// 1. 引入依赖
require("dotenv").config(); // 首先加载环境变量
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const OSS = require("ali-oss");
const path = require("path");
// 2. 初始化
const app = express();
const PORT = process.env. PORT || 3000;
// 3. 配置中间件
app.use(cors()); // 允许所有来源的跨域请求,在生产环境中应配置得更严格
app.use(express.json());
// 配置 multer: 我们不把文件存到本地磁盘,而是直接在内存中处理
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// 配置阿里云 OSS 客户端
const client = new OSS({
region: process.env. OSS_REGION,
accessKeyId: process.env. OSS_ACCESS_KEY_ID,
accessKeySecret: process.env. OSS_ACCESS_KEY_SECRET,
bucket: process.env. OSS_BUCKET,
});
console.log("OSS 客户端配置成功,使用 Bucket:", process.env. OSS_BUCKET);
// 4. 创建文件上传路由
// upload.single('audioFile') 表示处理名为 'audioFile' 的单个文件上传
app.post("/api/upload-voice", upload.single("audioFile"), async (req, res) => {
if (!req.file) {
return res
.status(400)
.json({ success: false, message: "没有检测到音频文件" });
}
try {
// 为文件生成一个独一无二的名字,避免覆盖
// 格式: voice-messages/1678886400000-random123.wav
const fileName = `voice-messages/${Date.now()}-${Math.floor(
Math.random() * 1000
)}${path.extname(req.file.originalname) || ".wav"}`;
console.log(`准备上传文件到 OSS: ${fileName}`);
// 使用 client.put 进行上传
// 第一个参数是上传到 OSS 后的文件名(包含路径)
// 第二个参数是文件内容,req.file.buffer 就是 multer 存放在内存中的文件数据
const result = await client.put(fileName, req.file.buffer);
console.log("上传成功, OSS 返回结果:", result);
// 【重要】确保你的 Bucket 读写权限为 "公共读",否则 URL 无法直接访问
// result.url 就是上传后文件的公开访问地址
res.status(200).json({
success: true,
message: "上传成功",
fileUrl: result.url,
});
} catch (error) {
console.error("上传到 OSS 时发生错误:", error);
res.status(500).json({ success: false, message: "服务器内部错误" });
}
});
// 5. 启动服务器
app.listen(PORT, () => {
console.log(`语音服务已启动,正在监听 http://localhost:${PORT}`);
});
小洛的前端技术博客