#! /usr/bin/env python # MPlayer synchronizer # Copyright 2014 Stefan Schuermans # Copyleft: GNU public license - http://www.gnu.org/copyleft/gpl.html import datetime import fcntl import os import re import select import socket import struct import subprocess import sys import time scriptdir = os.path.dirname(os.path.abspath(__file__)) verbose = False class Synchronizer: def __init__(self): """construct an MPlayer Synchronizer object""" # set constants self.info_timeout = 1.0 # timeout (s) for info from MPlayer or PoSy self.max_equal_offset = 0.1 # maximum offset tolerated as equal self.min_cmd_delay = 0.1 # minimum time (s) between MPlayer commands self.min_seek_offset = 0.5 # minimum offset (s) required for a seek self.select_timeout = 0.1 # timeout (s) for select syscall self.speed_change = 0.05 # change of MPlayer speed for catching up # create static objects self.re_mplayer_pos = re.compile(r"[AV]: *([0-9]+.[0-9]+) *\([0-9:.]*\) of .*") self.re_ignore_prefix = re.compile(r"[0-9a-zA-Z_]+__(.*)") # create member variables self.mplayer = None self.mplayer_buf_stdout = "" self.mplayer_buf_stderr = "" self.mplayer_last_cmd_timestamp = None self.mplayer_name = None self.mplayer_pause = None self.mplayer_pos = None self.mplayer_speed = None self.mplayer_timestamp = None self.offset_samples = [] self.playlist = [] self.playlist_idx = None self.posy_name = None self.posy_pause = None self.posy_pos = None self.posy_timestamp = None self.sock = None self.verbose = False # startup self.sockSetup() def __del__(self): """deconstruct object""" self.mplayerStop() self.sockClose() def dbg_print(self, txt): """output debug information in verbose mode""" if self.verbose: print >>sys.stderr, txt def mplayerClear(self): """clear MPlayer buffers and information""" self.mplayer_buf_stdin = "" self.mplayer_buf_stderr = "" self.mplayer_last_cmd_timestamp = None self.mplayer_name = None self.mplayer_pause = None self.mplayer_pos = None self.mplayer_speed = None self.mplayer_timestamp = None self.offset_samples = [] def mplayerExit(self): """react to MPlayer exit""" # close pipes self.mplayer.stdin.close() self.mplayer.stdout.close() self.mplayer.stderr.close() # close process self.mplayer.wait() self.mplayer = None # clear buffers and information self.mplayerClear() # play next file self.playNext() def mplayerLine(self, line, err): """process line from MPlayer stdout (err = False) or stderr (err = True)""" if len(line) > 0: if err: self.dbg_print("MPlayer stderr: " + line) else: self.dbg_print("MPlayer stdout: " + line) if not err: # MPlayer position information m_mplayer_pos = self.re_mplayer_pos.match(line) if m_mplayer_pos: self.mplayer_timestamp = datetime.datetime.now() self.mplayer_pos = float(m_mplayer_pos.group(1)) # synchronize self.sync() def mplayerPause(self, pause): """pause/unpause MPlayer""" # leave if no MPlayer running if self.mplayer is None: return # switch to pause mode if pause: self.dbg_print("MPlayer stdin: pausing seek 0 0") self.mplayer.stdin.write("pausing seek 0 0\n") # rel seek 0s, then pause self.mplayer_pause = True # continue playing else: self.dbg_print("MPlayer stdin: seek 0 0") self.mplayer.stdin.write("seek 0 0\n") # realtive seek of 0s self.mplayer_pause = False self.mplayer_last_cmd_timestamp = datetime.datetime.now() def mplayerSetPos(self, pos): """set MPlayer position""" # leave if no MPlayer running if self.mplayer is None: return # sanitize position to avoid mplayer crashes in any case if pos < 0.0: pos = 0.0 # set new position self.dbg_print("MPlayer stdin: seek %5.3f 2" % pos) self.mplayer.stdin.write("seek %5.3f 2\n" % pos) # 2 means absolute pos self.mplayer_pos = pos self.mplayer_last_cmd_timestamp = datetime.datetime.now() def mplayerSetSpeed(self, speed): """set MPlayer speed""" # leave if no MPlayer running if self.mplayer is None: return # sanitize speed to avoid mplayer crashes in any case if speed < 0.5: speed = 0.5 if speed > 2.0: speed = 2.0 # set new speed self.dbg_print("MPlayer stdin: speed_set %5.3f" % speed) self.mplayer.stdin.write("speed_set %5.3f\n" % speed) self.mplayer_speed = speed self.mplayer_last_cmd_timestamp = datetime.datetime.now() def mplayerStart(self, filename): """start MPlayer process in background""" # stop old MPlayer self.mplayerStop() # start MPlayer cmd = [ "mplayer", "-volume", "80", "-slave", "-af", "scaletempo", filename ] print >>sys.stderr, "starting background process: " + " ".join(cmd) self.mplayer = subprocess.Popen(cmd, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE) # make output pipes nonblocking fcntl.fcntl(self.mplayer.stdout, fcntl.F_SETFL, os.O_NONBLOCK) fcntl.fcntl(self.mplayer.stderr, fcntl.F_SETFL, os.O_NONBLOCK) # set initial information self.mplayer_name = filename self.mplayer_pause = False self.mplayer_speed = 1.0 self.mplayer_last_cmd_timestamp = datetime.datetime.now() def mplayerStdouterr(self, err): """process data from MPlayer stdout (err = False) or stderr (err = True)""" if self.mplayer is None: return # receive data if err: txt = self.mplayer.stderr.read() else: txt = self.mplayer.stdout.read() # check if MPlayer exited if len(txt) == 0: self.mplayerExit() return # MPlayer did not exit # replace CRs with LFs add data to buffer txt = txt.replace("\r", "\n") if err: buf = self.mplayer_buf_stderr + txt else: buf = self.mplayer_buf_stdout + txt # process complete lines and store remaining data in buffer lines = buf.split("\n") for line in lines[:-1]: self.mplayerLine(line, err) if err: self.mplayer_buf_stderr = buf[-1] else: self.mplayer_buf_stdout = buf[-1] def mplayerStop(self): """stop MPlayer process in background""" if self.mplayer is not None: # send quit command self.mplayer.stdin.write("quit\n") # close pipes self.mplayer.stdin.close() self.mplayer.stdout.close() self.mplayer.stderr.close() # terminate process self.mplayer.terminate() self.mplayer.kill() # close process self.mplayer.wait() self.mplayer = None # clear buffers and information self.mplayerClear() def playlistFind(self, posy_name): """find file in playlist by PoSy name""" idx = 0 for file_name in self.playlist: if self.posyCheckName(posy_name, file_name): return idx; idx += 1 return None def playlistRead(self, playlist): """read playlist file""" # read filenames from playlist filenames = [] try: with open(playlist, "rt") as f: filenames = f.readlines() except: return False filenames = [filename.strip() for filename in filenames] # convert filenames to absolute paths playlistdir = os.path.dirname(playlist) filenames = [os.path.join(playlistdir, filename) for filename in filenames] # replace playlist self.playlist = filenames self.playlist_idx = None return True def playNext(self): """play next file in playlist""" # playlist empty -> stop MPlayer if len(self.playlist) == 0: self.playlist_idx = None self.mplayerStop() # playlist not empty -> play next file else: if self.playlist_idx is None: self.playlist_idx = 0 else: self.playlist_idx += 1 if self.playlist_idx >= len(self.playlist): self.playlist_idx = 0 self.mplayerStart(self.playlist[self.playlist_idx]) def playPosyName(self, posy_name): """play file by PoSy name (if found)""" # find file in playlist idx = self.playlistFind(posy_name) # file not found -> stop MPlayer if idx is None: self.playlist_idx = None self.mplayerStop() # file found -> (re-)start MPlayer else: self.playlist_idx = idx self.mplayerStart(self.playlist[idx]) def posyCheckName(self, posy_name, file_name): """check if filename matches PoSyName""" # remove directory part of file name and check file_name = os.path.basename(file_name) if file_name == posy_name: return True # remove extension and check file_name = os.path.splitext(file_name)[0] if file_name == posy_name: return True # remove ignore prefix and check m_ignore_prefix = self.re_ignore_prefix.match(file_name) if m_ignore_prefix and m_ignore_prefix.group(1) == posy_name: return True # not matching return False def posyParse(self, data): """parse received PoSy packet""" if len(data) < 76 or data[0:4] != "PoSy": return False flags, name, pos_ms = struct.unpack("!I64sI", data[4:76]) name_end = name.find("\0") if name_end >= 0: name = name[:name_end] if flags & 1: pause = True else: pause = False # store info from PoSy packet self.posy_timestamp = datetime.datetime.now() self.posy_name = name self.posy_pos = pos_ms * 1e-3 self.posy_pause = pause return True def run(self): """run application""" try: while True: self.waitForInput() except KeyboardInterrupt: pass def sockRecv(self): """receive data from socket""" if self.sock is None: return # receive message data = self.sock.recv(4096) self.dbg_print("data from socket: %d bytes" % len(data)) # parse (ignore message on error) if not self.posyParse(data): return # synchronize self.sync() def sockClose(self): """close UDP socket""" if self.sock is not None: self.sock.close() self.sock = None def sockSetup(self): """create a new UDP socket and bind it""" self.sockClose() try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("0.0.0.0", 5740)) except: self.sockClose() def sync(self): """synchronize MPlayer to PoSy input""" now = datetime.datetime.now() # do nothing if PoSy information is missing if self.posy_name is None or \ self.posy_pause is None or \ self.posy_pos is None or \ self.posy_timestamp is None: return # do nothing if PoSy information is too old posy_age = (now - self.posy_timestamp).total_seconds() if posy_age > self.info_timeout: return # MPlayer not running -> play requested file (if found) if self.mplayer is None: self.playPosyName(self.posy_name) return # do nothing if MPlayer information is missing if self.mplayer_name is None or \ self.mplayer_pause is None or \ self.mplayer_pos is None or \ self.mplayer_timestamp is None: return # do nothing if MPlayer information is too old # (ignore MPlayer info age in MPlayer pause mode, as there is no info) if not self.mplayer_pause: mplayer_age = (now - self.mplayer_timestamp).total_seconds() if mplayer_age > self.info_timeout: return # do nothing if last MPlayer command has been sent shortly if self.mplayer_last_cmd_timestamp is not None: last_cmd_age = (now - self.mplayer_last_cmd_timestamp).total_seconds() if last_cmd_age < self.min_cmd_delay: return # output information for debugging self.dbg_print("MPlayer: %s name \"%s\" pos %f pause %s" % \ (self.mplayer_timestamp, self.mplayer_name, self.mplayer_pos, self.mplayer_pause)) self.dbg_print("PoSy: %s name \"%s\" pos %f pause %s" % \ (self.posy_timestamp, self.posy_name, self.posy_pos, self.posy_pause)) # name mismatch -> play requested file (if found) if not self.posyCheckName(self.posy_name, self.mplayer_name): self.playPosyName(self.posy_name) return # pause mode mismatch -> pause/unpause MPlayer if self.mplayer_pause != self.posy_pause: self.mplayerPause(self.posy_pause) return # never seek in MPlayer pause mode (this continues playback) if self.mplayer_pause: return # calculate offset (account for time elased since last info) mplayer_pos = self.mplayer_pos if not self.mplayer_pause: mplayer_pos += mplayer_age posy_pos = self.posy_pos if not self.posy_pause: posy_pos += posy_age offset = posy_pos - mplayer_pos self.dbg_print("offset: %5.3f" % offset) # seek if offset is too big if abs(offset) > self.min_seek_offset: self.mplayerSetSpeed(1.0) # position will be okay -> normal speed self.mplayerSetPos(posy_pos) return # compute sliding average of offset (to get rid of jitter) self.offset_samples.append(offset) self.offset_samples = self.offset_samples[-10:] off_avg = 0 for o in self.offset_samples: off_avg += o off_avg *= 0.1 self.dbg_print("off_avg: %5.3f" % off_avg) # normal speed, position almost matches -> everything fine (do nothing) if abs(off_avg) < self.max_equal_offset and self.mplayer_speed == 1.0: return # position is really good -> go to normal speed if abs(off_avg) < self.max_equal_offset / 3: self.mplayerSetSpeed(1.0) return # synchronize by varying speed if off_avg < 0.0: speed = 1.0 - self.speed_change else: speed = 1.0 + self.speed_change if self.mplayer_speed is None or self.mplayer_speed != speed: self.mplayerSetSpeed(speed) def verboseSet(self, verbose): """set verbose mode""" self.verbose = verbose def waitForInput(self): """wait for input from UDP socket or MPlayer pipes""" # poll for data inputs = [] outputs = [] errors = [] wait_txt = "" if self.sock is not None: inputs.append(self.sock) wait_txt += " socket" if self.mplayer is not None: inputs.append(self.mplayer.stdout) inputs.append(self.mplayer.stderr) wait_txt += " MPlayer" self.dbg_print("waiting for input:" + wait_txt) rds, wrs, exs = select.select(inputs, outputs, errors, self.select_timeout) # obtain available data for rd in rds: if self.sock is not None and rd is self.sock: self.dbg_print("input from socket") self.sockRecv() if self.mplayer is not None and rd is self.mplayer.stdout: self.dbg_print("input from MPlayer stdout") self.mplayerStdouterr(False) if self.mplayer is not None and rd is self.mplayer.stderr: self.dbg_print("input from MPlayer stderr") self.mplayerStdouterr(True) # main function def main(argv): # check parameters if len(argv) < 2: print >>sys.stderr, "usage: %s [-v]" % argv[0] return 2 playlist = argv[1] verbose = False if len(argv) >= 3: if argv[2] == "-v": verbose = True else: print >>sys.stderr, "unknown option \"%s\"" % argv[2] return 3 # run application app = Synchronizer() app.verboseSet(verbose) if not app.playlistRead(playlist): print >>sys.stderr, "could not read playlist \"%s\"" % playlist return 4 app.run() # done return 0 # main application entry point if __name__ == "__main__": sys.exit(main(sys.argv))