加载中
🤖
AI审核中

通意千应语音播放(TTS)技术方案

  Java   69分钟   106浏览   0评论
AI
AI智能摘要
正在分析文章内容

适用场景:AI 对话、智能客服、语音播报等实时流式语音交互场景
核心策略:异步分段合成 + 流式预加载 + 语义级文本分割

1. 方案概述

1.1 背景与挑战

在 AI 聊天系统中,将大模型生成的文本回复实时转换为语音播放,面临三个核心挑战:

挑战 说明 本方案对策
延迟高 长文本一次性合成耗时长,用户等待久 首段优先合成,后续段后台预加载
断句生硬 机械截断导致语义破碎 基于标点层级的智能文本分割
资源浪费 重复播放重复合成 多级缓存机制(内存 + 浏览器缓存)

1.2 功能特性

  • 流式播放:首段合成完成即播放,无需等待全文
  • 智能分句:三级分割策略(句末 > 逗号 > 强制截断)
  • 无缝衔接:利用前段播放时间预加载后段
  • 播放控制:支持播放 / 暂停 / 继续 / 停止 / 进度重置
  • 会话隔离:切换会话自动终止当前播放流
  • 异常自愈:合成失败自动重试,超限自动降级

2. 系统架构

2.1 技术栈

层级 技术选型 职责
前端 Vue 3 + Composition API UI 交互、音频调度、状态管理
前端音频引擎 HTML5 Audio API + Web Audio API 音频播放、进度控制、音量调节
后端 Spring Boot 3.x + Java 17 文本分段、Token 代理、流量控制
TTS 服务 百度智能云语音合成 API 语音合成
缓存 Caffeine(本地)+ Redis(分布式) Token 缓存、音频缓存

2.2 架构图

2.3 核心时序

3. 后端实现

3.1 配置类

使用 @ConfigurationProperties 实现类型安全的外部化配置:

@Data
@Component
@ConfigurationProperties(prefix = "baidu.tts")
public class BaiduTTSProperties {
    /** API Key */
    private String apiKey;
    /** Secret Key */
    private String secretKey;
    /** 发音人:0=度小美(女声), 1=度小宇(男声), 3=度逍遥(男声), 4=度丫丫(女声) */
    private Integer per = 0;
    /** 语速:0-15,默认 5 */
    private Integer spd = 5;
    /** 音调:0-15,默认 5 */
    private Integer pit = 5;
    /** 音量:0-15,默认 5 */
    private Integer vol = 5;
    /** 音频格式:3=mp3, 4=pcm-16k, 5=pcm-8k, 6=wav */
    private Integer aue = 3;
    /** 是否启用 TTS */
    private boolean enabled = false;
    /** 单段最大字符数(百度限制 512,留余量) */
    private Integer maxSegmentLength = 500;
}

3.2 文本分段算法

采用三级回退策略,确保语义完整:

@Component
public class TextSegmenter {

    /** 一级分隔符:句子结束 */
    private static final String PRIMARY_DELIMITERS = "。!?.!?";
    /** 二级分隔符:短停顿 */
    private static final String SECONDARY_DELIMITERS = ",、,;";

    /**
     * 智能文本分段
     * @param text 原始文本
     * @param maxLength 最大段长(建议 300-500)
     */
    public List<String> split(String text, int maxLength) {
        List<String> segments = new ArrayList<>();
        if (text == null || text.isEmpty()) {
            return segments;
        }

        // 去除 Markdown 标记、URL 等干扰内容(可选)
        String cleanText = cleanMarkdown(text);

        int start = 0;
        while (start < cleanText.length()) {
            if (cleanText.length() - start <= maxLength) {
                segments.add(cleanText.substring(start).trim());
                break;
            }

            int end = findBestSplitPoint(cleanText, start, maxLength);
            String segment = cleanText.substring(start, end).trim();
            if (!segment.isEmpty()) {
                segments.add(segment);
            }
            start = end;
        }
        return segments;
    }

