大模型本身是个只会读字的书呆子——它不认识声音,只会处理文字。所以你想跟它"说话",必须前后各装一个翻译官。
大模型语音交互的技术是: STT(Speech To Text)语音转文字,TTS(Text To Speech)文字转语音
你微信里那个"按住说话,松开发送"、手机输入法的语音输入,全是 STT。
导航里"前方 500 米左转"、抖音的"系统配音"、你说的小爱同学,全是 TTS。
你开口说话
│ 🎤 声波
↓
【STT】速记员:把声音敲成字
│ "一分钟后提醒我喝水"
↓
【大模型】书呆子:读字、思考、调工具
│ "好的,已设置提醒"
↓
【TTS】配音员:把字念成声音
│ 🔊 音频
↓
你听到回答
一.获取apikey
进入腾讯云语音技术:console.cloud.tencent.com/asr/resourc…
领取语音合成资源包和语音识别资源包
然后进入访问管理,获取SecretId和SecretKey
拿到 SecretId 和 SecretKey 以后,我们使用网站的ai助手就能够直接拿到使用案例。如图所示:
二.创建项目
nest new test-stt
npm install tencentcloud-sdk-nodejs --save
nest g resource voice --no-spec
2.1 固定文本合成语音-TTS
就是TTS(Text To Speech)文字转语音,这个例子的特殊之处在于,文本是固定的,有了固定的字符串,然后才开始读。
实现voice.service.ts文件
直接照抄官网案例代码即可,你要是想修改音色,直接进入cloud.tencent.com/document/pr… 然后将音色 ID改到下面即可
import { Injectable } from '@nestjs/common';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
// 语音合成 TTS 客户端(API 版本 v20190823)
const TtsClient = tencentcloud.tts.v20190823.Client;
@Injectable()
export class VoiceService {
private ttsClient;
constructor() {
const credential = {
secretId: 'AKIDJSN3V4d9jWLDulWLB6g7d3TuPvQzPbXJ',
secretKey: 'f5HPL5cuwkQ2TjDgv2xeMvGdmhPtKcgz',
};
const region = 'ap-guangzhou';
this.ttsClient = new TtsClient({
credential,
region,
profile: { httpProfile: { endpoint: 'tts.tencentcloudapi.com' } },
});
}
// ===== 语音合成 TTS:文字转语音 =====
async textToVoice(text: string) {
const params = {
Text: text, // 待合成文本
SessionId: `tts_${Date.now()}`, // 会话 ID,每次请求唯一
ModelType: 1, // 模型类型
VoiceType: 101001, // 音色 ID,具体取值以官方文档为准
Codec: 'mp3', // 输出格式:mp3 / wav / pcm
};
// 返回 Audio 字段为 Base64 编码的音频数据
const res = await this.ttsClient.TextToVoice(params);
return res.Audio;
}
}
实现voice.module.ts文件
你要记得exports: [VoiceService]
import { Module } from '@nestjs/common';
import { VoiceService } from './voice.service.js';
import { VoiceController } from './voice.controller.js';
@Module({
providers: [VoiceService],
controllers: [VoiceController],
})
export class VoiceModule {}
实现voice.controller.ts文件
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
他们俩生成的fs是有区别的,首先他们的方法都查不到,但是node:fs生成的fs是同步的,
node:fs/promises是异步的。在我们的案例里面,两个都能用。
await fs.mkdir(outDir, { recursive: true }); 里面{ recursive: true }表示如果有outDir就不重新创建,如果没有就创建。
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { VoiceService } from './voice.service.js';
import { Response } from 'express';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@Controller('voice')
export class VoiceController {
constructor(private readonly voiceService: VoiceService) {}
@Get('tts')
async tts(@Query('text') text: string) {
const audioBase64 = await this.voiceService.textToVoice(text);
//audioBase64是string
if (!audioBase64) {
return '';
}
const audioBuffer = Buffer.from(audioBase64, 'base64');
const outDir = path.resolve('./audio'); // 文件夹路径
const outPath = path.join(outDir, 'tts.mp3'); // 文件完整路径
await fs.mkdir(outDir, { recursive: true }); // ← 就加这一行! recursive: true ,没有audio就建,又就不管了
await fs.writeFile(outPath, audioBuffer);
await fs.writeFile(outPath, audioBuffer);
return '已保存成功!';
}
}
测试
测试代码: npm run start:dev
点开MP3播放即可。
2.2 流式文本合成语音-TTS
他也是是TTS(Text To Speech)文字转语音,这个例子的特殊之处在于,文本是流式的,那语音也是流式的。你生成多少文本,就读多少文本。
上面那个语音合成是生成好文本,再读出来。
流式文本语音是边生产边读。
为了测试流式语音方便,我把他的接口做成了get请求的。
实现tts-stream.controller.ts
下面信息是腾讯云获取的。
- appId: Number(this.configService.get('TENCENT_APP_ID')),
- secretId: this.configService.get('TENCENTCLOUD_SECRET_ID'),
- secretKey: this.configService.get('TENCENTCLOUD_SECRET_KEY'),
上一个案例,我们将获取到的音频写成文件存起来的代码写到了controller里面,这个案例我将这个代码放到了service里面。
import { Controller, Get, Res } from '@nestjs/common';
import * as path from 'path';
import { TtsService } from './tts-stream.service.js';
import { ConfigService } from '@nestjs/config';
@Controller('tts')
export class TtsController {
constructor(
private readonly ttsService: TtsService,
private readonly configService: ConfigService,
) {}
@Get('synthesize')
async synthesize(@Res() res: any) {
// ===== 参数定死 =====
const texts = [
'你好,欢迎使用流式语音合成。',
'我是你的语音小助手,你有任何需要都可以给我说',
]; // 必须以 。!?; 结尾
const codec: 'pcm' | 'mp3' = 'mp3';
const filename = 'demo';
const options = {
appId: Number(this.configService.get('TENCENT_APP_ID')),
secretId: this.configService.get('TENCENTCLOUD_SECRET_ID'),
secretKey: this.configService.get('TENCENTCLOUD_SECRET_KEY'),
codec,
};
const ext = codec === 'mp3' ? 'mp3' : 'wav';
const outputPath = path.join(process.cwd(), 'output', `${filename}.${ext}`);
res.setHeader('Content-Type', codec === 'mp3' ? 'audio/mpeg' : 'audio/pcm');
res.setHeader(
'Content-Disposition',
`inline; filename="${filename}.${ext}"`,
);
res.setHeader('Cache-Control', 'no-store'); // 防止浏览器缓存,改代码后能立刻生效
try {
await this.ttsService.synthesize(
options,
texts,
{
onAudio: (chunk: any) => res.write(chunk),
},
outputPath,
);
res.end();
} catch (err: any) {
if (!res.headersSent) res.status(500).end(err.message);
else res.destroy();
}
}
}
实现tts-stream.module.ts
import { Module } from '@nestjs/common';
import { TtsController } from './tts-stream.controller.js';
import { TtsService } from './tts-stream.service.js';
@Module({
controllers: [TtsController],
providers: [TtsService],
})
export class TtsModule {}
实现tts-stream.service.ts
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'node:fs';
import * as path from 'node:path';
import WebSocket from 'ws';
import { buildWsUrl, TtsOptions } from './tts-signature.js';
import * as crypto from 'crypto';
export interface TtsCallbacks {
onAudio?: (chunk: Buffer) => void; // 收到音频 binary 帧,可边收边播
onEvent?: (event: any) => void; // READY / FINAL / 心跳 / 时间戳
onReady?: () => void;
onFinal?: () => void;
onError?: (err: Error) => void;
onClose?: () => void;
}
/** 生成 44 字节 WAV 文件头(PCM 16bit 单声道) */
function buildWavHeader(
dataSize: number,
sampleRate = 16000,
channels = 1,
bitsPerSample = 16,
): Buffer {
const buf = Buffer.alloc(44);
buf.write('RIFF', 0);
buf.writeUInt32LE(36 + dataSize, 4);
buf.write('WAVE', 8);
buf.write('fmt ', 12);
buf.writeUInt32LE(16, 16); // fmt 块长度
buf.writeUInt16LE(1, 20); // 1 = PCM
buf.writeUInt16LE(channels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE((sampleRate * channels * bitsPerSample) / 8, 28); // 字节率
buf.writeUInt16LE((channels * bitsPerSample) / 8, 32); // 块对齐
buf.writeUInt16LE(bitsPerSample, 34);
buf.write('data', 36);
buf.writeUInt32LE(dataSize, 40);
return buf;
}
@Injectable()
export class TtsService {
private readonly logger = new Logger(TtsService.name);
/**
* 一次性合成:传入文本数组,自动逐句发送并收齐音频
* @param outputPath 可选。传了就同时把音频写到该文件(pcm→.wav,mp3→.mp3)
*/
async synthesize(
options: TtsOptions,
texts: string[],
callbacks: TtsCallbacks = {},
outputPath?: string,
): Promise<void> {
const url = buildWsUrl(options);
const ws = new WebSocket(url, { perMessageDeflate: false });
// ===== 写文件:准备 =====
const sampleRate = options.sampleRate ?? 16000;
const isPcm = (options.codec ?? 'pcm') === 'pcm';
let fileStream: fs.WriteStream | null = null;
let bytes = 0;
if (outputPath) {
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
fileStream = fs.createWriteStream(outputPath);
if (isPcm) fileStream.write(buildWavHeader(0, sampleRate)); // 先占位,收完回填
}
/** 音频帧统一出口:回调 + 落盘 */
const handleAudio = (chunk: Buffer) => {
callbacks.onAudio?.(chunk);
if (fileStream) {
bytes += chunk.length;
fileStream.write(chunk);
}
};
/** 收尾:补 WAV 头、关文件流 */
const finalizeFile = async () => {
if (!fileStream) return;
const stream = fileStream;
fileStream = null;
await new Promise<void>((resolve, reject) => {
stream.end((err?: Error | null) => (err ? reject(err) : resolve()));
});
if (isPcm) {
// 回填真实长度,否则播放器会认为文件损坏
const fh = await fs.promises.open(outputPath!, 'r+');
await fh.write(buildWavHeader(bytes, sampleRate), 0, 44, 0);
await fh.close();
}
this.logger.log(
`音频已写入 ${path.resolve(outputPath!)}(${bytes} 字节)`,
);
};
return new Promise<void>((resolve, reject) => {
let error: Error | null = null;
let settled = false;
const timer = setTimeout(() => {
this.logger.warn('30秒没收到结束信号,强制断开');
ws.close();
}, 30000);
const done = async () => {
clearTimeout(timer);
if (settled) return;
settled = true;
try {
await finalizeFile();
} catch (e) {
error = error ?? (e as Error);
}
callbacks.onClose?.();
if (error) reject(error);
else resolve();
};
ws.on('open', () => this.logger.log('WebSocket 已连接,等待 READY'));
ws.on('message', (data: any, isBinary?: boolean) => {
// 1. 统一归一化成 Buffer(兼容 Buffer / Buffer[] / ArrayBuffer / string)
let buf: Buffer;
if (Buffer.isBuffer(data)) buf = data;
else if (Array.isArray(data)) buf = Buffer.concat(data);
else if (data instanceof ArrayBuffer) buf = Buffer.from(data);
else buf = Buffer.from(String(data), 'utf8');
// 2. 用内容判断:JSON 事件帧一定以 `{"` 开头
const looksJson = buf[0] === 0x7b && buf[1] === 0x22;
const isTextFrame = isBinary === false || looksJson;
if (isTextFrame) {
let event: any;
try {
event = JSON.parse(buf.toString('utf8'));
} catch {
return;
}
this.logger.debug(`事件帧: ${buf.toString('utf8')}`); // 关键:看清服务端到底回什么
callbacks.onEvent?.(event);
if (event.code && event.code !== 0) {
error = new Error(`TTS 错误 ${event.code}: ${event.message}`);
callbacks.onError?.(error);
ws.close();
return;
}
if (event.ready === 1) {
this.logger.log('收到 READY,开始发送文本');
callbacks.onReady?.();
const sessionId = event.session_id;
texts.forEach((text) => {
ws.send(
JSON.stringify({
session_id: sessionId,
message_id: crypto.randomUUID(),
action: 'ACTION_SYNTHESIS',
data: text,
}),
);
});
ws.send(
JSON.stringify({
session_id: sessionId,
message_id: crypto.randomUUID(),
action: 'ACTION_COMPLETE',
data: '',
}),
);
}
if (event.final === 1) {
callbacks.onFinal?.();
ws.close();
}
return;
}
// 3. 到这里一定是音频帧
handleAudio(buf);
});
ws.on('error', (err) => {
console.error('[WS错误]', err); // ← 加这行
error = err;
callbacks.onError?.(err);
});
ws.on('close', () => {
void done();
});
});
}
}
实现tts-signature.ts
import * as crypto from 'crypto';
const HOST = 'tts.cloud.tencent.com/stream_wsv2';
export interface TtsOptions {
appId: number;
secretId: string;
secretKey: string;
voiceType?: number; // 音色 ID,默认 101001
volume?: number; // 音量 [-10, 10]
speed?: number; // 语速 [-2, 6]
sampleRate?: number; // 8000 / 16000 / 24000
codec?: 'pcm' | 'mp3';
enableSubtitle?: boolean;
emotionCategory?: string;
emotionIntensity?: number;
segmentRate?: number;
}
/** 签名:GET + 域名地址 + 请求参数,HMAC-SHA1 + base64 + urlencode */
export function generateSignature(
params: Record<string, string | number>,
secretKey: string,
): string {
const query = Object.keys(params)
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&');
const source = `GET${HOST}?${query}`;
const sig = crypto
.createHmac('sha1', secretKey)
.update(source)
.digest('base64');
return encodeURIComponent(sig);
}
/** 构建握手阶段 wss URL */
export function buildWsUrl(options: TtsOptions): string {
const timestamp = Math.floor(Date.now() / 1000);
const params: Record<string, string | number> = {
Action: 'TextToStreamAudioWSv2',
AppId: options.appId,
SecretId: options.secretId,
Timestamp: timestamp,
Expired: timestamp + 3600, // 有效期 1 小时,差须小于 90 天
SessionId: crypto.randomUUID().replace(/-/g, ''),
VoiceType: options.voiceType ?? 101001,
Volume: options.volume ?? 0,
Speed: options.speed ?? 0,
SampleRate: options.sampleRate ?? 16000,
Codec: options.codec ?? 'pcm',
EnableSubtitle: options.enableSubtitle ? 'True' : 'False',
};
if (options.emotionCategory) params.EmotionCategory = options.emotionCategory;
if (options.emotionIntensity !== undefined)
params.EmotionIntensity = options.emotionIntensity;
if (options.segmentRate !== undefined)
params.SegmentRate = options.segmentRate;
const signature = generateSignature(params, options.secretKey);
const query = Object.keys(params)
.sort()
.map(
(k) =>
`${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`,
)
.join('&');
console.log(`wss://${HOST}?${query}&Signature=${signature}`);
return `wss://${HOST}?${query}&Signature=${signature}`;
}
测试
流式语音的好处就是,在controller里面,你可以加入很多条信息,加多少,输出多少。
2.3 语音转化成文本-ASR/STT
语音识别 ASR(Automatic Speech Recognition),叫 STT (Speech To Text) 也可以,但 ASR 用的多一些。
他的api还是在tencentcloud-sdk-nodejs里面。
pnpm install tencentcloud-sdk-nodejs
实现 asr.controller.ts
import { AsrService } from './asr.service.js';
import { Controller, Get } from '@nestjs/common';
@Controller('asr')
export class AsrController {
constructor(private readonly asrService: AsrService) {}
@Get('asr')
async asr() {
const data = await this.asrService.voiceToText();
return data;
}
}
实现asr.module.ts
import { AsrService } from './asr.service.js';
import { Controller, Get } from '@nestjs/common';
@Controller('asr')
export class AsrController {
constructor(private readonly asrService: AsrService) {}
@Get('asr')
async asr() {
const data = await this.asrService.voiceToText();
return data;
}
}
实现asr.service.ts
import { Injectable } from '@nestjs/common';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
import { ConfigService } from '@nestjs/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
const AsrClient = tencentcloud.asr.v20190614.Client;
const AUDIO_FILE = path.join(process.cwd(), 'src', 'asr', 'demo.mp3');
@Injectable()
export class AsrService {
private client;
constructor(private readonly configService: ConfigService) {
const credential = {
secretId: this.configService.get('TENCENTCLOUD_SECRET_ID'),
secretKey: this.configService.get('TENCENTCLOUD_SECRET_KEY'),
};
const region = 'ap-guangzhou';
this.client = new AsrClient({
credential,
region,
profile: {
httpProfile: { reqMethod: 'POST', reqTimeout: 30 },
},
});
}
async voiceToText() {
const base64 = fs.readFileSync(AUDIO_FILE, 'base64');
const params = {
EngSerViceType: '16k_zh',
SourceType: 1,
Data: base64,
DataLen: Buffer.byteLength(base64),
VoiceFormat: 'mp3',
};
try {
const data = await this.client.SentenceRecognition(params);
console.log('识别结果:', data.Result);
return data.Result;
} catch (e) {
console.log(e);
}
}
}
测试
他会将上个案例里面生成的MP3文件里面的内容转化成text文件显示在页面上。
2.4 实现豆包语音模式回答问题
先生成一个简单的ai模块
nest g resource ai --no-spec
pnpm install @langchain/openai @langchain/core
实现 ai.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import type { Runnable } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(@Inject('CHAT_MODEL') model: ChatOpenAI) {
const prompt = PromptTemplate.fromTemplate(`请回答以下问题:{query}`);
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
async *streamChain(query: string): AsyncGenerator<string> {
const stream: any = await this.chain.stream({ query: query });
for await (const chunk of stream) {
yield chunk;
}
}
}
实现 ai.controller.ts
import { Controller, Get, Query, Sse } from '@nestjs/common';
import { from, map, Observable } from 'rxjs';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Sse('chat/stream')
chatStream(@Query('query') query: string): Observable<{ data: string }> {
return from(this.aiService.streamChain(query)).pipe(
map((chunk) => ({ data: chunk })),
);
}
}
实现 ai.module.ts
import { Module } from '@nestjs/common';
import { AiService } from './ai.service.js';
import { AiController } from './ai.controller.js';
import { ChatOpenAI } from '@langchain/openai';
import { ConfigService } from '@nestjs/config';
@Module({
controllers: [AiController],
providers: [
AiService,
{
provide: 'CHAT_MODEL',
useFactory: async (configService: ConfigService) => {
const model = new ChatOpenAI({
model: configService.get('MODEL_NAME'),
apiKey: configService.get('OPENAI_API_KEY'),
configuration: {
baseURL: configService.get('OPENAI_BASE_URL'),
},
});
return model;
},
inject: [ConfigService],
},
],
})
export class AiModule {}
测试
启动项目,访问接口。
2.5 实现前端录音功能
写html文件
录音这块用的是浏览器原生的 MediaRecorder API,整个流程就四步:拿权限 → 边录边切块 → 拼成 Blob → 上传。不需要第三方库。
当然你可以使用第三方库:xiangyuecn/Recorder
npm i recorder-core
具体使用看官方文档,此处不再赘述。我的原生代码如下:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ASR 录音上传测试</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei',
sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
line-height: 1.6;
}
button {
margin-right: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.status {
color: #666;
}
pre {
background: #f6f8fa;
border-radius: 6px;
padding: 12px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<h1>ASR 录音上传测试</h1>
<p>
点击「开始录音」并允许麦克风权限,点击「停止录音」后将录音上传到后端进行语音识别。
</p>
<button id="startBtn">开始录音</button>
<button id="stopBtn" disabled>停止录音</button>
<p class="status" id="status">状态:空闲</p>
<h3>识别结果</h3>
<pre id="result">(暂无)</pre>
<script>
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
let mediaRecorder = null;
let audioChunks = [];
let mediaStream = null;
const fileName = 'record.ogg';
function setStatus(text) {
statusEl.textContent = '状态:' + text;
}
async function startRecord() {
try {
audioChunks = [];
setStatus('正在请求麦克风权限...');
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setStatus('录音结束,正在上传...');
const blob = new Blob(audioChunks, {
type: mediaRecorder.mimeType || 'audio/ogg',
});
audioChunks = [];
await uploadAudio(blob);
};
mediaRecorder.start(250);
setStatus('录音中...');
startBtn.disabled = true;
stopBtn.disabled = false;
} catch (err) {
setStatus('录音失败:' + err.message);
stopRecordStream();
}
}
function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
startBtn.disabled = false;
stopBtn.disabled = true;
}
function stopRecordStream() {
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
startBtn.disabled = false;
stopBtn.disabled = true;
}
async function uploadAudio(blob) {
try {
const formData = new FormData();
formData.append('audio', blob, fileName);
const response = await fetch('/asr/asr', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败:HTTP ' + response.status);
}
const data = await response.json();
console.log(data, 6777);
resultEl.textContent = data.data || '(空结果)';
setStatus('识别完成');
} catch (err) {
setStatus('识别失败:' + err.message);
resultEl.textContent = '错误:' + err.message;
} finally {
stopRecordStream();
}
}
startBtn.addEventListener('click', startRecord);
stopBtn.addEventListener('click', stopRecord);
</script>
</body>
</html>
html访问接口
启动html后,有多种方式连接调用接口。
1.用live-server启动,启动以后(npx live-server public\asr.html ),地址变成了:http://127.0.0.1:8080/
此时,接口地址是:http://localhost:3000/ai/chat/stream?query=你说毛主席是谁
你会发现前后端url的端口号不一样,这样前端访问后端的时候就会有跨域的报错,解决办法:在nest项目的mian.ts里面添加 app.enableCors()。
而且你访问的接口地址要写全,不然会找不到。
2.利用@nestjs/serve-static,他的好处就是:
- 前后端用一个端口号,都是3000,不用再解决跨域问题。
- 而且访问接口的时候,我们写相对路径就好了,不用再带域名和端口号。
- nest项目启动以后,直接访问http://localhost:3000/index.html 就能进入前端页面,不需要在用live-server
我们使用@nestjs/serve-static
安装包
pnpm install @nestjs/serve-static
在app.module.ts里面注册一下
启动项目访问页面
npm run start:dev
连接asr
之前的asr读取MP3文件是写死的,现在我们有了前端代码,要他来读取录音文件。
我们修改一下asr.controller.ts,因为从前端录音过来的文件有可能是乱七八糟的东西,所以要用FileInterceptor处理下。
FileInterceptor 就是文件上传的"拆包工" 。
@UseInterceptors(FileInterceptor('audio')) 里面的audio和前端的 formData.append('audio', blob, fileName);的第一个参数保持一致,才能处理这个文件。
现在修改asr接口,之前的asr是get接口,现在需要改成post接口,修改地方如下:
注释掉的就是老代码
测试
启动项目
npm run start:dev
访问地址:http://localhost:3000/asr.html
2.6 调用大模型
修改前后端代码
想法很简单,就是当我拿到录音转成的文本以后,再调用大模型,让大模型回复我的问题。
主要添加代码如下:
JS代码
测试
2.7 大模型生成的东西用语音播放
页面很简单如图所示:
大模型回复生成以后,我点一下按钮,就能够给我朗读。
之前的“固定文本合成语音-tts”是将合成的文本写成MP3文件,存到了目录里面进行播放。
现在是点击按钮,直接播放。所以,前端需要一个播放器
将生成的接收到的信息传给player的src就好了。具体代码如下:
实现 html
实现voice.controller.ts
测试
点击语音播放,自动播放大模型搜出来的答案。
2.8 连生成文本带播放
上面这个例子是点击了小喇叭才能阅读,现在我想要的是,大模型流式输出多少,就能读多少。
连生成带读的效果。
先看看tts-stream.controller.ts里面,他的入参是一个数组,也就是我们只需要将接口返回的数据收集成数组就好了,但是阅读的时候是一句一句读的,生成的时候却不是,如图所示
从图中看到,他是一些没有规律的字符串,我们不能直接读,得等,等一句话成形以后,再读。所以在前端代码里面我们要去搜索标点符号。生成一句话,请求一次tts-stream接口。一共请求了4次。
实现html
代码块里面有一些注释的代码,是没有处理直接调用tts-stream接口的代码,和后面处理后一句一句读的进行对比。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ASR 录音上传测试</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei',
sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
line-height: 1.6;
}
button {
margin-right: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.status {
color: #666;
}
pre {
background: #f6f8fa;
border-radius: 6px;
padding: 12px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<h1>ASR 录音上传测试</h1>
<p>
点击「开始录音」并允许麦克风权限,点击「停止录音」后将录音上传到后端进行语音识别。
</p>
<button id="startBtn">开始录音</button>
<button id="stopBtn" disabled>停止录音</button>
<button id="clearBtn">清理聊天记录</button>
<p class="status" id="status">状态:空闲</p>
<h3>识别结果</h3>
<p>先识别出结果,然后再调用大模型回答你的问题</p>
<pre id="result">(暂无)</pre>
<h3 style="display: inline-block">大模型的回复</h3>
<button id="voiceBtn">语音播放</button>
<span style="color: red">生成好以后才能播放呀</span>
<audio id="player" hidden></audio>
<pre id="response">(暂无)</pre>
<script>
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const clearBtn = document.getElementById('clearBtn');
const voiceBtn = document.getElementById('voiceBtn');
const player = document.getElementById('player');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const responseE1 = document.getElementById('response');
let mediaRecorder = null;
let audioChunks = [];
let mediaStream = null;
const fileName = 'record.ogg';
function setStatus(text) {
statusEl.textContent = '状态:' + text;
}
//开启录音功能
async function startRecord() {
try {
audioChunks = [];
setStatus('正在请求麦克风权限...');
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setStatus('录音结束,正在上传...');
const blob = new Blob(audioChunks, {
type: mediaRecorder.mimeType || 'audio/ogg',
});
audioChunks = [];
const text = await uploadAudio(blob);
await askQuestion(text);
};
mediaRecorder.start(250);
setStatus('录音中...');
startBtn.disabled = true;
stopBtn.disabled = false;
} catch (err) {
setStatus('录音失败:' + err.message);
stopRecordStream();
}
}
function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
startBtn.disabled = false;
stopBtn.disabled = true;
}
function stopRecordStream() {
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
startBtn.disabled = false;
stopBtn.disabled = true;
}
async function uploadAudio(blob) {
try {
const formData = new FormData();
formData.append('audio', blob, fileName);
const response = await fetch('/asr/asr', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败:HTTP ' + response.status);
}
const data = await response.json();
resultEl.textContent = data.data || '(空结果)';
setStatus('识别完成');
return data.data || '';
} catch (err) {
setStatus('识别失败:' + err.message);
resultEl.textContent = '错误:' + err.message;
} finally {
stopRecordStream();
}
}
// async function autoTtsStream(text) {
// const res = await fetch('/tts/synthesize', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ texts: text }),
// });
// const blob = await res.blob();
// player.src = URL.createObjectURL(blob);
// await player.play();
// }
// function askQuestion(text) {
// // 参数只能走 URL query,EventSource 发不了 body
// const url = `/ai/chat/stream?query=${encodeURIComponent(text)}`;
// const es = new EventSource(url);
// const response = [];
// es.onmessage = (e) => {
// const chunk = JSON.parse(e.data); // 后端 map 出来是什么结构就怎么取
// console.log(chunk.content, 7777);
// if (chunk.content) {
// responseE1.textContent += chunk.content;
// response.push(chunk.content);
// autoTtsStream(response);
// } // 追加到页面
// if (chunk.done) es.close();
// };
// es.onerror = (e) => {
// console.error('SSE 出错或已结束', e);
// es.close(); // 必须关,否则浏览器会不断重连
// };
// }
//上述代码的知名问题是不断重复读,把浏览器干死
// ========== 朗读状态 ==========
let readBuffer = ''; // 攒文字
let readQueue = []; // 待播句子队列
let isReading = false; // 是否正在播(防并发)
let sseDone = false; // 大模型是否说完
let currentEs = null; // 当前 SSE 连接
/** 把新文字攒起来,凑够一句就切出来 */
function feedForRead(text) {
readBuffer += text;
// 找最后一个句末标点
let idx = -1;
for (const p of ['。', '!', '?', ';', '\n', '!', '?']) {
const i = readBuffer.lastIndexOf(p);
if (i > idx) idx = i;
}
if (idx === -1) return; // 还没凑够一句
const sentence = readBuffer.slice(0, idx + 1).trim();
readBuffer = readBuffer.slice(idx + 1);
if (sentence) {
console.log('切出一句:', sentence);
readQueue.push(sentence);
pumpQueue();
}
}
/** 播放队列:一次只播一段,播完自动取下一句 */
async function pumpQueue() {
if (isReading) return; // 正在播,直接返回(靠 finally 递归)
if (readQueue.length === 0) return;
isReading = true;
const sentence = readQueue.shift();
let url = null;
try {
setStatus('朗读中:' + sentence.slice(0, 15) + '...');
const res = await fetch('/tts/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ texts: [sentence], codec: 'mp3' }), // ← 只传一句
});
if (!res.ok) throw new Error('TTS HTTP ' + res.status);
const blob = await res.blob();
url = URL.createObjectURL(blob);
await playOne(url);
} catch (err) {
console.warn('朗读失败:', sentence, err);
} finally {
if (url) URL.revokeObjectURL(url); // 释放内存
isReading = false;
pumpQueue(); // 播完继续下一句
}
}
/** 播放单段,返回 Promise */
function playOne(url) {
return new Promise((resolve) => {
player.onended = resolve;
player.onerror = resolve; // 出错也继续,别卡死队列
player.src = url;
player.play().catch(resolve);
});
}
/** 收尾:把残留的半句也播掉 */
function finishReading() {
const rest = readBuffer.trim();
readBuffer = '';
sseDone = true;
if (rest) {
readQueue.push(rest);
pumpQueue();
}
}
/** 停止朗读 */
function stopReading() {
readQueue.length = 0;
readBuffer = '';
sseDone = false;
try {
player.pause();
player.removeAttribute('src');
} catch {}
if (currentEs) currentEs.close();
}
// ========== SSE 接收 ==========
function askQuestion(text) {
if (!text) return;
responseE1.textContent = '';
stopReading(); // 清掉上一轮
const url = `/ai/chat/stream?query=${encodeURIComponent(text)}`;
const es = new EventSource(url);
currentEs = es;
es.onmessage = (e) => {
let chunk;
try {
chunk = JSON.parse(e.data);
} catch {
return;
}
if (!chunk.content) return;
responseE1.textContent += chunk.content; // 文字照常逐字显示
feedForRead(chunk.content); // 同时喂给朗读器
if (chunk.done) {
es.close();
finishReading();
}
};
es.onerror = () => {
es.close(); // 必须关,否则自动重连
if (responseE1.textContent.trim()) finishReading(); // 后端不推 done 也能收尾
};
}
// clearRecord 里加停止朗读
function clearRecord() {
stopReading(); // ← 加这行
resultEl.textContent = '(暂无)';
responseE1.textContent = '(暂无)';
setStatus('空闲'); // ← 原来是 '状态:空闲',会显示两遍"状态:"
}
async function playTts() {
const text = responseE1.textContent.trim();
if (!text || text === '(暂无)') {
setStatus('还没有内容可以播');
return;
}
voiceBtn.disabled = true;
setStatus('正在生成语音...');
// 加时间戳防止浏览器缓存旧音频
player.src = `/voice/tts?text=${encodeURIComponent(text)}&t=${Date.now()}`;
try {
await player.play();
setStatus('播放中');
} catch (err) {
setStatus('播放失败:' + err.message);
} finally {
voiceBtn.disabled = false;
}
}
startBtn.addEventListener('click', startRecord);
stopBtn.addEventListener('click', stopRecord);
clearBtn.addEventListener('click', clearRecord);
voiceBtn.addEventListener('click', playTts);
</script>
</body>
</html>
实现tts-stream.controller.ts
把原来的get接口改成post接口,然后把原来存数音频的部分删掉。
import { Body, Controller, Post, Res } from '@nestjs/common';
import * as path from 'path';
import { TtsService } from './tts-stream.service.js';
import { ConfigService } from '@nestjs/config';
interface SynthesizeDto {
texts: string[]; // 必须以 。!?; 结尾
codec?: 'pcm' | 'mp3'; // 可选,默认 mp3
filename?: string; // 可选,默认 demo
}
@Controller('tts')
export class TtsController {
constructor(
private readonly ttsService: TtsService,
private readonly configService: ConfigService,
) {}
@Post('synthesize')
async synthesize(@Body() body: SynthesizeDto, @Res() res: any) {
// ===== 参数定死 =====
// const texts = [
// '你好,欢迎使用流式语音合成。',
// '我是你的语音小助手,你有任何需要都可以给我说',
// ]; // 必须以 。!?; 结尾
const texts = body?.texts;
if (!Array.isArray(texts) || texts.length === 0) {
return res
.status(400)
.json({ message: 'texts 不能为空,且必须是字符串数组' });
}
const codec: 'pcm' | 'mp3' = body.codec ?? 'mp3';
const filename = body.filename ?? 'demo';
const options = {
appId: Number(this.configService.get('TENCENT_APP_ID')),
secretId: this.configService.get('TENCENTCLOUD_SECRET_ID'),
secretKey: this.configService.get('TENCENTCLOUD_SECRET_KEY'),
codec,
};
const ext = codec === 'mp3' ? 'mp3' : 'wav';
//const outputPath = path.join(process.cwd(), 'output', `${filename}.${ext}`);
res.setHeader('Content-Type', codec === 'mp3' ? 'audio/mpeg' : 'audio/pcm');
res.setHeader(
'Content-Disposition',
`inline; filename="${filename}.${ext}"`,
);
res.setHeader('Cache-Control', 'no-store'); // 防止浏览器缓存,改代码后能立刻生效
try {
// await this.ttsService.synthesize(
// options,
// texts,
// {
// onAudio: (chunk: any) => res.write(chunk),
// },
// outputPath,
// );
//outputPath会存数据到磁盘,不写就不存入
await this.ttsService.synthesize(options, texts, {
onAudio: (chunk: any) => res.write(chunk),
});
res.end();
} catch (err: any) {
if (!res.headersSent) res.status(500).end(err.message);
else res.destroy();
}
}
}
测试
点击停止录音,数据进入大模型开始回答问题,实现边输出答案,边阅读的功能。
2.9 是否需要使用@nestjs/event-emitter
@nestjs/event-emitter 是 NestJS 官方的事件总线,让代码之间可以"喊一嗓子",而不用互相认识。
现在大模型的输出用的是SSE,实现了打印机效果。
流式文本合成语音使用的又是websocket。此时为了解决这个问题。
如果我们在一个接口里面,完成上述两个问题,那么就需要使用@nestjs/event-emitter,去调度事件。