tts-utils.ts 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /**
  2. * Shared TTS utilities used by both client-side and server-side generation.
  3. */
  4. import type { TTSProviderId } from './types';
  5. import type { Action, SpeechAction } from '@/lib/types/action';
  6. import { createLogger } from '@/lib/logger';
  7. const log = createLogger('TTS');
  8. /** Provider-specific max text length limits. */
  9. export const TTS_MAX_TEXT_LENGTH: Partial<Record<TTSProviderId, number>> = {
  10. 'glm-tts': 1024,
  11. };
  12. /**
  13. * Split long text into chunks that respect sentence boundaries.
  14. * Tries splitting at sentence-ending punctuation first, then clause-level
  15. * punctuation, and finally hard-splits at maxLength as a last resort.
  16. */
  17. export function splitLongSpeechText(text: string, maxLength: number): string[] {
  18. const normalized = text.trim();
  19. if (!normalized || normalized.length <= maxLength) return [normalized];
  20. const units = normalized
  21. .split(/(?<=[。!?!?;;::\n])/u)
  22. .map((part) => part.trim())
  23. .filter(Boolean);
  24. const chunks: string[] = [];
  25. let current = '';
  26. const pushChunk = (value: string) => {
  27. const trimmed = value.trim();
  28. if (trimmed) chunks.push(trimmed);
  29. };
  30. const appendUnit = (unit: string) => {
  31. if (!current) {
  32. current = unit;
  33. return;
  34. }
  35. if ((current + unit).length <= maxLength) {
  36. current += unit;
  37. return;
  38. }
  39. pushChunk(current);
  40. current = unit;
  41. };
  42. const hardSplitUnit = (unit: string) => {
  43. const parts = unit.split(/(?<=[,,、])/u).filter(Boolean);
  44. if (parts.length > 1) {
  45. for (const part of parts) {
  46. if (part.length <= maxLength) appendUnit(part);
  47. else hardSplitUnit(part);
  48. }
  49. return;
  50. }
  51. let start = 0;
  52. while (start < unit.length) {
  53. appendUnit(unit.slice(start, start + maxLength));
  54. start += maxLength;
  55. }
  56. };
  57. for (const unit of units.length > 0 ? units : [normalized]) {
  58. if (unit.length <= maxLength) appendUnit(unit);
  59. else hardSplitUnit(unit);
  60. }
  61. pushChunk(current);
  62. return chunks;
  63. }
  64. /**
  65. * Split long speech actions into multiple shorter actions so each stays
  66. * within the TTS provider's text length limit. Each sub-action gets its
  67. * own independent audio file — no byte concatenation needed.
  68. */
  69. export function splitLongSpeechActions(actions: Action[], providerId: TTSProviderId): Action[] {
  70. const maxLength = TTS_MAX_TEXT_LENGTH[providerId];
  71. if (!maxLength) return actions;
  72. let didSplit = false;
  73. const nextActions: Action[] = actions.flatMap((action) => {
  74. if (action.type !== 'speech' || !action.text || action.text.length <= maxLength)
  75. return [action];
  76. const chunks = splitLongSpeechText(action.text, maxLength);
  77. if (chunks.length <= 1) return [action];
  78. didSplit = true;
  79. const { audioId: _audioId, ...baseAction } = action as SpeechAction;
  80. log.info(
  81. `Split speech for ${providerId}: action=${action.id}, len=${action.text.length}, chunks=${chunks.length}`,
  82. );
  83. return chunks.map((chunk, i) => ({
  84. ...baseAction,
  85. id: `${action.id}_tts_${i + 1}`,
  86. text: chunk,
  87. }));
  88. });
  89. return didSplit ? nextActions : actions;
  90. }