]> git.parisson.com Git - timeside.git/commitdiff
first import of export, graph and analysis related classes
authoryomguy <yomguy@parisson.com>
Thu, 24 Sep 2009 15:22:35 +0000 (15:22 +0000)
committeryomguy <yomguy@parisson.com>
Thu, 24 Sep 2009 15:22:35 +0000 (15:22 +0000)
26 files changed:
analysis/__init__.py [new file with mode: 0644]
analysis/api.py [new file with mode: 0644]
analysis/channels.py [new file with mode: 0644]
analysis/core.py [new file with mode: 0644]
analysis/dc.py [new file with mode: 0644]
analysis/duration.py [new file with mode: 0644]
analysis/encoding.py [new file with mode: 0644]
analysis/format.py [new file with mode: 0644]
analysis/max_level.py [new file with mode: 0644]
analysis/mean_level.py [new file with mode: 0644]
analysis/resolution.py [new file with mode: 0644]
analysis/samplerate.py [new file with mode: 0644]
analysis/vamp/__init__.py [new file with mode: 0644]
analysis/vamp/core.py [new file with mode: 0644]
export/__init__.py [new file with mode: 0644]
export/api.py [new file with mode: 0644]
export/core.py [new file with mode: 0644]
export/flac.py [new file with mode: 0644]
export/mp3.py [new file with mode: 0644]
export/ogg.py [new file with mode: 0644]
export/wav.py [new file with mode: 0644]
graph/__init__.py [new file with mode: 0644]
graph/api.py [new file with mode: 0644]
graph/spectrogram_audiolab.py [new file with mode: 0644]
graph/wav2png.py [new file with mode: 0644]
graph/waveform_audiolab.py [new file with mode: 0644]

