ogg.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. # -*- coding: utf-8 -*-
  2. """
  3. OGG/Vorbis-Decoder.
  4. Dekodiert OGG Vorbis-Dateien zu PCM.
  5. """
  6. import asyncio
  7. import io
  8. import logging
  9. from pathlib import Path
  10. from typing import AsyncIterator
  11. from trixy_core.music.formats.base import (
  12. AudioDecoder,
  13. AudioFormat,
  14. AudioInfo,
  15. DecodedAudio,
  16. DecoderError,
  17. )
  18. class OGGDecoder(AudioDecoder):
  19. """
  20. OGG Vorbis-Decoder.
  21. Verwendet pydub (mit ffmpeg Backend) oder pyogg für das Dekodieren.
  22. """
  23. FORMAT = AudioFormat.OGG
  24. EXTENSIONS = {"ogg", "oga", "opus"}
  25. MIME_TYPES = {"audio/ogg", "audio/vorbis", "application/ogg", "audio/opus"}
  26. def __init__(self) -> None:
  27. super().__init__()
  28. self.logger = logging.getLogger(__name__)
  29. self._use_pydub = False
  30. self._use_ffmpeg = False
  31. def _check_dependencies(self) -> bool:
  32. """Prüft verfügbare Decoder-Backends."""
  33. # Versuche pydub
  34. try:
  35. from pydub import AudioSegment
  36. self._use_pydub = True
  37. return True
  38. except ImportError:
  39. pass
  40. # Versuche ffmpeg direkt
  41. import shutil
  42. if shutil.which("ffmpeg"):
  43. self._use_ffmpeg = True
  44. return True
  45. return False
  46. async def get_info(self, source: Path | bytes) -> AudioInfo:
  47. """Liest OGG-Informationen."""
  48. if isinstance(source, bytes):
  49. return await self._get_info_from_bytes(source)
  50. return await self._get_info_from_file(source)
  51. async def _get_info_from_file(self, path: Path) -> AudioInfo:
  52. """Liest Informationen aus OGG-Datei."""
  53. if not path.exists():
  54. raise DecoderError(f"Datei nicht gefunden: {path}")
  55. file_size = path.stat().st_size
  56. # Versuche Mutagen
  57. try:
  58. import mutagen.oggvorbis
  59. audio = mutagen.oggvorbis.OggVorbis(path)
  60. return AudioInfo(
  61. format=AudioFormat.OGG,
  62. duration_ms=int(audio.info.length * 1000),
  63. sample_rate=audio.info.sample_rate,
  64. channels=audio.info.channels,
  65. bit_rate=audio.info.bitrate,
  66. file_size=file_size,
  67. )
  68. except ImportError:
  69. pass
  70. except Exception:
  71. # Versuche Opus
  72. try:
  73. import mutagen.oggopus
  74. audio = mutagen.oggopus.OggOpus(path)
  75. return AudioInfo(
  76. format=AudioFormat.OGG,
  77. duration_ms=int(audio.info.length * 1000),
  78. sample_rate=48000, # Opus immer 48kHz
  79. channels=audio.info.channels,
  80. file_size=file_size,
  81. )
  82. except Exception:
  83. pass
  84. # Fallback: ffprobe
  85. if self._use_ffmpeg:
  86. return await self._get_info_ffprobe(path)
  87. return AudioInfo(
  88. format=AudioFormat.OGG,
  89. file_size=file_size,
  90. )
  91. async def _get_info_from_bytes(self, data: bytes) -> AudioInfo:
  92. """Liest Informationen aus OGG-Bytes."""
  93. # OGG Header parsen für grundlegende Info
  94. info = AudioInfo(
  95. format=AudioFormat.OGG,
  96. file_size=len(data),
  97. )
  98. # OGG Magic: "OggS"
  99. if data[:4] != b"OggS":
  100. raise DecoderError("Keine gültige OGG-Datei")
  101. return info
  102. async def _get_info_ffprobe(self, path: Path) -> AudioInfo:
  103. """Liest Info mit ffprobe."""
  104. try:
  105. cmd = [
  106. "ffprobe", "-v", "quiet",
  107. "-print_format", "json",
  108. "-show_format", "-show_streams",
  109. str(path)
  110. ]
  111. process = await asyncio.create_subprocess_exec(
  112. *cmd,
  113. stdout=asyncio.subprocess.PIPE,
  114. stderr=asyncio.subprocess.PIPE,
  115. )
  116. stdout, _ = await process.communicate()
  117. import json
  118. data = json.loads(stdout)
  119. stream = next((s for s in data.get("streams", []) if s.get("codec_type") == "audio"), {})
  120. format_info = data.get("format", {})
  121. return AudioInfo(
  122. format=AudioFormat.OGG,
  123. duration_ms=int(float(format_info.get("duration", 0)) * 1000),
  124. sample_rate=int(stream.get("sample_rate", 44100)),
  125. channels=int(stream.get("channels", 2)),
  126. bit_rate=int(format_info.get("bit_rate", 0)),
  127. file_size=int(format_info.get("size", 0)),
  128. )
  129. except Exception as e:
  130. self.logger.debug(f"ffprobe Fehler: {e}")
  131. return AudioInfo(format=AudioFormat.OGG)
  132. async def decode(
  133. self,
  134. source: Path | bytes,
  135. target_sample_rate: int | None = None,
  136. target_channels: int | None = None,
  137. ) -> DecodedAudio:
  138. """Dekodiert OGG vollständig zu PCM."""
  139. target_sr = target_sample_rate or self.OUTPUT_SAMPLE_RATE
  140. target_ch = target_channels or self.OUTPUT_CHANNELS
  141. if self._use_pydub:
  142. return await self._decode_pydub(source, target_sr, target_ch)
  143. elif self._use_ffmpeg:
  144. return await self._decode_ffmpeg(source, target_sr, target_ch)
  145. else:
  146. raise DecoderError("Kein OGG-Decoder verfügbar")
  147. async def _decode_pydub(
  148. self,
  149. source: Path | bytes,
  150. target_sr: int,
  151. target_ch: int,
  152. ) -> DecodedAudio:
  153. """Dekodiert mit pydub."""
  154. try:
  155. from pydub import AudioSegment
  156. def decode():
  157. if isinstance(source, bytes):
  158. audio = AudioSegment.from_ogg(io.BytesIO(source))
  159. else:
  160. audio = AudioSegment.from_ogg(str(source))
  161. # Resampling falls nötig
  162. if audio.frame_rate != target_sr:
  163. audio = audio.set_frame_rate(target_sr)
  164. # Channel-Anpassung
  165. if audio.channels != target_ch:
  166. audio = audio.set_channels(target_ch)
  167. return DecodedAudio(
  168. pcm_data=audio.raw_data,
  169. sample_rate=audio.frame_rate,
  170. channels=audio.channels,
  171. bit_depth=audio.sample_width * 8,
  172. )
  173. return await asyncio.to_thread(decode)
  174. except Exception as e:
  175. raise DecoderError(f"pydub OGG-Dekodierung fehlgeschlagen: {e}")
  176. async def _decode_ffmpeg(
  177. self,
  178. source: Path | bytes,
  179. target_sr: int,
  180. target_ch: int,
  181. ) -> DecodedAudio:
  182. """Dekodiert mit ffmpeg."""
  183. try:
  184. if isinstance(source, bytes):
  185. cmd = [
  186. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  187. "-i", "pipe:0",
  188. "-f", "s16le",
  189. "-acodec", "pcm_s16le",
  190. "-ar", str(target_sr),
  191. "-ac", str(target_ch),
  192. "pipe:1"
  193. ]
  194. process = await asyncio.create_subprocess_exec(
  195. *cmd,
  196. stdin=asyncio.subprocess.PIPE,
  197. stdout=asyncio.subprocess.PIPE,
  198. stderr=asyncio.subprocess.PIPE,
  199. )
  200. stdout, stderr = await process.communicate(input=source)
  201. else:
  202. cmd = [
  203. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  204. "-i", str(source),
  205. "-f", "s16le",
  206. "-acodec", "pcm_s16le",
  207. "-ar", str(target_sr),
  208. "-ac", str(target_ch),
  209. "pipe:1"
  210. ]
  211. process = await asyncio.create_subprocess_exec(
  212. *cmd,
  213. stdout=asyncio.subprocess.PIPE,
  214. stderr=asyncio.subprocess.PIPE,
  215. )
  216. stdout, stderr = await process.communicate()
  217. if process.returncode != 0:
  218. raise DecoderError(f"ffmpeg Fehler: {stderr.decode()}")
  219. return DecodedAudio(
  220. pcm_data=stdout,
  221. sample_rate=target_sr,
  222. channels=target_ch,
  223. bit_depth=16,
  224. )
  225. except DecoderError:
  226. raise
  227. except Exception as e:
  228. raise DecoderError(f"ffmpeg OGG-Dekodierung fehlgeschlagen: {e}")
  229. async def decode_stream(
  230. self,
  231. source: Path | bytes,
  232. chunk_duration_ms: int = 100,
  233. ) -> AsyncIterator[DecodedAudio]:
  234. """Dekodiert OGG als Stream."""
  235. bytes_per_sample = 2
  236. samples_per_chunk = int(self.OUTPUT_SAMPLE_RATE * chunk_duration_ms / 1000)
  237. chunk_size = samples_per_chunk * bytes_per_sample * self.OUTPUT_CHANNELS
  238. if self._use_pydub:
  239. decoded = await self.decode(source)
  240. data = decoded.pcm_data
  241. for i in range(0, len(data), chunk_size):
  242. yield DecodedAudio(
  243. pcm_data=data[i:i + chunk_size],
  244. sample_rate=decoded.sample_rate,
  245. channels=decoded.channels,
  246. bit_depth=decoded.bit_depth,
  247. )
  248. elif self._use_ffmpeg:
  249. async for chunk in self._stream_ffmpeg(source, chunk_size):
  250. yield chunk
  251. async def _stream_ffmpeg(
  252. self,
  253. source: Path | bytes,
  254. chunk_size: int,
  255. ) -> AsyncIterator[DecodedAudio]:
  256. """Streaming mit ffmpeg."""
  257. try:
  258. if isinstance(source, bytes):
  259. cmd = [
  260. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  261. "-i", "pipe:0",
  262. "-f", "s16le",
  263. "-acodec", "pcm_s16le",
  264. "-ar", str(self.OUTPUT_SAMPLE_RATE),
  265. "-ac", str(self.OUTPUT_CHANNELS),
  266. "pipe:1"
  267. ]
  268. process = await asyncio.create_subprocess_exec(
  269. *cmd,
  270. stdin=asyncio.subprocess.PIPE,
  271. stdout=asyncio.subprocess.PIPE,
  272. stderr=asyncio.subprocess.PIPE,
  273. )
  274. if process.stdin:
  275. process.stdin.write(source)
  276. await process.stdin.drain()
  277. process.stdin.close()
  278. else:
  279. cmd = [
  280. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  281. "-i", str(source),
  282. "-f", "s16le",
  283. "-acodec", "pcm_s16le",
  284. "-ar", str(self.OUTPUT_SAMPLE_RATE),
  285. "-ac", str(self.OUTPUT_CHANNELS),
  286. "pipe:1"
  287. ]
  288. process = await asyncio.create_subprocess_exec(
  289. *cmd,
  290. stdout=asyncio.subprocess.PIPE,
  291. stderr=asyncio.subprocess.PIPE,
  292. )
  293. while True:
  294. chunk = await process.stdout.read(chunk_size)
  295. if not chunk:
  296. break
  297. yield DecodedAudio(
  298. pcm_data=chunk,
  299. sample_rate=self.OUTPUT_SAMPLE_RATE,
  300. channels=self.OUTPUT_CHANNELS,
  301. bit_depth=16,
  302. )
  303. await process.wait()
  304. except Exception as e:
  305. raise DecoderError(f"OGG Streaming fehlgeschlagen: {e}")