5405fffbba31aa600ea950dfe0cacef77c80cc4c
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

1) #! /usr/bin/env python
2) 
3) # MPlayer synchronizer
4) # Copyright 2014 Stefan Schuermans <stefan@schuermans.info>
5) # Copyleft: GNU public license - http://www.gnu.org/copyleft/gpl.html
6) 
7) import datetime
8) import fcntl
9) import os
10) import re
11) import select
12) import socket
13) import struct
14) import subprocess
15) import sys
16) import time
17) 
18) scriptdir = os.path.dirname(os.path.abspath(__file__))
19) 
20) verbose = False
21) 
22) class Synchronizer:
23) 
24)   def __init__(self):
25)     """construct an MPlayer Synchronizer object"""
26)     # set constants
27)     self.info_timeout     = 1.0   # timeout (s) for info from MPlayer or PoSy
28)     self.max_equal_offset = 0.1   # maximum offset tolerated as equal
29)     self.min_cmd_delay    = 0.1   # minimum time (s) between MPlayer commands
30)     self.min_seek_offset  = 0.5   # minimum offset (s) required for a seek
31)     self.select_timeout   = 0.1   # timeout (s) for select syscall
32)     self.speed_change     = 0.05  # change of MPlayer speed for catching up
33)     # create static objects
Stefan Schuermans support synchronizing video...

Stefan Schuermans authored 10 years ago

34)     self.re_mplayer_audio_pos = re.compile(r"A: *([0-9]+.[0-9]+) *\([0-9:.]*\) of .*")
35)     self.re_mplayer_video_pos = re.compile(r"A: *([0-9]+.[0-9]+) *V: *[0-9]+.[0-9]+ A-V: .*")
Stefan Schuermans change local file name pref...

Stefan Schuermans authored 10 years ago

36)     self.re_ignore_prefix = re.compile(r"[0-9a-zA-Z_]+__(.*)")
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

37)     # create member variables
38)     self.mplayer = None
39)     self.mplayer_buf_stdout = ""
40)     self.mplayer_buf_stderr = ""
41)     self.mplayer_last_cmd_timestamp = None
42)     self.mplayer_name = None
43)     self.mplayer_pause = None
44)     self.mplayer_pos = None
45)     self.mplayer_speed = None
46)     self.mplayer_timestamp = None
47)     self.offset_samples = []
48)     self.playlist = []
49)     self.playlist_idx = None
50)     self.posy_name = None
51)     self.posy_pause = None
52)     self.posy_pos = None
53)     self.posy_timestamp = None
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

54)     self.restart = False
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

55)     self.sock = None
56)     self.verbose = False
57)     # startup
58)     self.sockSetup()
59) 
60)   def __del__(self):
61)     """deconstruct object"""
62)     self.mplayerStop()
63)     self.sockClose()
64) 
65)   def dbg_print(self, txt):
66)     """output debug information in verbose mode"""
67)     if self.verbose:
68)       print >>sys.stderr, txt
69) 
70)   def mplayerClear(self):
71)     """clear MPlayer buffers and information"""
72)     self.mplayer_buf_stdin = ""
73)     self.mplayer_buf_stderr = ""
74)     self.mplayer_last_cmd_timestamp = None
75)     self.mplayer_name = None
76)     self.mplayer_pause = None
77)     self.mplayer_pos = None
78)     self.mplayer_speed = None
79)     self.mplayer_timestamp = None
80)     self.offset_samples = []
81) 
82)   def mplayerExit(self):
83)     """react to MPlayer exit"""
84)     # close pipes
85)     self.mplayer.stdin.close()
86)     self.mplayer.stdout.close()
87)     self.mplayer.stderr.close()
88)     # close process
89)     self.mplayer.wait()
90)     self.mplayer = None
91)     # clear buffers and information
92)     self.mplayerClear()
93)     # play next file
94)     self.playNext()
95) 
96)   def mplayerLine(self, line, err):
97)     """process line from MPlayer stdout (err = False) or stderr (err = True)"""
98)     if len(line) > 0:
99)       if err:
100)         self.dbg_print("MPlayer stderr: " + line)
101)       else:
102)         self.dbg_print("MPlayer stdout: " + line)
103)     if not err:
Stefan Schuermans support synchronizing video...

Stefan Schuermans authored 10 years ago

104)       # MPlayer position information (audio)
105)       m_mplayer_audio_pos = self.re_mplayer_audio_pos.match(line)
106)       if m_mplayer_audio_pos:
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

