diff --git a/pysoundfile.py b/pysoundfile.py index d6c7ffe..8989bd2 100644 --- a/pysoundfile.py +++ b/pysoundfile.py @@ -1,7 +1,6 @@ -import os - -from cffi import FFI -import numpy as np +from cffi import FFI as _FFI +import numpy as _np +import os as _os """PySoundFile is an audio library based on libsndfile, CFFI and Numpy @@ -41,8 +40,8 @@ """ -ffi = FFI() -ffi.cdef(""" +_ffi = _FFI() +_ffi.cdef(""" typedef int64_t sf_count_t ; typedef struct SNDFILE_tag SNDFILE ; @@ -115,99 +114,193 @@ } SF_VIRTUAL_IO ; SNDFILE* sf_open_virtual (SF_VIRTUAL_IO *sfvirtual, int mode, SF_INFO *sfinfo, void *user_data) ; +SNDFILE* sf_open_fd (int fd, int mode, SF_INFO *sfinfo, int close_desc) ; +typedef struct SF_FORMAT_INFO +{ + int format ; + const char* name ; + const char* extension ; +} SF_FORMAT_INFO ; """) -read_mode = 0x10 -write_mode = 0x20 -read_write_mode = 0x30 - -snd_types = { - 'WAV': 0x010000, # Microsoft WAV format (little endian default). - 'AIFF': 0x020000, # Apple/SGI AIFF format (big endian). - 'AU': 0x030000, # Sun/NeXT AU format (big endian). - 'RAW': 0x040000, # RAW PCM data. - 'PAF': 0x050000, # Ensoniq PARIS file format. - 'SVX': 0x060000, # Amiga IFF / SVX8 / SV16 format. - 'NIST': 0x070000, # Sphere NIST format. - 'VOC': 0x080000, # VOC files. - 'IRCAM': 0x0A0000, # Berkeley/IRCAM/CARL - 'W64': 0x0B0000, # Sonic Foundry's 64 bit RIFF/WAV - 'MAT4': 0x0C0000, # Matlab (tm) V4.2 / GNU Octave 2.0 - 'MAT5': 0x0D0000, # Matlab (tm) V5.0 / GNU Octave 2.1 - 'PVF': 0x0E0000, # Portable Voice Format - 'XI': 0x0F0000, # Fasttracker 2 Extended Instrument - 'HTK': 0x100000, # HMM Tool Kit format - 'SDS': 0x110000, # Midi Sample Dump Standard - 'AVR': 0x120000, # Audio Visual Research - 'WAVEX': 0x130000, # MS WAVE with WAVEFORMATEX - 'SD2': 0x160000, # Sound Designer 2 - 'FLAC': 0x170000, # FLAC lossless file format - 'CAF': 0x180000, # Core Audio File format - 'WVE': 0x190000, # Psion WVE format - 'OGG': 0x200000, # Xiph OGG container - 'MPC2K': 0x210000, # Akai MPC 2000 sampler - 'RF64': 0x220000 # RF64 WAV file +_M_READ = 0x10 +_M_WRITE = 0x20 +_M_RDWR = 0x30 + +_formats = { + 0x010000: 'WAV', # Microsoft WAV format (little endian default). + 0x020000: 'AIFF', # Apple/SGI AIFF format (big endian). + 0x030000: 'AU', # Sun/NeXT AU format (big endian). + 0x040000: 'RAW', # RAW PCM data. + 0x050000: 'PAF', # Ensoniq PARIS file format. + 0x060000: 'SVX', # Amiga IFF / SVX8 / SV16 format. + 0x070000: 'NIST', # Sphere NIST format. + 0x080000: 'VOC', # VOC files. + 0x0A0000: 'IRCAM', # Berkeley/IRCAM/CARL + 0x0B0000: 'W64', # Sonic Foundry's 64 bit RIFF/WAV + 0x0C0000: 'MAT4', # Matlab (tm) V4.2 / GNU Octave 2.0 + 0x0D0000: 'MAT5', # Matlab (tm) V5.0 / GNU Octave 2.1 + 0x0E0000: 'PVF', # Portable Voice Format + 0x0F0000: 'XI', # Fasttracker 2 Extended Instrument + 0x100000: 'HTK', # HMM Tool Kit format + 0x110000: 'SDS', # Midi Sample Dump Standard + 0x120000: 'AVR', # Audio Visual Research + 0x130000: 'WAVEX', # MS WAVE with WAVEFORMATEX + 0x160000: 'SD2', # Sound Designer 2 + 0x170000: 'FLAC', # FLAC lossless file format + 0x180000: 'CAF', # Core Audio File format + 0x190000: 'WVE', # Psion WVE format + 0x200000: 'OGG', # Xiph OGG container + 0x210000: 'MPC2K', # Akai MPC 2000 sampler + 0x220000: 'RF64', # RF64 WAV file } -snd_subtypes = { - 'PCM_S8': 0x0001, # Signed 8 bit data - 'PCM_16': 0x0002, # Signed 16 bit data - 'PCM_24': 0x0003, # Signed 24 bit data - 'PCM_32': 0x0004, # Signed 32 bit data - 'PCM_U8': 0x0005, # Unsigned 8 bit data (WAV and RAW only) - 'FLOAT': 0x0006, # 32 bit float data - 'DOUBLE': 0x0007, # 64 bit float data - 'ULAW': 0x0010, # U-Law encoded. - 'ALAW': 0x0011, # A-Law encoded. - 'IMA_ADPCM': 0x0012, # IMA ADPCM. - 'MS_ADPCM': 0x0013, # Microsoft ADPCM. - 'GSM610': 0x0020, # GSM 6.10 encoding. - 'VOX_ADPCM': 0x0021, # OKI / Dialogix ADPCM - 'G721_32': 0x0030, # 32kbs G721 ADPCM encoding. - 'G723_24': 0x0031, # 24kbs G723 ADPCM encoding. - 'G723_40': 0x0032, # 40kbs G723 ADPCM encoding. - 'DWVW_12': 0x0040, # 12 bit Delta Width Variable Word encoding. - 'DWVW_16': 0x0041, # 16 bit Delta Width Variable Word encoding. - 'DWVW_24': 0x0042, # 24 bit Delta Width Variable Word encoding. - 'DWVW_N': 0x0043, # N bit Delta Width Variable Word encoding. - 'DPCM_8': 0x0050, # 8 bit differential PCM (XI only) - 'DPCM_16': 0x0051, # 16 bit differential PCM (XI only) - 'VORBIS': 0x0060, # Xiph Vorbis encoding. +_subtypes = { + 0x0001: 'PCM_S8', # Signed 8 bit data + 0x0002: 'PCM_16', # Signed 16 bit data + 0x0003: 'PCM_24', # Signed 24 bit data + 0x0004: 'PCM_32', # Signed 32 bit data + 0x0005: 'PCM_U8', # Unsigned 8 bit data (WAV and RAW only) + 0x0006: 'FLOAT', # 32 bit float data + 0x0007: 'DOUBLE', # 64 bit float data + 0x0010: 'ULAW', # U-Law encoded. + 0x0011: 'ALAW', # A-Law encoded. + 0x0012: 'IMA_ADPCM', # IMA ADPCM. + 0x0013: 'MS_ADPCM', # Microsoft ADPCM. + 0x0020: 'GSM610', # GSM 6.10 encoding. + 0x0021: 'VOX_ADPCM', # OKI / Dialogix ADPCM + 0x0030: 'G721_32', # 32kbs G721 ADPCM encoding. + 0x0031: 'G723_24', # 24kbs G723 ADPCM encoding. + 0x0032: 'G723_40', # 40kbs G723 ADPCM encoding. + 0x0040: 'DWVW_12', # 12 bit Delta Width Variable Word encoding. + 0x0041: 'DWVW_16', # 16 bit Delta Width Variable Word encoding. + 0x0042: 'DWVW_24', # 24 bit Delta Width Variable Word encoding. + 0x0043: 'DWVW_N', # N bit Delta Width Variable Word encoding. + 0x0050: 'DPCM_8', # 8 bit differential PCM (XI only) + 0x0051: 'DPCM_16', # 16 bit differential PCM (XI only) + 0x0060: 'VORBIS', # Xiph Vorbis encoding. } -snd_endians = { - 'FILE': 0x00000000, # Default file endian-ness. - 'LITTLE': 0x10000000, # Force little endian-ness. - 'BIG': 0x20000000, # Force big endian-ness. - 'CPU': 0x30000000, # Force CPU endian-ness. +_endians = { + 0x00000000: 'FILE', # Default file endian-ness. + 0x10000000: 'LITTLE', # Force little endian-ness. + 0x20000000: 'BIG', # Force big endian-ness. + 0x30000000: 'CPU', # Force CPU endian-ness. } -wave_file = ('WAV', 'PCM_16', 'FILE') -flac_file = ('FLAC', 'PCM_16', 'FILE') -matlab_file = ('MAT5', 'DOUBLE', 'FILE') -ogg_file = ('OGG', 'VORBIS', 'FILE') - -def _encodeformat(format): - type = snd_types[format[0]] - subtype = snd_subtypes[format[1]] - endianness = snd_endians[format[2]] - return type|subtype|endianness - -def _decodeformat(format): - sub_mask = 0x0000FFFF - type_mask = 0x0FFF0000 - end_mask = 0x30000000 - - def reverse_dict(d): return {value:key for key, value in d.items()} +_SUBMASK = 0x0000FFFF +_TYPEMASK = 0x0FFF0000 +_ENDMASK = 0x30000000 + +_TITLE = 0x01 +_COPYRIGHT = 0x02 +_SOFTWARE = 0x03 +_ARTIST = 0x04 +_COMMENT = 0x05 +_DATE = 0x06 +_ALBUM = 0x07 +_LICENSE = 0x08 +_TRACKNUMBER = 0x09 +_GENRE = 0x10 + +_GET_FORMAT_INFO = 0x1028 + +class FormatType(int): + def __repr__(self): + return _formats.get(self, int.__repr__(self)) + +class SubtypeType(int): + def __repr__(self): + return _subtypes.get(self, int.__repr__(self)) + +class EndianType(int): + def __repr__(self): + return _endians.get(self, int.__repr__(self)) + +def _add_formats_to_module_namespace(format_dict, format_type): + for k, v in format_dict.items(): + globals()[v] = format_type(k) + +_add_formats_to_module_namespace(_formats, FormatType) +_add_formats_to_module_namespace(_subtypes, SubtypeType) +_add_formats_to_module_namespace(_endians, EndianType) + +_format_by_extension = { + 'wav': WAV, + 'aif': AIFF, + 'aiff': AIFF, + 'aifc': AIFF | FLOAT, + 'au': AU, + 'raw': RAW, + 'paf': PAF, + 'svx': SVX, + 'nist': NIST, + 'voc': VOC, + 'ircam': IRCAM, + 'w64': W64, + 'mat4': MAT4, + 'mat': MAT5, + 'pvf': PVF, + 'xi': XI, + 'htk': HTK, + 'sds': SDS, + 'avr': AVR, + 'wavex': WAVEX, + 'sd2': SD2, + 'flac': FLAC, + 'caf': CAF, + 'wve': WVE, + 'ogg': OGG, + 'oga': OGG, + 'mpc2k': MPC2K, + 'rf64': RF64, + #'vox': RAW | VOX_ADPCM, +} - type = reverse_dict(snd_types)[format & type_mask] - subtype = reverse_dict(snd_subtypes)[format & sub_mask] - endianness = reverse_dict(snd_endians)[format & end_mask] +# see http://www.mega-nerd.com/libsndfile/ for supported subtypes +_default_subtypes = { + WAV: PCM_16, + AIFF: PCM_16, + AU: PCM_16, + #RAW: # subtype must be explicit! + PAF: PCM_16, + SVX: PCM_16, + NIST: PCM_16, + VOC: PCM_16, + IRCAM: PCM_16, + W64: PCM_16, + MAT4: DOUBLE, + MAT5: DOUBLE, + PVF: PCM_16, + XI: DPCM_16, + HTK: PCM_16, + #SDS: + #AVR: + WAVEX: PCM_16, + SD2: PCM_16, + FLAC: PCM_16, + CAF: PCM_16, + #WVE: + OGG: VORBIS, + #MPC2K: + #RF64: +} - return (type, subtype, endianness) +_snd_strings = { + 'title': _TITLE, + 'copyright': _COPYRIGHT, + 'software': _SOFTWARE, + 'artist': _ARTIST, + 'comment': _COMMENT, + 'date': _DATE, + 'album': _ALBUM, + 'license': _LICENSE, + 'tracknumber': _TRACKNUMBER, + 'genre': _GENRE +} -_snd = ffi.dlopen('sndfile') +_snd = _ffi.dlopen('sndfile') class SoundFile(object): @@ -216,14 +309,13 @@ class SoundFile(object): Each SoundFile opens one sound file on the disk. This sound file has a specific samplerate, data format and a set number of - channels. Each sound file can be opened in read_mode, write_mode, - or read_write_mode. Note that read_write_mode is unsupported for - some formats. + channels. Each sound file can be opened with mode 'r', 'w', or 'r+'. + Note that 'r+' is unsupported for some formats. Data can be written to the file using write(), or read from the file using read(). Every read and write operation starts at a certain position in the file. Reading N frames will change this - position by N frames as well. Alternatively, seek() and + position by N frames as well. Alternatively, seek_relative() and seek_absolute() can be used to set the current position to a frame index offset from the current position, the start of the file, or the end of the file, respectively. @@ -246,13 +338,13 @@ class SoundFile(object): """ - def __init__(self, name, sample_rate=0, channels=0, format=0, - mode=read_mode, virtual_io=False): + def __init__(self, file, mode=None, sample_rate=None, channels=None, + subtype=None, endian=None, format=None, closefd=True): """Open a new SoundFile. - If a file is only opened in read_mode or in read_write_mode, + If a file is only opened with mode='r' or mode='r+', no sample_rate, channels or file format need to be given. If a - file is opened in write_mode, you must provide a sample_rate, + file is opened with mode='w', you must provide a sample_rate, a number of channels, and a file format. An exception is the RAW data format, which requires these data points for reading as well. @@ -270,74 +362,125 @@ def __init__(self, name, sample_rate=0, channels=0, format=0, ogg_file. """ - info = ffi.new("SF_INFO*") - info.samplerate = sample_rate - info.channels = channels - if hasattr(format, '__getitem__'): - format = _encodeformat(format) - info.format = format - self._file_mode = mode - - if virtual_io: - fObj = name + assert _raise_error_if_format_type(file, sample_rate, channels) + + mode = mode or getattr(file, 'mode', 'r') + mode_chars = set(mode) + mode_chars.add('b') + if mode_chars == set('rb'): + self._mode = _M_READ + elif mode_chars == set('wb'): + self._mode = _M_WRITE + elif mode_chars == set('r+b'): + self._mode = _M_RDWR + else: + raise ValueError("Invalid mode: " + mode) + + original_format, original_endian = format, endian + if format is None and isinstance(file, str): + ext = file.rsplit('.', 1)[-1] + format = _format_by_extension.get(ext.lower(), 0x0) + + self._info = _ffi.new("SF_INFO*") + if self._mode == _M_WRITE or format == RAW: + assert sample_rate, \ + "sample_rate must be specified for mode='w' and format=RAW!" + self._info.samplerate = sample_rate + assert channels, \ + "channels must be specified for mode='w' and format=RAW!" + self._info.channels = channels + if subtype is None: + subtype = _default_subtypes.get(format, 0x0) + endian = endian or FILE + format = format | subtype | endian + assert format, "No format specified!" + assert format & _TYPEMASK, "Invalid format!" + assert format & _SUBMASK, "Invalid subtype!" + assert endian == FILE or format & _ENDMASK, "Invalid endian-ness!" + self._info.format = format + assert _snd.sf_format_check(self._info), \ + "Invalid combination of format, subtype and endian!" + else: + assert [sample_rate, channels, subtype, original_endian, + original_format] == [None] * 5, \ + "Only allowed if mode='w' or format=RAW: sample_rate, " \ + "channels, format, subtype, endian" + + self._name = file + if isinstance(file, str): + file = _ffi.new('char[]', file.encode()) + self._file = _snd.sf_open(file, self._mode, self._info) + elif isinstance(file, int): + self._file = _snd.sf_open_fd(file, self._mode, self._info, closefd) + else: for attr in ('seek', 'read', 'write', 'tell'): - if not hasattr(fObj, attr): - msg = 'File-like object must have: "%s"' % attr + if not hasattr(file, attr): + msg = "file must be a filename, a file descriptor or " \ + "a file-like object with the methods " \ + "'seek()', 'read()', 'write()' and 'tell()'!" raise RuntimeError(msg) - self._vio = self._init_vio(fObj) - vio = ffi.new("SF_VIRTUAL_IO*", self._vio) - self._vio['vio_cdata'] = vio - self._file = _snd.sf_open_virtual(vio, self._file_mode, info, - ffi.NULL) - else: - filename = ffi.new('char[]', name.encode()) - self._file = _snd.sf_open(filename, self._file_mode, info) - + self._name = str(file) + file = self._init_vio(file) + self._vio = _ffi.new("SF_VIRTUAL_IO*", file) + self._file = _snd.sf_open_virtual(self._vio, self._mode, + self._info, _ffi.NULL) self._handle_error() - - self.frames = info.frames - self.sample_rate = info.samplerate - self.channels = info.channels - self.format = _decodeformat(info.format) - self.sections = info.sections - self.seekable = info.seekable == 1 + if self._mode == _M_WRITE: + # this is not set by libsndfile: + self._info.frames = 0 + + name = property(lambda self: self._name) + frames = property(lambda self: self._info.frames) + sample_rate = property(lambda self: self._info.samplerate) + channels = property(lambda self: self._info.channels) + format = property(lambda self: FormatType(self._info.format & _TYPEMASK)) + subtype = property(lambda self: SubtypeType(self._info.format & _SUBMASK)) + endian = property(lambda self: EndianType(self._info.format & _ENDMASK)) + format_string = property(lambda self: get_format_info(self.format)) + subtype_string = property(lambda self: get_format_info(self.subtype)) + sections = property(lambda self: self._info.sections) + seekable = property(lambda self: self._info.seekable == 1) + closed = property(lambda self: self._file is None) + mode = property(lambda self: {_M_READ: 'r', + _M_WRITE: 'w', + _M_RDWR: 'r+'}[self._mode]) def _init_vio(self, fObj): # Define callbacks here, so they can reference fObj / size - @ffi.callback("sf_vio_get_filelen") + @_ffi.callback("sf_vio_get_filelen") def vio_get_filelen(user_data): # Streams must set _length or implement __len__ if hasattr(fObj, '_length'): size = fObj._length elif not hasattr(fObj, '__len__'): old_file_position = fObj.tell() - fObj.seek(0, os.SEEK_END) + fObj.seek(0, _os.SEEK_END) size = fObj.tell() - fObj.seek(old_file_position, os.SEEK_SET) + fObj.seek(old_file_position, _os.SEEK_SET) else: size = len(fObj) return size - @ffi.callback("sf_vio_seek") + @_ffi.callback("sf_vio_seek") def vio_seek(offset, whence, user_data): fObj.seek(offset, whence) curr = fObj.tell() return curr - @ffi.callback("sf_vio_read") + @_ffi.callback("sf_vio_read") def vio_read(ptr, count, user_data): - buf = ffi.buffer(ptr, count) + buf = _ffi.buffer(ptr, count) data_read = fObj.readinto(buf) return data_read - @ffi.callback("sf_vio_write") + @_ffi.callback("sf_vio_write") def vio_write(ptr, count, user_data): - buf = ffi.buffer(ptr) + buf = _ffi.buffer(ptr) data = buf[:] length = fObj.write(data) return length - @ffi.callback("sf_vio_tell") + @_ffi.callback("sf_vio_tell") def vio_tell(user_data): return fObj.tell() @@ -374,48 +517,38 @@ def _handle_error_number(self, err): # pretty-print a numerical error code if err != 0: err_str = _snd.sf_error_number(err) - raise RuntimeError(ffi.string(err_str).decode()) - - # these strings are used as properties to access text data n the - # sound file - _snd_strings = { - 'title': 0x01, - 'copyright': 0x02, - 'software': 0x03, - 'artist': 0x04, - 'comment': 0x05, - 'date': 0x06, - 'album': 0x07, - 'license': 0x08, - 'tracknumber': 0x09, - 'genre': 0x10 - } + raise RuntimeError(_ffi.string(err_str).decode()) + + def _getAttributeNames(self): + # return all possible attributes used in __setattr__ and __getattr__. + # This is useful for auto-completion (e.g. IPython) + return _snd_strings def __setattr__(self, name, value): # access text data in the sound file through properties - if name in self._snd_strings: - if self._file_mode == read_mode: + if name in _snd_strings: + if self._mode == _M_READ: raise RuntimeError("Can not change %s of file in read mode" % name) - data = ffi.new('char[]', value.encode()) - err = _snd.sf_set_string(self._file, self._snd_strings[name], data) + data = _ffi.new('char[]', value.encode()) + err = _snd.sf_set_string(self._file, _snd_strings[name], data) self._handle_error_number(err) else: - self.__dict__[name] = value + super(SoundFile, self).__setattr__(name, value) def __getattr__(self, name): # access text data in the sound file through properties - if name in self._snd_strings: - data = _snd.sf_get_string(self._file, self._snd_strings[name]) - if data == ffi.NULL: + if name in _snd_strings: + data = _snd.sf_get_string(self._file, _snd_strings[name]) + if data == _ffi.NULL: return "" else: - return ffi.string(data).decode() + return _ffi.string(data).decode() else: raise AttributeError("SoundFile has no attribute %s" % name) def __len__(self): - return(self.frames) + return self.frames def _get_slice_bounds(self, frame): # get start and stop index from slice, asserting step==1 @@ -438,7 +571,7 @@ def __getitem__(self, frame): "SoundFile can only be accessed in one or two dimensions") frame, second_frame = frame start, stop = self._get_slice_bounds(frame) - curr = self.seek(0) + curr = self.seek_relative(0) self.seek_absolute(start) data = self.read(stop - start) self.seek_absolute(curr) @@ -451,14 +584,14 @@ def __setitem__(self, frame, data): # access the file as if it where a one-dimensional Numpy # array. Data must be in the form (frames x channels). # Both open slice bounds and negative values are allowed. - if self._file_mode == read_mode: + if self._mode == _M_READ: raise RuntimeError("Can not write to read-only file") start, stop = self._get_slice_bounds(frame) if stop - start != len(data): raise IndexError( "Could not fit data of length %i into slice of length %i" % (len(data), stop - start)) - curr = self.seek(0) + curr = self.seek_relative(0) self.seek_absolute(start) self.write(data) self.seek_absolute(curr) @@ -468,36 +601,96 @@ def flush(self): """Write unwritten data to disk.""" _snd.sf_write_sync(self._file) - def seek(self, frames): - """Set the read position relative to the current position. + def _check_seek_arguments(self, both, w, r): + if self._mode == _M_WRITE: + raise RuntimeError("Seeking is not possible if mode='w'!") + if [both, w, r] == [None] * 3: + raise ValueError("At least one argument needed!") + assert both is None or r is None, "Either both or r must be None!" + + original_r, original_w = r, w + try: + r, w = both + both = None + assert original_r is None and original_w is None, \ + "If both has two items, r and w must be None!" + except (TypeError, ValueError): + pass # ignore errors and continue + + if (r, w) == (None, None) and self._mode != _M_READ: + w = both + if r is None: + r = both + + if self._mode == _M_READ and w is not None: + raise ValueError("Trying to set write position in read mode!") + if self._mode == _M_WRITE and r is not None: + raise ValueError("Trying to set read position in write mode!") + + # at least one of (r, w) is not None! + return r, w + + def seek_relative(self, both=None, w=None, r=None): + """Set the read/write positions relative to the current position. Positive values will fast-forward. Negative values will rewind. - Returns the new absolute read position in frames. + Returns the new absolute read/write positions in frames. + """ - return _snd.sf_seek(self._file, frames, os.SEEK_CUR) + r, w = self._check_seek_arguments(both, w, r) + + def seek_helper(frames, flag=0x0): + offset = _snd.sf_seek(self._file, frames, _os.SEEK_CUR | flag) + if offset < 0: + raise IndexError("Cannot seek to %d" % frames) + return offset + + if w is None: + return seek_helper(r, _M_READ) + elif r is None: + return seek_helper(w, _M_WRITE) + else: + return seek_helper(r, _M_READ), seek_helper(w, _M_WRITE) - def seek_absolute(self, frames): - """Set an absolute read position. + def seek_absolute(self, both=None, w=None, r=None): + """Set absolute read/write positions. - Positive values will set the read position to the given frame - index. Negative values will set the read position to the given + Positive values will set the read/write position to the given frame + index. Negative values will set the read/write position to the given index counted from the end of the file. - Returns the new absolute read position in frames. + Returns the new absolute read/write positions in frames. + """ - if frames >= 0: - return _snd.sf_seek(self._file, frames, os.SEEK_SET) + r, w = self._check_seek_arguments(both, w, r) + + def seek_helper(frames, flag=0x0): + if frames >= 0: + offset = _snd.sf_seek(self._file, frames, _os.SEEK_SET | flag) + else: + offset = _snd.sf_seek(self._file, frames, _os.SEEK_END | flag) + if offset < 0: + raise IndexError("Cannot seek to %d" % frames) + return offset + + if w is None: + return seek_helper(r, _M_READ) + elif r is None: + return seek_helper(w, _M_WRITE) + elif r == w: + offset = seek_helper(r) + return offset, offset else: - return _snd.sf_seek(self._file, frames, os.SEEK_END) + return seek_helper(r, _M_READ), seek_helper(w, _M_WRITE) - def read(self, frames=-1, format=np.float32): + def read(self, frames=None, dtype='float32'): """Read a number of frames from the file. Reads the given number of frames in the given data format from the current read position. This also advances the read position by the same number of frames. - Use frames=-1 to read until the end of the file. + Use frames=None to read until the end of the file. Returns the read data as a (frames x channels) NumPy array. @@ -505,29 +698,32 @@ def read(self, frames=-1, format=np.float32): smaller NumPy array will be returned. """ + if self._mode == _M_WRITE: + raise RuntimeError("Can not read if mode='w'!") formats = { - np.float64: 'double[]', - np.float32: 'float[]', - np.int32: 'int[]', - np.int16: 'short[]' + _np.float64: 'double[]', + _np.float32: 'float[]', + _np.int32: 'int[]', + _np.int16: 'short[]' } readers = { - np.float64: _snd.sf_readf_double, - np.float32: _snd.sf_readf_float, - np.int32: _snd.sf_readf_int, - np.int16: _snd.sf_readf_short + _np.float64: _snd.sf_readf_double, + _np.float32: _snd.sf_readf_float, + _np.int32: _snd.sf_readf_int, + _np.int16: _snd.sf_readf_short } - if format not in formats: + dtype = _np.dtype(dtype) + if dtype.type not in formats: raise ValueError("Can only read int16, int32, float32 and float64") - if frames == -1: - curr = self.seek(0) + if frames is None: + curr = self.seek_relative(r=0) frames = self.frames - curr - data = ffi.new(formats[format], frames*self.channels) - read = readers[format](self._file, data, frames) + data = _ffi.new(formats[dtype.type], frames*self.channels) + read = readers[dtype.type](self._file, data, frames) self._handle_error() - np_data = np.frombuffer(ffi.buffer(data), dtype=format, + np_data = _np.frombuffer(_ffi.buffer(data), dtype=dtype, count=read*self.channels) - return np.reshape(np_data, (read, self.channels)) + return _np.reshape(np_data, (read, self.channels)) def write(self, data): """Write a number of frames to the file. @@ -540,25 +736,83 @@ def write(self, data): array. """ - if self._file_mode == read_mode: - raise RuntimeError("Can not write to read-only file") + if self._mode == _M_READ: + raise RuntimeError("Can not write if mode='r'!") formats = { - np.dtype(np.float64): 'double*', - np.dtype(np.float32): 'float*', - np.dtype(np.int32): 'int*', - np.dtype(np.int16): 'short*' + _np.float64: 'double*', + _np.float32: 'float*', + _np.int32: 'int*', + _np.int16: 'short*' } writers = { - np.dtype(np.float64): _snd.sf_writef_double, - np.dtype(np.float32): _snd.sf_writef_float, - np.dtype(np.int32): _snd.sf_writef_int, - np.dtype(np.int16): _snd.sf_writef_short + _np.float64: _snd.sf_writef_double, + _np.float32: _snd.sf_writef_float, + _np.int32: _snd.sf_writef_int, + _np.int16: _snd.sf_writef_short } - if data.dtype not in writers: + if data.dtype.type not in writers: raise ValueError("Data must be int16, int32, float32 or float64") - raw_data = ffi.new('char[]', data.flatten().tostring()) - written = writers[data.dtype](self._file, - ffi.cast(formats[data.dtype], raw_data), + raw_data = _ffi.new('char[]', data.flatten().tostring()) + written = writers[data.dtype.type](self._file, + _ffi.cast( + formats[data.dtype.type], raw_data), len(data)) self._handle_error() + + if self._mode == _M_RDWR: + curr = self.seek_relative(0) + self._info.frames = _snd.sf_seek(self._file, 0, _os.SEEK_END) + self.seek_absolute(curr) + else: + # in mode='w', seeking is not possible + self._info.frames += written + return written + +def open(*args, **kwargs): + return SoundFile(*args, **kwargs) + +def read(filename, frames=None, start=None, stop=None, **kwargs): + # If frames and stop are both specified, frames takes precedence! + # start and stop accept negative indices. + read_kwargs = {} + if 'dtype' in kwargs: + read_kwargs['dtype'] = kwargs.pop('dtype') + with open(filename, 'r', **kwargs) as f: + start, stop, _ = slice(start, stop).indices(f.frames) + if frames is None: + frames = max(0, stop - start) + f.seek_absolute(start) + data = f.read(frames, **read_kwargs) + return data, f.sample_rate + +def write(data, filename, sample_rate, *args, **kwargs): + # e.g. write(myarray, 'myfile.wav', 44100, sf.FLOAT) + assert _raise_error_if_format_type(sample_rate) + if data.ndim == 1: + channels = 1 + elif data.ndim == 2: + channels = data.shape[1] + else: + raise RuntimeError("Only one- and two-dimensional arrays are allowed!") + frames = data.shape[0] + with open(filename, 'w', sample_rate, channels, *args, **kwargs) as f: + written = f.write(data) + assert frames == written, "Error writing file!" + +def get_format_info(format): + format_info = _ffi.new("struct SF_FORMAT_INFO*") + format_info.format = format + _snd.sf_command(_ffi.NULL, _GET_FORMAT_INFO, format_info, + _ffi.sizeof("SF_FORMAT_INFO")) + return _ffi.string(format_info.name).decode() if format_info.name else "" + +def _raise_error_if_format_type(*args): + # raise error if one of the arguments has one of the format types. + # For use in assertions to prevent accidentally passing soundfile formats + # where numeric values are expected (esp. when using positional arguments). + format_types = (FormatType, SubtypeType, EndianType) + for arg in args: + if isinstance(arg, format_types): + raise TypeError("%s is not allowed here!" % repr(arg)) + return True