summaryrefslogtreecommitdiff
path: root/functions
diff options
context:
space:
mode:
Diffstat (limited to 'functions')
-rw-r--r--functions/__init__.py0
-rw-r--r--functions/audio.py118
-rw-r--r--functions/function.py88
-rw-r--r--functions/scene.py144
4 files changed, 350 insertions, 0 deletions
diff --git a/functions/__init__.py b/functions/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/functions/__init__.py
diff --git a/functions/audio.py b/functions/audio.py
new file mode 100644
index 0000000..d0e599d
--- /dev/null
+++ b/functions/audio.py
@@ -0,0 +1,118 @@
+"""Audio function module.
+
+Contains the definition of the Audio, the audio primitive.
+"""
+
+import xml.etree.ElementTree as et
+
+from .function import Function
+
+from ..constants import AUDIO, BXW
+from ..exceptions import LoadError
+
+class Audio(Function):
+ """Class representing an audio cue.
+
+ This is the primitive for audio, and all sound cues must be based in some manner off of
+ Audio. This function merely plays a single audio file once, starting at t=0.
+
+ The duration of the audio is automatically determined and is zero if the file does not
+ exist or is unsupported by ffmpeg.
+ """
+ scope = ()
+ audio_scope = frozenset()
+ actual_duration = 0
+
+ def __init__(self, w, id_ = None, name = None, fade_in = 0, fade_out = 0,
+ filename: str = None):
+ super().__init__(w=w, type_=AUDIO, id_=id_, name=name, duration=0,
+ fade_in=fade_in, fade_out=fade_out)
+
+ self._filename = filename
+
+ if filename is not None:
+ self.duration = self.w.get_audio_length(filename)
+ self.audio_scope = frozenset(((filename,),))
+ else:
+ self.duration = 0
+ self.audio_scope = frozenset()
+
+ self.actual_duration = self.duration
+
+ def get_data(self):
+ return None
+
+ def copy_data(self, data):
+ return None
+
+ def render(self, t, data = None):
+ return ((),
+ ((self.id, self._filename, 0, self.fade_in, self.duration, self.fade_out),),
+ None)
+
+ @property
+ def filename(self):
+ """Return the current audio filename."""
+ return self._filename
+
+ @filename.setter
+ def filename(self, value: str):
+ """Set the current audio filename, updating the duration."""
+ if value is not None:
+ self.duration = self.w.get_audio_length(value)
+ self.audio_scope = frozenset(((value,),))
+ else:
+ self.duration = 0
+ self.audio_scope = frozenset()
+
+ self._filename = value
+ self.w.function_changed(self)
+
+ def serialize(self) -> et.Element:
+ e = et.Element(BXW+"function")
+ e.set("type", self.type)
+ e.set("id", str(self.id))
+ e.set("name", self.name)
+ e.set("fade-in", str(self.fade_in))
+ e.set("fade-out", str(self.fade_out))
+ if self.filename is not None:
+ filename = et.SubElement(e, BXW+"filename")
+ filename.text = self.filename
+
+ return e
+
+ @classmethod
+ def deserialize(cls, w, e):
+ if e.tag != BXW+"function":
+ raise LoadError("Invalid function tag")
+ elif e.get("type") != AUDIO:
+ raise LoadError("Load delegated to wrong class (this is a bug)")
+
+ id_ = cls.int_or_none(e.get("id"))
+ if id_ is None:
+ raise LoadError("Function tag has invalid/missing ID")
+
+ name = e.get("name")
+
+ fade_in = e.get("fade-in")
+ try:
+ fade_in = int(fade_in) if fade_in else 0
+ except ValueError:
+ raise LoadError("Invalid fade in")
+
+ fade_out = e.get("fade-out")
+ try:
+ fade_out = int(fade_out) if fade_out else 0
+ except ValueError:
+ raise LoadError("Invalid fade out")
+
+ if len(e) > 1:
+ raise LoadError("Audio tag can have at most one filename")
+ elif len(e) == 1:
+ filename, = e
+ filename = filename.text
+ else:
+ filename = None
+
+ return cls(w=w, id_=id_, name=name, filename=filename, fade_in=fade_in,
+ fade_out=fade_out)
diff --git a/functions/function.py b/functions/function.py
new file mode 100644
index 0000000..97c98f7
--- /dev/null
+++ b/functions/function.py
@@ -0,0 +1,88 @@
+"""Base function module.
+
+Contains the generic Function interface.
+"""
+
+from abc import ABCMeta, abstractmethod, abstractproperty
+from typing import Set, Any
+
+from ..topology import Fixture
+from ..constants import INFTY
+from ..interfaces import XMLSerializable
+
+class Function(XMLSerializable, metaclass=ABCMeta):
+ """Class representing a generic function.
+
+ Many of the properties here should not be implemented as properties for performance
+ reasons.
+
+ Functions and properties required for rendering, e.g. ``Function.render`` and
+ ``Function.actual_duration``, should be written to be performant.
+
+ Any change to the scope, audio scope, or actual duration of the function must be
+ reported using ``Workspace.function_changed`` with the new values. This should also be
+ extended to any change in e.g. values for lighting and audio primitives.
+ """
+ def __init__(self, w: "Workspace", type_: str, id_: int = None, name: str = None,
+ duration: int = INFTY, fade_in: int = 0, fade_out: int = 0):
+ self.w = w
+ self.id = id_ if id_ is not None else w.next_function_id
+ self.name = name if name else "%s %s" % (type_, self.id)
+ self.type = type_
+ self.duration = duration
+ self.fade_in = fade_in
+ self.fade_out = fade_out
+
+ self.w.register_function(self)
+
+ def delete(self):
+ """Delete the function from the Workspace."""
+ self.w.function_deleted(self)
+
+ @abstractproperty
+ def scope(self) -> Set[Fixture.Channel]:
+ """Return the set of channels affected by this function."""
+
+ @abstractproperty
+ def audio_scope(self) -> Set[str]:
+ """Return the set of audio filenames that may be used by this function."""
+
+ @abstractmethod
+ def get_data(self) -> Any:
+ """Return the default data for this function."""
+
+ @abstractmethod
+ def copy_data(self, data: Any) -> Any:
+ """Duplicate the given data."""
+
+ @abstractproperty
+ def actual_duration(self):
+ """Return the actual duration of the function, including all fades."""
+
+ @abstractmethod
+ def render(self, t: int, data: Any = None):
+ """Render the function at the given time.
+
+ This function must return a 3-tuple:
+
+ (light_cues, audio_cues, new_data)
+
+ Where ``light_cues`` is a iterable of (channel, value) pairs. It is an error for
+ ``light_cues`` to contain channels other than exactly this Function's scope.
+ ``audio_cues`` is an iterable of audio cues of the form:
+
+ (guid, filename, start_time, fade_in, end_time, fade_out)
+
+ The ``guid`` is globally unique: that is, it is unique to that specific audio cue,
+ even if the same file is played multiple times or in different places. Note that
+ ``end_time`` may be ``INFTY``, in which case the file should be played to its end.
+ In the case of infinite-duration chasers, the ``end_time`` may change over
+ subsequent calls to this function.
+
+ Note that ``data`` is not necessarily mutable: ``render`` should be called like:
+
+ lights, sound, data = f.render(t, data)
+
+ :param t: the time to render at, in milliseconds
+ :param data: the function data to use
+ """
diff --git a/functions/scene.py b/functions/scene.py
new file mode 100644
index 0000000..958138f
--- /dev/null
+++ b/functions/scene.py
@@ -0,0 +1,144 @@
+"""Scene function module.
+
+Contains the definition of the Scene, the lighting primitive.
+"""
+
+import xml.etree.ElementTree as et
+from typing import Mapping
+
+from .function import Function
+from ..topology import Fixture
+from ..constants import INFTY, SCENE, BXW
+from ..exceptions import LoadError
+
+class Scene(Function):
+ """Class representing a lighting scene.
+
+ This is the primitive for lighting, and all light cues must be based in some manner off
+ of Scene. Scenes are simple, having no fading and infinite duration.
+
+ Modifying the scene can be done in a few ways: the easiest is using the dictionary-
+ style accesses, e.g. setting the values using:
+
+ scene[channel] = value
+
+ Values may be removed from the scene using the ``del`` operator and retrieved in the
+ same fashion. Alternatively, values can be updated in bulk using ``Scene.update`` or
+ set in bulk with ``Scene.set``.
+ """
+ audio_scope = ()
+ scope = frozenset()
+ actual_duration = INFTY
+ values = frozenset()
+
+ def __init__(self, w, id_ = None, name = None, values: Mapping[Fixture.Channel, int] = None):
+ super().__init__(w=w, type_=SCENE, id_=id_, name=name, duration=INFTY, fade_in=0,
+ fade_out=0)
+ self._values = {}
+ self._render = ()
+
+ self._update(values, changed=False)
+
+ def _update_render(self, changed=True):
+ self._render = tuple(self._values.items())
+ self.scope = frozenset(self._values.keys())
+ self.values = self._render
+
+ if changed:
+ self.w.function_changed(self)
+
+ def set(self, v):
+ """Set the scene's values to the given ones.
+
+ Equivalent to update, except that all non-given values are deleted from the scene.
+ """
+ self._values = {}
+ self.update(v)
+
+ def _update(self, v, changed=True):
+ if isinstance(v, dict):
+ v = v.items()
+ vn = []
+ for c, val in v:
+ val = int(val)
+ if val < 0 or val > 255:
+ raise ValueError("Values must be integers on [0,256)")
+ vn.append((c, val))
+ for c, val in vn:
+ self._values[c] = val
+ self._update_render(changed=changed)
+
+ def update(self, v):
+ """Update the scene's values."""
+ self._update(v=v, changed=True)
+
+ def __getitem__(self, c: Fixture.Channel):
+ return self._values[c] if c in self._values else None
+
+ def __setitem__(self, c: Fixture.Channel, v: int):
+ self.update(((c, v),))
+
+ def __delitem__(self, c: Fixture.Channel):
+ if c in self._values:
+ del self._values[c]
+ self._update_render()
+
+ def get_data(self):
+ return None
+
+ def copy_data(self, data):
+ return None
+
+ def render(self, t, data = None):
+ return (self._render, (), None)
+
+ def serialize(self):
+ e = et.Element(BXW+"function")
+ e.set("type", self.type)
+ e.set("id", str(self.id))
+ e.set("name", self.name)
+ for c, v in self.values:
+ ce = et.SubElement(e, BXW+"value")
+ ce.set("fixture", str(c.f.id))
+ ce.set("channel", str(c.id))
+ ce.text = str(v)
+
+ return e
+
+ @classmethod
+ def deserialize(cls, w, e):
+ if e.tag != BXW+"function":
+ raise LoadError("Invalid function tag")
+ elif e.get("type") != SCENE:
+ raise LoadError("Load delegated to wrong class (this is a bug)")
+
+ id_ = cls.int_or_none(e.get("id"))
+ if id_ is None:
+ raise LoadError("Function tag has invalid/missing ID")
+
+ name = e.get("name")
+
+ values = {}
+ for ve in e:
+ if ve.tag != BXW+"value":
+ raise LoadError("Invalid value tag")
+
+ fixture = cls.int_or_none(ve.get("fixture"))
+ channel = cls.int_or_none(ve.get("channel"))
+
+ if None in (fixture, channel):
+ raise LoadError("Missing/invalid fixture/channel value")
+ elif fixture not in w.fixtures:
+ raise LoadError("Missing fixture ID %d" % fixture)
+ elif channel >= len(w.fixtures[fixture].channels):
+ raise LoadError("Fixture %d missing channel ID %d" % (fixture, channel))
+
+ channel = w.fixtures[fixture].channels[channel]
+
+ value = cls.int_or_none(ve.text)
+ if value is None:
+ raise LoadError("Missing/invalid value for channel")
+
+ values[channel] = value
+
+ return cls(w=w, id_=id_, name=name, values=values)