107)         self.mplayer_timestamp = datetime.datetime.now()
Stefan Schuermans support synchronizing video...

Stefan Schuermans authored 10 years ago

108)         self.mplayer_pos = float(m_mplayer_audio_pos.group(1))
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

109)         # synchronize
110)         self.sync()
Stefan Schuermans support synchronizing video...

Stefan Schuermans authored 10 years ago

111)       else:
112)         # MPlayer position information (video)
113)         m_mplayer_video_pos = self.re_mplayer_video_pos.match(line)
114)         if m_mplayer_video_pos:
115)           self.mplayer_timestamp = datetime.datetime.now()
116)           self.mplayer_pos = float(m_mplayer_video_pos.group(1))
117)           # synchronize
118)           self.sync()
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

119) 
120)   def mplayerPause(self, pause):
121)     """pause/unpause MPlayer"""
122)     # leave if no MPlayer running
123)     if self.mplayer is None:
124)       return
125)     # switch to pause mode
126)     if pause:
127)       self.dbg_print("MPlayer stdin: pausing seek 0 0")
Stefan Schuermans avoid exception on broken p...

Stefan Schuermans authored 10 years ago

128)       try:
129)         self.mplayer.stdin.write("pausing seek 0 0\n") # rel seek 0s, then pause
130)       except:
131)         pass
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

132)       self.mplayer_pause = True
133)     # continue playing
134)     else:
135)       self.dbg_print("MPlayer stdin: seek 0 0")
Stefan Schuermans avoid exception on broken p...

Stefan Schuermans authored 10 years ago

136)       try:
137)         self.mplayer.stdin.write("seek 0 0\n") # realtive seek of 0s
138)       except:
139)         pass
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

140)       self.mplayer_pause = False
141)     self.mplayer_last_cmd_timestamp = datetime.datetime.now()
142) 
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

143)   def mplayerSetFile(self, filename):
144)     """set file played by MPlayer"""
145)     # start MPlayer if not running or if restart mode is active
146)     if self.mplayer is None or self.restart:
147)       self.mplayerStart(filename)
148)       return
149)     # set new filename
150)     self.dbg_print("MPlayer stdin: loadfile \"%s\"" % filename)
151)     try:
152)       self.mplayer.stdin.write("loadfile \"%s\"\n" % filename)
153)     except:
154)       pass
155)     self.mplayer_name = filename
156)     self.mplayer_last_cmd_timestamp = datetime.datetime.now()
157) 
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

158)   def mplayerSetPos(self, pos):
159)     """set MPlayer position"""
160)     # leave if no MPlayer running
161)     if self.mplayer is None:
162)       return
163)     # sanitize position to avoid mplayer crashes in any case
164)     if pos < 0.0:
165)       pos = 0.0
166)     # set new position
167)     self.dbg_print("MPlayer stdin: seek %5.3f 2" % pos)
Stefan Schuermans avoid exception on broken p...

Stefan Schuermans authored 10 years ago

168)     try:
169)       self.mplayer.stdin.write("seek %5.3f 2\n" % pos) # 2 means absolute pos
170)     except:
171)       pass
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

172)     self.mplayer_pos = pos
173)     self.mplayer_last_cmd_timestamp = datetime.datetime.now()
174) 
175)   def mplayerSetSpeed(self, speed):
176)     """set MPlayer speed"""
177)     # leave if no MPlayer running
178)     if self.mplayer is None:
179)       return
180)     # sanitize speed to avoid mplayer crashes in any case
181)     if speed < 0.5:
182)       speed = 0.5
183)     if speed > 2.0:
184)       speed = 2.0
185)     # set new speed
186)     self.dbg_print("MPlayer stdin: speed_set %5.3f" % speed)
Stefan Schuermans avoid exception on broken p...

Stefan Schuermans authored 10 years ago

187)     try:
188)       self.mplayer.stdin.write("speed_set %5.3f\n" % speed)
189)     except:
190)       pass
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

191)     self.mplayer_speed = speed
192)     self.mplayer_last_cmd_timestamp = datetime.datetime.now()
193) 
194)   def mplayerStart(self, filename):
195)     """start MPlayer process in background"""
196)     # stop old MPlayer
197)     self.mplayerStop()
198)     # start MPlayer
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

199)     cmd = [ "mplayer", "-volume", "80", "-slave", "-af", "scaletempo"]
200)     if not self.restart:
201)       cmd.append("-idle") # keep mplayer in idle mode if restart not desired
202)     cmd.append(filename)
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

