ai agent---语音交互

0 阅读12分钟

大模型本身是个只会读字的书呆子——它不认识声音,只会处理文字。所以你想跟它"说话",必须前后各装一个翻译官。

image.png

大模型语音交互的技术是: STT(Speech To Text)语音转文字,TTS(Text To Speech)文字转语音

你微信里那个"按住说话,松开发送"、手机输入法的语音输入,全是 STT

导航里"前方 500 米左转"、抖音的"系统配音"、你说的小爱同学,全是 TTS

你开口说话
   │  🎤 声波
   ↓
【STT】速记员:把声音敲成字
   │  "一分钟后提醒我喝水"
   ↓
【大模型】书呆子:读字、思考、调工具
   │  "好的,已设置提醒"
   ↓
【TTS】配音员:把字念成声音
   │  🔊 音频
   ↓
你听到回答

一.获取apikey

进入腾讯云语音技术:console.cloud.tencent.com/asr/resourc…

领取语音合成资源包和语音识别资源包

image.png

然后进入访问管理,获取SecretIdSecretKey image.png

image.png

拿到 SecretId 和 SecretKey 以后,我们使用网站的ai助手就能够直接拿到使用案例。如图所示:

image.png

二.创建项目

 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改到下面即可

image.png

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

image.png

image.png

点开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'),

image.png

上一个案例,我们将获取到的音频写成文件存起来的代码写到了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}`;
}

测试

image.png

流式语音的好处就是,在controller里面,你可以加入很多条信息,加多少,输出多少。

image.png

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);
    }
  }
}

image.png

测试

他会将上个案例里面生成的MP3文件里面的内容转化成text文件显示在页面上。

image.png

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 {}

测试

启动项目,访问接口。

image.png

2.5 实现前端录音功能

写html文件

录音这块用的是浏览器原生的 MediaRecorder API,整个流程就四步:拿权限 → 边录边切块 → 拼成 Blob → 上传。不需要第三方库。

image.png 当然你可以使用第三方库: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()。

而且你访问的接口地址要写全,不然会找不到。

image.png

2.利用@nestjs/serve-static,他的好处就是:

  • 前后端用一个端口号,都是3000,不用再解决跨域问题。
  • 而且访问接口的时候,我们写相对路径就好了,不用再带域名和端口号。
  • nest项目启动以后,直接访问http://localhost:3000/index.html 就能进入前端页面,不需要在用live-server

我们使用@nestjs/serve-static

安装包

pnpm install @nestjs/serve-static

在app.module.ts里面注册一下

image.png

启动项目访问页面

npm run start:dev

image.png

连接asr

之前的asr读取MP3文件是写死的,现在我们有了前端代码,要他来读取录音文件。

我们修改一下asr.controller.ts,因为从前端录音过来的文件有可能是乱七八糟的东西,所以要用FileInterceptor处理下。

image.png FileInterceptor 就是文件上传的"拆包工"

@UseInterceptors(FileInterceptor('audio')) 里面的audio和前端的 formData.append('audio', blob, fileName);的第一个参数保持一致,才能处理这个文件。

image.png

现在修改asr接口,之前的asr是get接口,现在需要改成post接口,修改地方如下:

image.png

image.png 注释掉的就是老代码

测试

启动项目

npm run start:dev

访问地址:http://localhost:3000/asr.html

image.png

2.6 调用大模型

修改前后端代码

想法很简单,就是当我拿到录音转成的文本以后,再调用大模型,让大模型回复我的问题。

image.png

主要添加代码如下:

image.png

JS代码 image.png

image.png

测试

image.png

2.7 大模型生成的东西用语音播放

页面很简单如图所示:

大模型回复生成以后,我点一下按钮,就能够给我朗读。 image.png

之前的“固定文本合成语音-tts”是将合成的文本写成MP3文件,存到了目录里面进行播放。

现在是点击按钮,直接播放。所以,前端需要一个播放器

将生成的接收到的信息传给player的src就好了。具体代码如下:

实现 html

image.png

image.png

实现voice.controller.ts

image.png

测试

点击语音播放,自动播放大模型搜出来的答案。

image.png

2.8 连生成文本带播放

上面这个例子是点击了小喇叭才能阅读,现在我想要的是,大模型流式输出多少,就能读多少。

连生成带读的效果。

先看看tts-stream.controller.ts里面,他的入参是一个数组,也就是我们只需要将接口返回的数据收集成数组就好了,但是阅读的时候是一句一句读的,生成的时候却不是,如图所示

image.png

image.png

从图中看到,他是一些没有规律的字符串,我们不能直接读,得等,等一句话成形以后,再读。所以在前端代码里面我们要去搜索标点符号。生成一句话,请求一次tts-stream接口。一共请求了4次。

image.png

image.png

实现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();
    }
  }
}

测试

点击停止录音,数据进入大模型开始回答问题,实现边输出答案,边阅读的功能。

image.png

2.9 是否需要使用@nestjs/event-emitter

@nestjs/event-emitter 是 NestJS 官方的事件总线,让代码之间可以"喊一嗓子",而不用互相认识。

image.png

image.png

现在大模型的输出用的是SSE,实现了打印机效果。

流式文本合成语音使用的又是websocket。此时为了解决这个问题。

如果我们在一个接口里面,完成上述两个问题,那么就需要使用@nestjs/event-emitter,去调度事件。