audio_processor.py 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. import subprocess
  5. import tempfile
  6. from dataclasses import dataclass
  7. from pathlib import Path
  8. @dataclass(frozen=True)
  9. class AudioInfo:
  10. path: Path
  11. codec: str | None
  12. sample_rate: int | None
  13. channels: int
  14. channel_layout: str | None
  15. duration: float | None
  16. class AudioProcessor:
  17. async def inspect(self, path: str | Path) -> AudioInfo:
  18. path = Path(path)
  19. if not path.is_file():
  20. raise FileNotFoundError(path)
  21. return await asyncio.to_thread(
  22. self._inspect_sync,
  23. path,
  24. )
  25. @staticmethod
  26. def _inspect_sync(path: Path) -> AudioInfo:
  27. result = subprocess.run(
  28. [
  29. "ffprobe",
  30. "-v", "error",
  31. "-select_streams", "a:0",
  32. "-show_entries",
  33. "stream=codec_name,sample_rate,channels,channel_layout",
  34. "-show_entries",
  35. "format=duration",
  36. "-of", "json",
  37. str(path),
  38. ],
  39. capture_output=True,
  40. text=True,
  41. check=True,
  42. )
  43. data = json.loads(result.stdout)
  44. stream = (data.get("streams") or [{}])[0]
  45. fmt = data.get("format") or {}
  46. return AudioInfo(
  47. path=path,
  48. codec=stream.get("codec_name"),
  49. sample_rate=(
  50. int(stream["sample_rate"])
  51. if stream.get("sample_rate")
  52. else None
  53. ),
  54. channels=int(stream.get("channels") or 1),
  55. channel_layout=stream.get("channel_layout"),
  56. duration=(
  57. float(fmt["duration"])
  58. if fmt.get("duration")
  59. else None
  60. ),
  61. )
  62. async def prepare_for_transcription(
  63. self,
  64. path: str | Path,
  65. ) -> list[Path]:
  66. info = await self.inspect(path)
  67. if info.channels <= 1:
  68. return [Path(path)]
  69. return await asyncio.to_thread(
  70. self._split_stereo_sync,
  71. info.path,
  72. )
  73. @staticmethod
  74. def _split_stereo_sync(path: Path) -> list[Path]:
  75. tmp = Path(
  76. tempfile.mkdtemp(prefix="3cx-audio-")
  77. )
  78. outputs = []
  79. for channel in (0, 1):
  80. output = tmp / f"channel-{channel}.wav"
  81. subprocess.run(
  82. [
  83. "ffmpeg",
  84. "-hide_banner",
  85. "-loglevel", "error",
  86. "-i", str(path),
  87. "-map_channel", f"0.0.{channel}",
  88. "-ar", "16000",
  89. "-ac", "1",
  90. "-c:a", "pcm_s16le",
  91. str(output),
  92. ],
  93. check=True,
  94. )
  95. outputs.append(output)
  96. return outputs