    /**
     * 查找最佳分割点:优先句末 > 逗号 > 强制截断
     */
    private int findBestSplitPoint(String text, int start, int maxLength) {
        int candidate = start + maxLength;

        // 一级:在 maxLength 前找最后一个句末标点
        for (int i = candidate - 1; i > start; i--) {
            if (PRIMARY_DELIMITERS.indexOf(text.charAt(i)) >= 0) {
                return i + 1;
            }
        }

        // 二级:找逗号类标点(至少保留 20 字符,避免段过短)
        int minLength = Math.min(20, maxLength / 2);
        for (int i = candidate - 1; i > start + minLength; i--) {
            if (SECONDARY_DELIMITERS.indexOf(text.charAt(i)) >= 0) {
                return i + 1;
            }
        }

        // 三级:强制截断(但避免截断英文单词或数字)
        return findSafeTruncatePoint(text, start, candidate);
    }

    /**
     * 安全截断:避免在英文单词或数字中间截断
     */
    private int findSafeTruncatePoint(String text, int start, int candidate) {
        if (candidate >= text.length()) return text.length();

        // 如果截断点是字母或数字,向前回退到非字母数字处
        while (candidate > start + 10 && 
               Character.isLetterOrDigit(text.charAt(candidate - 1))) {
            candidate--;
        }
        return candidate;
    }

    private String cleanMarkdown(String text) {
        // 简单去除 Markdown 链接,保留文本
        return text.replaceAll("\\[(.*?)]\\(.*?\\)", "$1")
                   .replaceAll("[#*`>]", "");
    }
}

3.3 Token 管理(带缓存)

百度 TTS 需要 Access Token,有效期通常为 30 天,应集中管理避免重复获取:

@Service
@Slf4j
public class BaiduTokenManager {

    @Autowired
    private BaiduTTSProperties props;

    /** 本地缓存,Token 提前 5 分钟过期 */
    private final Cache<String, String> tokenCache = Caffeine.newBuilder()
        .expireAfterWrite(25, TimeUnit.MINUTES)
        .build();

    private static final String TOKEN_URL = 
        "https://aip.baidubce.com/oauth/2.0/token";

    public String getAccessToken() {
        String cached = tokenCache.getIfPresent("baidu_tts_token");
        if (cached != null) {
            return cached;
        }

        synchronized (this) {
            // 双重检查
            cached = tokenCache.getIfPresent("baidu_tts_token");
            if (cached != null) return cached;

            String token = fetchTokenFromRemote();
            tokenCache.put("baidu_tts_token", token);
            return token;
        }
    }

    private String fetchTokenFromRemote() {
        String url = TOKEN_URL + "?grant_type=client_credentials" +
            "&client_id=" + props.getApiKey() +
            "&client_secret=" + props.getSecretKey();

        try {
            RestTemplate rest = new RestTemplate();
            ResponseEntity<Map> response = rest.postForEntity(url, null, Map.class);
            Map body = response.getBody();
            if (body == null || body.get("access_token") == null) {
                throw new RuntimeException("获取百度 Token 失败: " + body);
            }
            return (String) body.get("access_token");
        } catch (Exception e) {
            log.error("获取百度 TTS Token 异常", e);
            throw new RuntimeException("语音服务暂不可用", e);
        }
    }
}

3.4 单段合成服务

@Service
@Slf4j
public class TTSService {

    @Autowired
    private BaiduTTSProperties props;
    @Autowired
    private BaiduTokenManager tokenManager;

    private static final String TTS_URL = 
        "https://tsn.baidu.com/text2audio";

