mp3.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. # -*- coding: utf-8 -*-
  2. """
  3. MP3-Decoder.
  4. Dekodiert MP3-Dateien zu PCM mit pydub oder ffmpeg.
  5. """
  6. import asyncio
  7. import io
  8. import logging
  9. import struct
  10. from pathlib import Path
  11. from typing import AsyncIterator
  12. from trixy_core.music.formats.base import (
  13. AudioDecoder,
  14. AudioFormat,
  15. AudioInfo,
  16. DecodedAudio,
  17. DecoderError,
  18. )
  19. class MP3Decoder(AudioDecoder):
  20. """
  21. MP3-Decoder.
  22. Verwendet pydub (mit ffmpeg Backend) für das Dekodieren.
  23. Fallback: Direktes ffmpeg-Subprocess.
  24. """
  25. FORMAT = AudioFormat.MP3
  26. EXTENSIONS = {"mp3"}
  27. MIME_TYPES = {"audio/mpeg", "audio/mp3"}
  28. def __init__(self) -> None:
  29. super().__init__()
  30. self.logger = logging.getLogger(__name__)
  31. self._use_pydub = False
  32. self._use_ffmpeg = False
  33. def _check_dependencies(self) -> bool:
  34. """Prüft verfügbare Decoder-Backends."""
  35. # Versuche pydub
  36. try:
  37. from pydub import AudioSegment
  38. self._use_pydub = True
  39. return True
  40. except ImportError:
  41. pass
  42. # Versuche ffmpeg direkt
  43. import shutil
  44. if shutil.which("ffmpeg"):
  45. self._use_ffmpeg = True
  46. return True
  47. return False
  48. async def get_info(self, source: Path | bytes) -> AudioInfo:
  49. """Liest MP3-Informationen."""
  50. if isinstance(source, bytes):
  51. return await self._get_info_from_bytes(source)
  52. return await self._get_info_from_file(source)
  53. async def _get_info_from_file(self, path: Path) -> AudioInfo:
  54. """Liest Informationen aus MP3-Datei."""
  55. if not path.exists():
  56. raise DecoderError(f"Datei nicht gefunden: {path}")
  57. file_size = path.stat().st_size
  58. # Versuche Mutagen für Metadaten
  59. try:
  60. import mutagen.mp3
  61. audio = mutagen.mp3.MP3(path)
  62. return AudioInfo(
  63. format=AudioFormat.MP3,
  64. duration_ms=int(audio.info.length * 1000),
  65. sample_rate=audio.info.sample_rate,
  66. channels=2 if audio.info.mode < 3 else 1, # 0-2=Stereo, 3=Mono
  67. bit_rate=audio.info.bitrate,
  68. file_size=file_size,
  69. )
  70. except ImportError:
  71. pass
  72. except Exception as e:
  73. self.logger.debug(f"Mutagen-Fehler: {e}")
  74. # Fallback: Basis-Info aus Header
  75. try:
  76. info = await asyncio.to_thread(self._parse_mp3_header, path)
  77. info.file_size = file_size
  78. return info
  79. except Exception as e:
  80. raise DecoderError(f"Kann MP3-Info nicht lesen: {e}")
  81. async def _get_info_from_bytes(self, data: bytes) -> AudioInfo:
  82. """Liest Informationen aus MP3-Bytes."""
  83. try:
  84. info = self._parse_mp3_header_bytes(data)
  85. info.file_size = len(data)
  86. return info
  87. except Exception as e:
  88. raise DecoderError(f"Kann MP3-Info nicht lesen: {e}")
  89. def _parse_mp3_header(self, path: Path) -> AudioInfo:
  90. """Parst MP3-Frame-Header."""
  91. with open(path, "rb") as f:
  92. data = f.read(4096)
  93. return self._parse_mp3_header_bytes(data)
  94. def _parse_mp3_header_bytes(self, data: bytes) -> AudioInfo:
  95. """Parst MP3-Frame-Header aus Bytes."""
  96. # ID3v2 Tag überspringen
  97. offset = 0
  98. if data[:3] == b"ID3":
  99. size_bytes = data[6:10]
  100. size = (
  101. (size_bytes[0] & 0x7f) << 21 |
  102. (size_bytes[1] & 0x7f) << 14 |
  103. (size_bytes[2] & 0x7f) << 7 |
  104. (size_bytes[3] & 0x7f)
  105. )
  106. offset = 10 + size
  107. # MP3 Frame Header finden (0xFF 0xFB, 0xFF 0xFA, 0xFF 0xF3, 0xFF 0xF2)
  108. while offset < len(data) - 4:
  109. if data[offset] == 0xFF and (data[offset + 1] & 0xE0) == 0xE0:
  110. break
  111. offset += 1
  112. if offset >= len(data) - 4:
  113. return AudioInfo(format=AudioFormat.MP3)
  114. # Header parsen
  115. header = struct.unpack(">I", data[offset:offset+4])[0]
  116. # Version (Bits 19-20)
  117. version_bits = (header >> 19) & 0x03
  118. version_map = {0: 2.5, 2: 2, 3: 1}
  119. version = version_map.get(version_bits, 1)
  120. # Layer (Bits 17-18)
  121. layer_bits = (header >> 17) & 0x03
  122. layer = 4 - layer_bits if layer_bits > 0 else 0
  123. # Bitrate (Bits 12-15)
  124. bitrate_index = (header >> 12) & 0x0F
  125. bitrate_table = {
  126. # V1 L3
  127. (1, 3): [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
  128. # V2 L3
  129. (2, 3): [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
  130. }
  131. bitrate_list = bitrate_table.get((int(version), layer), bitrate_table[(1, 3)])
  132. bitrate = bitrate_list[bitrate_index] * 1000 if bitrate_index < len(bitrate_list) else 128000
  133. # Sample Rate (Bits 10-11)
  134. sample_rate_index = (header >> 10) & 0x03
  135. sample_rate_table = {
  136. 1: [44100, 48000, 32000],
  137. 2: [22050, 24000, 16000],
  138. 2.5: [11025, 12000, 8000],
  139. }
  140. sample_rate_list = sample_rate_table.get(version, sample_rate_table[1])
  141. sample_rate = sample_rate_list[sample_rate_index] if sample_rate_index < len(sample_rate_list) else 44100
  142. # Channel Mode (Bits 6-7)
  143. channel_mode = (header >> 6) & 0x03
  144. channels = 1 if channel_mode == 3 else 2
  145. return AudioInfo(
  146. format=AudioFormat.MP3,
  147. sample_rate=sample_rate,
  148. channels=channels,
  149. bit_rate=bitrate,
  150. bit_depth=16,
  151. )
  152. async def decode(
  153. self,
  154. source: Path | bytes,
  155. target_sample_rate: int | None = None,
  156. target_channels: int | None = None,
  157. ) -> DecodedAudio:
  158. """Dekodiert MP3 vollständig zu PCM."""
  159. target_sr = target_sample_rate or self.OUTPUT_SAMPLE_RATE
  160. target_ch = target_channels or self.OUTPUT_CHANNELS
  161. if self._use_pydub:
  162. return await self._decode_pydub(source, target_sr, target_ch)
  163. elif self._use_ffmpeg:
  164. return await self._decode_ffmpeg(source, target_sr, target_ch)
  165. else:
  166. raise DecoderError("Kein MP3-Decoder verfügbar (pydub oder ffmpeg erforderlich)")
  167. async def _decode_pydub(
  168. self,
  169. source: Path | bytes,
  170. target_sr: int,
  171. target_ch: int,
  172. ) -> DecodedAudio:
  173. """Dekodiert mit pydub."""
  174. try:
  175. from pydub import AudioSegment
  176. def decode():
  177. if isinstance(source, bytes):
  178. audio = AudioSegment.from_mp3(io.BytesIO(source))
  179. else:
  180. audio = AudioSegment.from_mp3(str(source))
  181. # Resampling falls nötig
  182. if audio.frame_rate != target_sr:
  183. audio = audio.set_frame_rate(target_sr)
  184. # Channel-Anpassung
  185. if audio.channels != target_ch:
  186. if target_ch == 1:
  187. audio = audio.set_channels(1)
  188. else:
  189. audio = audio.set_channels(2)
  190. return DecodedAudio(
  191. pcm_data=audio.raw_data,
  192. sample_rate=audio.frame_rate,
  193. channels=audio.channels,
  194. bit_depth=audio.sample_width * 8,
  195. )
  196. return await asyncio.to_thread(decode)
  197. except Exception as e:
  198. raise DecoderError(f"pydub Dekodierung fehlgeschlagen: {e}")
  199. async def _decode_ffmpeg(
  200. self,
  201. source: Path | bytes,
  202. target_sr: int,
  203. target_ch: int,
  204. ) -> DecodedAudio:
  205. """Dekodiert mit ffmpeg Subprocess."""
  206. try:
  207. if isinstance(source, bytes):
  208. # Pipe-Input
  209. cmd = [
  210. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  211. "-i", "pipe:0",
  212. "-f", "s16le",
  213. "-acodec", "pcm_s16le",
  214. "-ar", str(target_sr),
  215. "-ac", str(target_ch),
  216. "pipe:1"
  217. ]
  218. process = await asyncio.create_subprocess_exec(
  219. *cmd,
  220. stdin=asyncio.subprocess.PIPE,
  221. stdout=asyncio.subprocess.PIPE,
  222. stderr=asyncio.subprocess.PIPE,
  223. )
  224. stdout, stderr = await process.communicate(input=source)
  225. else:
  226. # Datei-Input
  227. cmd = [
  228. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  229. "-i", str(source),
  230. "-f", "s16le",
  231. "-acodec", "pcm_s16le",
  232. "-ar", str(target_sr),
  233. "-ac", str(target_ch),
  234. "pipe:1"
  235. ]
  236. process = await asyncio.create_subprocess_exec(
  237. *cmd,
  238. stdout=asyncio.subprocess.PIPE,
  239. stderr=asyncio.subprocess.PIPE,
  240. )
  241. stdout, stderr = await process.communicate()
  242. if process.returncode != 0:
  243. raise DecoderError(f"ffmpeg Fehler: {stderr.decode()}")
  244. return DecodedAudio(
  245. pcm_data=stdout,
  246. sample_rate=target_sr,
  247. channels=target_ch,
  248. bit_depth=16,
  249. )
  250. except Exception as e:
  251. if isinstance(e, DecoderError):
  252. raise
  253. raise DecoderError(f"ffmpeg Dekodierung fehlgeschlagen: {e}")
  254. async def decode_stream(
  255. self,
  256. source: Path | bytes,
  257. chunk_duration_ms: int = 100,
  258. ) -> AsyncIterator[DecodedAudio]:
  259. """Dekodiert MP3 als Stream."""
  260. # Berechne Chunk-Größe
  261. bytes_per_sample = 2 # 16-bit
  262. samples_per_chunk = int(self.OUTPUT_SAMPLE_RATE * chunk_duration_ms / 1000)
  263. chunk_size = samples_per_chunk * bytes_per_sample * self.OUTPUT_CHANNELS
  264. if self._use_pydub:
  265. # Bei pydub alles dekodieren und in Chunks liefern
  266. decoded = await self.decode(source)
  267. data = decoded.pcm_data
  268. for i in range(0, len(data), chunk_size):
  269. yield DecodedAudio(
  270. pcm_data=data[i:i + chunk_size],
  271. sample_rate=decoded.sample_rate,
  272. channels=decoded.channels,
  273. bit_depth=decoded.bit_depth,
  274. )
  275. elif self._use_ffmpeg:
  276. # ffmpeg streaming
  277. async for chunk in self._stream_ffmpeg(source, chunk_size):
  278. yield chunk
  279. async def _stream_ffmpeg(
  280. self,
  281. source: Path | bytes,
  282. chunk_size: int,
  283. ) -> AsyncIterator[DecodedAudio]:
  284. """Streaming-Dekodierung mit ffmpeg."""
  285. try:
  286. if isinstance(source, bytes):
  287. cmd = [
  288. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  289. "-i", "pipe:0",
  290. "-f", "s16le",
  291. "-acodec", "pcm_s16le",
  292. "-ar", str(self.OUTPUT_SAMPLE_RATE),
  293. "-ac", str(self.OUTPUT_CHANNELS),
  294. "pipe:1"
  295. ]
  296. process = await asyncio.create_subprocess_exec(
  297. *cmd,
  298. stdin=asyncio.subprocess.PIPE,
  299. stdout=asyncio.subprocess.PIPE,
  300. stderr=asyncio.subprocess.PIPE,
  301. )
  302. # Input schreiben
  303. if process.stdin:
  304. process.stdin.write(source)
  305. await process.stdin.drain()
  306. process.stdin.close()
  307. else:
  308. cmd = [
  309. "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
  310. "-i", str(source),
  311. "-f", "s16le",
  312. "-acodec", "pcm_s16le",
  313. "-ar", str(self.OUTPUT_SAMPLE_RATE),
  314. "-ac", str(self.OUTPUT_CHANNELS),
  315. "pipe:1"
  316. ]
  317. process = await asyncio.create_subprocess_exec(
  318. *cmd,
  319. stdout=asyncio.subprocess.PIPE,
  320. stderr=asyncio.subprocess.PIPE,
  321. )
  322. # Output streamen
  323. while True:
  324. chunk = await process.stdout.read(chunk_size)
  325. if not chunk:
  326. break
  327. yield DecodedAudio(
  328. pcm_data=chunk,
  329. sample_rate=self.OUTPUT_SAMPLE_RATE,
  330. channels=self.OUTPUT_CHANNELS,
  331. bit_depth=16,
  332. )
  333. await process.wait()
  334. except Exception as e:
  335. raise DecoderError(f"ffmpeg Streaming fehlgeschlagen: {e}")