From 0ec61cf1734f8005fb4861860031acdca9a3e479 Mon Sep 17 00:00:00 2001 From: Olivier Guilyardi Date: Tue, 1 Dec 2009 16:13:42 +0000 Subject: [PATCH] component: - add ability ro register abstract classes. These can register as implementing some interfaces, but are by default not listed by implementations() - add automatic interface-to-component __doc__ propagation, with the @interfacedoc method decorator api: - general idea: prepare for processor "pipes" high-level api - move process() into IProcessor, make it a generic method - add IProcessors methods that relate to buffersize, and input/output format - add IDecoder.nframes() - add IEffect, an interface for effect processors tests: - fix testnewcore.py - add test and examples of processors implementing the new api and component system others: - add empty Metadata class --- __init__.py | 1 + api.py | 144 +++++++++++++++++++-------------- component.py | 75 +++++++++++++---- core.py | 29 +++++-- metadata.py | 25 ++++++ tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/examples.py | 182 ++++++++++++++++++++++++++++++++++++++++++ tests/api/test.py | 47 +++++++++++ tests/testnewcore.py | 71 +++++++++++++++- 10 files changed, 492 insertions(+), 82 deletions(-) create mode 100644 metadata.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/examples.py create mode 100644 tests/api/test.py diff --git a/__init__.py b/__init__.py index 67efe02..a97a1a6 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,6 @@ from core import * +from metadata import Metadata import decode import encode import analyze diff --git a/api.py b/api.py index 7674679..28419c5 100644 --- a/api.py +++ b/api.py @@ -22,31 +22,68 @@ from timeside.component import Interface class IProcessor(Interface): - """Base processor interface""" + """Common processor interface""" @staticmethod def id(): - """Return a short alphanumeric, lower-case string which uniquely - identify this processor. Only letters and digits are allowed. - An exception will be raised by MetaProcessor if the id is malformed or - not unique amongst registered processors. + """Short alphanumeric, lower-case string which uniquely identify this + processor, suitable for use as an HTTP/GET argument value, in filenames, + etc...""" + + # implementation: only letters and digits are allowed. An exception will + # be raised by MetaProcessor if the id is malformed or not unique amongst + # registered processors. + + def buffersize(self): + """Buffersize this processor operates on, that is; the number of frames + expected/returned by process().""" + + def set_buffersize(self, value): + """Set the buffer size used by this processor.""" + + def set_input_format(self, nchannels, samplerate): + """Set the format of input data passed to process(). It is required to call + this method before calling process(), except for output-only processors.""" + + def input_format(self): + """Return a tuple of the form (nchannels, samplerate) indicating the + format of the data expected by process(), with the same values as the + nchannels and samplerate arguments passed to the constructor.""" + + def output_format(self): + """Return a tuple of the form (nchannels, samplerate) indicating the + format of the data returned by process(). These may differ from the values + passed to the constructor (ie: stereo-to-mono effect, samplerate converter, + etc...)""" + + def process(self, frames=None): + """Process buffersize input frames and return buffersize output frames, both + as numpy arrays, where columns are channels. An input/output of less than + buffersize frames (or None) means that the end-of-data has been reached (the + caller must ensure that this happens). - Typically this identifier is likely to be used during HTTP requests - and be passed as a GET parameter. Thus it should be as short as possible.""" + Output-only processors (such as decoders) will raise an exception if the + frames argument is not None. All processors (even encoders) return data, + even if that means returning the input unchanged. + + Warning: it is required to call set_input_format() before this method + for processors which accept input.""" class IEncoder(IProcessor): """Encoder driver interface. Each encoder is expected to support a specific format.""" - def __init__(self, output, nchannels, samplerate): - """The constructor must always accept the output, nchannels and samplerate - arguments. It may accepts extra arguments such as bitrate, depth, etc.., - but these must be optionnal, that is have a default value. - - The output must either be a filepath or a callback function/method for - for the streaming mode. The callback must accept one argument which is - block of binary data. - """ + def __init__(self, output): + """Create a new encoder. output can either be a filename or a python callback + function/method for streaming mode. + + The streaming callback prototype is: callback(data, eod) + Where data is a block of binary data of an undetermined size, and eod + True when end-of-data is reached.""" + + # implementation: the constructor must always accept the output argument. It may + # accepts extra arguments such as bitrate, depth, etc.., but these must be optionnal, + # that is have a default value. @staticmethod def format(): @@ -70,24 +107,14 @@ class IEncoder(IProcessor): """Return the mime type corresponding to this encode format""" def set_metadata(self, metadata): - """metadata is a tuple containing tuples for each descriptor return by - the dc.Ressource of the item, in the model order : - ((name1, value1),(name2, value2),(name1, value3), ...)""" - - def update(self): - """Updates the metadata into the file passed as the output argument - to the constructor. This method can't be called in streaming - mode.""" - - def process(self, frames): - """Encode buffersize frames passed as a numpy array, where columns are channels. + """Set the metadata to be embedded in the encoded output. - A number of frames less than buffersize means that the end of the data - has been reached, and that the encoder should close the output file, stream, - etc... - - In streaming mode the callback passed to the constructor is called whenever - a block of encoded data is ready.""" + In non-streaming mode, this method updates the metadata directly into the + output file, without re-encoding the audio data, provided this file already + exists. + + It isn't required to call this method, but if called, it must be before + process().""" class IDecoder(IProcessor): """Decoder driver interface. Decoders are different of encoders in that @@ -95,18 +122,15 @@ class IDecoder(IProcessor): export any static method, all informations are dynamic.""" def __init__(self, filename): - """Create a new decoder for filename. Implementations of this interface - may accept optionnal arguments after filename.""" - - def channels(): - """Return the number of channels""" - - def samplerate(): - """Return the samplerate""" + """Create a new decoder for filename.""" + # implementation: additional optionnal arguments are allowed def duration(): """Return the duration in seconds""" + def nframes(): + """Return the number of frames""" + def format(): """Return a user-friendly file format string""" @@ -116,34 +140,30 @@ class IDecoder(IProcessor): def resolution(): """Return the sample depth""" - def process(self): - """Return a generator over the decoded data, as numpy arrays, where columns are - channels, each array containing buffersize frames or less if the end of file - has been reached.""" + def metadata(self): + """Return the metadata embedded into the encoded stream, if any.""" class IGrapher(IProcessor): """Media item visualizer driver interface""" def __init__(self, width, height): - """Create a new grapher. Implementations of this interface - may accept optionnal arguments. width and height are generally + """Create a new grapher. width and height are generally in pixels but could be something else for eg. svg rendering, etc..""" + # implementation: additional optionnal arguments are allowed @staticmethod def name(): """Return the graph name, such as "Waveform", "Spectral view", etc.. """ + def set_nframes(self, nframes): + """Duration in frames of the input data. Must be called before process().""" + def set_colors(self, background=None, scheme=None): """Set the colors used for image generation. background is a RGB tuple, and scheme a a predefined color theme name""" pass - def process(self, frames): - """Process a block of buffersize frames passed as a numpy array, where - columns are channels. Passing less than buffersize frames means that - the end of data has been reached""" - def render(self): """Return a PIL Image object visually representing all of the data passed by repeatedly calling process()""" @@ -158,8 +178,8 @@ class IAnalyzer(IProcessor): for each block of data (as in Vamp).""" def __init__(self): - """Create a new analyzer. Implementations of this interface - may accept optionnal arguments.""" + """Create a new analyzer.""" + # implementation: additional optionnal arguments are allowed @staticmethod def name(): @@ -170,11 +190,6 @@ class IAnalyzer(IProcessor): def unit(): """Return the unit of the data such as "dB", "seconds", etc... """ - def process(self, frames): - """Process a block of buffersize frames passed as a numpy array, where - columns are channels. Passing less than buffersize frames means that - the end of data has been reached""" - class IValueAnalyzer(IAnalyzer): """Interface for analyzers which return a single numeric value from result()""" @@ -182,3 +197,14 @@ class IValueAnalyzer(IAnalyzer): """Return the final result of the analysis performed over the data passed by repeatedly calling process()""" +class IEffect(IProcessor): + """Effect processor interface""" + + def __init__(self): + """Create a new effect.""" + # implementation: additional optionnal arguments are allowed + + @staticmethod + def name(): + """Return the effect name""" + diff --git a/component.py b/component.py index a789528..ff20670 100644 --- a/component.py +++ b/component.py @@ -42,7 +42,8 @@ # implementing a given interface are not automatically considered to implement this # interface too. -__all__ = ['Component', 'MetaComponent', 'implements', 'Interface', 'implementations'] +__all__ = ['Component', 'MetaComponent', 'implements', 'abstract', + 'interfacedoc', 'Interface', 'implementations', 'ComponentError'] class Interface(object): """Marker base class for interfaces.""" @@ -50,27 +51,67 @@ class Interface(object): def implements(*interfaces): """Registers the interfaces implemented by a component when placed in the class header""" - _implements.extend(interfaces) + MetaComponent.implements.extend(interfaces) -def implementations(interface, recurse=True): +def abstract(): + """Declare a component as abstract when placed in the class header""" + MetaComponent.abstract = True + +def implementations(interface, recurse=True, abstract=False): """Returns the components implementing interface, and if recurse, any of - the descendants of interface.""" + the descendants of interface. If abstract is True, also return the + abstract implementations.""" result = [] - find_implementations(interface, recurse, result) + find_implementations(interface, recurse, abstract, result) return result -_implementations = [] -_implements = [] +def interfacedoc(func): + if isinstance(func, staticmethod): + raise ComponentError("@interfacedoc can't handle staticmethod (try to put @staticmethod above @interfacedoc)") + + if not func.__doc__: + func.__doc__ = "@interfacedoc" + func._interfacedoc = True + return func class MetaComponent(type): """Metaclass of the Component class, used mainly to register the interface declared to be implemented by a component.""" + + implementations = [] + implements = [] + abstract = False + def __new__(cls, name, bases, d): new_class = type.__new__(cls, name, bases, d) - if _implements: - for i in _implements: - _implementations.append((i, new_class)) - del _implements[:] + + # Register implementations + if MetaComponent.implements: + for i in MetaComponent.implements: + MetaComponent.implementations.append({ + 'interface': i, + 'class': new_class, + 'abstract': MetaComponent.abstract}) + + # Propagate @interfacedoc + for name in new_class.__dict__: + member = new_class.__dict__[name] + if isinstance(member, staticmethod): + member = getattr(new_class, name) + + if member.__doc__ == "@interfacedoc": + if_member = None + for i in MetaComponent.implements: + if hasattr(i, name): + if_member = getattr(i, name) + if not if_member: + raise ComponentError("@interfacedoc: %s.%s: no such member in implemented interfaces: %s" + % (new_class.__name__, name, str(MetaComponent.implements))) + member.__doc__ = if_member.__doc__ + + MetaComponent.implements = [] + MetaComponent.abstract = False + return new_class class Component(object): @@ -84,16 +125,18 @@ def extend_unique(list1, list2): if item not in list1: list1.append(item) -def find_implementations(interface, recurse, result): +def find_implementations(interface, recurse, abstract, result): """Find implementations of an interface or of one of its descendants and extend result with the classes found.""" - for i, cls in _implementations: - if (i == interface): - extend_unique(result, [cls]) + for item in MetaComponent.implementations: + if (item['interface'] == interface and (abstract or not item['abstract'])): + extend_unique(result, [item['class']]) if recurse: subinterfaces = interface.__subclasses__() if subinterfaces: for i in subinterfaces: - find_implementations(i, recurse, result) + find_implementations(i, recurse, abstract, result) +class ComponentError(Exception): + pass diff --git a/core.py b/core.py index c7a26c9..95c1f61 100644 --- a/core.py +++ b/core.py @@ -22,8 +22,8 @@ from timeside.api import IProcessor from timeside.exceptions import Error, ApiError import re -__all__ = ['Processor', 'MetaProcessor', 'implements', 'processors', - 'get_processor'] +__all__ = ['Processor', 'MetaProcessor', 'implements', 'abstract', + 'interfacedoc', 'processors', 'get_processor'] _processors = {} @@ -31,7 +31,7 @@ class MetaProcessor(MetaComponent): """Metaclass of the Processor class, used mainly for ensuring that processor id's are wellformed and unique""" - valid_id = re.compile("^[a-z][a-z0-9]*$") + valid_id = re.compile("^[a-z][_a-z0-9]*$") def __new__(cls, name, bases, d): new_class = MetaComponent.__new__(cls, name, bases, d) @@ -52,14 +52,17 @@ class Processor(Component): """Base component class of all processors""" __metaclass__ = MetaProcessor + abstract() + implements(IProcessor) + DEFAULT_BUFFERSIZE = 0x10000 MIN_BUFFERSIZE = 0x1000 __buffersize = DEFAULT_BUFFERSIZE + @interfacedoc def buffersize(self): - """Get the current buffer size""" - return __buffersize + return self.__buffersize def set_buffersize(self, value): """Set the buffer size used by this processor. The buffersize must be a @@ -77,6 +80,22 @@ class Processor(Component): self.__buffersize = value + @interfacedoc + def set_input_format(self, nchannels=None, samplerate=None): + self.input_channels = nchannels + self.input_samplerate = samplerate + + @interfacedoc + def input_format(self): + return (self.input_channels, self.input_samplerate) + + @interfacedoc + def output_format(self): + return (self.input_channels, self.input_samplerate) + + @interfacedoc + def process(self, frames): + return frames def processors(interface=IProcessor, recurse=True): """Returns the processors implementing a given interface and, if recurse, diff --git a/metadata.py b/metadata.py new file mode 100644 index 0000000..86abf87 --- /dev/null +++ b/metadata.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007-2009 Parisson +# Copyright (c) 2007 Olivier Guilyardi +# Copyright (c) 2007-2009 Guillaume Pellerin +# +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +class Metadata(object): + pass + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/examples.py b/tests/api/examples.py new file mode 100644 index 0000000..efaaedf --- /dev/null +++ b/tests/api/examples.py @@ -0,0 +1,182 @@ +from timeside.core import Processor, implements, interfacedoc +from timeside.api import * +from timeside import Metadata +from scikits import audiolab +import numpy + +class AudiolabDecoder(Processor): + """A simple audiolab-based example decoder""" + implements(IDecoder) + + @staticmethod + @interfacedoc + def id(): + return "test_audiolabdec" + + @interfacedoc + def __init__(self, filename): + self.file = audiolab.sndfile(filename, 'read') + self.position = 0 + + @interfacedoc + def output_format(self): + return (self.file.get_channels(), self.file.get_samplerate()) + + @interfacedoc + def duration(self): + return self.file.get_nframes() / self.file.get_samplerate() + + @interfacedoc + def nframes(self): + return self.file.get_nframes() + + @interfacedoc + def format(self): + return self.file.get_file_format() + + @interfacedoc + def encoding(self): + return self.file.get_encoding() + + @interfacedoc + def resolution(self): + resolution = None + encoding = self.file.get_encoding() + + if encoding == "pcm8": + resolution = 8 + elif encoding == "pcm16": + resolution = 16 + elif encoding == "pcm32": + resolution = 32 + + return resolution + + @interfacedoc + def metadata(self): + #TODO + return Metadata() + + @interfacedoc + def process(self, frames=None): + if frames: + raise Exception("Decoder doesn't accept input frames") + + buffersize = self.buffersize() + + # Need this because audiolab raises a bogus exception when asked + # to read passed the end of the file + toread = self.nframes() - self.position + if toread > buffersize: + toread = buffersize + + frames = self.file.read_frames(toread) + + self.position += toread + + if toread < buffersize: + self.file.close() + + return frames + +class MaxLevelAnalyzer(Processor): + implements(IValueAnalyzer) + + @interfacedoc + def __init__(self): + self.max_value = 0 + + @staticmethod + @interfacedoc + def id(): + return "test_maxlevel" + + @staticmethod + @interfacedoc + def name(): + return "Max level test analyzer" + + @staticmethod + @interfacedoc + def unit(): + # power? amplitude? + return "" + + def process(self, frames=None): + max = frames.max() + if max > self.max_value: + self.max_value = max + + return frames + + def result(self): + return self.max_value + +class GainEffect(Processor): + implements(IEffect) + + @interfacedoc + def __init__(self, gain=1.0): + self.gain = gain + + @staticmethod + @interfacedoc + def id(): + return "test_gain" + + @staticmethod + @interfacedoc + def name(): + return "Gain test effect" + + def process(self, frames=None): + return numpy.multiply(frames, self.gain) + +class WavEncoder(Processor): + implements(IEncoder) + + def __init__(self, output): + self.file = None + if isinstance(output, basestring): + self.filename = output + else: + raise Exception("Streaming not supported") + + @staticmethod + @interfacedoc + def id(): + return "test_wavenc" + + @staticmethod + @interfacedoc + def description(): + return "Hackish wave encoder" + + @staticmethod + @interfacedoc + def file_extension(): + return "wav" + + @staticmethod + @interfacedoc + def mime_type(): + return "audio/x-wav" + + @interfacedoc + def set_metadata(self, metadata): + #TODO + pass + + @interfacedoc + def process(self, frames): + if not self.file: + # Can't open the file in constructor because input_channels and input_samplerate + # aren't available before set_input_format() has been called + info = audiolab.formatinfo("wav", "pcm16") + self.file = audiolab.sndfile(self.filename, "write", format=info, channels=self.input_channels, + samplerate=self.input_samplerate) + self.file.write_frames(frames) + if len(frames) < self.buffersize(): + self.file.close() + + return frames diff --git a/tests/api/test.py b/tests/api/test.py new file mode 100644 index 0000000..e629a8a --- /dev/null +++ b/tests/api/test.py @@ -0,0 +1,47 @@ +from timeside.tests.api import examples +import os + +source=os.path.dirname(__file__) + "../samples/sweep_source.wav" + +Decoder = examples.AudiolabDecoder +print "Creating decoder with id=%s for: %s" % (Decoder.id(), source) +decoder = Decoder(source) +nchannels, samplerate = decoder.output_format() +print "Stats: duration=%f, nframes=%d, nchannels=%d, samplerate=%d, resolution=%d" % ( + decoder.duration(), decoder.nframes(), nchannels, samplerate, decoder.resolution()) + +analyzer = examples.MaxLevelAnalyzer() +analyzer.set_input_format(nchannels, samplerate) + +while True: + frames = decoder.process() + analyzer.process(frames) + if len(frames) < decoder.buffersize(): + break + +max_level = analyzer.result() +print "Max level: %f" % max_level + +destination = "normalized.wav" +Encoder = examples.WavEncoder +print "Creating encoder with id=%s for: %s" % (Encoder.id(), destination) +encoder = Encoder(destination) +decoder = Decoder(source) + +nchannels, samplerate = decoder.output_format() +encoder.set_input_format(nchannels, samplerate) + +gain = 1 +if max_level > 0: + gain = 0.9 / max_level + +effect = examples.GainEffect(gain) + +print "Applying effect id=%s with gain=%f" % (effect.id(), gain) + +while True: + frames = decoder.process() + encoder.process(effect.process(frames)) + if len(frames) < decoder.buffersize(): + break + diff --git a/tests/testnewcore.py b/tests/testnewcore.py index c28437e..b483a17 100644 --- a/tests/testnewcore.py +++ b/tests/testnewcore.py @@ -1,5 +1,5 @@ -from timeside.core import * +from timeside.component import * from sys import stdout class I1(Interface): @@ -29,6 +29,17 @@ class I8(Interface): class I9(I8): pass +class I10(Interface): + def test(self): + """testdoc""" + + @staticmethod + def teststatic(self): + """teststaticdoc""" + +class I11(Interface): + pass + class C1(Component): implements(I1) @@ -56,6 +67,22 @@ class C8(Component): class C9(Component): implements(I8, I9) +class C10(Component): + implements(I10) + + @interfacedoc + def test(self): + pass + + @staticmethod + @interfacedoc + def teststatic(self): + pass + +class C11(Component): + abstract() + implements(I11) + def list_equals(list1, list2): if len(list1) != len(list2): return False @@ -72,7 +99,13 @@ def list_equals(list1, list2): def test(desc, actual, expected): stdout.write(desc + ": ") - if list_equals(actual, expected): + equals = False + if isinstance(actual, list) and isinstance(expected, list): + equals = list_equals(actual, expected) + else: + equals = (actual == expected) + + if equals: stdout.write("OK\n") else: stdout.write("FAILED\n") @@ -87,3 +120,37 @@ test("Test an interface implemented by two components", implementations(I4), [C3 test("Test whether a component implements an interface's parent", implementations(I5), [C5]) test("Test that a component doesn't implement the interface implemented by its parent", implementations(I7), [C6]) test("Test implementation redundancy across inheritance", implementations(I8), [C8, C9]) +test("Test abstract implementation 1/2", implementations(I11), []) +test("Test abstract implementation 2/2", implementations(I11, abstract=True), [C11]) +test("Test @interfacedoc", C10.test.__doc__, "testdoc") +test("Test @interfacedoc on static method", C10.teststatic.__doc__, "teststaticdoc") +stdout.write("Test @interfacedoc on static method (decorators reversed): ") + +try: + + class BogusDoc1(Component): + implements(I10) + + @interfacedoc + @staticmethod + def teststatic(self): + pass + + stdout.write("FAILED\n") + +except ComponentError: + stdout.write("OK\n") + +stdout.write("Test @interfacedoc with unexistant method in interface: ") +try: + class BogusDoc2(Component): + implements(I10) + + @interfacedoc + def nosuchmethod(self): + pass + + stdout.write("FAILED\n") + +except ComponentError: + stdout.write("OK\n") -- 2.39.5