    /**
     * 合成单段文本
     * @return Base64 编码的音频数据
     */
    public TTSResult synthesize(String text) {
        if (!props.isEnabled()) {
            throw new IllegalStateException("TTS 服务未启用");
        }

        String token = tokenManager.getAccessToken();

        // 构建请求参数
        Map<String, Object> params = new HashMap<>();
        params.put("tex", UriUtils.encode(text, StandardCharsets.UTF_8));
        params.put("tok", token);
        params.put("cuid", generateCuid()); // 设备唯一标识
        params.put("ctp", 1);
        params.put("lan", "zh");
        params.put("spd", props.getSpd());
        params.put("pit", props.getPit());
        params.put("vol", props.getVol());
        params.put("per", props.getPer());
        params.put("aue", props.getAue());

        try {
            RestTemplate rest = new RestTemplate();
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

            String body = params.entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("&"));

            HttpEntity<String> request = new HttpEntity<>(body, headers);
            ResponseEntity<byte[]> response = rest.postForEntity(
                TTS_URL, request, byte[].class);

            byte[] audioBytes = response.getBody();
            if (audioBytes == null || audioBytes.length == 0) {
                throw new RuntimeException("合成返回空数据");
            }

            // 百度成功时返回音频二进制,失败时返回 JSON(以 "err" 开头)
            if (audioBytes.length < 100 && new String(audioBytes).contains("err")) {
                String error = new String(audioBytes);
                log.error("百度 TTS 合成错误: {}", error);
                throw new RuntimeException("语音合成失败: " + error);
            }

            String base64Audio = Base64.getEncoder().encodeToString(audioBytes);

            return TTSResult.builder()
                .audioData(base64Audio)
                .format(getAudioFormat(props.getAue()))
                .duration(estimateDuration(text, props.getSpd())) // 预估时长
                .build();

        } catch (Exception e) {
            log.error("TTS 合成异常, text={}", text.substring(0, Math.min(50, text.length())), e);
            throw new RuntimeException("语音合成服务异常", e);
        }
    }

    private String generateCuid() {
        // 使用服务器标识 + 随机数,确保唯一性
        return "server_" + System.currentTimeMillis();
    }

    private String getAudioFormat(int aue) {
        return switch (aue) {
            case 3 -> "mp3";
            case 4, 5 -> "pcm";
            case 6 -> "wav";
            default -> "mp3";
        };
    }

    /**
     * 预估音频时长(秒),用于前端进度条
     */
    private int estimateDuration(String text, int spd) {
        // 粗略估算:中文字符约 0.3s/字,spd 每增加 1 减少约 5%
        double baseTime = text.length() * 0.3;
        double speedFactor = 1 - (spd - 5) * 0.05;
        return (int) Math.ceil(baseTime * speedFactor);
    }
}

3.5 控制器层

@RestController
@RequestMapping("/api/tts")
@RequiredArgsConstructor
public class TTSController {

    private final TextSegmenter segmenter;
    private final TTSService ttsService;
    private final BaiduTTSProperties props;

    @GetMapping("/config")
    public Result<TTSConfigVO> getConfig() {
        TTSConfigVO vo = new TTSConfigVO();
        vo.setEnabled(props.isEnabled());
        vo.setVoices(List.of(
            new VoiceVO(0, "度小美", "女声", "标准"),
            new VoiceVO(1, "度小宇", "男声", "标准"),
            new VoiceVO(3, "度逍遥", "男声", "情感"),
            new VoiceVO(4, "度丫丫", "女声", "童声")
        ));
        vo.setDefaultSpd(props.getSpd());
        vo.setDefaultPit(props.getPit());
        vo.setDefaultVol(props.getVol());
        return Result.success(vo);
    }

    @PostMapping("/segment")
    public Result<SegmentVO> segmentText(@RequestBody @Valid SegmentRequest request) {
        List<String> segments = segmenter.split(
            request.getText(), 
            props.getMaxSegmentLength()
        );
        SegmentVO vo = new SegmentVO();
        vo.setSegments(segments);
        vo.setTotalSegments(segments.size());
        vo.setTotalChars(request.getText().length());
        return Result.success(vo);
    }

    @PostMapping("/synthesize")
    public Result<TTSResult> synthesize(@RequestBody @Valid SynthesizeRequest request) {
        // 单段长度校验
        if (request.getText().length() > props.getMaxSegmentLength()) {
            return Result.fail("单段文本过长,请先调用分段接口");
        }
        TTSResult result = ttsService.synthesize(request.getText());
        return Result.success(result);
    }
}

4. 前端实现

4.1 语音播放器核心类

采用 State Machine(状态机) 管理播放生命周期:

// types.ts
export type PlayerState = 
  | 'idle'      // 空闲
  | 'loading'   // 加载中(首段合成)
  | 'buffering' // 缓冲中(后续段合成)
  | 'playing'   // 播放中
  | 'paused'    // 已暂停
  | 'ended'     // 已结束
  | 'error';    // 错误

