Update torchlight_changes_unloze/torchlight3/Torchlight/FFmpegAudioPlayer.py

This commit is contained in:
Metroid_Skittles 2026-02-06 19:40:29 +01:00
parent 67529b652f
commit 3e44425df9

View File

@ -1,207 +1,234 @@
#!/usr/bin/python3 #!/usr/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import traceback import traceback
import asyncio import asyncio
import datetime import datetime
import time import time
import socket import socket
import struct import struct
import sys import sys
SAMPLEBYTES = 2 SAMPLEBYTES = 2
class FFmpegAudioPlayerFactory(): class FFmpegAudioPlayerFactory():
VALID_CALLBACKS = ["Play", "Stop", "Update"] VALID_CALLBACKS = ["Play", "Stop", "Update"]
def __init__(self, master): def __init__(self, master):
self.Logger = logging.getLogger(__class__.__name__) self.Logger = logging.getLogger(__class__.__name__)
self.Master = master self.Master = master
self.Torchlight = self.Master.Torchlight self.Torchlight = self.Master.Torchlight
def __del__(self): def __del__(self):
self.Master.Logger.info("~FFmpegAudioPlayerFactory()") self.Master.Logger.info("~FFmpegAudioPlayerFactory()")
self.Quit() self.Quit()
def NewPlayer(self): def NewPlayer(self):
self.Logger.debug(sys._getframe().f_code.co_name) self.Logger.debug(sys._getframe().f_code.co_name)
Player = FFmpegAudioPlayer(self) Player = FFmpegAudioPlayer(self)
return Player return Player
def Quit(self): def Quit(self):
self.Master.Logger.info("FFmpegAudioPlayerFactory->Quit()") self.Master.Logger.info("FFmpegAudioPlayerFactory->Quit()")
class FFmpegAudioPlayer(): class FFmpegAudioPlayer():
def __init__(self, master): def __init__(self, master):
self.Master = master self.Master = master
self.Torchlight = self.Master.Torchlight self.Torchlight = self.Master.Torchlight
self.Playing = False self.Playing = False
self.Host = ( self.Host = (
self.Torchlight().Config["VoiceServer"]["Host"], self.Torchlight().Config["VoiceServer"]["Host"],
self.Torchlight().Config["VoiceServer"]["Port"] self.Torchlight().Config["VoiceServer"]["Port"]
) )
self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"]) self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"])
self.Channels = int(self.Torchlight().Config["VoiceServer"].get("Channels", 2))
self.StartedPlaying = None
self.StoppedPlaying = None self.StartedPlaying = None
self.Seconds = 0.0 self.StoppedPlaying = None
self.Seconds = 0.0
self.Writer = None
self.Process = None self.Writer = None
self.Process = None
self.Callbacks = []
self.Callbacks = []
def __del__(self):
self.Master.Logger.debug("~FFmpegAudioPlayer()") def __del__(self):
self.Stop() self.Master.Logger.debug("~FFmpegAudioPlayer()")
self.Stop()
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
backwards = None, *args): def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
if position: backwards = None, *args):
PosStr = str(datetime.timedelta(seconds = position)) if position:
#Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args] PosStr = str(datetime.timedelta(seconds = position))
Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-af", "highpass=f=20", "-acodec", "pcm_s16le", "-ac", "2", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args] #Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
else: Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
#Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args] else:
Command = ["/usr/bin/ffmpeg", "-i", uri, "-af", "highpass=f=20", "-acodec", "pcm_s16le", "-ac", "2", "-ar", str(int(self.SampleRate)),"-f", "s16le", "-vn"] #Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", str(self.Channels), "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn"]
self.Playing = True
if dec_params: if args:
Command += dec_params Command += list(args)
if rubberband and backwards: self.Playing = True
Command += ["-filter:a"] if dec_params:
rubberCommand = "" Command += dec_params
for rubber in rubberband:
rubberCommand += rubber + ", " filter_chain = ["highpass=f=20"]
rubberCommand = rubberCommand[:-2] if rubberband:
Command += [rubberCommand + "[reversed];[reversed]areverse"] #[reversed] is intermediate stream label so reverse knows what stream label to reverse # Treat rubberband as either a single pre-built filtergraph string
else: # or an iterable of simple, comma-safe filters. Validate to avoid
if rubberband: # generating invalid ffmpeg filter syntax.
Command += ["-filter:a"] rubberband_filters = []
rubberCommand = "" if isinstance(rubberband, str):
for rubber in rubberband: rubberband_filters = [rubberband]
rubberCommand += rubber + ", " else:
rubberCommand = rubberCommand[:-2] try:
Command += [rubberCommand] iterator = iter(rubberband)
if backwards: except TypeError:
Command += ["-af"] # Not iterable: coerce to string and treat as single element.
Command += ["areverse"] rubberband_filters = [str(rubberband)]
if bitrate: else:
Command += ["-ab ", str(bitrate), "k"] for f in iterator:
self.Master.Logger.debug(f"command: {Command}") if not isinstance(f, str):
Command += ["-"] f = str(f)
#self.Master.Logger.debug(f"command: {Command}") # Reject potentially complex/unsafe filter fragments that
asyncio.ensure_future(self._stream_subprocess(Command)) # could break the filtergraph when joined with commas.
return True if any(ch in f for ch in [",", ";", "[", "]"]):
self.Master.Logger.error(
def Stop(self, force = True): "Skipping unsafe rubberband filter fragment %r: "
if not self.Playing: "contains ',', ';', '[' or ']'", f
return False )
continue
if self.Process: rubberband_filters.append(f)
try: filter_chain.extend(rubberband_filters)
self.Process.terminate() if backwards:
self.Process.kill() filter_chain.append("areverse")
self.Process = None Command += ["-filter:a", ",".join(filter_chain)]
except ProcessLookupError: if bitrate:
pass Command += ["-b:a", f"{bitrate}k"]
self.Master.Logger.debug(f"command: {Command}")
if self.Writer: Command += ["-"]
if force: #self.Master.Logger.debug(f"command: {Command}")
Socket = self.Writer.transport.get_extra_info("socket") asyncio.ensure_future(self._stream_subprocess(Command))
if Socket: return True
Socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
struct.pack("ii", 1, 0)) def Stop(self, force = True):
if not self.Playing:
self.Writer.transport.abort() return False
self.Writer.close() if self.Process:
try:
self.Playing = False self.Process.terminate()
self.Process.kill()
self.Callback("Stop") self.Process = None
del self.Callbacks except ProcessLookupError:
pass
return True
if self.Writer:
def AddCallback(self, cbtype, cbfunc): if force:
if not cbtype in FFmpegAudioPlayerFactory.VALID_CALLBACKS: Socket = self.Writer.transport.get_extra_info("socket")
return False if Socket:
Socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
self.Callbacks.append((cbtype, cbfunc)) struct.pack("ii", 1, 0))
return True
self.Writer.transport.abort()
def Callback(self, cbtype, *args, **kwargs):
for Callback in self.Callbacks: self.Writer.close()
if Callback[0] == cbtype:
try: self.Playing = False
Callback[1](*args, **kwargs)
except Exception as e: self.Callback("Stop")
self.Master.Logger.error(traceback.format_exc()) self.Callbacks = []
async def _updater(self): return True
LastSecondsElapsed = 0.0
def AddCallback(self, cbtype, cbfunc):
while self.Playing: if not cbtype in FFmpegAudioPlayerFactory.VALID_CALLBACKS:
SecondsElapsed = time.time() - self.StartedPlaying return False
if SecondsElapsed > self.Seconds: self.Callbacks.append((cbtype, cbfunc))
SecondsElapsed = self.Seconds return True
self.Callback("Update", LastSecondsElapsed, SecondsElapsed) def Callback(self, cbtype, *args, **kwargs):
for Callback in self.Callbacks:
if SecondsElapsed >= self.Seconds: if Callback[0] == cbtype:
if not self.StoppedPlaying: try:
print("BUFFER UNDERRUN!") Callback[1](*args, **kwargs)
self.Stop(False) except Exception as e:
return self.Master.Logger.error(traceback.format_exc())
LastSecondsElapsed = SecondsElapsed async def _updater(self):
LastSecondsElapsed = 0.0
await asyncio.sleep(0.1)
while self.Playing:
async def _read_stream(self, stream, writer): SecondsElapsed = time.time() - self.StartedPlaying
Started = False
if SecondsElapsed > self.Seconds:
while stream and self.Playing: SecondsElapsed = self.Seconds
Data = await stream.read(65536)
self.Callback("Update", LastSecondsElapsed, SecondsElapsed)
if Data:
writer.write(Data) if SecondsElapsed >= self.Seconds:
await writer.drain() if not self.StoppedPlaying:
print("BUFFER UNDERRUN!")
Bytes = len(Data) self.Stop(False)
Samples = Bytes / SAMPLEBYTES return
Seconds = Samples / self.SampleRate
await asyncio.sleep(0.1)
self.Seconds += Seconds
async def _read_stream(self, stream, writer):
if not Started: Started = False
Started = True
self.Callback("Play") while stream and self.Playing:
self.StartedPlaying = time.time() Data = await stream.read(65536)
asyncio.ensure_future(self._updater())
else: if Data:
self.Process = None writer.write(Data)
break await writer.drain()
self.StoppedPlaying = time.time() Bytes = len(Data)
frame_size = SAMPLEBYTES * self.Channels
async def _stream_subprocess(self, cmd):
if not self.Playing: # Guard against invalid configuration and use integral frame counting
return if frame_size <= 0 or self.SampleRate <= 0:
self.Master.Logger.error(
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1]) "Invalid audio configuration: Channels=%r, SampleRate=%r",
self.Channels,
Process = await asyncio.create_subprocess_exec(*cmd, self.SampleRate,
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL) )
self.Process = Process else:
# Accumulate remainder bytes to avoid systematic undercounting
await self._read_stream(Process.stdout, self.Writer) total_bytes = getattr(self, "_byte_remainder", 0) + Bytes
await Process.wait() Frames = total_bytes // frame_size
self._byte_remainder = total_bytes % frame_size
if self.Seconds == 0.0: Seconds = Frames / self.SampleRate
self.Stop() self.Seconds += Seconds
if not Started:
Started = True
self.Callback("Play")
self.StartedPlaying = time.time()
asyncio.ensure_future(self._updater())
else:
self.Process = None
break
self.StoppedPlaying = time.time()
async def _stream_subprocess(self, cmd):
if not self.Playing:
return
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
Process = await asyncio.create_subprocess_exec(*cmd,
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL)
self.Process = Process
await self._read_stream(Process.stdout, self.Writer)
await Process.wait()
if self.Seconds == 0.0:
self.Stop()