203)     print >>sys.stderr, "starting background process: " + " ".join(cmd)
204)     self.mplayer = subprocess.Popen(cmd, stdin = subprocess.PIPE,
205)                                          stdout = subprocess.PIPE,
206)                                          stderr = subprocess.PIPE)
207)     # make output pipes nonblocking
208)     fcntl.fcntl(self.mplayer.stdout, fcntl.F_SETFL, os.O_NONBLOCK)
209)     fcntl.fcntl(self.mplayer.stderr, fcntl.F_SETFL, os.O_NONBLOCK)
210)     # set initial information
211)     self.mplayer_name = filename
212)     self.mplayer_pause = False
213)     self.mplayer_speed = 1.0
214)     self.mplayer_last_cmd_timestamp = datetime.datetime.now()
215) 
216)   def mplayerStdouterr(self, err):
217)     """process data from MPlayer stdout (err = False) or stderr (err = True)"""
218)     if self.mplayer is None:
219)       return
220)     # receive data
221)     if err:
222)       txt = self.mplayer.stderr.read()
223)     else:
224)       txt = self.mplayer.stdout.read()
225)     # check if MPlayer exited
226)     if len(txt) == 0:
227)       self.mplayerExit()
228)       return
229)     # MPlayer did not exit
230)     # replace CRs with LFs add data to buffer
231)     txt = txt.replace("\r", "\n")
232)     if err:
233)       buf = self.mplayer_buf_stderr + txt
234)     else:
235)       buf = self.mplayer_buf_stdout + txt
236)     # process complete lines and store remaining data in buffer
237)     lines = buf.split("\n")
238)     for line in lines[:-1]:
239)       self.mplayerLine(line, err)
240)     if err:
241)       self.mplayer_buf_stderr = buf[-1]
242)     else:
243)       self.mplayer_buf_stdout = buf[-1]
244) 
245)   def mplayerStop(self):
246)     """stop MPlayer process in background"""
247)     if self.mplayer is not None:
248)       # send quit command
Stefan Schuermans avoid exception on broken p...

Stefan Schuermans authored 10 years ago

249)       try:
250)         self.mplayer.stdin.write("quit\n")
251)       except:
252)         pass
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

253)       # close pipes
254)       self.mplayer.stdin.close()
255)       self.mplayer.stdout.close()
256)       self.mplayer.stderr.close()
257)       # terminate process
258)       self.mplayer.terminate()
259)       self.mplayer.kill()
260)       # close process
261)       self.mplayer.wait()
262)       self.mplayer = None
263)     # clear buffers and information
264)     self.mplayerClear()
265) 
266)   def playlistFind(self, posy_name):
267)     """find file in playlist by PoSy name"""
268)     idx = 0
269)     for file_name in self.playlist:
270)       if self.posyCheckName(posy_name, file_name):
271)         return idx;
272)       idx += 1
273)     return None
274) 
275)   def playlistRead(self, playlist):
276)     """read playlist file"""
277)     # read filenames from playlist
278)     filenames = []
279)     try:
280)       with open(playlist, "rt") as f:
281)         filenames = f.readlines()
282)     except:
283)       return False
284)     filenames = [filename.strip() for filename in filenames]
285)     # convert filenames to absolute paths
286)     playlistdir = os.path.dirname(playlist)
287)     filenames = [os.path.join(playlistdir, filename) for filename in filenames]
288)     # replace playlist
289)     self.playlist = filenames
290)     self.playlist_idx = None
291)     return True
292) 
293)   def playNext(self):
294)     """play next file in playlist"""
295)     # playlist empty -> stop MPlayer
296)     if len(self.playlist) == 0:
297)       self.playlist_idx = None
298)       self.mplayerStop()
299)     # playlist not empty -> play next file
300)     else:
301)       if self.playlist_idx is None:
302)         self.playlist_idx = 0
303)       else:
304)         self.playlist_idx += 1
305)         if self.playlist_idx >= len(self.playlist):
306)           self.playlist_idx = 0
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

307)       self.mplayerSetFile(self.playlist[self.playlist_idx])
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

308) 
309)   def playPosyName(self, posy_name):
310)     """play file by PoSy name (if found)"""
311)     # find file in playlist
312)     idx = self.playlistFind(posy_name)
313)     # file not found -> stop MPlayer
314)     if idx is None:
315)       self.playlist_idx = None
316)       self.mplayerStop()
317)     # file found -> (re-)start MPlayer
318)     else:
319)       self.playlist_idx = idx
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

320)       self.mplayerSetFile(self.playlist[idx])
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