diff --git a/analysis/__init__.py b/analysis/__init__.py
new file mode 100644 (file)
index 0000000..0c2c597
--- /dev/null
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+from timeside.analysis.api import *
+from timeside.analysis.core import *
+from timeside.analysis.channels import *
+from timeside.analysis.format import *
+from timeside.analysis.encoding import *
+from timeside.analysis.resolution import *
+from timeside.analysis.samplerate import *
+from timeside.analysis.duration import *
+from timeside.analysis.max_level import *
+from timeside.analysis.mean_level import *
+from timeside.analysis.dc import *
+
+
diff --git a/analysis/api.py b/analysis/api.py
new file mode 100644 (file)
index 0000000..634ef2a
--- /dev/null
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.core import *
+
+class IMediaItemAnalyzer(Interface):
+    """Media item analyzer driver interface"""
+
+    def get_id():
+        """Return a short id alphanumeric, lower-case string."""
+
+    def get_name():
+        """Return the analysis name, such as "Mean Level", "Max level",
+        "Total length, etc..
+        """
+
+    def get_unit():
+        """Return the unit of the data such as "dB", "seconds", etc...
+        """
+    
+    def render(media_item, options=None):
+        """Return the result data of the process"""
+            
diff --git a/analysis/channels.py b/analysis/channels.py
new file mode 100644 (file)
index 0000000..5f1c2ee
--- /dev/null
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class ChannelAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "nb_channels"
+
+    def get_name(self):
+        return "Channels"
+
+    def get_unit(self):
+        return ""
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        if self.channels == 1:
+            return 'mono'
+        if self.channels == 2:
+            return 'stereo'
+        else:
+            return self.channels
diff --git a/analysis/core.py b/analysis/core.py
new file mode 100644 (file)
index 0000000..135babc
--- /dev/null
@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Authors:
+#   Bram de Jong <bram.dejong at domain.com where domain in gmail>
+#   Guillaume Pellerin <yomguy at parisson.com>
+
+from django.conf import settings
+from timeside.core import *
+import optparse, math, sys
+import numpy
+import scikits.audiolab as audiolab
+
+class AudioProcessor(Component):
+    
+    def __init__(self):
+        self.fft_size = 2048
+        self.window_function = numpy.ones
+        self.window = self.window_function(self.fft_size)
+        self.spectrum_range = None
+        self.lower = 100
+        self.higher = 22050
+        self.lower_log = math.log10(self.lower)
+        self.higher_log = math.log10(self.higher)
+        self.clip = lambda val, low, high: min(high, max(low, val))
+
+    def pre_process(self, media_item):
+        wav_file = media_item.file.path
+        self.audio_file = audiolab.sndfile(wav_file, 'read')
+        self.frames = self.audio_file.get_nframes()
+        self.samplerate = self.audio_file.get_samplerate()
+        self.channels = self.audio_file.get_channels()
+        self.format = self.audio_file.get_file_format()
+        self.encoding = self.audio_file.get_encoding()
+
+    def get_samples(self):
+        samples = self.audio_file.read_frames(self.frames)
+        return samples
+        
+    def get_mono_samples(self):
+        # convert to mono by selecting left channel only
+        samples = self.get_samples()
+        if self.channels > 1:
+            return samples[:,0]
+        else:
+            return samples
+
+    def read(self, start, size, resize_if_less=False):
+        """ read size samples starting at start, if resize_if_less is True and less than size
+        samples are read, resize the array to size and fill with zeros """
+        
+        # number of zeros to add to start and end of the buffer
+        add_to_start = 0
+        add_to_end = 0
+        
+        if start < 0:
+            # the first FFT window starts centered around zero
+            if size + start <= 0:
+                if resize_if_less:
+                    return numpy.zeros(size)
+                else:
+                    return numpy.array([])
+            else:
+                self.audio_file.seek(0)
+
+                add_to_start = -start # remember: start is negative!
+                to_read = size + start
+
+                if to_read > self.frames:
+                    add_to_end = to_read - self.frames
+                    to_read = self.frames
+        else:
+            self.audio_file.seek(start)
+        
+            to_read = size
+            if start + to_read >= self.frames:
+                to_read = self.frames - start
+                add_to_end = size - to_read
+        
+        try:
+            samples = self.audio_file.read_frames(to_read)
+        except IOError:
+            # this can happen for wave files with broken headers...
+            if resize_if_less:
+                return numpy.zeros(size)
+            else:
+                return numpy.zeros(2)
+
+        # convert to mono by selecting left channel only
+        if self.channels > 1:
+            samples = samples[:,0]
+
+        if resize_if_less and (add_to_start > 0 or add_to_end > 0):
+            if add_to_start > 0:
+                samples = numpy.concatenate((numpy.zeros(add_to_start), samples), axis=1)
+            
+            if add_to_end > 0:
+                samples = numpy.resize(samples, size)
+                samples[size - add_to_end:] = 0
+        
+        return samples
+
+
+    def spectral_centroid(self, seek_point, spec_range=120.0):
+        """ starting at seek_point read fft_size samples, and calculate the spectral centroid """
+        
+        samples = self.read(seek_point - self.fft_size/2, self.fft_size, True)
+
+        samples *= self.window
+        fft = numpy.fft.fft(samples)
+        spectrum = numpy.abs(fft[:fft.shape[0] / 2 + 1]) / float(self.fft_size) # normalized abs(FFT) between 0 and 1
+        length = numpy.float64(spectrum.shape[0])
+        
+        # scale the db spectrum from [- spec_range db ... 0 db] > [0..1]
+        db_spectrum = ((20*(numpy.log10(spectrum + 1e-30))).clip(-spec_range, 0.0) + spec_range)/spec_range
+        
+        energy = spectrum.sum()
+        spectral_centroid = 0
+        
+        if energy > 1e-20:
+            # calculate the spectral centroid
+            
+            if self.spectrum_range == None:
+                self.spectrum_range = numpy.arange(length)
+        
+            spectral_centroid = (spectrum * self.spectrum_range).sum() / (energy * (length - 1)) * self.samplerate * 0.5
+            
+            # clip > log10 > scale between 0 and 1
+            spectral_centroid = (math.log10(self.clip(spectral_centroid, self.lower, self.higher)) - self.lower_log) / (self.higher_log - self.lower_log)
+        
+        return (spectral_centroid, db_spectrum)
+
+
+    def peaks(self, start_seek, end_seek):
+        """ read all samples between start_seek and end_seek, then find the minimum and maximum peak
+        in that range. Returns that pair in the order they were found. So if min was found first,
+        it returns (min, max) else the other way around. """
+        
+        # larger blocksizes are faster but take more mem...
+        # Aha, Watson, a clue, a tradeof!
+        block_size = 4096
+    
+        max_index = -1
+        max_value = -1
+        min_index = -1
+        min_value = 1
+    
+        if end_seek > self.frames:
+            end_seek = self.frames
+    
+        if block_size > end_seek - start_seek:
+            block_size = end_seek - start_seek
+            
+        if block_size <= 1:
+            samples = self.read(start_seek, 1)
+            return samples[0], samples[0]
+        elif block_size == 2:
+            samples = self.read(start_seek, True)
+            return samples[0], samples[1]
+        
+        for i in range(start_seek, end_seek, block_size):
+            samples = self.read(i, block_size)
+    
+            local_max_index = numpy.argmax(samples)
+            local_max_value = samples[local_max_index]
+    
+            if local_max_value > max_value:
+                max_value = local_max_value
+                max_index = local_max_index
+    
+            local_min_index = numpy.argmin(samples)
+            local_min_value = samples[local_min_index]
+            
+            if local_min_value < min_value:
+                min_value = local_min_value
+                min_index = local_min_index
+
+        if min_index < max_index:
+            return (min_value, max_value)
+        else:
+            return (max_value, min_value)
+
+
+        
diff --git a/analysis/dc.py b/analysis/dc.py
new file mode 100644 (file)
index 0000000..c15a6f8
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class MeanDCShiftAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "dc"
+
+    def get_name(self):
+        return "Mean DC shift"
+
+    def get_unit(self):
+        return "%"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        samples = self.get_mono_samples()
+        return numpy.round(100*numpy.mean(samples),4)
diff --git a/analysis/duration.py b/analysis/duration.py
new file mode 100644 (file)
index 0000000..9428021
--- /dev/null
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+import datetime
+
+class DurationAnalyzer(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "duration"
+
+    def get_name(self):
+        return "Duration"
+
+    def get_unit(self):
+        return "h:m:s"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        media_time = numpy.round(float(self.frames)/float(self.samplerate),0)
+        return datetime.timedelta(0,media_time)
diff --git a/analysis/encoding.py b/analysis/encoding.py
new file mode 100644 (file)
index 0000000..897ff04
--- /dev/null
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class EncodingAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "encoding"
+
+    def get_name(self):
+        return "Encoding format"
+
+    def get_unit(self):
+        return ""
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        return self.encoding
diff --git a/analysis/format.py b/analysis/format.py
new file mode 100644 (file)
index 0000000..480ce00
--- /dev/null
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class FormatAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "format"
+
+    def get_name(self):
+        return "File format"
+
+    def get_unit(self):
+        return ""
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        return self.format
diff --git a/analysis/max_level.py b/analysis/max_level.py
new file mode 100644 (file)
index 0000000..a54e460
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class MaxLevelAnalyzer(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "max_level"
+
+    def get_name(self):
+        return "Maximum peak level"
+
+    def get_unit(self):
+        return "dB"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        samples = self.get_samples()
+        return numpy.round(20*numpy.log10(numpy.max(samples)),2)
\ No newline at end of file
diff --git a/analysis/mean_level.py b/analysis/mean_level.py
new file mode 100644 (file)
index 0000000..b7696ed
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class MeanLevelAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "mean_level"
+
+    def get_name(self):
+        return "Mean RMS level"
+
+    def get_unit(self):
+        return "dB"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        samples = self.get_mono_samples()
+        return numpy.round(20*numpy.log10(numpy.mean(numpy.sqrt(numpy.square(samples)))),2)
diff --git a/analysis/resolution.py b/analysis/resolution.py
new file mode 100644 (file)
index 0000000..98c2901
--- /dev/null
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.analysis.core import *
+from timeside.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class ResolutionAnalyser(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "resolution"
+
+    def get_name(self):
+        return "Resolution"
+
+    def get_unit(self):
+        return "bits"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        if '8' in self.encoding:
+            return 8
+        if '16' in self.encoding:
+            return 16
+        if '24' in self.encoding:
+            return 24
+        if '32' in self.encoding:
+            return 32
+        else:
+            return ''
\ No newline at end of file
diff --git a/analysis/samplerate.py b/analysis/samplerate.py
new file mode 100644 (file)
index 0000000..6daae7f
--- /dev/null
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2008 Parisson SARL
+
+# This software is a computer program whose purpose is to backup, analyse,
+# transcode and stream any audio content with its metadata over a web frontend.
+
+# This software is governed by the CeCILL  license under French law and
+# abiding by the rules of distribution of free software.  You can  use,
+# modify and/ or redistribute the software under the terms of the CeCILL
+# license as circulated by CEA, CNRS and INRIA at the following URL
+# "http://www.cecill.info".
+
+# As a counterpart to the access to the source code and  rights to copy,
+# modify and redistribute granted by the license, users are provided only
+# with a limited warranty  and the software's author,  the holder of the
+# economic rights,  and the successive licensors  have only  limited
+# liability.
+
+# In this respect, the user's attention is drawn to the risks associated
+# with loading,  using,  modifying and/or developing or reproducing the
+# software by the user in light of its specific status of free software,
+# that may mean  that it is complicated to manipulate,  and  that  also
+# therefore means  that it is reserved for developers  and  experienced
+# professionals having in-depth computer knowledge. Users are therefore
+# encouraged to load and test the software's suitability as regards their
+# requirements in conditions enabling the security of their systems and/or
+# data to be ensured and,  more generally, to use and operate it in the
+# same conditions as regards security.
+
+# The fact that you are presently reading this means that you have had
+# knowledge of the CeCILL license and that you accept its terms.
+#
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from telemeta.analysis.core import *
+from telemeta.analysis.api import IMediaItemAnalyzer
+import numpy
+
+class SampleRateAnalyzer(AudioProcessor):
+    """Media item analyzer driver interface"""
+
+    implements(IMediaItemAnalyzer)
+
+    def get_id(self):
+        return "samplerate"
+
+    def get_name(self):
+        return "Samplerate"
+
+    def get_unit(self):
+        return "Hz"
+
+    def render(self, media_item, options=None):
+        self.pre_process(media_item)
+        return self.samplerate
diff --git a/analysis/vamp/__init__.py b/analysis/vamp/__init__.py
new file mode 100644 (file)
index 0000000..1dc0d67
--- /dev/null
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from timeside.analysis.vamp.core import *
diff --git a/analysis/vamp/core.py b/analysis/vamp/core.py
new file mode 100644 (file)
index 0000000..06519f6
--- /dev/null
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.core import *
+from tempfile import NamedTemporaryFile
+from timeside.analysis.api import IMediaItemAnalyzer
+import os
+import random
+import subprocess
+import signal
+import time
+
+class VampCoreAnalyzer:
+    """Parent class for Vamp plugin drivers"""
+
+    def __init__(self):
+        self.vamp_path = '/usr/lib/vamp/'
+        # needs vamp-examples package
+        self.host = 'vamp-simple-host'
+        self.buffer_size = 0xFFFF
+               
+    def get_id(self):
+        return "vamp_plugins"
+
+    def get_name(self):
+        return "Vamp plugins"
+
+    def get_unit(self):
+        return ""
+
+    def get_plugins_list(self):
+        if os.path.exists(self.vamp_path):
+            args = ' --list-outputs'
+            command = self.host + args
+            #tmp_file = NamedTemporaryFile()
+            data = self.core_process(command, self.buffer_size)
+            text = ''
+            plugins = []
+            for chunk in data:
+                text = text + chunk
+            lines = text.split('\n')
+            for line in lines:
+                if line != '':
+                    struct = line.split(':')
+                    struct = struct[1:]
+                    plugins.append(struct)
+            return plugins
+        else:
+            return []
+
+    def get_wav_path(self, media_item):
+        return settings.MEDIA_ROOT + '/' + media_item.file
+        #return media_item
+        
+    def render(self, plugin, media_item):
+        self.wavFile = self.get_wav_path(media_item)
+        args = ' -s ' + ':'.join(plugin) + ' ' + str(self.wavFile)
+        command = command = self.host + args
+        data = self.core_process(command, self.buffer_size)
+        string = ''
+        values = {}
+        for chunk in data:
+            string = string + chunk
+        lines = string.split('\n')
+        for line in lines:
+            if line != '':
+                struct = line.split(':')
+                values[struct[0]] = struct[1]
+        return values
+
+    def core_process(self, command, buffer_size):
+        """Encode and stream audio data through a generator"""
+        
+        __chunk = 0
+
+        try:
+            proc = subprocess.Popen(command,
+                    shell = True,
+                    bufsize = buffer_size,
+                    stdin = subprocess.PIPE,
+                    stdout = subprocess.PIPE,
+                    close_fds = True)
+        except:
+            raise VampProcessError('Command failure:', command, proc)
+            
+        # Core processing
+        while True:
+            __chunk = proc.stdout.read(buffer_size)
+            status = proc.poll()
+            if status != None and status != 0:
+                raise VampProcessError('Command failure:', command, proc)
+            if len(__chunk) == 0:
+                break
+            yield __chunk
+        
+
+class VampProcessError(TelemetaError):
+
+    def __init__(self, message, command, subprocess):
+        self.message = message
+        self.command = str(command)
+        self.subprocess = subprocess
+
+    def __str__(self):
+        if self.subprocess.stderr != None:
+            error = self.subprocess.stderr.read()
+        else:
+            error = ''
+        return "%s ; command: %s; error: %s" % (self.message,
+                                                self.command,
+                                                error)
diff --git a/export/__init__.py b/export/__init__.py
new file mode 100644 (file)
index 0000000..06cb43a
--- /dev/null
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+from timeside.export.api import *
+from timeside.export.core import *
+from timeside.export.ogg import *
+from timeside.export.flac import *
+from timeside.export.wav import *
+from timeside.export.mp3 import *
\ No newline at end of file
diff --git a/export/api.py b/export/api.py
new file mode 100644 (file)
index 0000000..7ee861c
--- /dev/null
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2007-2009 Parisson
+# Copyright (c) 2007 Olivier Guilyardi <olivier@samalyse.com>
+# Copyright (c) 2007-2009 Guillaume Pellerin <pellerin@parisson.com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+from timeside.core import Interface, TimeSideError
+
+class IExporter(Interface):
+    """Export driver interface"""
+
+    # Remark: the method prototypes do not include any self or cls argument 
+    # because an interface is meant to show what methods a class must expose 
+    # from the caller's point of view. However, when implementing the class 
+    # you'll obviously want to include this extra argument.
+
+    def get_format():
+        """Return the export/encoding format as a short string 
+        Example: "MP3", "OGG", "AVI", ...
+        """
+   
+    def get_description():
+        """Return a string describing what this export format provides, is good 
+        for, etc... The description is meant to help the end user decide what 
+        format is good for him/her
+        """
+
+    def get_file_extension():
+        """Return the filename extension corresponding to this export format"""
+
+    def get_mime_type():
+        """Return the mime type corresponding to this export format"""
+
+    def set_cache_dir(path):
+        """Set the directory where cached files should be stored. Does nothing
+        if the exporter doesn't support caching. 
+       
+        The driver shouldn't assume that this method will always get called. A
+        temporary directory should be used if that's not the case.
+        """
+
+    def process(item_id, source, metadata, options=None):
+        """Perform the exporting process and return the absolute path 
+        to the resulting file.
+
+        item_id is the media item id that uniquely identifies this audio/video
+        resource
+
+        source is the audio/video source file absolute path. For audio that
+        should be a WAV file
+
+        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), ...)
+
+        The returned file path is not meant to be permanent in any way, it 
+        should be considered temporary/volatile by the caller.
+
+        It is highly recommended that export drivers implement some sort of
+        cache instead of re-encoding each time process() is called.
+
+        It should be possible to make subsequent calls to process() with
+        different items, using the same driver instance.
+        """
+
+class ExportProcessError(TimeSideError):
+
+    def __init__(self, message, command, subprocess):
+        self.message = message
+        self.command = str(command)
+        self.subprocess = subprocess
+
+    def __str__(self):
+        if self.subprocess.stderr != None:
+            error = self.subprocess.stderr.read()
+        else:
+            error = ''
+        return "%s ; command: %s; error: %s" % (self.message,
+                                                self.command,
+                                                error)
diff --git a/export/core.py b/export/core.py
new file mode 100644 (file)
index 0000000..6f83356
--- /dev/null
@@ -0,0 +1,277 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+import os
+import re
+import md5
+import string
+import subprocess
+import mutagen
+
+from timeside.export import *
+from timeside.core import *
+import xml.dom.minidom
+import xml.dom.ext
+
+class ExporterCore(Component):
+    """Defines the main parts of the exporting tools :
+    paths, metadata parsing, data streaming thru system command"""
+
+    def __init__(self):
+        self.source = ''
+        self.collection = ''
+        self.verbose = ''
+        self.dest = ''
+        self.metadata = []
+        self.cache_dir = 'cache'
+        self.buffer_size = 0xFFFF
+
+    def set_cache_dir(self,path):
+        self.cache_dir = path
+
+    def normalize(self):
+        """ Normalize the source and return its path """
+        args = ''
+        if self.verbose == '0':
+            args = '-q'
+        try:
+            os.system('normalize-audio '+args+' "'+self.source+'"')
+            return self.source
+        except:
+            raise IOError('ExporterError: cannot normalize, path does not exist.')
+
+    def check_md5_key(self):
+        """ Check if the md5 key is OK and return a boolean """
+        try:
+            md5_log = os.popen4('md5sum -c "'+self.dest+ \
+                                '" "'+self.dest+'.md5"')
+            return 'OK' in md5_log.split(':')
+        except IOError:
+            raise IOError('ExporterError: cannot check the md5 key.')
+    
+    def get_file_info(self):
+        """ Return the list of informations of the dest """
+        return self.export.get_file_info()
+
+    def get_wav_length_sec(self) :
+        """ Return the length of the audio source file in seconds """
+        try:
+            file1, file2 = os.popen4('wavinfo "'+self.source+ \
+                                     '" | grep wavDataSize')
+            for line in file2.readlines():
+                line_split = line.split(':')
+                value = int(int(line_split[1])/(4*44100))
+                return value
+        except:
+            raise IOError('ExporterError: cannot get the wav length.')
+
+    def compare_md5_key(self, source, dest):
+        """ Compare source and dest files wih md5 method """
+        f_source = open(source).read()
+        f_dest = open(dest).read()
+        return md5.new(f_source).digest() == md5.new(f_dest).digest()
+
+    def write_metadata_xml(self,path):
+        doc = xml.dom.minidom.Document()
+        root = doc.createElement('timeside')
+        doc.appendChild(root)
+        for tag in self.metadata.keys() :
+            value = self.metadata[tag]
+            node = doc.createElement(tag)
+            node.setAttribute('value', str(value))
+            #node.setAttribute('type', get_type(value))
+            root.appendChild(node)
+        xml_file = open(path, "w")
+        xml.dom.ext.PrettyPrint(doc, xml_file)
+        xml_file.close()
+
+    def pre_process(self, item_id, source, metadata, ext,
+                    cache_dir, options=None):
+        """ Pre processing : prepare the export path and return it"""
+        self.item_id = str(item_id)
+        self.source = source
+        file_name = get_file_name(self.source)
+        file_name_wo_ext, file_ext = split_file_name(file_name)
+        self.cache_dir = cache_dir
+        self.metadata = metadata
+        #self.collection = self.metadata['Collection']
+        #self.artist = self.metadata['Artist']
+        #self.title = self.metadata['Title']
+
+        # Normalize if demanded
+        if not options is None:
+            self.options = options
+            if 'normalize' in self.options and \
+                self.options['normalize'] == True:
+                self.normalize()
+
+        # Define the export directory
+        self.ext = self.get_file_extension()
+        export_dir = os.path.join(self.cache_dir,self.ext)
+
+        if not os.path.exists(export_dir):
+            export_dir_split = export_dir.split(os.sep)
+            path = os.sep + export_dir_split[0]
+            for _dir in export_dir_split[1:]:
+                path = os.path.join(path,_dir)
+                if not os.path.exists(path):
+                    os.mkdir(path)
+        else:
+            path = export_dir
+
+        # Set the target file
+        target_file = self.item_id+'.'+self.ext
+        dest = os.path.join(path,target_file)
+        return dest
+
+    def core_process(self, command, buffer_size, dest):
+        """Encode and stream audio data through a generator"""
+
+        __chunk = 0
+        file_out = open(dest,'w')
+
+        proc = subprocess.Popen(command.encode('utf-8'),
+                    shell = True,
+                    bufsize = buffer_size,
+                    stdin = subprocess.PIPE,
+                    stdout = subprocess.PIPE,
+                    close_fds = True)
+
+        # Core processing
+        while True:
+            __chunk = proc.stdout.read(buffer_size)
+            status = proc.poll()
+            if status != None and status != 0:
+                raise ExportProcessError('Command failure:', command, proc)
+            if len(__chunk) == 0:
+                break
+            yield __chunk
+            file_out.write(__chunk)
+
+        file_out.close()
+
+    def post_process(self, item_id, source, metadata, ext, 
+                     cache_dir, options=None):
+        """ Post processing : write tags, print infos, etc..."""
+        #self.write_tags()
+        if not options is None:
+            if 'verbose' in self.options and self.options['verbose'] != '0':
+                print self.dest
+                print self.get_file_info()
+
+
+# External functions
+
+def get_type(value):
+    """ Return a String with the type of value """
+    types = {bool : 'bool', int : 'int', str : 'str'}
+    # 'bool' type must be placed *before* 'int' type, otherwise booleans are
+    # detected as integers
+    for type in types.keys():
+        if isinstance(value, type) :
+            return types[type]
+    raise TypeError(str(value) + ' has an unsupported type')
+
+def get_cast(value, type) :
+    """ Return value, casted into type """
+    if type == 'bool' :
+        if value == 'True' :
+            return True
+        return False
+    elif type == 'int' :
+        return int(value)
+    elif type == 'str' :
+        return str(value)
+    raise TypeError(type + ' is an unsupported type')
+
+def get_file_mime_type(path):
+    """ Return the mime type of a file """
+    try:
+        file_out1, file_out2 = os.popen4('file -i "'+path+'"')
+        for line in file_out2.readlines():
+            line_split = line.split(': ')
+            mime = line_split[len(line_split)-1]
+            return mime[:len(mime)-1]
+    except:
+        raise IOError('ExporterError: path does not exist.')
+
+def get_file_type_desc(path):
+    """ Return the type of a file given by the 'file' command """
+    try:
+        file_out1, file_out2 = os.popen4('file "'+path+'"')
+        for line in file_out2.readlines():
+            description = line.split(': ')
+            description = description[1].split(', ')
+            return description
+    except:
+        raise IOError('ExporterError: path does not exist.')
+
+def iswav(path):
+    """ Tell if path is a WAV """
+    try:
+        mime = get_file_mime_type(path)
+        return mime == 'audio/x-wav'
+    except:
+        raise IOError('ExporterError: path does not exist.')
+
+def iswav16(path):
+    """ Tell if path is a 16 bit WAV """
+    try:
+        file_type_desc = get_file_type_desc(path)
+        return iswav(path) and '16 bit' in file_type_desc
+    except:
+        raise IOError('ExporterError: path does not exist.')
+
+def get_file_name(path):
+    """ Return the file name targeted in the path """
+    return os.path.split(path)[1]
+
+def split_file_name(file):
+    """ Return main file name and its extension """
+    try:
+        return os.path.splitext(file)
+    except:
+        raise IOError('ExporterError: path does not exist.')
+
+def clean_word(word) :
+    """ Return the word without excessive blank spaces, underscores and
+    characters causing problem to exporters"""
+    word = re.sub("^[^\w]+","",word)    #trim the beginning
+    word = re.sub("[^\w]+$","",word)    #trim the end
+    word = re.sub("_+","_",word)        #squeeze continuous _ to one _
+    word = re.sub("^[^\w]+","",word)    #trim the beginning _
+    #word = string.replace(word,' ','_')
+    #word = string.capitalize(word)
+    dict = '&[];"*:,'
+    for letter in dict:
+        word = string.replace(word,letter,'_')
+    return word
+
+def recover_par_key(path):
+    """ Recover a file with par2 key """
+    os.system('par2 r "'+path+'"')
+
+def verify_par_key(path):
+    """ Verify a par2 key """
+    os.system('par2 v "'+path+'.par2"')
+
+
diff --git a/export/flac.py b/export/flac.py
new file mode 100644 (file)
index 0000000..60f2f92
--- /dev/null
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+import os
+import string
+import subprocess
+
+from timeside.export.core import *
+from timeside.export.api import IExporter
+from mutagen.flac import FLAC
+from tempfile import NamedTemporaryFile
+
+class FlacExporter(ExporterCore):
+    """Defines methods to export to FLAC"""
+
+    implements(IExporter)
+    
+    def __init__(self):
+        self.item_id = ''
+        self.source = ''
+        self.metadata = {}
+        self.options = {}
+        self.description = ''
+        self.dest = ''
+        self.quality_default = '-5'
+        self.info = []
+        self.buffer_size = 0xFFFF
+
+    def get_format(self):
+        return 'FLAC'
+    
+    def get_file_extension(self):
+        return 'flac'
+
+    def get_mime_type(self):
+        return 'application/flac'
+
+    def get_description(self):
+        return 'FIXME'
+
+    def get_file_info(self):
+        try:
+            file1, file2 = os.popen4('metaflac --list "'+self.dest+'"')
+            info = []
+            for line in file2.readlines():
+                info.append(clean_word(line[:-1]))
+            self.info = info
+            return self.info
+        except:
+            raise IOError('ExporterError: metaflac is not installed or ' + \
+                           'file does not exist.')
+
+    def set_cache_dir(self,path):
+        """Set the directory where cached files should be stored. Does nothing
+        if the exporter doesn't support caching. 
+       
+        The driver shouldn't assume that this method will always get called. A
+        temporary directory should be used if that's not the case.
+        """
+        self.cache_dir = path
+
+    def decode(self):
+        try:
+            file_name, ext = get_file_name(self.source)
+            dest = self.cache_dir+os.sep+file_name+'.wav'
+            os.system('flac -d -o "'+dest+'" "'+self.source+'"')
+            self.source = dest
+            return dest
+        except:
+            raise IOError('ExporterError: decoder is not compatible.')
+
+    def write_tags(self, file):
+        media = FLAC(file)
+        for tag in self.metadata:
+            name = tag[0]
+            value = clean_word(tag[1])
+            if name == 'COMMENT':
+                media['DESCRIPTION'] = unicode(value)
+            else:
+                media[name] = unicode(value)
+        try:
+            media.save()
+        except:
+            raise IOError('ExporterError: cannot write tags.')
+
+    def get_args(self,options=None):
+        """Get process options and return arguments for the encoder"""
+        args = []
+        if not options is None:
+            self.options = options
+            if not ('verbose' in self.options and self.options['verbose'] != '0'):
+                args.append('-s')
+            if 'flac_quality' in self.options:
+                args.append('-f ' + self.options['flac_quality'])
+            else:
+                args.append('-f ' + self.quality_default)
+        else:
+            args.append('-s -f ' + self.quality_default)
+
+        #for tag in self.metadata.keys():
+            #value = clean_word(self.metadata[tag])
+            #args.append('-c %s="%s"' % (tag, value))
+            #if tag in self.dub2args_dict.keys():
+                #arg = self.dub2args_dict[tag]
+                #args.append('-c %s="%s"' % (arg, value))
+
+        return args
+
+    def process(self, item_id, source, metadata, options=None):
+        self.item_id = item_id
+        self.source = source
+        self.metadata = metadata
+        self.args = self.get_args(options)
+        self.ext = self.get_file_extension()
+        self.args = ' '.join(self.args)
+        self.command = 'sox "%s" -s -q -b 16 -r 44100 -t wav -c2 - | flac -c %s - ' % (self.source, self.args)
+
+        # Pre-proccessing
+        self.dest = self.pre_process(self.item_id,
+                                         self.source,
+                                         self.metadata,
+                                         self.ext,
+                                         self.cache_dir,
+                                         self.options)
+
+        # Processing (streaming + cache writing)
+        stream = self.core_process(self.command, self.buffer_size, self.dest)
+
+        for chunk in stream:
+            pass
+
+        self.write_tags(self.dest)
+        file = open(self.dest,'r')
+        
+        while True:
+            chunk = file.read(self.buffer_size)
+            if len(chunk) == 0:
+                break
+            yield chunk
+
+        file.close()
+
+        # Post-proccessing
+        #self.post_process(self.item_id,
+                         #self.source,
+                         #self.metadata,
+                         #self.ext,
+                         #self.cache_dir,
+                         #self.options)
+
diff --git a/export/mp3.py b/export/mp3.py
new file mode 100644 (file)
index 0000000..e75f15f
--- /dev/null
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2007 Parisson SARL
+# Copyright (c) 2006-2007 Guillaume Pellerin <pellerin@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+import os
+import string
+import subprocess
+
+from timeside.export.core import *
+from timeside.export.api import IExporter
+#from mutagen.id3 import *
+
+class Mp3Exporter(ExporterCore):
+    """Defines methods to export to MP3"""
+
+    implements(IExporter)
+    
+    def __init__(self):
+        self.item_id = ''
+        self.metadata = {}
+        self.description = ''
+        self.info = []
+        self.source = ''
+        self.dest = ''
+        self.options = {}
+        self.bitrate_default = '192'
+        self.buffer_size = 0xFFFF
+        self.dub2id3_dict = {'title': 'TIT2', #title2
+                             'creator': 'TCOM', #composer
+                             'creator': 'TPE1', #lead
+                             'identifier': 'UFID', #Unique ID...
+                             'identifier': 'TALB', #album
+                             'type': 'TCON', #genre
+                             'publisher': 'TPUB', #comment
+                             #'date': 'TYER', #year
+                             }
+        self.dub2args_dict = {'title': 'tt', #title2
+                             'creator': 'ta', #composerS
+                             'relation': 'tl', #album
+                             #'type': 'tg', #genre
+                             'publisher': 'tc', #comment
+                             'date': 'ty', #year
+                             }
+    def get_format(self):
+        return 'MP3'
+    
+    def get_file_extension(self):
+        return 'mp3'
+
+    def get_mime_type(self):
+        return 'audio/mpeg'
+
+    def get_description(self):
+        return "FIXME"
+
+    def set_cache_dir(self,path):
+       self.cache_dir = path
+
+    def get_file_info(self):
+        try:
+            file_out1, file_out2 = os.popen4('mp3info "'+self.dest+'"')
+            info = []
+            for line in file_out2.readlines():
+                info.append(clean_word(line[:-1]))
+            self.info = info
+            return self.info
+        except:
+            raise IOError('ExporterError: file does not exist.')
+
+    def decode(self):
+        try:
+            os.system('sox "'+self.source+'" -s -q -r 44100 -t wav "' \
+                        +self.cache_dir+os.sep+self.item_id+'"')
+            return self.cache_dir+os.sep+self.item_id+'.wav'
+        except:
+            raise IOError('ExporterError: decoder is not compatible.')
+
+    def write_tags(self):
+        """Write all ID3v2.4 tags by mapping dub2id3_dict dictionnary with the
+            respect of mutagen classes and methods"""
+        from mutagen import id3  
+        id3 = id3.ID3(self.dest)
+        for tag in self.metadata.keys():
+            if tag in self.dub2id3_dict.keys():
+                frame_text = self.dub2id3_dict[tag]
+                value = self.metadata[tag]
+                frame = mutagen.id3.Frames[frame_text](3,value)
+                try:
+                    id3.add(frame)
+                except:
+                    raise IOError('ExporterError: cannot tag "'+tag+'"')
+        try:
+            id3.save()
+        except:
+            raise IOError('ExporterError: cannot write tags')
+
+    def get_args(self, options=None):
+        """Get process options and return arguments for the encoder"""
+        args = []
+        if not options is None: 
+            self.options = options
+            if not ( 'verbose' in self.options and self.options['verbose'] != '0' ):
+                args.append('-S')
+            if 'mp3_bitrate' in self.options:
+                args.append('-b ' + self.options['mp3_bitrate'])
+            else:
+                args.append('-b '+self.bitrate_default)
+            #Copyrights, etc..
+            args.append('-c -o')
+        else:
+            args.append('-S -c -o')
+
+        for tag in self.metadata:
+            name = tag[0]
+            value = clean_word(tag[1])
+            if name in self.dub2args_dict.keys():
+                arg = self.dub2args_dict[name]
+                args.append('--' + arg + ' "' + value + '"')
+        return args
+
+    def process(self, item_id, source, metadata, options=None):
+        self.item_id = item_id
+        self.source = source
+        self.metadata = metadata
+        self.args = self.get_args(options)
+        self.ext = self.get_file_extension()
+        self.args = ' '.join(self.args)
+        self.command = 'sox "%s" -q -b 16 -r 44100 -t wav - | lame %s -' % (self.source, self.args)
+        #self.command = 'lame %s "%s" -' % (self.args, self.source)
+
+        # Pre-proccessing
+        self.dest = self.pre_process(self.item_id,
+                                         self.source,
+                                         self.metadata,
+                                         self.ext,
+                                         self.cache_dir,
+                                         self.options)
+
+        # Processing (streaming + cache writing)
+        stream = self.core_process(self.command, self.buffer_size, self.dest)
+        for chunk in stream:
+            yield chunk
+    
+        # Post-proccessing
+        #self.post_process(self.item_id,
+                         #self.source,
+                         #self.metadata,
+                         #self.ext,
+                         #self.cache_dir,
+                         #self.options)
+
diff --git a/export/ogg.py b/export/ogg.py
new file mode 100644 (file)
index 0000000..270449a
--- /dev/null
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+import os
+import string
+import subprocess
+
+from timeside.export.core import *
+from timeside.export.api import IExporter
+from mutagen.oggvorbis import OggVorbis
+
+class OggExporter(ExporterCore):
+    """Defines methods to export to OGG Vorbis"""
+
+    implements(IExporter)
+    
+    def __init__(self):
+        self.item_id = ''
+        self.metadata = {}
+        self.description = ''
+        self.info = []
+        self.source = ''
+        self.dest = ''
+        self.options = {}
+        self.bitrate_default = '192'
+        self.buffer_size = 0xFFFF
+        self.dub2args_dict = {'creator': 'artist',
+                             'relation': 'album'
+                             }
+        
+    def get_format(self):
+        return 'OGG'
+    
+    def get_file_extension(self):
+        return 'ogg'
+
+    def get_mime_type(self):
+        return 'application/ogg'
+
+    def get_description(self):
+        return 'FIXME'
+
+    def get_file_info(self):
+        try:
+            file_out1, file_out2 = os.popen4('ogginfo "'+self.dest+'"')
+            info = []
+            for line in file_out2.readlines():
+                info.append(clean_word(line[:-1]))
+            self.info = info
+            return self.info
+        except:
+            raise IOError('ExporterError: file does not exist.')
+
+    def set_cache_dir(self,path):
+       self.cache_dir = path
+
+    def decode(self):
+        try:
+            os.system('oggdec -o "'+self.cache_dir+os.sep+self.item_id+
+                      '.wav" "'+self.source+'"')
+            return self.cache_dir+os.sep+self.item_id+'.wav'
+        except:
+            raise IOError('ExporterError: decoder is not compatible.')
+
+    def write_tags(self):
+        media = OggVorbis(self.dest)
+        for tag in self.metadata.keys():
+            media[tag] = str(self.metadata[tag])
+        media.save()
+
+    def get_args(self,options=None):
+        """Get process options and return arguments for the encoder"""
+        args = []
+        if not options is None:
+            self.options = options
+            if not ('verbose' in self.options and self.options['verbose'] != '0'):
+                args.append('-Q ')
+            if 'ogg_bitrate' in self.options:
+                args.append('-b '+self.options['ogg_bitrate'])
+            elif 'ogg_quality' in self.options:
+                args.append('-q '+self.options['ogg_quality'])
+            else:
+                args.append('-b '+self.bitrate_default)
+        else:
+            args.append('-Q -b '+self.bitrate_default)
+
+        for tag in self.metadata:
+            name = tag[0]
+            value = clean_word(tag[1])
+            args.append('-c %s="%s"' % (name, value))
+            if name in self.dub2args_dict.keys():
+                arg = self.dub2args_dict[name]
+                args.append('-c %s="%s"' % (arg, value))
+        return args
+
+    def process(self, item_id, source, metadata, options=None):
+        self.item_id = item_id
+        self.source = source
+        self.metadata = metadata
+        self.args = self.get_args(options)
+        self.ext = self.get_file_extension()
+        self.args = ' '.join(self.args)
+        self.command = 'sox "%s" -s -q -b 16 -r 44100 -t wav -c2 - | oggenc %s -' % (self.source, self.args)
+
+        # Pre-proccessing
+        self.dest = self.pre_process(self.item_id,
+                                        self.source,
+                                        self.metadata,
+                                        self.ext,
+                                        self.cache_dir,
+                                        self.options)
+
+        # Processing (streaming + cache writing)
+        stream = self.core_process(self.command, self.buffer_size, self.dest)
+        for chunk in stream:
+            yield chunk
+    
+        # Post-proccessing
+        #self.post_process(self.item_id,
+                        #self.source,
+                        #self.metadata,
+                        #self.ext,
+                        #self.cache_dir,
+                        #self.options)
+
diff --git a/export/wav.py b/export/wav.py
new file mode 100644 (file)
index 0000000..c98a172
--- /dev/null
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+import os
+import string
+
+from timeside.export.core import *
+from timeside.export.api import IExporter
+
+class WavExporter(ExporterCore):
+    """Defines methods to export to WAV"""
+
+    implements(IExporter)
+    
+    def __init__(self):
+        self.item_id = ''
+        self.metadata = {}
+        self.description = ''
+        self.info = []
+        self.source = ''
+        self.dest = ''
+        self.options = {}
+        self.buffer_size = 0xFFFF
+
+    def get_format(self):
+        return 'WAV'
+    
+    def get_file_extension(self):
+        return 'wav'
+
+    def get_mime_type(self):
+        return 'audio/x-wav'
+
+    def get_description(self):
+        return 'FIXME'
+
+    def get_file_info(self):
+        try:
+            file1, file2 = os.popen4('wavinfo "'+self.dest+'"')
+            info = []
+            for line in file2.readlines():
+                info.append(clean_word(line[:-1]))
+            self.info = info
+            return self.info
+        except:
+            raise IOError('ExporterError: wavinfo id not installed or file does not exist.')
+
+    def set_cache_dir(self,path):
+        self.cache_dir = path
+
+    def decode(self):
+        try:
+            file_name, ext = get_file_name(self.source)
+            dest = self.cache_dir+os.sep+file_name+'.wav'
+            os.system('sox "'+self.source+'" -s -r 44100 -t wav -c2 "'+ \
+                      dest+'.wav"')
+            self.source = dest
+            return dest
+        except:
+            raise IOError('ExporterError: decoder is not compatible.')
+
+    def write_tags(self):
+        # Create metadata XML file !
+        self.write_metadata_xml(self.dest+'.xml')
+    
+    def create_md5_key(self):
+        """ Create the md5 keys of the dest """
+        try:
+            os.system('md5sum -b "'+self.dest+'" >"'+self.dest+'.md5"')
+        except:
+            raise IOError('ExporterError: cannot create the md5 key.')
+    
+    def create_par_key(self):
+        """ Create the par2 keys of the dest """
+        args = 'c -n1 '
+        if 'verbose' in self.options and self.options['verbose'] != '0':
+            args = args
+        else:
+            args = args + '-q -q '
+
+        try:
+            os.system('par2 '+args+' "'+self.dest+'"')
+        except:
+            raise IOError('ExporterError: cannot create the par2 key.')
+
+    def process(self, item_id, source, metadata, options=None):
+        self.item_id = item_id
+        self.source = source
+        self.metadata = metadata
+        self.options = {}
+
+        if not options is None:
+            self.options = options
+
+        # Pre-proccessing
+        self.ext = self.get_file_extension()
+        self.dest = self.pre_process(self.item_id,
+                                        self.source,
+                                        self.metadata,
+                                        self.ext,
+                                        self.cache_dir,
+                                        self.options)
+
+        # Initializing
+        file_in = open(self.source,'rb')
+        file_out = open(self.dest,'w')
+
+        # Core Processing
+        while True:
+            chunk = file_in.read(self.buffer_size)
+            if len(chunk) == 0:
+                break
+            yield chunk
+            file_out.write(chunk)
+
+        file_in.close()
+        file_out.close()
+
+        # Create the md5 key
+        #if 'md5' in self.metadata and self.metadata['md5']:
+        self.create_md5_key()
+
+        # Create the par2 key
+        #if 'par2' in self.metadata and self.metadata['par2']:
+        #self.create_par_key()
+
+        # Pre-proccessing
+        self.post_process(self.item_id,
+                        self.source,
+                        self.metadata,
+                        self.ext,
+                        self.cache_dir,
+                        self.options)
+
+
+
+            #if self.compare_md5_key():
+            #os.system('cp -a "'+self.source+'" "'+ self.dest+'"')
+            #print 'COPIED'
+
diff --git a/graph/__init__.py b/graph/__init__.py
new file mode 100644 (file)
index 0000000..5d006e6
--- /dev/null
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from timeside.graph.api import *
+from timeside.graph.waveform_audiolab import *
+from timeside.graph.spectrogram_audiolab import *
diff --git a/graph/api.py b/graph/api.py
new file mode 100644 (file)
index 0000000..1df7e01
--- /dev/null
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2007 Samalyse SARL#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+# Author: Olivier Guilyardi <olivier@samalyse.com>
+
+from timeside.core import *
+
+class IMediaItemGrapher(Interface):
+    """Media item visualizer driver interface"""
+
+    def get_id():
+        """Return a short id alphanumeric, lower-case string."""
+
+    def get_name():
+        """Return the graph name, such as "Waveform", "Spectral view",
+        etc..
+        """
+
+    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 render(media_item, width=None, height=None, options=None):
+        """Generator that streams the graph output as a PNG image"""
diff --git a/graph/spectrogram_audiolab.py b/graph/spectrogram_audiolab.py
new file mode 100644 (file)
index 0000000..b156b86
--- /dev/null
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.core import *
+from timeside.graph.api import IMediaItemGrapher
+from django.conf import settings
+from tempfile import NamedTemporaryFile
+from timeside.graph.wav2png import *
+
+class SpectrogramGrapherAudiolab(Component):
+    """Spectrogram graph driver (python style thanks to wav2png.py and scikits.audiolab)"""
+
+    implements(IMediaItemGrapher)
+
+    bg_color = None
+    color_scheme = None
+
+    def get_id(self):
+        return "spectrogram_audiolab"
+
+    def get_name(self):
+        return "Spectrogram (audiolab)"
+    
+    def set_colors(self, background=None, scheme=None):
+        self.bg_color = background
+        self.color_scheme = scheme
+
+    def render(self, media_item, width=None, height=None, options=None):
+        """Generator that streams the spectrogram as a PNG image with a python method"""
+
+        wav_file = media_item.file.path
+        pngFile = NamedTemporaryFile(suffix='.png')
+
+        if not width == None:
+            image_width = width
+        else:
+            image_width = 1500
+        if not height == None:
+            image_height = height
+        else:
+            image_height = 200
+            
+        fft_size = 2048
+        args = (wav_file, pngFile.name, image_width, image_height, fft_size, 
+                self.bg_color, self.color_scheme)
+        create_spectrogram_png(*args)
+
+        buffer = pngFile.read(0xFFFF)
+        while buffer:
+            yield buffer
+            buffer = pngFile.read(0xFFFF)
+
+        pngFile.close()
diff --git a/graph/wav2png.py b/graph/wav2png.py
new file mode 100644 (file)
index 0000000..8b0cce3
--- /dev/null
@@ -0,0 +1,447 @@
+#!/usr/bin/env python
+
+# wav2png.py -- converts wave files to wave file and spectrogram images
+# Copyright (C) 2008 MUSIC TECHNOLOGY GROUP (MTG)
+#                    UNIVERSITAT POMPEU FABRA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Authors:
+#   Bram de Jong <bram.dejong at domain.com where domain in gmail>
+# Contributors:
+#   Guillaume Pellerin <pellerin@parisson.com>
+
+
+import optparse, math, sys
+import ImageFilter, ImageChops, Image, ImageDraw, ImageColor
+import numpy
+import scikits.audiolab as audiolab
+
+color_schemes = {
+    'default': {
+        'waveform': [(50,0,200), (0,220,80), (255,224,0), (255,0,0)],
+        'spectrogram': [(0, 0, 0), (58/4,68/4,65/4), (80/2,100/2,153/2), (90,180,100),
+                      (224,224,44), (255,60,30), (255,255,255)]
+    },
+    'iso': {
+        'waveform': [(0,0,255), (0,255,255), (255,255,0), (255,0,0)],
+        'spectrogram': [(0, 0, 0), (58/4,68/4,65/4), (80/2,100/2,153/2), (90,180,100),
+                      (224,224,44), (255,60,30), (255,255,255)]
+    },
+    'purple': {
+        'waveform': [(173,173,173), (147,149,196), (77,80,138), (108,66,0)],
+        'spectrogram': [(0, 0, 0), (58/4,68/4,65/4), (80/2,100/2,153/2), (90,180,100),
+                      (224,224,44), (255,60,30), (255,255,255)]
+    }
+}
+
+class TestAudioFile(object):
+    """A class that mimics audiolab.sndfile but generates noise instead of reading
+    a wave file. Additionally it can be told to have a "broken" header and thus crashing
+    in the middle of the file. Also useful for testing ultra-short files of 20 samples."""
+    def __init__(self, num_frames, has_broken_header=False):
+        self.seekpoint = 0
+        self.num_frames = num_frames
+        self.has_broken_header = has_broken_header
+
+    def seek(self, seekpoint):
+        self.seekpoint = seekpoint
+
+    def get_nframes(self):
+        return self.num_frames
+
+    def get_samplerate(self):
+        return 44100
+
+    def get_channels(self):
+        return 1
+
+    def read_frames(self, frames_to_read):
+        if self.has_broken_header and self.seekpoint + frames_to_read > self.num_frames / 2:
+            raise IOError()
+
+        num_frames_left = self.num_frames - self.seekpoint
+        if num_frames_left < frames_to_read:
+            will_read = num_frames_left
+        else:
+            will_read = frames_to_read
+        self.seekpoint += will_read
+        return numpy.random.random(will_read)*2 - 1 
+
+
+class AudioProcessor(object):
+    def __init__(self, audio_file, fft_size, window_function=numpy.ones):
+        self.fft_size = fft_size
+        self.window = window_function(self.fft_size)
+        self.audio_file = audio_file
+        self.frames = audio_file.get_nframes()
+        self.samplerate = audio_file.get_samplerate()
+        self.channels = audio_file.get_channels()
+        self.spectrum_range = None
+        self.lower = 100
+        self.higher = 22050
+        self.lower_log = math.log10(self.lower)
+        self.higher_log = math.log10(self.higher)
+        self.clip = lambda val, low, high: min(high, max(low, val))
+
+    def read(self, start, size, resize_if_less=False):
+        """ read size samples starting at start, if resize_if_less is True and less than size
+        samples are read, resize the array to size and fill with zeros """
+        
+        # number of zeros to add to start and end of the buffer
+        add_to_start = 0
+        add_to_end = 0
+        
+        if start < 0:
+            # the first FFT window starts centered around zero
+            if size + start <= 0:
+                if resize_if_less:
+                    return numpy.zeros(size)
+                else:
+                    return numpy.array([])
+            else:
+                self.audio_file.seek(0)
+
+                add_to_start = -start # remember: start is negative!
+                to_read = size + start
+
+                if to_read > self.frames:
+                    add_to_end = to_read - self.frames
+                    to_read = self.frames
+        else:
+            self.audio_file.seek(start)
+        
+            to_read = size
+            if start + to_read >= self.frames:
+                to_read = self.frames - start
+                add_to_end = size - to_read
+        
+        try:
+            samples = self.audio_file.read_frames(to_read)
+        except IOError:
+            # this can happen for wave files with broken headers...
+            if resize_if_less:
+                return numpy.zeros(size)
+            else:
+                return numpy.zeros(2)
+
+        # convert to mono by selecting left channel only
+        if self.channels > 1:
+            samples = samples[:,0]
+
+        if resize_if_less and (add_to_start > 0 or add_to_end > 0):
+            if add_to_start > 0:
+                samples = numpy.concatenate((numpy.zeros(add_to_start), samples), axis=1)
+            
+            if add_to_end > 0:
+                samples = numpy.resize(samples, size)
+                samples[size - add_to_end:] = 0
+        
+        return samples
+
+
+    def spectral_centroid(self, seek_point, spec_range=120.0):
+        """ starting at seek_point read fft_size samples, and calculate the spectral centroid """
+        
+        samples = self.read(seek_point - self.fft_size/2, self.fft_size, True)
+
+        samples *= self.window
+        fft = numpy.fft.fft(samples)
+        spectrum = numpy.abs(fft[:fft.shape[0] / 2 + 1]) / float(self.fft_size) # normalized abs(FFT) between 0 and 1
+        length = numpy.float64(spectrum.shape[0])
+        
+        # scale the db spectrum from [- spec_range db ... 0 db] > [0..1]
+        db_spectrum = ((20*(numpy.log10(spectrum + 1e-30))).clip(-spec_range, 0.0) + spec_range)/spec_range
+        
+        energy = spectrum.sum()
+        spectral_centroid = 0
+        
+        if energy > 1e-20:
+            # calculate the spectral centroid
+            
+            if self.spectrum_range == None:
+                self.spectrum_range = numpy.arange(length)
+        
+            spectral_centroid = (spectrum * self.spectrum_range).sum() / (energy * (length - 1)) * self.samplerate * 0.5
+            
+            # clip > log10 > scale between 0 and 1
+            spectral_centroid = (math.log10(self.clip(spectral_centroid, self.lower, self.higher)) - self.lower_log) / (self.higher_log - self.lower_log)
+        
+        return (spectral_centroid, db_spectrum)
+
+
+    def peaks(self, start_seek, end_seek):
+        """ read all samples between start_seek and end_seek, then find the minimum and maximum peak
+        in that range. Returns that pair in the order they were found. So if min was found first,
+        it returns (min, max) else the other way around. """
+        
+        # larger blocksizes are faster but take more mem...
+        # Aha, Watson, a clue, a tradeof!
+        block_size = 4096
+    
+        max_index = -1
+        max_value = -1
+        min_index = -1
+        min_value = 1
+    
+        if end_seek > self.frames:
+            end_seek = self.frames
+    
+        if block_size > end_seek - start_seek:
+            block_size = end_seek - start_seek
+            
+        if block_size <= 1:
+            samples = self.read(start_seek, 1)
+            return samples[0], samples[0]
+        elif block_size == 2:
+            samples = self.read(start_seek, True)
+            return samples[0], samples[1]
+        
+        for i in range(start_seek, end_seek, block_size):
+            samples = self.read(i, block_size)
+    
+            local_max_index = numpy.argmax(samples)
+            local_max_value = samples[local_max_index]
+    
+            if local_max_value > max_value:
+                max_value = local_max_value
+                max_index = local_max_index
+    
+            local_min_index = numpy.argmin(samples)
+            local_min_value = samples[local_min_index]
+            
+            if local_min_value < min_value:
+                min_value = local_min_value
+                min_index = local_min_index
+
+        if min_index < max_index:
+            return (min_value, max_value)
+        else:
+            return (max_value, min_value)
+
+
+def interpolate_colors(colors, flat=False, num_colors=256):
+    """ given a list of colors, create a larger list of colors interpolating
+    the first one. If flatten is True a list of numers will be returned. If
+    False, a list of (r,g,b) tuples. num_colors is the number of colors wanted
+    in the final list """
+    
+    palette = []
+    
+    for i in range(num_colors):
+        index = (i * (len(colors) - 1))/(num_colors - 1.0)
+        index_int = int(index)
+        alpha = index - float(index_int)
+        
+        if alpha > 0:
+            r = (1.0 - alpha) * colors[index_int][0] + alpha * colors[index_int + 1][0]
+            g = (1.0 - alpha) * colors[index_int][1] + alpha * colors[index_int + 1][1]
+            b = (1.0 - alpha) * colors[index_int][2] + alpha * colors[index_int + 1][2]
+        else:
+            r = (1.0 - alpha) * colors[index_int][0]
+            g = (1.0 - alpha) * colors[index_int][1]
+            b = (1.0 - alpha) * colors[index_int][2]
+        
+        if flat:
+            palette.extend((int(r), int(g), int(b)))
+        else:
+            palette.append((int(r), int(g), int(b)))
+        
+    return palette
+    
+
+class WaveformImage(object):
+    def __init__(self, image_width, image_height, bg_color = None, color_scheme = None):
+        if not bg_color:
+            bg_color = (0,0,0)
+        if not color_scheme: 
+            color_scheme = 'default'
+
+        self.image = Image.new("RGB", (image_width, image_height), bg_color)
+        
+        self.image_width = image_width
+        self.image_height = image_height
+        
+        self.draw = ImageDraw.Draw(self.image)
+        self.previous_x, self.previous_y = None, None
+        
+        colors = color_schemes[color_scheme]['waveform']
+        
+        # this line gets the old "screaming" colors back...
+        # colors = [self.color_from_value(value/29.0) for value in range(0,30)]
+        
+        self.color_lookup = interpolate_colors(colors)
+        self.pix = self.image.load()
+
+    def color_from_value(self, value):
+        """ given a value between 0 and 1, return an (r,g,b) tuple """
+
+        return ImageColor.getrgb("hsl(%d,%d%%,%d%%)" % (int( (1.0 - value) * 360 ), 80, 50))
+        
+    def draw_peaks(self, x, peaks, spectral_centroid):
+        """ draw 2 peaks at x using the spectral_centroid for color """
+
+        y1 = self.image_height * 0.5 - peaks[0] * (self.image_height - 4) * 0.5
+        y2 = self.image_height * 0.5 - peaks[1] * (self.image_height - 4) * 0.5
+        
+        line_color = self.color_lookup[int(spectral_centroid*255.0)]
+        
+        if self.previous_y != None:
+            self.draw.line([self.previous_x, self.previous_y, x, y1, x, y2], line_color)
+        else:
+            self.draw.line([x, y1, x, y2], line_color)
+    
+        self.previous_x, self.previous_y = x, y2
+        
+        self.draw_anti_aliased_pixels(x, y1, y2, line_color)
+    
+    def draw_anti_aliased_pixels(self, x, y1, y2, color):
+        """ vertical anti-aliasing at y1 and y2 """
+
+        y_max = max(y1, y2)
+        y_max_int = int(y_max)
+        alpha = y_max - y_max_int
+        
+        if alpha > 0.0 and alpha < 1.0 and y_max_int + 1 < self.image_height:
+            current_pix = self.pix[x, y_max_int + 1]
+            
+            r = int((1-alpha)*current_pix[0] + alpha*color[0])
+            g = int((1-alpha)*current_pix[1] + alpha*color[1])
+            b = int((1-alpha)*current_pix[2] + alpha*color[2])
+            
+            self.pix[x, y_max_int + 1] = (r,g,b)
+            
+        y_min = min(y1, y2)
+        y_min_int = int(y_min)
+        alpha = 1.0 - (y_min - y_min_int)
+        
+        if alpha > 0.0 and alpha < 1.0 and y_min_int - 1 >= 0:
+            current_pix = self.pix[x, y_min_int - 1]
+                
+            r = int((1-alpha)*current_pix[0] + alpha*color[0])
+            g = int((1-alpha)*current_pix[1] + alpha*color[1])
+            b = int((1-alpha)*current_pix[2] + alpha*color[2])
+            
+            self.pix[x, y_min_int - 1] = (r,g,b)
+            
+    def save(self, filename):
+        # draw a zero "zero" line
+        a = 25
+        for x in range(self.image_width):
+            self.pix[x, self.image_height/2] = tuple(map(lambda p: p+a, self.pix[x, self.image_height/2]))
+        
+        self.image.save(filename)
+        
+        
+class SpectrogramImage(object):
+    def __init__(self, image_width, image_height, fft_size, bg_color = None, color_scheme = None):
+
+        #FIXME: bg_color is ignored
+
+        if not color_scheme: 
+            color_scheme = 'default'
+
+        self.image = Image.new("P", (image_height, image_width))
+        
+        self.image_width = image_width
+        self.image_height = image_height
+        self.fft_size = fft_size
+        
+        colors = color_schemes[color_scheme]['spectrogram']
+        
+        self.image.putpalette(interpolate_colors(colors, True))
+
+        # generate the lookup which translates y-coordinate to fft-bin
+        self.y_to_bin = []
+        f_min = 100.0
+        f_max = 22050.0
+        y_min = math.log10(f_min)
+        y_max = math.log10(f_max)
+        for y in range(self.image_height):
+            freq = math.pow(10.0, y_min + y / (image_height - 1.0) *(y_max - y_min))
+            bin = freq / 22050.0 * (self.fft_size/2 + 1)
+
+            if bin < self.fft_size/2:
+                alpha = bin - int(bin)
+                
+                self.y_to_bin.append((int(bin), alpha * 255))
+           
+        # this is a bit strange, but using image.load()[x,y] = ... is
+        # a lot slower than using image.putadata and then rotating the image
+        # so we store all the pixels in an array and then create the image when saving
+        self.pixels = []
+            
+    def draw_spectrum(self, x, spectrum):
+        for (index, alpha) in self.y_to_bin:
+            self.pixels.append( int( ((255.0-alpha) * spectrum[index] + alpha * spectrum[index + 1] )) )
+            
+        for y in range(len(self.y_to_bin), self.image_height):
+            self.pixels.append(0)
+
+    def save(self, filename):
+        self.image.putdata(self.pixels)
+        self.image.transpose(Image.ROTATE_90).save(filename)
+
+
+def create_wavform_png(input_filename, output_filename_w, image_width, image_height, fft_size,
+                       bg_color = None, color_scheme = None):
+    audio_file = audiolab.sndfile(input_filename, 'read')
+
+    samples_per_pixel = audio_file.get_nframes() / float(image_width)
+    processor = AudioProcessor(audio_file, fft_size, numpy.hanning)
+    
+    waveform = WaveformImage(image_width, image_height, bg_color, color_scheme)
+    
+    for x in range(image_width):
+        
+        if x % (image_width/10) == 0:
+            sys.stdout.write('.')
+            sys.stdout.flush()
+            
+        seek_point = int(x * samples_per_pixel)
+        next_seek_point = int((x + 1) * samples_per_pixel)
+        
+        (spectral_centroid, db_spectrum) = processor.spectral_centroid(seek_point)
+        peaks = processor.peaks(seek_point, next_seek_point)
+        
+        waveform.draw_peaks(x, peaks, spectral_centroid)
+    
+    waveform.save(output_filename_w)
+    
+    print " done"
+
+def create_spectrogram_png(input_filename, output_filename_s, image_width, image_height, fft_size,
+                           bg_color = None, color_scheme = None):
+    audio_file = audiolab.sndfile(input_filename, 'read')
+
+    samples_per_pixel = audio_file.get_nframes() / float(image_width)
+    processor = AudioProcessor(audio_file, fft_size, numpy.hanning)
+    
+    spectrogram = SpectrogramImage(image_width, image_height, fft_size, bg_color, color_scheme)
+    
+    for x in range(image_width):
+   
+        if x % (image_width/10) == 0:
+            sys.stdout.write('.')
+            sys.stdout.flush()
+            
+        seek_point = int(x * samples_per_pixel)
+        next_seek_point = int((x + 1) * samples_per_pixel)  
+        (spectral_centroid, db_spectrum) = processor.spectral_centroid(seek_point) 
+        spectrogram.draw_spectrum(x, db_spectrum)
+    
+    spectrogram.save(output_filename_s)
+    
+    print " done"
+
diff --git a/graph/waveform_audiolab.py b/graph/waveform_audiolab.py
new file mode 100644 (file)
index 0000000..6fd2b07
--- /dev/null
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2007-2009 Guillaume Pellerin <yomguy@parisson.com>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+# Author: Guillaume Pellerin <yomguy@parisson.com>
+
+from timeside.core import *
+from timeside.graph.api import IMediaItemGrapher
+from django.conf import settings
+from tempfile import NamedTemporaryFile
+from timeside.graph.wav2png import *
+
+class WaveFormGrapherAudiolab(Component):
+    """WaveForm graph driver (python style thanks to wav2png.py and scikits.audiolab)"""
+
+    implements(IMediaItemGrapher)
+
+    bg_color = None
+    color_scheme = None
+
+    def get_id(self):
+        return "waveform_audiolab"
+
+    def get_name(self):
+        return "Waveform (audiolab)"
+
+    def set_colors(self, background=None, scheme=None):
+        self.bg_color = background
+        self.color_scheme = scheme
+
+    def render(self, media_item, width=None, height=None, options=None):
+        """Generator that streams the waveform as a PNG image with a python method"""
+
+        wav_file = media_item.file.path
+        pngFile = NamedTemporaryFile(suffix='.png')
+
+        if not width == None:
+            image_width = width
+        else:
+            image_width = 1500
+        if not height == None:
+            image_height = height
+        else:
+            image_height = 200
+
+        fft_size = 2048
+        args = (wav_file, pngFile.name, image_width, image_height, fft_size, 
+                self.bg_color, self.color_scheme)
+        create_wavform_png(*args)
+
+        buffer = pngFile.read(0xFFFF)
+        while buffer:
+            yield buffer
+            buffer = pngFile.read(0xFFFF)
+
+        pngFile.close()
+