export interface TTSResult {
  audioData: string;  // Base64
  format: string;     // mp3 / wav
  duration: number;   // 预估时长(秒)
}

export interface SegmentInfo {
  text: string;
  index: number;
  audio?: TTSResult;
  status: 'pending' | 'loading' | 'ready' | 'error';
}
// VoicePlayer.ts
export class VoicePlayer extends EventTarget {
  // 状态
  private _state: PlayerState = 'idle';
  private currentMessageId: string | null = null;
  private segments: SegmentInfo[] = [];
  private currentIndex: number = 0;

  // 音频对象
  private audioContext: AudioContext | null = null;
  private currentAudio: HTMLAudioElement | null = null;

  // 缓存与并发控制
  private audioCache = new Map<number, TTSResult>();
  private pendingRequests = new Set<number>();
  private abortController: AbortController | null = null;

  // 配置
  private readonly API_BASE = '/api/tts';

  constructor() {
    super();
    // 监听页面可见性变化,后台时暂停(可选策略)
    document.addEventListener('visibilitychange', () => {
      if (document.hidden && this._state === 'playing') {
        this.pause();
      }
    });
  }

  get state(): PlayerState {
    return this._state;
  }

  // ==================== 公共控制接口 ====================

  /**
   * 切换播放状态(入口方法)
   */
  async toggle(messageId: string, text: string): Promise<void> {
    if (this.currentMessageId === messageId) {
      // 同一消息:根据当前状态切换
      switch (this._state) {
        case 'playing':
          await this.pause();
          return;
        case 'paused':
          await this.resume();
          return;
        case 'loading':
        case 'buffering':
          this.stop(); // 取消加载
          return;
        default:
          await this.play(messageId, text);
          return;
      }
    }

    // 不同消息:先停止,再播放新消息
    this.stop();
    await this.play(messageId, text);
  }

  /**
   * 开始播放流程
   */
  async play(messageId: string, text: string): Promise<void> {
    try {
      this.currentMessageId = messageId;
      this._setState('loading');

      // 1. 文本分段
      const segmentData = await this.fetchSegments(text);
      this.segments = segmentData.segments.map((text: string, idx: number) => ({
        text,
        index: idx,
        status: 'pending'
      }));

      // 2. 合成并播放第一段
      this.currentIndex = 0;
      await this.synthesizeAndPlay(0);

      // 3. 后台预加载后续段
      this.preloadRemaining();

    } catch (error) {
      this._setState('error');
      console.error('[VoicePlayer] 播放失败:', error);
      this.dispatchEvent(new CustomEvent('error', { detail: error }));
    }
  }

  async pause(): Promise<void> {
    if (this.currentAudio && this._state === 'playing') {
      this.currentAudio.pause();
      this._setState('paused');
    }
  }

  async resume(): Promise<void> {
    if (this.currentAudio && this._state === 'paused') {
      await this.currentAudio.play();
      this._setState('playing');
    }
  }

  stop(): void {
    // 取消所有进行中的请求
    if (this.abortController) {
      this.abortController.abort();
      this.abortController = null;
    }

    if (this.currentAudio) {
      this.currentAudio.pause();
      this.currentAudio.src = '';
      this.currentAudio = null;
    }

    this.pendingRequests.clear();
    this.currentMessageId = null;
    this.currentIndex = 0;
    this.segments = [];
    this._setState('idle');
  }

  // ==================== 核心播放逻辑 ====================

  /**
   * 合成指定段并播放
   */
  private async synthesizeAndPlay(index: number): Promise<void> {
    if (index >= this.segments.length) {
      this._setState('ended');
      this.dispatchEvent(new CustomEvent('ended'));
      return;
    }

    this.currentIndex = index;

    // 获取音频数据(优先缓存)
    const audioData = await this.getSegmentAudio(index);
    if (!audioData) {
      throw new Error(`第 ${index} 段音频获取失败`);
    }

    // 播放
    await this.playAudio(index, audioData);
  }

