About | Log | Files | Refs
commit 9b3230d72efc51a504edf102090c219e9dd35f99
parent fff5e34c9864532b5e38e70b658eccb0ff35d1d3
Author: Ben Connors <benconnors@outlook.com>
Date: Thu, 24 Jan 2019 21:14:02 -0500
Move stuff around; fix rendering
- Now actually able to render shows
- Maybe able to render chasers
- Add untested OLA lighting output
- General fixes
Diffstat:
12 files changed, 382 insertions(+), 298 deletions(-)
diff --git a/audio.py b/audio.py
@@ -1,154 +0,0 @@
-#!/usr/bin/env python3
-
-"""Audio module for BLC.
-
-This module defines an AudioPlayer interface which allows for various audio backends to be used
-interchangeably. It also defines a bare-bones better-than-nothing "FFPlayer" implementation and
-a better "MPVPlayer" implementation.
-
-"DefaultAudioPlayer" should be used in general and will refer to MPVPlayer if available and
-FFPlayer otherwise.
-"""
-
-import atexit
-import subprocess as subp
-import time
-import warnings
-
-from abc import ABC, abstractmethod, abstractproperty
-
-def ttoti(t):
- """Convert seconds to milliseconds."""
- return int(1000*t + 0.5)
-
-def titot(ti):
- """Convert milliseconds to seconds."""
- return ti/1000
-
-class AudioPlayer(ABC):
- """Class for playing audio.
-
- All time indices must be integers in milliseconds.
- """
- @abstractmethod
- def play(self, start=-1):
- """Play the audio from the given time.
-
- If start is -1, play it from the current time index (e.g. if paused). If the player is
- already playing, throw an error.
- """
- return
-
- @abstractmethod
- def seek(self, t):
- """Seek to the given time index."""
- return
-
- @abstractmethod
- def pause(self):
- """Pause the player."""
- return
-
- @abstractmethod
- def stop(self):
- """Stop the player and reset to the first time index."""
- return
-
- @abstractproperty
- def volume(self):
- """Get or set the current volume."""
- return
-
- @abstractproperty
- def position(self) -> int:
- """The current position in milliseconds."""
- return
-
- @abstractproperty
- def playing(self) -> bool:
- """Return if the player is playing or not."""
- return
-
- def __init__(self, fname, args=()):
- self.fname = fname
- self.args = args
-
-class FFPlayer(AudioPlayer):
- """Audio player using ffplay.
-
- Note that this is incredibly bad: the current position is guessed based on the start time of
- the subprocess (meaning startup time of the ffplay process is counted in the current
- position), no preloading of files is done, seeking is inaccurate and requires killing and
- restarting the ffplay process, volume is ignored, and more. This is due to the fact that you
- can't provide input to ffplay because it uses SDL exclusively for input (even though it can
- be run without SDL?) so any change requires restarting the process. Use MPVPlayer if
- possible.
- """
- def play(self, start=-1):
- if self.playing:
- raise ValueError("Already playing")
-
- if start != -1:
- self.start = titot(start)
- self.player = subp.Popen(["ffplay", "-nodisp", "-autoexit", "-ss", str(self.start), *self.args, self.fname],
- stdin=subp.DEVNULL, stdout=subp.DEVNULL, stderr=subp.DEVNULL)
- atexit.register(self.stop)
- self.start_time = time.monotonic()
-
- def stop(self):
- if not self.playing:
- return
- self.player.terminate()
- atexit.unregister(self.stop)
- self.player = None
- self.start = 0
-
- def seek(self, t):
- if self.playing:
- self.stop()
- self.start = titot(t)
- self.play()
- else:
- self.start = titot(t)
-
- def pause(self):
- if not self.playing:
- return
- self.stop()
- self.start = self.start + time.monotonic()
-
- @property
- def position(self):
- if not self.playing:
- return self.start
- return ttoti(self.start + time.monotonic() - self.start_time)
-
- @property
- def volume(self):
- return 100
-
- @volume.setter
- def volume(self, vol):
- return
-
- @property
- def playing(self):
- if self.player is not None:
- if self.player.poll() is not None:
- self.player = None
-
- return self.player is not None
-
- def __init__(self, fname, args=()):
- super().__init__(fname, args=args)
-
- self.player = None
- self.start = 0
- self.start_time = 0
-
-try:
- import mpv
-except (OSError, ImportError):
- warnings.warn("mpv backend unavailable, falling back to ffplay", RuntimeWarning)
-
- DefaultAudioPlayer = FFPlayer
diff --git a/__init__.py b/blc/__init__.py
diff --git a/blc/audio.py b/blc/audio.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+
+"""Audio module for BLC.
+
+This module defines an AudioPlayer interface which allows for various audio backends to be used
+interchangeably. It also defines a bare-bones better-than-nothing "FFPlayer" implementation and
+a better "MPVPlayer" implementation.
+
+"DefaultAudioPlayer" should be used in general and will refer to MPVPlayer if available and
+FFPlayer otherwise.
+"""
+
+import atexit
+import subprocess as subp
+import time
+import warnings
+
+from abc import ABC, abstractmethod, abstractproperty
+
+def ttoti(t):
+ """Convert seconds to milliseconds."""
+ return int(1000*t + 0.5)
+
+def titot(ti):
+ """Convert milliseconds to seconds."""
+ return ti/1000
+
+class AudioPlayer(ABC):
+ """Class for playing audio.
+
+ All time indices must be integers in milliseconds.
+ """
+ @abstractmethod
+ def play(self, start=-1):
+ """Play the audio from the given time.
+
+ If start is -1, play it from the current time index (e.g. if paused). If the player is
+ already playing, throw an error.
+ """
+ return
+
+ @abstractmethod
+ def seek(self, t):
+ """Seek to the given time index."""
+ return
+
+ @abstractmethod
+ def pause(self):
+ """Pause the player."""
+ return
+
+ @abstractmethod
+ def stop(self):
+ """Stop the player and reset to the first time index."""
+ return
+
+ @abstractproperty
+ def volume(self):
+ """Get or set the current volume."""
+ return
+
+ @abstractproperty
+ def position(self) -> int:
+ """The current position in milliseconds."""
+ return
+
+ @abstractproperty
+ def playing(self) -> bool:
+ """Return if the player is playing or not."""
+ return
+
+ def __init__(self, fname, args=()):
+ self.fname = fname
+ self.args = args
+
+class FFPlayer(AudioPlayer):
+ """Audio player using ffplay.
+
+ Note that this is incredibly bad: the current position is guessed based on the start time of
+ the subprocess (meaning startup time of the ffplay process is counted in the current
+ position), no preloading of files is done, seeking is inaccurate and requires killing and
+ restarting the ffplay process, volume is ignored, and more. This is due to the fact that you
+ can't provide input to ffplay because it uses SDL exclusively for input (even though it can
+ be run without SDL?) so any change requires restarting the process. Use MPVPlayer if
+ possible.
+ """
+ def play(self, start=-1):
+ if self.playing:
+ raise ValueError("Already playing")
+
+ if start != -1:
+ self.start = titot(start)
+ if self.start <= 0.1:
+ self.start = 0
+ self.player = subp.Popen(["ffplay", "-nodisp", "-autoexit", "-ss", str(self.start), *self.args, self.fname],
+ stdin=subp.DEVNULL, stdout=subp.DEVNULL, stderr=subp.DEVNULL)
+ atexit.register(self.stop)
+ self.start_time = time.monotonic()
+
+ def stop(self):
+ if not self.playing:
+ return
+ self.player.terminate()
+ atexit.unregister(self.stop)
+ self.player = None
+ self.start = 0
+
+ def seek(self, t):
+ if self.playing:
+ self.stop()
+ self.start = titot(t)
+ self.play()
+ else:
+ self.start = titot(t)
+
+ def pause(self):
+ if not self.playing:
+ return
+ self.stop()
+ self.start = self.start + time.monotonic()
+
+ @property
+ def position(self):
+ if not self.playing:
+ return self.start
+ return ttoti(self.start + time.monotonic() - self.start_time)
+
+ @property
+ def volume(self):
+ return 100
+
+ @volume.setter
+ def volume(self, vol):
+ return
+
+ @property
+ def playing(self):
+ if self.player is not None:
+ if self.player.poll() is not None:
+ self.player = None
+
+ return self.player is not None
+
+ def __init__(self, fname, args=()):
+ super().__init__(fname, args=args)
+
+ self.player = None
+ self.start = 0
+ self.start_time = 0
+
+## try:
+## import mpv
+## except (OSError, ImportError):
+## warnings.warn("mpv backend unavailable, falling back to ffplay", RuntimeWarning)
+
+DefaultAudioPlayer = FFPlayer
diff --git a/image.py b/blc/image.py
diff --git a/blc/ola.py b/blc/ola.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+
+"""Classes for use with the OLA project."""
+
+import array
+
+from ola.OlaClient import OlaClient
+
+from .output import LightingOutput
+
+class OLAOutput(LightingOutput):
+ """An OLA client for BLC.
+
+ universe_map must be a dictionary associating the numeric QLC+ universe ID with a numeric
+ OLA universe.
+ """
+ def set_values(self, values):
+ send = set()
+ for c, v in values:
+ if c.universe.id in self.universe_map:
+ au = self.universe_map[c.universe.id]
+ else:
+ au = c.universe.id
+ if au not in self.universes:
+ self.universes[au] = array.array('B', (0 for i in range(512)))
+ uni = self.universes[au]
+ if uni[c.address] != v:
+ uni[c.address] = v
+ send.add(au)
+ for au in send:
+ self.client.SendDmx(au, self.universes[au])
+
+ def __init__(self, universe_map=None):
+ self.universe_map = universe_map if universe_map is not None else {}
+ self.client = OlaClient()
+
+ self.universes = {0: array.array('B', (0 for i in range(512)))}
diff --git a/blc/output.py b/blc/output.py
@@ -0,0 +1,40 @@
+"""DMX module.
+
+Defines a generic interface for a DMX interface.
+"""
+
+from abc import ABC, abstractmethod
+
+class LightingOutput(ABC):
+ """Generic lighting interface."""
+
+ ## Set this to how long it takes to transmit one set of values. May be ignored by client
+ ## code
+ trans_time = 1
+
+ @abstractmethod
+ def set_values(self, values):
+ """Set the current DMX values.
+
+ values must be an iterable of the form:
+
+ (channel, value), ...
+
+ channel entries may not be repeated and each channel will be an instance of
+ workspace.Channel. value must be between 0 and 255, inclusive.
+ """
+ return
+
+class ChainedLightingOutput(LightingOutput):
+ """Class for combining lighting outputs.
+
+ Useful for having one output display current light values, while another actually outputs
+ the values.
+ """
+ def set_values(self, values):
+ for o in self.outputs:
+ o.set_values(values)
+
+ def __init__(self, *outputs):
+ self.outputs = outputs
+ self.trans_time = min((i.trans_time for i in outputs))
diff --git a/blc/render.py b/blc/render.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+import queue
+import time
+import threading
+
+from .audio import DefaultAudioPlayer, AudioPlayer
+from .output import LightingOutput
+from .workspace import SHOW, CHASER, Advanceable, QLC_INFTY
+
+class FunctionQueue:
+ def after(self, t: float, f: callable):
+ """Run the given function after t milliseconds."""
+ self.queue.put((time.monotonic() + t/1000, f))
+
+ def start(self):
+ """Run until the queue is empty."""
+ while not self.queue.empty():
+ t, f = self.queue.get()
+ time.sleep(max(0, t-time.monotonic()))
+ f()
+
+ def __init__(self):
+ self.queue = queue.SimpleQueue()
+
+class Renderer:
+ """Basic renderer for functions.
+
+ Supports live-rendering Chasers and Shows.
+
+ Instances of this class are NOT thread-safe, with the exception of the advance() method,
+ which may be called from other threads.
+ """
+ def start(self):
+ """Start the function."""
+ if self.start_time is not None:
+ raise ValueError("Already running")
+ self.fq.after(0, self.render_step)
+ self.fq.start()
+ self.nx = None
+ self.data = None
+
+ def render_step(self):
+ """Output the current step and render the next one."""
+ if self.nx not in (None, -1, QLC_INFTY):
+ self.fq.after(max(self.minnx, self.nx), self.render_step)
+ if self.nx is None:
+ self.nx = 0
+ self.fq.after(0, self.render_step)
+ elif self.start_time is None:
+ self.start_time = time.monotonic()
+
+ self.lo.set_values(tuple(self.values.items()))
+ for st, ap in self.anext:
+ ap.play(max(int((time.monotonic() - self.start_time)*1000+1)-st, 0))
+ self.anext = []
+
+ if self.nx == QLC_INFTY:
+ ## Acquire the lock twice and block the process, we're stalled
+ self.stall_lock.acquire()
+ self.stall_lock.acquire()
+ ## Restart the rendering
+ self.fq.after(0, self.render_step)
+
+ with self.data_lock:
+ if self.start_time is not None:
+ t = int((time.monotonic() - self.start_time)*1000 + 1) + self.nx
+ else:
+ t = 0
+ vals, acues, self.nx, self.data = self.f.render(t)
+ for c, v in vals:
+ self.values[c] = v
+ for aid, st, fname, *_ in acues:
+ if aid not in self.aplayers:
+ self.aplayers.add(aid)
+ self.anext.append((st, self.ap(fname)))
+
+ def advance(self):
+ """Advance the function, if possible.
+
+ It is not an error to call this function when dealing with non-Advanceable toplevel
+ functions; this will just do nothing.
+ """
+ with self.data_lock:
+ if self.start_time == -1:
+ raise ValueError("Cannot advance a function that has not been started!")
+ if issubclass(type(self.f), Advanceable):
+ t = 1000*int(time.monotonic() - self.start_time)
+ self.data = self.f.advance(self.data, time.monotonic() - self.start_time)
+ *_, self.data = self.f.render(t)
+
+ ## This will make the lock unlocked
+ self.stall_lock.acquire(blocking=False)
+ self.stall_lock.release()
+
+ def __init__(self, f, lo:LightingOutput, ap: AudioPlayer=DefaultAudioPlayer, minnx=-1):
+ if f.type not in (SHOW, CHASER):
+ raise ValueError("Only Shows and Chasers may be used as toplevel functions")
+ self.start_time = None
+ self.f = f
+ self.fq = FunctionQueue()
+ self.minnx = minnx
+ self.nx = None
+ self.data = None
+ self.data_lock = threading.Lock()
+ self.values = {c: 0 for c in self.f.scope}
+ self.lo = lo
+ self.ap = ap
+ self.aplayers = set()
+ self.anext = []
+ self.stall_lock = threading.Lock()
diff --git a/blc/tk.py b/blc/tk.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+
+"""Module containing Tk widgets for BLC."""
+
+from .output import LightingOutput
+
+from tkinter import Frame, N, E, S, W, VERTICAL
+from tkinter.ttk import Label, Scale
+
+class DMXView(Frame):
+ """Class for viewing DMX values."""
+ def update_values(self, vals:tuple):
+ """Update the current values.
+
+ Parameters:
+ vals: a tuple of (channel, value) pairs. values must be integers from 0 to 255,
+ inclusive.
+ """
+ for c,v in vals:
+ self.channels[c-1-self.offset].set(255-v)
+
+ def __init__(self, master, count=36, offset=0):
+ super().__init__(master)
+
+ self.channels = []
+ self.offset = 0
+ self.rowconfigure(0,weight=1)
+ for c in range(count):
+ self.columnconfigure(c,weight=1)
+ s = Scale(self, from_=0, to=255, orient=VERTICAL, length=300)
+ s.grid(row=0, column=c, sticky=N+E+S+W)
+ s.set(255)
+ Label(self, text=str(c+1+offset)).grid(row=1, column=c,sticky=N+E+S+W)
+ self.channels.append(s)
+
+class TkOutput(LightingOutput, DMXView):
+ def set_values(self, values):
+ self.update_values(((c.address, v) for c,v in values))
diff --git a/workspace.py b/blc/workspace.py
diff --git a/output.py b/output.py
@@ -1,27 +0,0 @@
-"""DMX module.
-
-Defines a generic interface for a DMX interface.
-"""
-
-from abc import ABC, abstractmethod
-
-class LightingOutput(ABC):
- """Generic lighting interface."""
-
- ## Set this to how long it takes to transmit one set of values. May be ignored by client
- ## code
- trans_time = 1
-
-
- @abstractmethod
- def set_values(self, values):
- """Set the current DMX values.
-
- values must be an iterable of the form:
-
- (channel, value), ...
-
- channel entries may not be repeated and each channel will be an instance of
- workspace.Channel. value must be between 0 and 255, inclusive.
- """
- return
diff --git a/render.py b/render.py
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-
-import queue
-import time
-import threading
-
-from .audio import DefaultAudioPlayer, AudioPlayer
-from .output import LightingOutput
-from .workspace import SHOW, CHASER, Advanceable
-
-class FunctionQueue:
- def after(self, t: float, f: callable):
- """Run the given function after t milliseconds."""
- self.queue.put((time.monotonic() + t/1000, f))
-
- def start(self):
- """Run until the queue is empty."""
- while not self.queue.empty():
- t, f = self.queue.get()
- time.sleep(max(0, time.monotonic() - t))
- f()
-
- def __init__(self):
- self.queue = queue.SimpleQueue()
-
-class Renderer:
- """Basic renderer for functions.
-
- Supports live-rendering Chasers and Shows.
-
- Instances of this class are NOT thread-safe, with the exception of the advance() method,
- which may be called from other threads.
- """
- def start(self):
- if self.start_time != -1:
- raise ValueError("Already running")
- self.f.after(0, self.render_step)
- self.f.start()
- self.nx = None
- self.data = None
- self.vals = {}
-
- def render_step(self):
- """Output the current step and render the next one."""
- if self.nx not in (None, -1):
- self.fq.after((max(self.minnx, self.nx), self.render_step))
- elif self.nx is None:
- self.start_time = time.monotonic()
-
- self.lo.set_values(tuple(self.values.items()))
-
- with self.data_lock:
- t = 1000*(int((time.monotonic() - self.start_time)/1000 + 1) + self.nx)
- vals, acues, self.nx, self.data = self.f.render(t)
- for c, v in vals:
- self.values[c] = v
-
- def advance(self):
- """Advance the function, if possible.
-
- It is not an error to call this function when dealing with non-Advanceable toplevel
- functions; this will just do nothing.
- """
- with self.data_lock:
- if self.start_time == -1:
- raise ValueError("Cannot advance a function that has not been started!")
- if issubclass(type(self.f), Advanceable):
- t = 1000*int(self.monotonic() - self.start_time)
- self.data = self.f.advance(self.data, time.monotonic() - self.start_time)
- *_, self.data = self.f.render(t)
-
- def __init__(self, f, lo:LightingOutput, ao: AudioPlayer=DefaultAudioPlayer, minnx=-1):
- if f.type not in (SHOW, CHASER):
- raise ValueError("Only Shows and Chasers may be used as toplevel functions")
- self.start_time = -1
- self.f = f
- self.fq = FunctionQueue()
- self.minnx = minnx
- self.nx = None
- self.data = None
- self.data_lock = threading.Lock()
- self.values = {c: 0 for c in self.f.scope}
- self.lo = lo
- self.ao = ao
- self.aplayers = {}
diff --git a/tk.py b/tk.py
@@ -1,32 +0,0 @@
-#!/usr/bin/env python3
-
-"""Module containing Tk widgets for BLC."""
-
-from tkinter import Frame, N, E, S, W, VERTICAL
-from tkinter.ttk import Label, Scale
-
-class DMXView(Frame):
- """Class for viewing DMX values."""
- def update_vals(self, vals:tuple):
- """Update the current values.
-
- Parameters:
- vals: a tuple of (channel, value) pairs. values must be integers from 0 to 255,
- inclusive.
- """
- for c,v in vals:
- self.channels[c-1-self.offset].set(255-v)
-
- def __init__(self, master, count=36, offset=0):
- super().__init__(master)
-
- self.channels = []
- self.offset = 0
- self.rowconfigure(0,weight=1)
- for c in range(count):
- self.columnconfigure(c,weight=1)
- s = Scale(self, from_=0, to=255, orient=VERTICAL, length=300)
- s.grid(row=0, column=c, sticky=N+E+S+W)
- s.set(255)
- Label(self, text=str(c+1+offset)).grid(row=1, column=c,sticky=N+E+S+W)
- self.channels.append(s)