Skip to content

Commit 78a1a86

Browse files
author
Benjamin Moody
committed
Merge pull request #372 into main
This adds support for reading FLAC compressed signal files (indicated by format codes 508, 516, and 524, which were added in libwfdb version 10.7.0.) Reading FLAC files uses the Python soundfile package, which in turn requires the libsndfile and libFLAC C libraries to be installed. soundfile is now included as a dependency of wfdb; you can install it even if you don't have libsndfile, but trying to 'import soundfile' will fail in that case. Consequently, wfdb will only try to import soundfile when you actually try to read a FLAC signal file. Note that most desktop Linux systems include libsndfile by default, and the soundfile wheel packages include libsndfile binaries for x86-64 Windows and Mac OS platforms.
2 parents 14248d1 + d49a5c3 commit 78a1a86

16 files changed

+391
-36
lines changed

.github/workflows/run-tests.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ jobs:
2828
run: |
2929
python -m pip install --upgrade pip poetry
3030
pip install ".[dev]"
31+
- name: Install libsndfile
32+
if: startsWith(matrix.os, 'ubuntu')
33+
run: |
34+
sudo apt-get install -y libsndfile1
3135
- name: Run tests
3236
run: pytest
3337
- name: Validate poetry file
@@ -48,6 +52,7 @@ jobs:
4852
python3-pandas \
4953
python3-requests \
5054
python3-scipy \
55+
python3-soundfile \
5156
python3-pytest \
5257
git
5358

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pip install wfdb
3535
poetry add wfdb
3636
```
3737

38+
On Linux systems, accessing *compressed* WFDB signal files requires installing `libsndfile`, by running `sudo apt-get install libsndfile1` or `sudo yum install libsndfile`. Support for Apple M1 systems is a work in progess (see <https://github.com/bastibe/python-soundfile/issues/310> and <https://github.com/bastibe/python-soundfile/issues/325>).
39+
3840
The development version is hosted at: <https://github.com/MIT-LCP/wfdb-python>. This repository also contains demo scripts and example data. To install the development version, clone or download the repository, navigate to the base directory, and run:
3941

4042
```sh

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ python = "^3.7"
1010
numpy = "^1.10.1"
1111
scipy = "^1.0.0"
1212
pandas = "^1.0.0"
13+
SoundFile = ">=0.10.0, <0.12.0"
1314
matplotlib = "^3.2.2"
1415
requests = "^2.8.1"
1516
pytest = {version = "^7.1.1", optional = true}

sample-data/flacformats.d0

524 Bytes
Binary file not shown.

sample-data/flacformats.d1

881 Bytes
Binary file not shown.

sample-data/flacformats.d2

1.26 KB
Binary file not shown.

sample-data/flacformats.hea

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
flacformats 3 200 499
2+
flacformats.d0 508 200/mV 8 0 -127 -484 0 sig 0, fmt 508
3+
flacformats.d1 516 200/mV 16 0 -32766 -750 0 sig 1, fmt 516
4+
flacformats.d2 524 200/mV 24 0 -8388605 8721 0 sig 2, fmt 524

sample-data/mixedsignals.hea

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mixedsignals 6 62.4725/999.56 14400
2+
mixedsignals_e.dat 516x4 200/mV 14 8192 0 24460 0 II
3+
mixedsignals_e.dat 516x4 200/mV 14 8192 0 19772 0 III
4+
mixedsignals_e.dat 516x4 200/mV 14 8192 0 22261 0 V
5+
mixedsignals_p.dat 516x2 16(800)/mmHg 12 2048 0 49347 0 ABP
6+
mixedsignals_p.dat 516x2 4096(0)/NU 12 2048 0 36026 0 Pleth
7+
mixedsignals_r.dat 516 4093(2)/Ohm 12 2048 0 35395 0 Resp

sample-data/mixedsignals_e.dat

72.1 KB
Binary file not shown.

sample-data/mixedsignals_p.dat

33.2 KB
Binary file not shown.

sample-data/mixedsignals_r.dat

6.56 KB
Binary file not shown.

tests/target-output/record-flac.gz

4.91 KB
Binary file not shown.

tests/test_record.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,37 @@ def test_1f(self):
218218
"Mismatch in %s" % name,
219219
)
220220

221+
def test_read_flac(self):
222+
"""
223+
All FLAC formats, multiple signal files in one record.
224+
225+
Target file created with:
226+
rdsamp -r sample-data/flacformats | cut -f 2- |
227+
gzip -9 -n > record-flac.gz
228+
"""
229+
record = wfdb.rdrecord("sample-data/flacformats", physical=False)
230+
sig_target = np.genfromtxt("tests/target-output/record-flac.gz")
231+
232+
for n, name in enumerate(record.sig_name):
233+
np.testing.assert_array_equal(
234+
record.d_signal[:, n], sig_target[:, n], f"Mismatch in {name}"
235+
)
236+
237+
for sampfrom in range(0, 3):
238+
for sampto in range(record.sig_len - 3, record.sig_len):
239+
record_2 = wfdb.rdrecord(
240+
"sample-data/flacformats",
241+
physical=False,
242+
sampfrom=sampfrom,
243+
sampto=sampto,
244+
)
245+
for n, name in enumerate(record.sig_name):
246+
np.testing.assert_array_equal(
247+
record_2.d_signal[:, n],
248+
sig_target[sampfrom:sampto, n],
249+
f"Mismatch in {name}",
250+
)
251+
221252
# ------------------ 2. Special format records ------------------ #
222253