321) 
322)   def posyCheckName(self, posy_name, file_name):
323)     """check if filename matches PoSyName"""
324)     # remove directory part of file name and check
325)     file_name = os.path.basename(file_name)
326)     if file_name == posy_name:
327)       return True
328)     # remove extension and check
329)     file_name = os.path.splitext(file_name)[0]
330)     if file_name == posy_name:
331)       return True
Stefan Schuermans change local file name pref...

Stefan Schuermans authored 10 years ago

332)     # remove ignore prefix and check
333)     m_ignore_prefix = self.re_ignore_prefix.match(file_name)
334)     if m_ignore_prefix and m_ignore_prefix.group(1) == posy_name:
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

335)       return True
336)     # not matching
337)     return False
338) 
339)   def posyParse(self, data):
340)     """parse received PoSy packet"""
341)     if len(data) < 76 or data[0:4] != "PoSy":
342)       return False
343)     flags, name, pos_ms = struct.unpack("!I64sI", data[4:76])
344)     name_end = name.find("\0")
345)     if name_end >= 0:
346)       name = name[:name_end]
347)     if flags & 1:
348)       pause = True
349)     else:
350)       pause = False
351)     # store info from PoSy packet
352)     self.posy_timestamp = datetime.datetime.now()
353)     self.posy_name = name
354)     self.posy_pos = pos_ms * 1e-3
355)     self.posy_pause = pause
356)     return True
357) 
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

358)   def restartSet(self, restart):
359)     """set restart mode"""
360)     self.restart = restart
361) 
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

