#!/usr/bin/env python # this is a fork from semprini/ascotalmi to use mpd """ a separate player program from Semprini.py. name means 'listen to me' in italian. features and limitations: xmlrpc interface for: getting the current time in the playing song requesting what song is playing saying what song should play setup: get mpd root path correct run mpd that's been modified to give precise times: dash(pts/21):/my/dl/modified/mpd% src/mpd --no-daemon tell it to scan mpc update (mpc listall to check) run ascoltami todo: """ from __future__ import division,nested_scopes from optparse import OptionParser import sys, os,math,time from rdflib import URIRef import Tkinter as tk import logging log = logging.getLogger() log.setLevel(logging.INFO) from twisted.internet import reactor,tksupport from twisted.internet.error import CannotListenError from twisted.web import xmlrpc, server import run_local from light9 import networking, showconfig, wavelength from light9.namespaces import L9, MUS from light9.uihelpers import toplevelat sys.path.append("/home/drewp/projects/pympd_0") sys.path.append("/my/proj/pympd_0") from pympd import Mpd appstyle={'fg':'white','bg':'black'} def shortSongPath(song, all): prefixlen = len(os.path.commonprefix(all)) # include to the last os.sep- dont crop path elements in the middle prefixlen = all[0].rfind(os.sep)+1 return os.path.splitext(song[prefixlen:])[0] class XMLRPCServe(xmlrpc.XMLRPC): def __init__(self,player): xmlrpc.XMLRPC.__init__(self) self.player=player def xmlrpc_echo(self,x): return x def xmlrpc_playfile(self,musicfilename): self.player.play(musicfilename) return 'ok' def xmlrpc_stop(self): self.player.state.set('stop') return 'ok' def xmlrpc_seek_to(self,t): self.player.seek_to(t) return 'ok' def xmlrpc_seekplay_or_pause(self,t): """either seek to t and play; or pause. this is the curvecalc click-play interface""" if self.player.state.get() == "play": self.player.pause() return 'paused' else: self.player.seek_to(t) self.player.play() return 'playing' def xmlrpc_gettime(self): """returns seconds from start of song""" return float(self.player.smoothCurrentTime()) def xmlrpc_songlength(self): """song length, in seconds""" return float(self.player.total_time.get()) def xmlrpc_song_uri(self): """song URI, or None""" return self.player.song_uri.encode('utf8') or "No song" class Player: """semprini-style access to mpd. in here is where we add the padding""" song_pad_time = 10 def __init__(self): self.mpd = Mpd() args = (networking.mpdServer()+(self.mpd,)) log.info("connecting to %r", args) reactor.connectTCP(*args) self.state = tk.StringVar() self.state.set("stop") # 'stop' 'pause' 'play' self.current_time = tk.DoubleVar() self.total_time = tk.DoubleVar() self.filename_var = tk.StringVar() self.song_uri = None self.pre_post_names = showconfig.prePostSong() self.last_poll_time = None self.last_autopause_time = None self.pollStatus() self.mpd_is_lying = False # mpd reports bad times in certain intervals def smoothCurrentTime(self): """like self.current_time.get, but more accurate""" if self.last_poll_time and self.state.get() == 'play': dt = time.time() - self.last_poll_time else: dt = 0 return self.current_time.get() + dt def pollStatus(self): if self.state.get() == 'stop': self.current_time.set(-4) self.mpd.status().addCallback(self.pollStatus2) def pollStatus2(self, stat): try: if self.state.get() != stat.state: self.state.set(stat.state) if hasattr(stat, 'time_elapsed'): elapsed = stat.time_elapsed songnum = stat.song total = stat.time_total if self.mpd_is_lying and elapsed < 3: self.mpd_is_lying = False # mpd lies about elapsed, song, and total during the last # .5sec of each song. so we coast through that part if elapsed > total - .75 or self.mpd_is_lying: if not self.mpd_is_lying: self.mpd_is_lying = True self.true_song_total = songnum, total self.marked_time = time.time() self.marked_val = elapsed elapsed = self.marked_val + (time.time() - self.marked_time) songnum, total = self.true_song_total t = -1 if songnum == 1: t = elapsed elif songnum == 0: t = elapsed - total elif songnum == 2: t = self.total_time.get() + elapsed self.current_time.set(t) self.last_poll_time = time.time() self.check_autopause() finally: reactor.callLater(.05, self.pollStatus) def set_total_time(self, song): # currently only good for .wav p = showconfig.songOnDisk(song) self.total_time.set(wavelength.wavelength(p)) def play(self, song=None): if song is None: self.mpd.play() return self.mpd.clear() self.mpd.add(showconfig.songInMpd(MUS['preSong'])) self.mpd.add(showconfig.songInMpd(song)) self.mpd.add(showconfig.songInMpd(MUS['postSong'])) self.fillCache(song) self.filename_var.set(graph.value(song, L9['showPath'])) self.song_uri = song self.set_total_time(song) self.seek_to(-4) def fillCache(self, song): """read the song's entire wav file into memory just before playing, so that mpd should never hit the disk during playback. On score in 2007, we had some mpd stutters that were always timed with a disk read.""" p = showconfig.songOnDisk(song) print "reading %s to prime the cache" % p open(p).read() def check_autopause(self): pause_time = self.total_time.get() + self.song_pad_time t = self.current_time.get() if (self.state.get() == "play" and self.last_autopause_time < pause_time < t): self.mpd.pause() self.last_autopause_time = t def stop(self): self.mpd.seek(seconds=0, song=0) self.mpd.stop() def seek_to(self, time): if time < 0: # seeking to anything within my 4-sec silence ogg goes # right to zero. maybe ogg seeking is too coarse? self.mpd.seek(seconds=time - (-4), song=0) elif time > self.total_time.get(): self.mpd.seek(seconds=time - self.total_time.get(), song=2) else: self.mpd.seek(seconds=time, song=1) self.last_autopause_time = time def skip_intro(self): self.seek_to(0) def in_post(self): return (self.current_time.get() > self.total_time.get() + self.song_pad_time) def play_pause_toggle(self): def finish(status): if status.state == 'play': self.mpd.pause() else: self.mpd.play() self.mpd.status().addCallback(finish) def pause(self): self.mpd.pause() def skip_to_post(self): self.seek_to(self.total_time.get() + self.song_pad_time) self.play() class GoButton: def __init__(self, player, statusLabel, songURIs): self.player = player self.statusLabel = statusLabel self.songURIs = songURIs self.player.current_time.trace("w", self.updateStatus) def _nextAction(self): state = self.player.state.get() if state == 'stop': currentPath = self.player.song_uri try: nextPath = self.songURIs[self.songURIs.index(currentPath) + 1] except (ValueError, IndexError): nextPath = self.songURIs[0] return ("next song %s" % shortSongPath(nextPath, self.songURIs), lambda: self.player.play(nextPath)) if state == 'pause': return "play", self.player.play if state == 'play': if self.player.current_time.get() < 0: return "skip intro", self.player.skip_intro if self.player.in_post(): return "", lambda: None return "skip to post", self.player.skip_to_post def action(self): desc, func = self._nextAction() func() def updateStatus(self, *args): desc, func = self._nextAction() self.statusLabel.config(text=desc) def buildsonglist(root, graph, songs, player): songlist=tk.Frame(root,bd=2,relief='raised',bg='black') maxsfwidth=max([len(graph.label(song)) for song in songs]) for i,song in enumerate(songs): b=tk.Button(songlist,text=graph.label(song),width=maxsfwidth, anchor='w',pady=0,bd=0,relief='flat', font="arial 14 bold") b.bind("",lambda ev,b=b: b.config(font="arial %d bold" % min(12,int((ev.height-3)*.8)))) try: # rainbow colors frac=i/len(songs) b.config(bg='black', fg="#%02x%02x%02x" % tuple([int(255*(.7+.3* math.sin(frac*4+x)) ) for x in 1,2,3])) except Exception,e: print "rainbow failed: %s"%e b.config(command=lambda song=song: player.play(song)) b.pack(side='top',fill='both',exp=1,padx=0,pady=0,ipadx=0,ipady=0) def color_buttons(x, y, z, song=song, b=b): name = player.filename_var.get() if name == graph.value(song, L9['showPath']): b['bg'] = 'grey50' else: b['bg'] = 'black' player.filename_var.trace("w", color_buttons) return songlist class TimeScale(tk.Scale): def __init__(self,master,player): tk.Scale.__init__(self, master, orient="horiz", from_=-4,to_=100, sliderlen=20,width=20, res=0.001, showvalue=0, troughcolor='black', bg='lightblue3', ) self.player = player self.dragging = False self.button_down = False self.drag_start_player_state = None self.bind("", self.b1down) self.config(command=self.scale_changed) self.bind("", self.b1up) self.player.current_time.trace('w', self.current_time_changed) def current_time_changed(self, *args): """attach player.current_time to scale (one-way)""" if not self.dragging: self.set(self.player.current_time.get()) def b1down(self,evt): self.button_down = True self.dragging = False def scale_changed(self, time): if not self.button_down: return if not self.dragging: self.dragging = True self.drag_start_player_state = self.player.state.get() if self.drag_start_player_state == "play": # due to mpd having to seemingly play a whole block at # every new seek point, it is better to pause playback # while the mouse is down self.player.pause() # ok to seek around when paused. this keeps the displayed time # up to date, which is how the user knows where he is self.player.seek_to(float(time)) def b1up(self,evt): self.button_down = False self.dragging = False if self.drag_start_player_state == "play": self.player.play() class Seeker(tk.Frame): """includes scale AND labels below it""" def __init__(self,master,player): tk.Frame.__init__(self,master,bg='black') self.scl = TimeScale(self,player) self.scl.pack(fill='x',side='top') self.buildstatus(player) def buildstatus(self,player): left_var=tk.DoubleVar() for txt,var,fmt in (('Current',player.current_time,"%.2f"), ('Song len',player.total_time,"%.2f",), ('Left',left_var, "%.2f"), ('Song',player.filename_var, "%s"), ('State', player.state, "%s")): tk.Label(self,text=txt, relief='raised',bd=1,font='arial 9', **appstyle).pack(side='left',fill='y') l = tk.Label(self,width=7, anchor='w', text=var.get(), relief='ridge',bd=1,font='arial 12 bold', padx=2,pady=2, bg='#800000',fg='white') l.pack(side='left',expand=1, fill='x') var.trace_variable('w', lambda a,b,c,l=l,fmt=fmt,var=var: l.config(text=fmt % var.get())) # update end time as the song changes player.total_time.trace("w",lambda *args: ( self.scl.config(to_=player.total_time.get()+15))) def fixleft(*args): # update time-left variable left_var.set(player.total_time.get()-player.current_time.get()) if player.current_time.get() < 0 or left_var.get() < 0: self.scl['troughcolor'] = 'blue' else: self.scl['troughcolor'] = 'black' player.total_time.trace("w",fixleft) player.current_time.trace("w",fixleft) class ControlButtons(tk.Frame): def __init__(self, master, goButton, player, root): tk.Frame.__init__(self,master,bg='black') self.statebuttons = {} # lowercased name : Button self.goButton = goButton self.player = player for tag, txt,cmd,key in [ ('stop', 'Stop\nC-s', player.stop, ""), ('pause', 'Pause\nC-p', player.play_pause_toggle, ""), ('skip intro', 'Skip Intro\nC-i', player.skip_intro, ""), ('skip to post', 'Skip to Post\nC-t', player.skip_to_post, ""), ('go', 'Go\nspace', self.goButton.action, ""), ]: b = tk.Button(self, text=txt, command=cmd, font='arial 16 bold', height=3,**appstyle) b.pack(side='left', fill='both', expand=True) # keyboard bindings root.bind(key, lambda evt, cmd=cmd: cmd()) self.statebuttons[tag] = b def update_state_buttons(self,*args): state = self.player.state.get() if state in ('stop', 'pause'): self.statebuttons['pause']['text'] = 'Play\nC-p' else: self.statebuttons['pause']['text'] = 'Pause\nC-p' colors = {'stop' : 'red', 'play' : 'blue', 'pause' : 'green'} # very confusing -- play and pause supply colors # for each other! for name, button in self.statebuttons.items(): if name == 'pause' and state not in ('stop', 'pause'): name = 'play' if state == name: # name gets changed sometimes for 'pause' -- see right above button['bg'] = colors.get(name, 'black') else: button['bg'] = 'black' self.goButton.updateStatus() ############################ def main(): global graph parser = OptionParser() parser.add_option('--show', help='show URI, like http://light9.bigasterisk.com/show/dance2008') graph = showconfig.getGraph() (options, songfiles) = parser.parse_args() if len(songfiles)<1: graph = showconfig.getGraph() if not options.show: raise ValueError("missing --show http://...") playList = graph.value(URIRef(options.show), L9['playList']) songs = list(graph.items(playList)) else: raise NotImplementedError("don't know how to make rdf song nodes from cmdline song paths") root=tk.Tk() root.wm_title("ascoltami") toplevelat("ascoltami", root) root.config(bg="black") player=Player() songlist = buildsonglist(root, graph, songs, player) songlist.pack(fill='both',exp=1) seeker = Seeker(root, player) goRow = tk.Frame(root) tk.Label(goRow, text="Go button action", font='arial 9', **appstyle).pack(side='left', fill='both') goStatus = tk.Label(goRow, font='arial 12 bold', **appstyle) goStatus.config(bg='#800000', fg='white', relief='ridge') goStatus.pack(side='left', expand=True, fill='x') go = GoButton(player, goStatus, songs) buts = ControlButtons(root, go, player, root) songlist.pack(fill='both', expand=True) buts.pack(side='top', fill='x') seeker.pack(side='top', fill='x') goRow.pack(side='top', fill='x') player.state.trace_variable('w', buts.update_state_buttons) buts.update_state_buttons() tksupport.install(root,ms=10) try: reactor.listenTCP(networking.musicPort(),server.Site(XMLRPCServe(player))) print "started server on %s" % networking.musicPort() except CannotListenError: print "no server started- %s is in use" % networking.musicPort() root.bind("",lambda ev: reactor.stop) root.protocol('WM_DELETE_WINDOW', reactor.stop) reactor.run() if __name__ == '__main__': main()