diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 16d64d5..9340845 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,6 +15,8 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.14" + - "3.14t" - "pypy-3.7" - "pypy-3.8" - "pypy-3.9" @@ -44,6 +46,7 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} + allow-prereleases: true - name: Install requirements run: pip install numpy pytest - name: "Workaround: Generate _soundfile.py explicitly" diff --git a/setup.py b/setup.py index 5951972..d007cb5 100644 --- a/setup.py +++ b/setup.py @@ -95,8 +95,16 @@ def get_tag(self): package_data=package_data, zip_safe=zip_safe, license='BSD 3-Clause License', - setup_requires=["cffi>=1.0"], - install_requires=['cffi>=1.0', 'numpy', 'typing-extensions'], + setup_requires=[ + "cffi>=1.0; python_version < '3.14'", + "cffi>=2.0.0b1; python_version >= '3.14'", + ], + install_requires=[ + "cffi>=1.0; python_version < '3.14'", + "cffi>=2.0.0b1; python_version >= '3.14'", + 'numpy', + 'typing-extensions' + ], cffi_modules=["soundfile_build.py:ffibuilder"], extras_require={'numpy': []}, # This option is no longer relevant, but the empty entry must be left in to avoid breaking old build scripts. platforms='any', @@ -112,6 +120,7 @@ def get_tag(self): 'Programming Language :: Python :: 2', 'Programming Language :: Python :: Implementation :: PyPy', 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Free Threading :: 2 - Beta' 'Topic :: Multimedia :: Sound/Audio', ], long_description=open('README.rst').read(), diff --git a/soundfile.py b/soundfile.py index f17b3cc..ecbefc5 100644 --- a/soundfile.py +++ b/soundfile.py @@ -12,9 +12,12 @@ import os as _os import sys as _sys +import threading import numpy.typing from os import SEEK_SET, SEEK_CUR, SEEK_END from ctypes.util import find_library as _find_library +from contextlib import contextmanager +from functools import wraps from typing import Any, BinaryIO, Dict, Generator, Optional, Tuple, Union from typing_extensions import TypeAlias, Self, Final from _soundfile import ffi as _ffi @@ -590,6 +593,19 @@ def default_subtype(format: str) -> Optional[str]: return _default_subtypes.get(format.upper()) +def with_lock(method): + @wraps(method) + def wrapper(self, *args, **kwargs): + if self._lock.acquire(blocking=False): + try: + return method(self, *args, **kwargs) + finally: + self._lock.release() + else: + raise RuntimeError("Multithreaded use of a SoundFile object detected") + return wrapper + + class SoundFile(object): """A sound file. @@ -702,6 +718,7 @@ def __init__(self, file: FileDescriptorOrPath, mode: Optional[str] = 'r', >>> assert myfile.closed """ + self._lock = threading.RLock() if isinstance(file, _os.PathLike): file = _os.fspath(file) self._name = file @@ -768,6 +785,7 @@ def __init__(self, file: FileDescriptorOrPath, mode: Optional[str] = 'r', """The bitrate mode on 'write()'""" @property + @with_lock def extra_info(self): """Retrieve the log string generated when opening the file.""" info = _ffi.new("char[]", 2**14) @@ -800,19 +818,21 @@ def __exit__(self, *args: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: """Write text meta-data in the sound file through properties.""" if name in _str_types: - self._check_if_closed() - err = _snd.sf_set_string(self._file, _str_types[name], - value.encode()) - _error_check(err) + with self._lock: + self._check_if_closed() + err = _snd.sf_set_string(self._file, _str_types[name], + value.encode()) + _error_check(err) else: object.__setattr__(self, name, value) def __getattr__(self, name: str) -> Any: """Read text meta-data in the sound file through properties.""" if name in _str_types: - self._check_if_closed() - data = _snd.sf_get_string(self._file, _str_types[name]) - return _ffi.string(data).decode('utf-8', 'replace') if data else "" + with self._lock: + self._check_if_closed() + data = _snd.sf_get_string(self._file, _str_types[name]) + return _ffi.string(data).decode('utf-8', 'replace') if data else "" else: raise AttributeError( "'SoundFile' object has no attribute {0!r}".format(name)) @@ -836,6 +856,7 @@ def seekable(self) -> bool: """Return True if the file supports seeking.""" return self._info.seekable == _snd.SF_TRUE + @with_lock def seek(self, frames: int, whence: int = SEEK_SET) -> int: """Set the read/write position. @@ -881,7 +902,7 @@ def tell(self) -> int: """Return the current read/write position.""" return self.seek(0, SEEK_CUR) - + @with_lock def read(self, frames: int = -1, dtype: str = 'float64', always_2d: bool = False, fill_value: Optional[float] = None, out: Optional[AudioData] = None) -> AudioData: @@ -977,7 +998,7 @@ def read(self, frames: int = -1, dtype: str = 'float64', out[frames:] = fill_value return out - + @with_lock def buffer_read(self, frames: int = -1, dtype: Optional[str] = None) -> memoryview: """Read from the file and return data as buffer object. @@ -1013,6 +1034,7 @@ def buffer_read(self, frames: int = -1, dtype: Optional[str] = None) -> memoryvi assert read_frames == frames return _ffi.buffer(cdata) + @with_lock def buffer_read_into(self, buffer: Union[bytearray, memoryview, Any], dtype: str) -> int: """Read from the file into a given buffer object. @@ -1046,6 +1068,7 @@ def buffer_read_into(self, buffer: Union[bytearray, memoryview, Any], dtype: str frames = self._cdata_io('read', cdata, ctype, frames) return frames + @with_lock def write(self, data: AudioData) -> None: """Write audio data from a NumPy array to the file. @@ -1100,6 +1123,7 @@ def write(self, data: AudioData) -> None: assert written == len(data) self._update_frames(written) + @with_lock def buffer_write(self, data: Any, dtype: str) -> None: """Write audio data from a buffer/bytes object to the file. @@ -1127,6 +1151,7 @@ def buffer_write(self, data: Any, dtype: str) -> None: assert written == frames self._update_frames(written) + @with_lock def blocks(self, blocksize: Optional[int] = None, overlap: int = 0, frames: int = -1, dtype: str = 'float64', always_2d: bool = False, fill_value: Optional[float] = None, @@ -1222,6 +1247,7 @@ def blocks(self, blocksize: Optional[int] = None, overlap: int = 0, yield np.copy(block) if copy_out else block frames -= toread + @with_lock def truncate(self, frames: Optional[int] = None) -> None: """Truncate the file to a given number of frames. @@ -1246,6 +1272,7 @@ def truncate(self, frames: Optional[int] = None) -> None: raise LibsndfileError(err, "Error truncating the file") self._info.frames = frames + @with_lock def flush(self) -> None: """Write unwritten data to the file system. @@ -1260,6 +1287,7 @@ def flush(self) -> None: self._check_if_closed() _snd.sf_write_sync(self._file) + @with_lock def close(self) -> None: """Close the file. Can be called multiple times.""" if not self.closed: @@ -1465,6 +1493,7 @@ def _prepare_read(self, start, stop, frames): self.seek(start, SEEK_SET) return frames + @with_lock def copy_metadata(self): """Get all metadata present in this SoundFile diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index bba9c67..ee1a933 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -9,6 +9,7 @@ import gc import weakref import threading +import concurrent.futures # floating point data is typically limited to the interval [-1.0, 1.0], # but smaller/larger values are supported as well @@ -702,7 +703,7 @@ def test__repr__(sf_stereo_r): "samplerate=44100, channels=2, " "format='WAV', subtype='FLOAT', " "endian='FILE')").format(sf_stereo_r) - + sf_stereo_r._compression_level = 0 sf_stereo_r._bitrate_mode = "CONSTANT" assert repr(sf_stereo_r) == ("SoundFile({0.name!r}, mode='r', " @@ -871,6 +872,87 @@ def target(): assert n_reported_errors[0] == n_threads * n_trials_per_thread +def test_concurrent_file_processing(): + n_threads = 4 + iterations = 10 + b = threading.Barrier(n_threads) + + def target(): + b.wait() + for _ in range(iterations): + my_file = io.BytesIO() + sf.write(my_file, data_stereo, 44100, format='WAV', subtype='FLOAT') + my_file.seek(0) + read, fs = sf.read(my_file) + assert np.all(read == data_stereo) + assert fs == 44100 + + threads = [threading.Thread(target=target) for _ in range(n_threads)] + try: + for thread in threads: + thread.start() + for thread in threads: + thread.join() + finally: + b.abort() + + + +def test_shared_file_raises(): + n_threads = 2 + b = threading.Barrier(n_threads) + + sf_file = sf.SoundFile(filename_mp3) + + def target(): + b.wait() + try: + sf_file.read() + return 0 + except RuntimeError as e: + assert str(e) == "Multithreaded use of a SoundFile object detected" + return 1 + + with concurrent.futures.ThreadPoolExecutor(max_workers=n_threads) as tpe: + try: + futures = [] + for _ in range(n_threads): + futures.append(tpe.submit(target)) + # a maximum of one thread raised an exception + assert(sum([f.result() for f in futures]) in [0, 1]) + finally: + b.abort() + + +def test_concurrent_close_doesnt_crash(): + # See issue #467, where calling close() concurrently + # led to a segfault + + num_threads = 2 + + b = threading.Barrier(num_threads) + + def worker(f): + b.wait() + try: + f.close() + return 0 + except RuntimeError: + # we may see an error about shared multithreaded use if there is a race + return 1 + + with sf.SoundFile(filename_stereo, 'r') as f: + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as tpe: + futures = [] + for _ in range(num_threads): + futures.append(tpe.submit(worker, f)) + # a maximum of one thread raised an exception + assert(sum([f.result() for f in futures]) in [0, 1]) + finally: + b.abort() + + # ----------------------------------------------------------------------------- # Test buffer read # -----------------------------------------------------------------------------