  /**
   * 获取段音频(带缓存和去重)
   */
  private async getSegmentAudio(index: number): Promise<TTSResult | null> {
    // 1. 检查内存缓存
    if (this.audioCache.has(index)) {
      return this.audioCache.get(index)!;
    }

    // 2. 检查是否已有请求在进行
    if (this.pendingRequests.has(index)) {
      // 等待已有请求完成
      while (this.pendingRequests.has(index)) {
        await this.delay(50);
      }
      return this.audioCache.get(index) || null;
    }

    // 3. 发起新请求
    this.pendingRequests.add(index);
    this.segments[index].status = 'loading';

    try {
      const result = await this.fetchSynthesize(this.segments[index].text);
      this.audioCache.set(index, result);
      this.segments[index].audio = result;
      this.segments[index].status = 'ready';
      return result;
    } catch (error) {
      this.segments[index].status = 'error';
      console.error(`[VoicePlayer] 第 ${index} 段合成失败`, error);
      return null;
    } finally {
      this.pendingRequests.delete(index);
    }
  }

  /**
   * 播放音频段
   */
  private async playAudio(index: number, audioResult: TTSResult): Promise<void> {
    return new Promise((resolve, reject) => {
      // Base64 转 Blob URL
      const blob = this.base64ToBlob(audioResult.audioData, 
        `audio/${audioResult.format}`);
      const url = URL.createObjectURL(blob);

      this.currentAudio = new Audio(url);

      // 绑定事件
      this.currentAudio.addEventListener('play', () => {
        this._setState('playing');
      });

      this.currentAudio.addEventListener('ended', () => {
        URL.revokeObjectURL(url); // 释放内存
        this.cleanupAudio();
        resolve();
        // 自动播放下一段
        this.synthesizeAndPlay(index + 1);
      });

      this.currentAudio.addEventListener('error', (e) => {
        URL.revokeObjectURL(url);
        reject(new Error('音频播放失败'));
      });

      // 播放
      this.currentAudio.play().catch(reject);
    });
  }

  /**
   * 后台预加载剩余段
   */
  private preloadRemaining(): void {
    // 从当前播放段的下一段开始,预加载 2 段
    const preloadCount = 2;
    for (let i = 1; i <= preloadCount; i++) {
      const targetIndex = this.currentIndex + i;
      if (targetIndex < this.segments.length && 
          !this.audioCache.has(targetIndex) &&
          !this.pendingRequests.has(targetIndex)) {
        // 不 await,后台执行
        this.getSegmentAudio(targetIndex).catch(err => {
          console.warn(`[VoicePlayer] 预加载第 ${targetIndex} 段失败`, err);
        });
      }
    }
  }

  // ==================== 网络请求 ====================

  private async fetchSegments(text: string): Promise<any> {
    const res = await fetch(`${this.API_BASE}/segment`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text })
    });
    if (!res.ok) throw new Error('分段请求失败');
    return (await res.json()).data;
  }

  private async fetchSynthesize(text: string): Promise<TTSResult> {
    this.abortController = new AbortController();
    const res = await fetch(`${this.API_BASE}/synthesize`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text }),
      signal: this.abortController.signal
    });
    if (!res.ok) throw new Error('合成请求失败');
    return (await res.json()).data;
  }

  // ==================== 工具方法 ====================

  private base64ToBlob(base64: string, mimeType: string): Blob {
    const byteCharacters = atob(base64);
    const byteArrays: Uint8Array[] = [];

    for (let offset = 0; offset < byteCharacters.length; offset += 512) {
      const slice = byteCharacters.slice(offset, offset + 512);
      const byteNumbers = new Array(slice.length);
      for (let i = 0; i < slice.length; i++) {
        byteNumbers[i] = slice.charCodeAt(i);
      }
      byteArrays.push(new Uint8Array(byteNumbers));
    }

    return new Blob(byteArrays, { type: mimeType });
  }

  private cleanupAudio(): void {
    if (this.currentAudio) {
      this.currentAudio.src = '';
      this.currentAudio = null;
    }
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  private _setState(state: PlayerState): void {
    this._state = state;
    this.dispatchEvent(new CustomEvent('statechange', { detail: state }));
  }
}

