mattrguerra/transcription-pipelinemainPrivate on GitHub, published here in full

pipeline.py

Language Python3.6 KB88 lines

transcription_pipeline/pipeline.py

  1. """One recording through the whole pipeline.
  2. normalize -> transcribe (local or DeepInfra) -> align (word timestamps)
  3. -> diarize (who spoke when) -> sentences and speaker turns -> write outputs.
  4. The model-backed steps are passed in as callables, so tests can drive the whole
  5. flow with stand-ins and the CLI wires in the real ones (steps.py).
  6. """
  7. from __future__ import annotations
  8. import datetime as dt
  9. import tempfile
  10. from dataclasses import dataclass
  11. from pathlib import Path
  12. from typing import Callable
  13. from . import audio, render
  14. from .engines.local import DEFAULT_MODEL
  15. from .filing import write_unique
  16. from .shape import rename_speakers, sentences_from_segments
  17. Transcriber = Callable[[Path, Path, "Options"], dict] # (wav16k, source, opts) -> result
  18. Aligner = Callable[[dict, Path, "Options"], dict] # (result, wav16k, opts) -> result with words
  19. Diarizer = Callable[[dict, Path, "Options"], dict] # (result, wav16k, opts) -> result with speakers
  20. @dataclass
  21. class Options:
  22. engine: str = "local" # local | deepinfra
  23. language: str | None = None
  24. diarize: bool = True
  25. min_speakers: int | None = None
  26. max_speakers: int | None = None
  27. formats: tuple[str, ...] = ("txt", "md", "json")
  28. timestamps: bool = True
  29. model: str = DEFAULT_MODEL
  30. @dataclass
  31. class Result:
  32. outputs: dict[str, Path]
  33. speakers: list[str]
  34. duration: float
  35. sentences: int
  36. language: str | None
  37. def process(source: Path, out_dir: Path, opts: Options, transcriber: Transcriber,
  38. aligner: Aligner | None, diarizer: Diarizer | None,
  39. vault_inbox: Path | None = None) -> Result:
  40. source = Path(source)
  41. with tempfile.TemporaryDirectory(prefix="tp-") as tmp:
  42. wav = audio.to_wav16k(source, Path(tmp) / "audio.wav")
  43. duration = audio.wav_duration(wav)
  44. result = transcriber(wav, source, opts)
  45. if aligner is not None and result.get("segments"):
  46. result = aligner(result, wav, opts)
  47. if opts.diarize and diarizer is not None and result.get("segments"):
  48. result = diarizer(result, wav, opts)
  49. sentences, speakers = rename_speakers(sentences_from_segments(result.get("segments", [])))
  50. stem = source.stem
  51. recorded = dt.datetime.fromtimestamp(source.stat().st_mtime).date().isoformat() \
  52. if source.exists() else dt.date.today().isoformat()
  53. meta = {"title": stem, "source": source.name, "recorded": recorded,
  54. "transcribed": dt.datetime.now().isoformat(timespec="seconds"),
  55. "duration": audio.hms(duration), "engine": opts.engine,
  56. "model": result.get("model"), "language": result.get("language"),
  57. "speakers": speakers, "diarized": bool(opts.diarize and diarizer is not None)}
  58. outputs: dict[str, Path] = {}
  59. writers = {
  60. "txt": lambda: render.to_txt(sentences, opts.timestamps),
  61. "md": lambda: render.to_markdown(sentences, meta),
  62. "json": lambda: render.to_json(sentences, result, meta),
  63. "srt": lambda: render.to_srt(sentences),
  64. "vtt": lambda: render.to_vtt(sentences),
  65. }
  66. for fmt in opts.formats:
  67. if fmt not in render.FORMATS:
  68. *rest, last = render.FORMATS
  69. raise ValueError(f"Unknown format {fmt!r}: use {', '.join(rest)} or {last}.")
  70. outputs[fmt] = write_unique(out_dir, f"{stem}.{fmt}", writers[fmt]())
  71. if vault_inbox is not None:
  72. outputs["vault"] = write_unique(vault_inbox, f"{stem}.md",
  73. render.to_markdown(sentences, meta))
  74. return Result(outputs, speakers, duration, len(sentences), result.get("language"))