Skip to content

语音功能全流程

在一些聊天软件中,语音是必备的功能,语音的工作模式:录制-->存储成文件-->上传-->下载播放,存储可以使用阿里云 OSS,录音需要权限。浏览器出于安全和隐私的考虑,在使用麦克风之前必须获得用户的明确授权。这通常通过 navigator.mediaDevices.getUserMedia() 方法来实现。

前端代码

需安装模块

sh
pnpm add js-audio-recorder
pnpm 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>

后端代码

  1. 需安装下面模块
sh
npm install express multer ali-oss cors dotenv
npm 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}`);
});

程序员小洛文档