4.2 Vue 3 组件封装示例

<template>
  <button 
    class="tts-button"
    :class="playerState"
    @click="handleToggle"
    :disabled="!ttsEnabled"
  >
    <span class="icon">
      <svg v-if="playerState === 'playing'" class="animate-pulse">...暂停图标</svg>
      <svg v-else-if="playerState === 'loading' || playerState === 'buffering'" class="animate-spin">...加载图标</svg>
      <svg v-else>...播放图标</svg>
    </span>
    <span class="label">{{ stateLabel }}</span>
  </button>
</template>

<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue';
import { VoicePlayer, PlayerState } from './VoicePlayer';

const props = defineProps<{
  messageId: string;
  content: string;
}>();

const player = new VoicePlayer();
const playerState = ref<PlayerState>('idle');

// 监听状态变化
player.addEventListener('statechange', (e: any) => {
  playerState.value = e.detail;
});

const ttsEnabled = ref(true); // 从 /api/tts/config 获取

const stateLabel = computed(() => {
  const map: Record<string, string> = {
    idle: '朗读',
    loading: '准备中',
    buffering: '缓冲中',
    playing: '暂停',
    paused: '继续',
    ended: '重播',
    error: '失败'
  };
  return map[playerState.value] || '朗读';
});

const handleToggle = () => {
  player.toggle(props.messageId, props.content);
};

// 组件卸载时清理
onUnmounted(() => {
  player.stop();
});
</script>

5. 关键设计详解

5.1 异步合成策略

阶段 行为 目的
P0(首段) 阻塞等待,合成后立刻播放 最小化首响延迟
P1(预加载) 播放首段时,后台并发合成第 2~3 段 利用播放时间窗口
P2(流式) 每段播放结束时,下一段已就绪 实现无缝衔接

并发控制:通过 pendingRequests Set 确保同一段不会重复请求,避免额度浪费。

5.2 智能文本分割策略

原文:今天天气真好,适合出去走走。你有什么计划吗?要不一起去公园?

策略执行:
1. 按 500 字符限制检查 → 未超限,无需分割
2. 若超限 20 字符:
   - 在 "?" 处分割 → [..., "你有什么计划吗?", "要不一起去公园?"]
3. 若句末标点均不满足:
   - 在 "," 处分割(保留至少 20 字符)
4. 若仍不满足:
   - 强制截断,但避开英文单词边界

5.3 音频缓存机制

采用三级缓存

// L1: 内存缓存(当前会话)
this.audioCache: Map<number, TTSResult>

// L2: SessionStorage(同一会话内刷新保留)
sessionStorage.setItem(`tts_${messageId}_${index}`, JSON.stringify(result))

// L3: IndexedDB(长期缓存,可选)
// 适合高频固定文本(如系统提示语)

5.4 内存管理

  • Blob URL 释放:每段播放结束后立即 URL.revokeObjectURL()
  • Audio 对象清理:设置 src = '' 解除引用
  • AbortController:切换会话时取消未完成的 fetch 请求

6. 会话与生命周期管理

6.1 会话切换控制

// 在会话管理器中注入
class SessionManager {
  private voicePlayer: VoicePlayer;

  switchSession(newSessionId: string) {
    // 切换前停止当前播放
    this.voicePlayer.stop();
    // ... 加载新会话
  }
}

6.2 页面生命周期适配

场景 策略
页面隐藏(切 Tab) 自动暂停,回到页面时询问是否继续
页面关闭 无需处理(进程结束自动释放)
组件卸载 调用 player.stop() 清理资源
长文本中途离开 保留进度,支持断点续播(可选)

7. 配置与部署

7.1 后端配置(application.yml)

baidu:
  tts:
    api-key: ${BAIDU_TTS_API_KEY}
    secret-key: ${BAIDU_TTS_SECRET_KEY}
    per: 0              # 度小美
    spd: 5              # 标准语速
    pit: 5              # 标准音调
    vol: 7              # 稍大音量(AI 场景建议稍大)
    aue: 3              # MP3 格式(兼容性最佳)
    enabled: true
    max-segment-length: 500

