openai-tts.ts 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. import type {
  2. TTSSynthesizeRequest,
  3. TTSResult,
  4. VoiceInfo,
  5. WordTimestamp,
  6. } from "@pipeline/shared";
  7. import type { TTSProvider } from "../types.js";
  8. import { registerProvider } from "../registry.js";
  9. import { measureAudioDuration } from "../duration.js";
  10. import { fetchWithRetry } from "../retry.js";
  11. class OpenAITTSProvider implements TTSProvider {
  12. readonly name = "openai-tts";
  13. async synthesize(request: TTSSynthesizeRequest): Promise<TTSResult> {
  14. const apiKey = process.env.OPENAI_TTS_API_KEY;
  15. if (!apiKey) throw new Error("OPENAI_TTS_API_KEY not set");
  16. const baseURL =
  17. process.env.OPENAI_TTS_BASE_URL ||
  18. "https://new-api.corp.shuidi.tech/v1";
  19. const model = process.env.OPENAI_TTS_MODEL || "seed-tts-1.1";
  20. const response = await fetchWithRetry(`${baseURL}/audio/speech`, {
  21. method: "POST",
  22. headers: {
  23. "Content-Type": "application/json",
  24. Authorization: `Bearer ${apiKey}`,
  25. },
  26. body: JSON.stringify({
  27. input: request.text,
  28. model,
  29. response_format: request.format || "mp3",
  30. speed: request.speed ?? 1.0,
  31. voice: request.voiceId || "zh_female_linjianvhai_uranus_bigtts",
  32. }),
  33. });
  34. if (!response.ok) {
  35. const body = await response.text();
  36. throw new Error(`OpenAI TTS API error: ${response.status} ${body}`);
  37. }
  38. const audioBuffer = Buffer.from(await response.arrayBuffer());
  39. const format = request.format || "mp3";
  40. const outputPath = `${request.outputDir}/${request.filename}.${format}`;
  41. const { writeFile, mkdir } = await import("node:fs/promises");
  42. await mkdir(request.outputDir, { recursive: true });
  43. await writeFile(outputPath, audioBuffer);
  44. const durationSeconds = await measureAudioDuration(outputPath);
  45. return {
  46. audioFilePath: outputPath,
  47. durationSeconds,
  48. wordTimestamps: estimateWordTimestamps(request.text, durationSeconds),
  49. format,
  50. };
  51. }
  52. async listVoices(): Promise<VoiceInfo[]> {
  53. // Voice IDs are configured via Volcengine docs:
  54. // https://www.volcengine.com/docs/6561/1257544
  55. return [];
  56. }
  57. async align(audio: Buffer, text: string): Promise<WordTimestamp[]> {
  58. return estimateWordTimestamps(text, (audio.length * 8) / 128000);
  59. }
  60. }
  61. function estimateWordTimestamps(
  62. text: string,
  63. totalDuration: number
  64. ): WordTimestamp[] {
  65. const isChinese = /[一-鿿]/.test(text);
  66. const words = isChinese
  67. ? text.split("").filter((c) => c.trim())
  68. : text.split(/\s+/).filter((w) => w);
  69. const durationPerWord = totalDuration / Math.max(words.length, 1);
  70. return words.map((word, i) => ({
  71. word,
  72. startSeconds: i * durationPerWord,
  73. endSeconds: (i + 1) * durationPerWord,
  74. }));
  75. }
  76. registerProvider("openai-tts", () => new OpenAITTSProvider());