diff options
Diffstat (limited to 'functions')
-rw-r--r-- | functions/__init__.py | 0 | ||||
-rw-r--r-- | functions/audio.py | 118 | ||||
-rw-r--r-- | functions/function.py | 88 | ||||
-rw-r--r-- | functions/scene.py | 144 |
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) |