From: yomguy Date: Thu, 24 Sep 2009 15:22:35 +0000 (+0000) Subject: first import of export, graph and analysis related classes X-Git-Tag: 0.3.2~254 X-Git-Url: https://git.parisson.com/?a=commitdiff_plain;h=c1a4e556c70605c9eb87a14a0f8a364006d78aa1;p=timeside.git first import of export, graph and analysis related classes --- diff --git a/analysis/__init__.py b/analysis/__init__.py new file mode 100644 index 0000000..0c2c597 --- /dev/null +++ b/analysis/__init__.py @@ -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 index 0000000..634ef2a --- /dev/null +++ b/analysis/api.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..5f1c2ee --- /dev/null +++ b/analysis/channels.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..135babc --- /dev/null +++ b/analysis/core.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Authors: +# Bram de Jong +# Guillaume Pellerin + +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 index 0000000..c15a6f8 --- /dev/null +++ b/analysis/dc.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..9428021 --- /dev/null +++ b/analysis/duration.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..897ff04 --- /dev/null +++ b/analysis/encoding.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..480ce00 --- /dev/null +++ b/analysis/format.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..a54e460 --- /dev/null +++ b/analysis/max_level.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..b7696ed --- /dev/null +++ b/analysis/mean_level.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..98c2901 --- /dev/null +++ b/analysis/resolution.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..6daae7f --- /dev/null +++ b/analysis/samplerate.py @@ -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 + +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 index 0000000..1dc0d67 --- /dev/null +++ b/analysis/vamp/__init__.py @@ -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 index 0000000..06519f6 --- /dev/null +++ b/analysis/vamp/core.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..06cb43a --- /dev/null +++ b/export/__init__.py @@ -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 index 0000000..7ee861c --- /dev/null +++ b/export/api.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007-2009 Parisson +# Copyright (c) 2007 Olivier Guilyardi +# Copyright (c) 2007-2009 Guillaume Pellerin +# +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +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 index 0000000..6f83356 --- /dev/null +++ b/export/core.py @@ -0,0 +1,277 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..60f2f92 --- /dev/null +++ b/export/flac.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..e75f15f --- /dev/null +++ b/export/mp3.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Parisson SARL +# Copyright (c) 2006-2007 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..270449a --- /dev/null +++ b/export/ogg.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..c98a172 --- /dev/null +++ b/export/wav.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..5d006e6 --- /dev/null +++ b/graph/__init__.py @@ -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 index 0000000..1df7e01 --- /dev/null +++ b/graph/api.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2007 Samalyse SARL# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin +# Author: Olivier Guilyardi + +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 index 0000000..b156b86 --- /dev/null +++ b/graph/spectrogram_audiolab.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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 index 0000000..8b0cce3 --- /dev/null +++ b/graph/wav2png.py @@ -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 . +# +# Authors: +# Bram de Jong +# Contributors: +# Guillaume Pellerin + + +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 index 0000000..6fd2b07 --- /dev/null +++ b/graph/waveform_audiolab.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2007-2009 Guillaume Pellerin + +# This file is part of TimeSide. + +# TimeSide is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. + +# TimeSide is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with TimeSide. If not, see . + +# Author: Guillaume Pellerin + +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() +