script to synchronize mplayer to stage director
Stefan Schuermans

Stefan Schuermans commited on 2014-05-01 11:51:39
Showing 2 changed files, with 501 additions and 0 deletions.

... ...
@@ -1,3 +1,4 @@
1 1
 *.pyc
2 2
 .*.swp
3
+nogit
3 4
 zips
... ...
@@ -0,0 +1,500 @@
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
34
+    self.re_mplayer_pos = re.compile(r"[AV]: *([0-9]+.[0-9]+) *\([0-9:.]*\) of .*")
35
+    self.re_num_prefix = re.compile(r"[0-9]\+\.(.*)$")
36
+    # create member variables
37
+    self.mplayer = None
38
+    self.mplayer_buf_stdout = ""
39
+    self.mplayer_buf_stderr = ""
40
+    self.mplayer_last_cmd_timestamp = None
41
+    self.mplayer_name = None
42
+    self.mplayer_pause = None
43
+    self.mplayer_pos = None
44
+    self.mplayer_speed = None
45
+    self.mplayer_timestamp = None
46
+    self.offset_samples = []
47
+    self.playlist = []
48
+    self.playlist_idx = None
49
+    self.posy_name = None
50
+    self.posy_pause = None
51
+    self.posy_pos = None
52
+    self.posy_timestamp = None
53
+    self.sock = None
54
+    self.verbose = False
55
+    # startup
56
+    self.sockSetup()
57
+
58
+  def __del__(self):
59
+    """deconstruct object"""
60
+    self.mplayerStop()
61
+    self.sockClose()
62
+
63
+  def dbg_print(self, txt):
64
+    """output debug information in verbose mode"""
65
+    if self.verbose:
66
+      print >>sys.stderr, txt
67
+
68
+  def mplayerClear(self):
69
+    """clear MPlayer buffers and information"""
70
+    self.mplayer_buf_stdin = ""
71
+    self.mplayer_buf_stderr = ""
72
+    self.mplayer_last_cmd_timestamp = None
73
+    self.mplayer_name = None
74
+    self.mplayer_pause = None
75
+    self.mplayer_pos = None
76
+    self.mplayer_speed = None
77
+    self.mplayer_timestamp = None
78
+    self.offset_samples = []
79
+
80
+  def mplayerExit(self):
81
+    """react to MPlayer exit"""
82
+    # close pipes
83
+    self.mplayer.stdin.close()
84
+    self.mplayer.stdout.close()
85
+    self.mplayer.stderr.close()
86
+    # close process
87
+    self.mplayer.wait()
88
+    self.mplayer = None
89
+    # clear buffers and information
90
+    self.mplayerClear()
91
+    # play next file
92
+    self.playNext()
93
+
94
+  def mplayerLine(self, line, err):
95
+    """process line from MPlayer stdout (err = False) or stderr (err = True)"""
96
+    if len(line) > 0:
97
+      if err:
98
+        self.dbg_print("MPlayer stderr: " + line)
99
+      else:
100
+        self.dbg_print("MPlayer stdout: " + line)
101
+    if not err:
102
+      # MPlayer position information
103
+      m_mplayer_pos = self.re_mplayer_pos.match(line)
104
+      if m_mplayer_pos:
105
+        self.mplayer_timestamp = datetime.datetime.now()
106
+        self.mplayer_pos = float(m_mplayer_pos.group(1))
107
+        # synchronize
108
+        self.sync()
109
+
110
+  def mplayerPause(self, pause):
111
+    """pause/unpause MPlayer"""
112
+    # leave if no MPlayer running
113
+    if self.mplayer is None:
114
+      return
115
+    # switch to pause mode
116
+    if pause:
117
+      self.dbg_print("MPlayer stdin: pausing seek 0 0")
118
+      self.mplayer.stdin.write("pausing seek 0 0\n") # rel seek 0s, then pause
119
+      self.mplayer_pause = True
120
+    # continue playing
121
+    else:
122
+      self.dbg_print("MPlayer stdin: seek 0 0")
123
+      self.mplayer.stdin.write("seek 0 0\n") # realtive seek of 0s
124
+      self.mplayer_pause = False
125
+    self.mplayer_last_cmd_timestamp = datetime.datetime.now()
126
+
127
+  def mplayerSetPos(self, pos):
128
+    """set MPlayer position"""
129
+    # leave if no MPlayer running
130
+    if self.mplayer is None:
131
+      return
132
+    # sanitize position to avoid mplayer crashes in any case
133
+    if pos < 0.0:
134
+      pos = 0.0
135
+    # set new position
136
+    self.dbg_print("MPlayer stdin: seek %5.3f 2" % pos)
137
+    self.mplayer.stdin.write("seek %5.3f 2\n" % pos) # 2 means absolute pos
138
+    self.mplayer_pos = pos
139
+    self.mplayer_last_cmd_timestamp = datetime.datetime.now()
140
+
141
+  def mplayerSetSpeed(self, speed):
142
+    """set MPlayer speed"""
143
+    # leave if no MPlayer running
144
+    if self.mplayer is None:
145
+      return
146
+    # sanitize speed to avoid mplayer crashes in any case
147
+    if speed < 0.5:
148
+      speed = 0.5
149
+    if speed > 2.0:
150
+      speed = 2.0
151
+    # set new speed
152
+    self.dbg_print("MPlayer stdin: speed_set %5.3f" % speed)
153
+    self.mplayer.stdin.write("speed_set %5.3f\n" % speed)
154
+    self.mplayer_speed = speed
155
+    self.mplayer_last_cmd_timestamp = datetime.datetime.now()
156
+
157
+  def mplayerStart(self, filename):
158
+    """start MPlayer process in background"""
159
+    # stop old MPlayer
160
+    self.mplayerStop()
161
+    # start MPlayer
162
+    cmd = [ "mplayer", "-slave", "-af", "scaletempo", filename ]
163
+    print >>sys.stderr, "starting background process: " + " ".join(cmd)
164
+    self.mplayer = subprocess.Popen(cmd, stdin = subprocess.PIPE,
165
+                                         stdout = subprocess.PIPE,
166
+                                         stderr = subprocess.PIPE)
167
+    # make output pipes nonblocking
168
+    fcntl.fcntl(self.mplayer.stdout, fcntl.F_SETFL, os.O_NONBLOCK)
169
+    fcntl.fcntl(self.mplayer.stderr, fcntl.F_SETFL, os.O_NONBLOCK)
170
+    # set initial information
171
+    self.mplayer_name = filename
172
+    self.mplayer_pause = False
173
+    self.mplayer_speed = 1.0
174
+    self.mplayer_last_cmd_timestamp = datetime.datetime.now()
175
+
176
+  def mplayerStdouterr(self, err):
177
+    """process data from MPlayer stdout (err = False) or stderr (err = True)"""
178
+    if self.mplayer is None:
179
+      return
180
+    # receive data
181
+    if err:
182
+      txt = self.mplayer.stderr.read()
183
+    else:
184
+      txt = self.mplayer.stdout.read()
185
+    # check if MPlayer exited
186
+    if len(txt) == 0:
187
+      self.mplayerExit()
188
+      return
189
+    # MPlayer did not exit
190
+    # replace CRs with LFs add data to buffer
191
+    txt = txt.replace("\r", "\n")
192
+    if err:
193
+      buf = self.mplayer_buf_stderr + txt
194
+    else:
195
+      buf = self.mplayer_buf_stdout + txt
196
+    # process complete lines and store remaining data in buffer
197
+    lines = buf.split("\n")
198
+    for line in lines[:-1]:
199
+      self.mplayerLine(line, err)
200
+    if err:
201
+      self.mplayer_buf_stderr = buf[-1]
202
+    else:
203
+      self.mplayer_buf_stdout = buf[-1]
204
+
205
+  def mplayerStop(self):
206
+    """stop MPlayer process in background"""
207
+    if self.mplayer is not None:
208
+      # send quit command
209
+      self.mplayer.stdin.write("quit\n")
210
+      # close pipes
211
+      self.mplayer.stdin.close()
212
+      self.mplayer.stdout.close()
213
+      self.mplayer.stderr.close()
214
+      # terminate process
215
+      self.mplayer.terminate()
216
+      self.mplayer.kill()
217
+      # close process
218
+      self.mplayer.wait()
219
+      self.mplayer = None
220
+    # clear buffers and information
221
+    self.mplayerClear()
222
+
223
+  def playlistFind(self, posy_name):
224
+    """find file in playlist by PoSy name"""
225
+    idx = 0
226
+    for file_name in self.playlist:
227
+      if self.posyCheckName(posy_name, file_name):
228
+        return idx;
229
+      idx += 1
230
+    return None
231
+
232
+  def playlistRead(self, playlist):
233
+    """read playlist file"""
234
+    # read filenames from playlist
235
+    filenames = []
236
+    try:
237
+      with open(playlist, "rt") as f:
238
+        filenames = f.readlines()
239
+    except:
240
+      return False
241
+    filenames = [filename.strip() for filename in filenames]
242
+    # convert filenames to absolute paths
243
+    playlistdir = os.path.dirname(playlist)
244
+    filenames = [os.path.join(playlistdir, filename) for filename in filenames]
245
+    # replace playlist
246
+    self.playlist = filenames
247
+    self.playlist_idx = None
248
+    return True
249
+
250
+  def playNext(self):
251
+    """play next file in playlist"""
252
+    # playlist empty -> stop MPlayer
253
+    if len(self.playlist) == 0:
254
+      self.playlist_idx = None
255
+      self.mplayerStop()
256
+    # playlist not empty -> play next file
257
+    else:
258
+      if self.playlist_idx is None:
259
+        self.playlist_idx = 0
260
+      else:
261
+        self.playlist_idx += 1
262
+        if self.playlist_idx >= len(self.playlist):
263
+          self.playlist_idx = 0
264
+      self.mplayerStart(self.playlist[self.playlist_idx])
265
+
266
+  def playPosyName(self, posy_name):
267
+    """play file by PoSy name (if found)"""
268
+    # find file in playlist
269
+    idx = self.playlistFind(posy_name)
270
+    # file not found -> stop MPlayer
271
+    if idx is None:
272
+      self.playlist_idx = None
273
+      self.mplayerStop()
274
+    # file found -> (re-)start MPlayer
275
+    else:
276
+      self.playlist_idx = idx
277
+      self.mplayerStart(self.playlist[idx])
278
+
279
+  def posyCheckName(self, posy_name, file_name):
280
+    """check if filename matches PoSyName"""
281
+    # remove directory part of file name and check
282
+    file_name = os.path.basename(file_name)
283
+    if file_name == posy_name:
284
+      return True
285
+    # remove extension and check
286
+    file_name = os.path.splitext(file_name)[0]
287
+    if file_name == posy_name:
288
+      return True
289
+    # remove number prefix and check
290
+    m_num_prefix = self.re_num_prefix.match(file_name)
291
+    if m_num_prefix and m_num_prefix.group(1) == posy_name:
292
+      return True
293
+    # not matching
294
+    return False
295
+
296
+  def posyParse(self, data):
297
+    """parse received PoSy packet"""
298
+    if len(data) < 76 or data[0:4] != "PoSy":
299
+      return False
300
+    flags, name, pos_ms = struct.unpack("!I64sI", data[4:76])
301
+    name_end = name.find("\0")
302
+    if name_end >= 0:
303
+      name = name[:name_end]
304
+    if flags & 1:
305
+      pause = True
306
+    else:
307
+      pause = False
308
+    # store info from PoSy packet
309
+    self.posy_timestamp = datetime.datetime.now()
310
+    self.posy_name = name
311
+    self.posy_pos = pos_ms * 1e-3
312
+    self.posy_pause = pause
313
+    return True
314
+
315
+  def run(self):
316
+    """run application"""
317
+    try:
318
+      while True:
319
+        self.waitForInput()
320
+    except KeyboardInterrupt:
321
+      pass
322
+
323
+  def sockRecv(self):
324
+    """receive data from socket"""
325
+    if self.sock is None:
326
+      return
327
+    # receive message
328
+    data = self.sock.recv(4096)
329
+    self.dbg_print("data from socket: %d bytes" % len(data))
330
+    # parse (ignore message on error)
331
+    if not self.posyParse(data):
332
+      return
333
+    # synchronize
334
+    self.sync()
335
+
336
+  def sockClose(self):
337
+    """close UDP socket"""
338
+    if self.sock is not None:
339
+      self.sock.close()
340
+      self.sock = None
341
+
342
+  def sockSetup(self):
343
+    """create a new UDP socket and bind it"""
344
+    self.sockClose()
345
+    try:
346
+      self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
347
+      self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
348
+      self.sock.bind(("0.0.0.0", 5740))
349
+    except:
350
+      self.sockClose()
351
+
352
+  def sync(self):
353
+    """synchronize MPlayer to PoSy input"""
354
+    now = datetime.datetime.now()
355
+    # do nothing if PoSy information is missing
356
+    if self.posy_name is None or \
357
+       self.posy_pause is None or \
358
+       self.posy_pos is None or \
359
+       self.posy_timestamp is None:
360
+      return
361
+    # do nothing if PoSy information is too old
362
+    posy_age = (now - self.posy_timestamp).total_seconds()
363
+    if posy_age > self.info_timeout:
364
+      return
365
+    # MPlayer not running -> play requested file (if found)
366
+    if self.mplayer is None:
367
+      self.playPosyName(self.posy_name)
368
+      return
369
+    # do nothing if MPlayer information is missing
370
+    if self.mplayer_name is None or \
371
+       self.mplayer_pause is None or \
372
+       self.mplayer_pos is None or \
373
+       self.mplayer_timestamp is None:
374
+      return
375
+    # do nothing if MPlayer information is too old
376
+    # (ignore MPlayer info age in MPlayer pause mode, as there is no info)
377
+    if not self.mplayer_pause:
378
+      mplayer_age = (now - self.mplayer_timestamp).total_seconds()
379
+      if mplayer_age > self.info_timeout:
380
+        return
381
+    # do nothing if last MPlayer command has been sent shortly
382
+    if self.mplayer_last_cmd_timestamp is not None:
383
+      last_cmd_age = (now - self.mplayer_last_cmd_timestamp).total_seconds()
384
+      if last_cmd_age < self.min_cmd_delay:
385
+        return
386
+    # output information for debugging
387
+    self.dbg_print("MPlayer: %s name \"%s\" pos %f pause %s" % \
388
+              (self.mplayer_timestamp, self.mplayer_name, self.mplayer_pos,
389
+               self.mplayer_pause))
390
+    self.dbg_print("PoSy:    %s name \"%s\" pos %f pause %s" % \
391
+              (self.posy_timestamp, self.posy_name, self.posy_pos,
392
+               self.posy_pause))
393
+    # name mismatch -> play requested file (if found)
394
+    if not self.posyCheckName(self.posy_name, self.mplayer_name):
395
+      self.playPosyName(self.posy_name)
396
+      return
397
+    # pause mode mismatch -> pause/unpause MPlayer
398
+    if self.mplayer_pause != self.posy_pause:
399
+      self.mplayerPause(self.posy_pause)
400
+      return
401
+    # never seek in MPlayer pause mode (this continues playback)
402
+    if self.mplayer_pause:
403
+      return
404
+    # calculate offset (account for time elased since last info)
405
+    mplayer_pos = self.mplayer_pos
406
+    if not self.mplayer_pause:
407
+      mplayer_pos += mplayer_age
408
+    posy_pos = self.posy_pos
409
+    if not self.posy_pause:
410
+      posy_pos += posy_age
411
+    offset = posy_pos - mplayer_pos
412
+    self.dbg_print("offset: %5.3f" % offset)
413
+    # seek if offset is too big
414
+    if abs(offset) > self.min_seek_offset:
415
+      self.mplayerSetSpeed(1.0) # position will be okay -> normal speed
416
+      self.mplayerSetPos(posy_pos)
417
+      return
418
+    # compute sliding average of offset (to get rid of jitter)
419
+    self.offset_samples.append(offset)
420
+    self.offset_samples = self.offset_samples[-10:]
421
+    off_avg = 0
422
+    for o in self.offset_samples:
423
+      off_avg += o
424
+    off_avg *= 0.1
425
+    self.dbg_print("off_avg: %5.3f" % off_avg)
426
+    # normal speed, position almost matches -> everything fine (do nothing)
427
+    if abs(off_avg) < self.max_equal_offset and self.mplayer_speed == 1.0:
428
+      return
429
+    # position is really good -> go to normal speed
430
+    if abs(off_avg) < self.max_equal_offset / 3:
431
+      self.mplayerSetSpeed(1.0)
432
+      return
433
+    # synchronize by varying speed
434
+    if off_avg < 0.0:
435
+      speed = 1.0 - self.speed_change
436
+    else:
437
+      speed = 1.0 + self.speed_change
438
+    if self.mplayer_speed is None or self.mplayer_speed != speed:
439
+      self.mplayerSetSpeed(speed)
440
+
441
+  def verboseSet(self, verbose):
442
+    """set verbose mode"""
443
+    self.verbose = verbose
444
+
445
+  def waitForInput(self):
446
+    """wait for input from UDP socket or MPlayer pipes"""
447
+    # poll for data
448
+    inputs = []
449
+    outputs = []
450
+    errors = []
451
+    wait_txt = ""
452
+    if self.sock is not None:
453
+      inputs.append(self.sock)
454
+      wait_txt += " socket"
455
+    if self.mplayer is not None:
456
+      inputs.append(self.mplayer.stdout)
457
+      inputs.append(self.mplayer.stderr)
458
+      wait_txt += " MPlayer"
459
+    self.dbg_print("waiting for input:" + wait_txt)
460
+    rds, wrs, exs = select.select(inputs, outputs, errors, self.select_timeout)
461
+    # obtain available data
462
+    for rd in rds:
463
+      if self.sock is not None and rd is self.sock:
464
+        self.dbg_print("input from socket")
465
+        self.sockRecv()
466
+      if self.mplayer is not None and rd is self.mplayer.stdout:
467
+        self.dbg_print("input from MPlayer stdout")
468
+        self.mplayerStdouterr(False)
469
+      if self.mplayer is not None and rd is self.mplayer.stderr:
470
+        self.dbg_print("input from MPlayer stderr")
471
+        self.mplayerStdouterr(True)
472
+
473
+# main function
474
+def main(argv):
475
+  # check parameters
476
+  if len(argv) < 2:
477
+    print >>sys.stderr, "usage: %s <playlist.txt> [-v]" % argv[0]
478
+    return 2
479
+  playlist = argv[1]
480
+  verbose = False
481
+  if len(argv) >= 3:
482
+    if argv[2] == "-v":
483
+      verbose = True
484
+    else:
485
+      print >>sys.stderr, "unknown option \"%s\"" % argv[2]
486
+      return 3
487
+  # run application
488
+  app = Synchronizer()
489
+  app.verboseSet(verbose)
490
+  if not app.playlistRead(playlist):
491
+    print >>sys.stderr, "could not read playlist \"%s\"" % playlist
492
+    return 4
493
+  app.run()
494
+  # done
495
+  return 0
496
+
497
+# main application entry point
498
+if __name__ == "__main__":
499
+  sys.exit(main(sys.argv))
500
+
0 501