whisper-align.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { spawn } from "node:child_process";
  2. import { fileURLToPath } from "node:url";
  3. import { dirname, join } from "node:path";
  4. import type { WordTimestamp } from "@pipeline/shared";
  5. const __filename = fileURLToPath(import.meta.url);
  6. const __dirname = dirname(__filename);
  7. const ALIGN_SCRIPT = join(__dirname, "..", "scripts", "align.py");
  8. export interface WhisperAlignOptions {
  9. audioFilePath: string;
  10. text?: string;
  11. language?: string;
  12. model?: string;
  13. timeout?: number;
  14. }
  15. interface WhisperWord {
  16. word: string;
  17. start: number;
  18. end: number;
  19. }
  20. interface AlignResult {
  21. ok: boolean;
  22. words?: WhisperWord[];
  23. error?: string;
  24. }
  25. /**
  26. * Run Whisper forced alignment on a TTS-generated audio file.
  27. * Returns word-level timestamps mapped to the original narration text.
  28. * Falls back to null if whisper is unavailable.
  29. */
  30. export async function alignWithWhisper(
  31. options: WhisperAlignOptions
  32. ): Promise<WordTimestamp[] | null> {
  33. const args = [
  34. ALIGN_SCRIPT,
  35. options.audioFilePath,
  36. ...(options.language ? ["--language", options.language] : []),
  37. ...(options.model ? ["--model", options.model] : []),
  38. ];
  39. const result = await runPython(args, options.timeout ?? 120_000);
  40. if (!result.ok || !result.words || result.words.length === 0) {
  41. return null;
  42. }
  43. return mapWhisperToOriginal(result.words, options.text);
  44. }
  45. function runPython(args: string[], timeout: number): Promise<AlignResult> {
  46. return new Promise((resolve) => {
  47. const proc = spawn("python3", args, {
  48. timeout,
  49. stdio: ["ignore", "pipe", "pipe"],
  50. });
  51. let stdout = "";
  52. let stderr = "";
  53. proc.stdout.on("data", (d: Buffer) => (stdout += d));
  54. proc.stderr.on("data", (d: Buffer) => (stderr += d));
  55. proc.on("close", () => {
  56. try {
  57. resolve(JSON.parse(stdout) as AlignResult);
  58. } catch {
  59. resolve({ ok: false, error: stderr || stdout || "parse error" });
  60. }
  61. });
  62. proc.on("error", (err) => {
  63. resolve({ ok: false, error: err.message });
  64. });
  65. });
  66. }
  67. /**
  68. * Map whisper's transcription timestamps back to the original text's words.
  69. *
  70. * Strategy: build a character-to-time mapping from whisper output, then
  71. * interpolate each original word/character into that timeline. This works
  72. * well for TTS audio where the spoken content closely matches the script.
  73. */
  74. function mapWhisperToOriginal(
  75. whisperWords: WhisperWord[],
  76. originalText?: string
  77. ): WordTimestamp[] {
  78. if (!originalText || whisperWords.length === 0) {
  79. return whisperWords.map((w) => ({
  80. word: w.word,
  81. startSeconds: w.start,
  82. endSeconds: w.end,
  83. }));
  84. }
  85. // Build character-level time array from whisper output
  86. const charTimes: number[] = [];
  87. for (const w of whisperWords) {
  88. const perChar = (w.end - w.start) / Math.max(w.word.length, 1);
  89. for (let i = 0; i < w.word.length; i++) {
  90. charTimes.push(w.start + i * perChar);
  91. }
  92. }
  93. if (charTimes.length === 0) {
  94. return whisperWords.map((w) => ({
  95. word: w.word,
  96. startSeconds: w.start,
  97. endSeconds: w.end,
  98. }));
  99. }
  100. // Split original text into display units
  101. const isChinese = /[一-鿿]/.test(originalText);
  102. const units = isChinese
  103. ? originalText.split("").filter((c) => c.trim())
  104. : originalText.split(/\s+/).filter((w) => w);
  105. const totalOrigChars = units.reduce((sum, u) => sum + u.length, 0);
  106. let charOffset = 0;
  107. return units.map((unit) => {
  108. const startRatio = charOffset / totalOrigChars;
  109. const endRatio = (charOffset + unit.length) / totalOrigChars;
  110. const startIdx = Math.min(
  111. Math.floor(startRatio * charTimes.length),
  112. charTimes.length - 1
  113. );
  114. const endIdx = Math.min(
  115. Math.floor(endRatio * charTimes.length),
  116. charTimes.length - 1
  117. );
  118. const startSeconds = charTimes[startIdx];
  119. const endSeconds = charTimes[Math.max(endIdx, startIdx)];
  120. charOffset += unit.length;
  121. return { word: unit, startSeconds, endSeconds };
  122. });
  123. }