# 限流配置(防止恶意刷接口)
rate-limiter:
  tts:
    qps: 10             # 单用户每秒最多 10 次合成请求

7.2 环境变量(生产安全)

切勿将 Key 硬编码,应通过环境变量注入:

# .env.production
BAIDU_TTS_API_KEY=your-key
BAIDU_TTS_SECRET_KEY=your-secret

8. API 接口规范

接口 方法 请求体 响应 说明
/api/tts/config GET - TTSConfigVO 获取配置、音色列表
/api/tts/segment POST { text: string } SegmentVO 文本智能分段
/api/tts/synthesize POST { text: string } TTSResult 单段合成(Base64)
/api/tts/voices GET - List 获取可用音色

8.1 关键 VO 定义

@Data
public class TTSResult {
    private String audioData;   // Base64 音频
    private String format;      // mp3 / pcm / wav
    private int duration;       // 预估时长(秒)
}

@Data
public class SegmentVO {
    private List<String> segments;
    private int totalSegments;
    private int totalChars;
}

9. 性能与稳定性优化

9.1 优化清单

优化点 实现方式 效果
Token 缓存 Caffeine 本地缓存 25 分钟 减少 90% 以上鉴权请求
音频缓存 Map + SessionStorage 重复播放零延迟
预加载窗口 当前段播放时预加载后 2 段 消除网络等待
连接复用 RestTemplate / HTTP2 降低 TCP 握手开销
文本压缩 去除 Markdown、URL 减少无效字符,提升合成质量
Gzip 传输 开启 Nginx / Spring Boot Gzip Base64 文本压缩率约 30%

9.2 预估性能指标

  • 首段延迟:< 800ms(文本 < 100 字,网络正常)
  • 段间切换延迟:< 100ms(预加载命中时)
  • 并发合成上限:取决于百度 QPS 额度(默认 100 QPS)

10. 错误处理与降级策略

10.1 错误码映射(百度 TTS)

错误码 含义 后端处理 前端提示
500 后端内部错误 记录日志,返回 500 "服务繁忙,请稍后重试"
502 额度用完 返回特定错误码 "语音额度已用完,请联系管理员"
503 Token 失效 清除缓存,自动重试 1 次 无感刷新
506 文本过长 返回 400,提示分段 "文本过长,已自动分段"
511 并发超限 返回 429,触发限流 "操作过于频繁,请稍候"
网络超时 请求无响应 重试 2 次(指数退避) "网络不稳定,正在重试..."

10.2 降级策略

// 当 TTS 服务不可用时,提供降级方案
if (error.code === 'TTS_UNAVAILABLE') {
  // 方案 A:提示用户自行阅读
  showToast('语音服务暂不可用,请阅读文字内容');

  // 方案 B:切换备用 TTS(如浏览器内置 speechSynthesis)
  if ('speechSynthesis' in window) {
    const utterance = new SpeechSynthesisUtterance(text);
    window.speechSynthesis.speak(utterance);
  }
}

10.3 限流与防护

// Spring Boot + Bucket4j 限流示例
@RateLimited(
    capacity = 10,      // 令牌桶容量
    refillTokens = 5,   // 每秒补充 5 个
    key = "#request.userId"
)
@PostMapping("/synthesize")
public Result<TTSResult> synthesize(...) { ... }

11. 总结

本方案通过异步分段合成智能预加载机制,解决了 AI 聊天场景中长文本语音播放的三大痛点:

目标 达成方式
🚀 低延迟启动 首段优先合成,无需等待全文
🎵 流畅播放 后台预加载 + 音频缓存,段间无缝衔接
🧠 语义完整 三级智能分割,避免在词/句中截断
♻️ 资源高效 多级缓存 + 并发去重,避免重复合成
🛡️ 稳定可靠 异常重试 + 服务降级 + 限流防护

后续可扩展方向

  1. WebSocket 流式传输:后端合成一段推送一段,进一步降低延迟
  2. 情感识别:根据文本情感(疑问、感叹)动态调整 pit(音调)
  3. SSML 支持:支持停顿、重读等富文本标记
  4. 用户音色偏好记忆:记住用户选择的发音人、语速
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论
AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点