Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.14t"
- "pypy-3.7"
- "pypy-3.8"
- "pypy-3.9"
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 11 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(),
Expand Down
47 changes: 38 additions & 9 deletions soundfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
84 changes: 83 additions & 1 deletion tests/test_soundfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', "
Expand Down Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down