223254
def test_2a(self):

tests/test_url.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,62 @@ def _test_binary(self, url, content, buffering):
196196
self.assertEqual(bf.tell(), len(content))
197197

198198

199+
class TestRemoteFLACFiles(unittest.TestCase):
200+
"""
201+
Test reading FLAC files over HTTP.
202+
"""
203+
204+
def test_whole_file(self):
205+
"""
206+
Test reading a complete FLAC file using local and HTTP APIs.
207+
208+
This tests that we can read the file 'sample-data/flacformats.d2'
209+
(a 24-bit FLAC stream) using the soundfile library, first by
210+
reading the file from the local filesystem, and then using
211+
wfdb.io._url.openurl() to access it through a simulated web server.
212+
213+
This is meant to verify that the soundfile library works using only
214+
the standard Python file object API (as implemented by
215+
wfdb.io._url.NetFile), and doesn't require the input file to be an
216+
actual io.FileIO object.
217+
218+
Parameters
219+
----------
220+
N/A
221+
222+
Returns
223+
-------
224+
N/A
225+
226+
"""
227+
import soundfile
228+
import numpy as np
229+
230+
data_file_path = "sample-data/flacformats.d2"
231+
expected_format = "FLAC"
232+
expected_subtype = "PCM_24"
233+
234+
# Read the file using standard file I/O
235+
sf1 = soundfile.SoundFile(data_file_path)
236+
self.assertEqual(sf1.format, expected_format)
237+
self.assertEqual(sf1.subtype, expected_subtype)
238+
data1 = sf1.read()
239+
240+
# Read the file using HTTP
241+
with open(data_file_path, "rb") as f:
242+
file_content = {"/foo.dat": f.read()}
243+
with DummyHTTPServer(file_content) as server:
244+
url = server.url("/foo.dat")
245+
file2 = wfdb.io._url.openurl(url, "rb")
246+
sf2 = soundfile.SoundFile(file2)
247+
self.assertEqual(sf2.format, expected_format)
248+
self.assertEqual(sf2.subtype, expected_subtype)
249+
data2 = sf2.read()
250+
251+
# Check that results are equal
252+
np.testing.assert_array_equal(data1, data2)
253+
254+
199255
class DummyHTTPServer(http.server.HTTPServer):
200256
"""
201257
HTTPServer used to simulate a web server for testing.

wfdb/io/_coreio.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from wfdb.io import _url
2+
3+
4+
def _open_file(
5+
pn_dir,
6+
file_name,
7+
mode="r",
8+
*,
9+
buffering=-1,
10+
encoding=None,
11+
errors=None,
12+
newline=None,
13+
check_access=False,
14+
):
15+
"""
16+
Open a data file as a random-access file object.
17+
18+
See the documentation of `open` and `wfdb.io._url.openurl` for details
19+
about the `mode`, `buffering`, `encoding`, `errors`, and `newline`
20+
parameters.
21+
22+
Parameters
23+
----------
24+
pn_dir : str or None
25+
The PhysioNet database directory where the file is stored, or None
26+
if file_name is a local path.
27+
file_name : str
28+
The name of the file, either as a local filesystem path (if
29+
`pn_dir` is None) or a URL path (if `pn_dir` is a string.)
30+
mode : str, optional
31+
The standard I/O mode for the file ("r" by default). If `pn_dir`
32+
is not None, this must be "r", "rt", or "rb".
33+
buffering : int, optional
34+
Buffering policy.
35+
encoding : str, optional
36+
Name of character encoding used in text mode.
37+
errors : str, optional
38+
Error handling strategy used in text mode.
39+
newline : str, optional
40+
Newline translation mode used in text mode.
41+
check_access : bool, optional
42+
If true, raise an exception immediately if the file does not
43+
exist or is not accessible.
44+
45+
"""
46+
if pn_dir is None:
47+
return open(
48+
file_name,
49+
mode,
50+
buffering=buffering,
51+
encoding=encoding,
52+
errors=errors,
53+
newline=newline,
54+
)
55+
else:
56+
url = posixpath.join(config.db_index_url, pn_dir, file_name)
57+
return _url.openurl(
58+
url,
59+
mode,
60+
buffering=buffering,
61+
encoding=encoding,
62+
errors=errors,
63+
newline=newline,
64+
check_access=check_access,
65+
)

0 commit comments

Comments
 (0)