From: Olivier Guilyardi Date: Wed, 2 Dec 2009 14:38:11 +0000 (+0000) Subject: - add processing pipes support, using ProcessPipe and operator overloading X-Git-Tag: 0.3.2~211 X-Git-Url: https://git.parisson.com/?a=commitdiff_plain;h=7b84e62807b4a6108dc27022424c90bd12a14c34;p=timeside.git - add processing pipes support, using ProcessPipe and operator overloading - add tests/api/test_pipe.py and rename tests/api/test.py to test_lolevel.py - remove all buffersize constraints in core/api - replace IProcessor.set_input_format() with setup(), which also resets the processor - suppress IProcessor.input_format() - replace IProcessor.output_format() with channels() and samplerate() - remove IGrapher.set_nframes(), this is implementation specific - add short guitar.wav (sweep samples drives my crazy ;) - shorten example processor names (GainEffect -> Gain, etc...) --- diff --git a/api.py b/api.py index 28419c5..ae9c1b0 100644 --- a/api.py +++ b/api.py @@ -34,40 +34,29 @@ class IProcessor(Interface): # 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). + def setup(self, channels=None, samplerate=None): + """Setup this processor to handle incoming data with nchannels at samplerate. + Also reset the processor internal state so that it can be reused.""" + + def channels(self): + """Number of channels in the data returned by process(). May be different from + the number of channels passed to setup()""" + + def samplerate(self): + """Samplerate of the data returned by process(). May be different from + the samplerate passed to setup()""" + + def process(self, frames=None, eod=False): + """Process input frames and return a (output_frames, eod) tuple. + Both input and output frames are numpy arrays, where columns are + channels, and containing an undetermined number of frames. eod=True + means that the end-of-data has been reached. 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.""" + Warning: it is required to call setup() before this method.""" class IEncoder(IProcessor): """Encoder driver interface. Each encoder is expected to support a specific @@ -148,17 +137,16 @@ class IGrapher(IProcessor): def __init__(self, width, height): """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 + in pixels but could be something else for eg. svg rendering, etc.. """ + + # implementation: additional optionnal arguments (such as the total number + # of frames, etc...) 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""" diff --git a/core.py b/core.py index 95c1f61..57f4405 100644 --- a/core.py +++ b/core.py @@ -23,7 +23,7 @@ from timeside.exceptions import Error, ApiError import re __all__ = ['Processor', 'MetaProcessor', 'implements', 'abstract', - 'interfacedoc', 'processors', 'get_processor'] + 'interfacedoc', 'processors', 'get_processor', 'ProcessPipe'] _processors = {} @@ -55,48 +55,30 @@ class Processor(Component): abstract() implements(IProcessor) - DEFAULT_BUFFERSIZE = 0x10000 - MIN_BUFFERSIZE = 0x1000 - - __buffersize = DEFAULT_BUFFERSIZE - - @interfacedoc - def buffersize(self): - return self.__buffersize - - def set_buffersize(self, value): - """Set the buffer size used by this processor. The buffersize must be a - power of 2 and greater than or equal to MIN_BUFFERSIZE or an exception will - be raised.""" - if value < self.MIN_BUFFERSIZE: - raise Error("Invalid buffer size: %d. Must be greater than or equal to %d", - value, MIN_BUFFERSIZE); - v = value - while v > 1: - if v & 1: - raise Error("Invalid buffer size: %d. Must be a power of two", - value, MIN_BUFFERSIZE); - v >>= 1 - - self.__buffersize = value - @interfacedoc - def set_input_format(self, nchannels=None, samplerate=None): - self.input_channels = nchannels + def setup(self, channels=None, samplerate=None): + self.input_channels = channels self.input_samplerate = samplerate @interfacedoc - def input_format(self): - return (self.input_channels, self.input_samplerate) + def channels(self): + # default implementation returns the input channels, but processors may change + # this behaviour by overloading this method + return self.input_channels @interfacedoc - def output_format(self): - return (self.input_channels, self.input_samplerate) + def samplerate(self): + # default implementation returns the input samplerate, but processors may change + # this behaviour by overloading this method + return self.input_samplerate @interfacedoc def process(self, frames): return frames + def __or__(self, item): + return ProcessPipe(self, item) + def processors(interface=IProcessor, recurse=True): """Returns the processors implementing a given interface and, if recurse, any of the descendants of this interface.""" @@ -111,3 +93,36 @@ def get_processor(processor_id): return _processors[processor_id] +class ProcessPipe(object): + """Handle a pipe of processors""" + + def __init__(self, *processors): + self.processors = processors + + def __or__(self, processor): + p = [] + p.extend(self.processors) + p.append(processor) + return ProcessPipe(*p) + + def run(self): + """Setup/reset all processors in cascade and stream audio data along + the pipe""" + + source = self.processors[0] + items = self.processors[1:] + + # setup/reset processors and configure channels and samplerate throughout the pipe + source.setup() + last = source + for item in items: + item.setup(last.channels(), last.samplerate()) + last = item + + # now stream audio data along the pipe + eod = False + while not eod: + frames, eod = source.process() + for item in items: + frames, eod = item.process(frames, eod) + diff --git a/tests/api/examples.py b/tests/api/examples.py index efaaedf..5502bf6 100644 --- a/tests/api/examples.py +++ b/tests/api/examples.py @@ -4,7 +4,7 @@ from timeside import Metadata from scikits import audiolab import numpy -class AudiolabDecoder(Processor): +class FileDecoder(Processor): """A simple audiolab-based example decoder""" implements(IDecoder) @@ -15,12 +15,24 @@ class AudiolabDecoder(Processor): @interfacedoc def __init__(self, filename): - self.file = audiolab.sndfile(filename, 'read') + self.filename = filename + self.file = None + + @interfacedoc + def setup(self, channels=None, samplerate=None): + Processor.setup(self, channels, samplerate) + if self.file: + self.file.close(); + self.file = audiolab.sndfile(self.filename, 'read') self.position = 0 @interfacedoc - def output_format(self): - return (self.file.get_channels(), self.file.get_samplerate()) + def channels(self): + return self.file.get_channels() + + @interfacedoc + def samplerate(self): + return self.file.get_samplerate() @interfacedoc def duration(self): @@ -58,11 +70,11 @@ class AudiolabDecoder(Processor): return Metadata() @interfacedoc - def process(self, frames=None): + def process(self, frames=None, eod=False): if frames: raise Exception("Decoder doesn't accept input frames") - buffersize = self.buffersize() + buffersize = 0x10000 # Need this because audiolab raises a bogus exception when asked # to read passed the end of the file @@ -74,16 +86,20 @@ class AudiolabDecoder(Processor): self.position += toread + eod = False if toread < buffersize: self.file.close() + self.file = None + eod = True - return frames + return frames, eod -class MaxLevelAnalyzer(Processor): +class MaxLevel(Processor): implements(IValueAnalyzer) @interfacedoc - def __init__(self): + def setup(self, channels=None, samplerate=None): + Processor.setup(self, channels, samplerate) self.max_value = 0 @staticmethod @@ -102,17 +118,17 @@ class MaxLevelAnalyzer(Processor): # power? amplitude? return "" - def process(self, frames=None): + def process(self, frames, eod=False): max = frames.max() if max > self.max_value: self.max_value = max - return frames + return frames, eod def result(self): return self.max_value -class GainEffect(Processor): +class Gain(Processor): implements(IEffect) @interfacedoc @@ -129,8 +145,8 @@ class GainEffect(Processor): def name(): return "Gain test effect" - def process(self, frames=None): - return numpy.multiply(frames, self.gain) + def process(self, frames, eod=False): + return numpy.multiply(frames, self.gain), eod class WavEncoder(Processor): implements(IEncoder) @@ -142,6 +158,16 @@ class WavEncoder(Processor): else: raise Exception("Streaming not supported") + @interfacedoc + def setup(self, channels=None, samplerate=None): + Processor.setup(self, channels, samplerate) + if self.file: + self.file.close(); + + info = audiolab.formatinfo("wav", "pcm16") + self.file = audiolab.sndfile(self.filename, "write", format=info, channels=channels, + samplerate=samplerate) + @staticmethod @interfacedoc def id(): @@ -168,15 +194,10 @@ class WavEncoder(Processor): 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) + def process(self, frames, eod=False): self.file.write_frames(frames) - if len(frames) < self.buffersize(): + if eod: self.file.close() + self.file = None - return frames + return frames, eod diff --git a/tests/api/test.py b/tests/api/test.py deleted file mode 100644 index e629a8a..0000000 --- a/tests/api/test.py +++ /dev/null @@ -1,47 +0,0 @@ -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/api/test_lolevel.py b/tests/api/test_lolevel.py new file mode 100644 index 0000000..9c341e3 --- /dev/null +++ b/tests/api/test_lolevel.py @@ -0,0 +1,49 @@ +from timeside.tests.api import examples +import os + +source=os.path.dirname(__file__) + "../samples/guitar.wav" + +Decoder = examples.AudiolabDecoder +print "Creating decoder with id=%s for: %s" % (Decoder.id(), source) +decoder = Decoder(source) +analyzer = examples.MaxLevelAnalyzer() +decoder.setup() +nchannels = decoder.channels() +samplerate = decoder.samplerate() +analyzer.setup(nchannels, samplerate) + +print "Stats: duration=%f, nframes=%d, nchannels=%d, samplerate=%d, resolution=%d" % ( + decoder.duration(), decoder.nframes(), nchannels, samplerate, decoder.resolution()) + +while True: + frames, eod = decoder.process() + analyzer.process(frames, eod) + if eod: + 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) + +gain = 1 +if max_level > 0: + gain = 0.9 / max_level + +effect = examples.GainEffect(gain) + +decoder.setup() +effect.setup(decoder.channels(), decoder.samplerate()) +encoder.setup(effect.channels(), effect.samplerate()) + +print "Applying effect id=%s with gain=%f" % (effect.id(), gain) + +while True: + frames, eod = decoder.process() + encoder.process(*effect.process(frames, eod)) + if eod: + break + diff --git a/tests/api/test_pipe.py b/tests/api/test_pipe.py new file mode 100644 index 0000000..d139840 --- /dev/null +++ b/tests/api/test_pipe.py @@ -0,0 +1,24 @@ +from timeside.tests.api import examples +from timeside.core import * + +import os +source=os.path.dirname(__file__) + "../samples/guitar.wav" + +decoder = examples.FileDecoder(source) +maxlevel = examples.MaxLevel() + +(decoder | maxlevel).run() + +gain = 1 +if maxlevel.result() > 0: + gain = 0.9 / maxlevel.result() + +print "input maxlevel: %f" % maxlevel.result() +print "gain: %f" % gain + +gain = examples.Gain(gain) +encoder = examples.WavEncoder("normalized.wav") + +(decoder | gain | maxlevel | encoder).run() + +print "output maxlevel: %f" % maxlevel.result() diff --git a/tests/samples/guitar.wav b/tests/samples/guitar.wav new file mode 100644 index 0000000..b5a9e80 Binary files /dev/null and b/tests/samples/guitar.wav differ