362)   def run(self):
363)     """run application"""
364)     try:
365)       while True:
366)         self.waitForInput()
367)     except KeyboardInterrupt:
368)       pass
369) 
370)   def sockRecv(self):
371)     """receive data from socket"""
372)     if self.sock is None:
373)       return
374)     # receive message
375)     data = self.sock.recv(4096)
376)     self.dbg_print("data from socket: %d bytes" % len(data))
377)     # parse (ignore message on error)
378)     if not self.posyParse(data):
379)       return
380)     # synchronize
381)     self.sync()
382) 
383)   def sockClose(self):
384)     """close UDP socket"""
385)     if self.sock is not None:
386)       self.sock.close()
387)       self.sock = None
388) 
389)   def sockSetup(self):
390)     """create a new UDP socket and bind it"""
391)     self.sockClose()
392)     try:
393)       self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
394)       self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
395)       self.sock.bind(("0.0.0.0", 5740))
396)     except:
397)       self.sockClose()
398) 
399)   def sync(self):
400)     """synchronize MPlayer to PoSy input"""
401)     now = datetime.datetime.now()
402)     # do nothing if PoSy information is missing
403)     if self.posy_name is None or \
404)        self.posy_pause is None or \
405)        self.posy_pos is None or \
406)        self.posy_timestamp is None:
407)       return
408)     # do nothing if PoSy information is too old
409)     posy_age = (now - self.posy_timestamp).total_seconds()
410)     if posy_age > self.info_timeout:
411)       return
412)     # MPlayer not running -> play requested file (if found)
413)     if self.mplayer is None:
414)       self.playPosyName(self.posy_name)
415)       return
416)     # do nothing if MPlayer information is missing
417)     if self.mplayer_name is None or \
418)        self.mplayer_pause is None or \
419)        self.mplayer_pos is None or \
420)        self.mplayer_timestamp is None:
421)       return
422)     # do nothing if MPlayer information is too old
423)     # (ignore MPlayer info age in MPlayer pause mode, as there is no info)
424)     if not self.mplayer_pause:
425)       mplayer_age = (now - self.mplayer_timestamp).total_seconds()
426)       if mplayer_age > self.info_timeout:
427)         return
428)     # do nothing if last MPlayer command has been sent shortly
429)     if self.mplayer_last_cmd_timestamp is not None:
430)       last_cmd_age = (now - self.mplayer_last_cmd_timestamp).total_seconds()
431)       if last_cmd_age < self.min_cmd_delay:
432)         return
433)     # output information for debugging
434)     self.dbg_print("MPlayer: %s name \"%s\" pos %f pause %s" % \
435)               (self.mplayer_timestamp, self.mplayer_name, self.mplayer_pos,
436)                self.mplayer_pause))
437)     self.dbg_print("PoSy:    %s name \"%s\" pos %f pause %s" % \
438)               (self.posy_timestamp, self.posy_name, self.posy_pos,
439)                self.posy_pause))
440)     # name mismatch -> play requested file (if found)
441)     if not self.posyCheckName(self.posy_name, self.mplayer_name):
442)       self.playPosyName(self.posy_name)
443)       return
444)     # pause mode mismatch -> pause/unpause MPlayer
445)     if self.mplayer_pause != self.posy_pause:
446)       self.mplayerPause(self.posy_pause)
447)       return
448)     # never seek in MPlayer pause mode (this continues playback)
449)     if self.mplayer_pause:
450)       return
451)     # calculate offset (account for time elased since last info)
452)     mplayer_pos = self.mplayer_pos
453)     if not self.mplayer_pause:
454)       mplayer_pos += mplayer_age
455)     posy_pos = self.posy_pos
456)     if not self.posy_pause:
457)       posy_pos += posy_age
458)     offset = posy_pos - mplayer_pos
459)     self.dbg_print("offset: %5.3f" % offset)
460)     # seek if offset is too big
461)     if abs(offset) > self.min_seek_offset:
462)       self.mplayerSetSpeed(1.0) # position will be okay -> normal speed
463)       self.mplayerSetPos(posy_pos)
464)       return
465)     # compute sliding average of offset (to get rid of jitter)
466)     self.offset_samples.append(offset)
467)     self.offset_samples = self.offset_samples[-10:]
468)     off_avg = 0
469)     for o in self.offset_samples:
470)       off_avg += o
471)     off_avg *= 0.1
472)     self.dbg_print("off_avg: %5.3f" % off_avg)
473)     # normal speed, position almost matches -> everything fine (do nothing)
474)     if abs(off_avg) < self.max_equal_offset and self.mplayer_speed == 1.0:
475)       return
476)     # position is really good -> go to normal speed
477)     if abs(off_avg) < self.max_equal_offset / 3:
478)       self.mplayerSetSpeed(1.0)
479)       return
480)     # synchronize by varying speed
481)     if off_avg < 0.0:
482)       speed = 1.0 - self.speed_change
483)     else:
484)       speed = 1.0 + self.speed_change
485)     if self.mplayer_speed is None or self.mplayer_speed != speed:
486)       self.mplayerSetSpeed(speed)
487) 
488)   def verboseSet(self, verbose):
489)     """set verbose mode"""
490)     self.verbose = verbose
491) 
492)   def waitForInput(self):
493)     """wait for input from UDP socket or MPlayer pipes"""
494)     # poll for data
495)     inputs = []
496)     outputs = []
497)     errors = []
498)     wait_txt = ""
499)     if self.sock is not None:
500)       inputs.append(self.sock)
501)       wait_txt += " socket"
502)     if self.mplayer is not None:
503)       inputs.append(self.mplayer.stdout)
504)       inputs.append(self.mplayer.stderr)
505)       wait_txt += " MPlayer"
506)     self.dbg_print("waiting for input:" + wait_txt)
507)     rds, wrs, exs = select.select(inputs, outputs, errors, self.select_timeout)
508)     # obtain available data
509)     for rd in rds:
510)       if self.sock is not None and rd is self.sock:
511)         self.dbg_print("input from socket")
512)         self.sockRecv()
513)       if self.mplayer is not None and rd is self.mplayer.stdout:
514)         self.dbg_print("input from MPlayer stdout")
515)         self.mplayerStdouterr(False)
516)       if self.mplayer is not None and rd is self.mplayer.stderr:
517)         self.dbg_print("input from MPlayer stderr")
518)         self.mplayerStdouterr(True)
519) 
520) # main function
521) def main(argv):
522)   # check parameters
523)   if len(argv) < 2:
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

524)     print >>sys.stderr, "usage: %s <playlist.txt> [<options>]" % argv[0]
525)     print >>sys.stderr, "options: -v   verbose"
526)     print >>sys.stderr, "         -r   restart MPlayer for each entry"
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

527)     return 2
528)   playlist = argv[1]
529)   verbose = False
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

530)   restart = False
531)   for arg in argv[2:]:
532)     if arg == "-v":
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

533)       verbose = True
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

534)     elif arg == "-r":
535)       restart = True
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

536)     else:
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

537)       print >>sys.stderr, "unknown option \"%s\"" % arg
Stefan Schuermans script to synchronize mplay...

Stefan Schuermans authored 10 years ago

538)       return 3
539)   # run application
540)   app = Synchronizer()
541)   app.verboseSet(verbose)
Stefan Schuermans do not restart MPlayer for...

Stefan Schuermans authored 6 years ago

542)   app.restartSet(restart)