Skip to content

Commit e221c2b

Browse files
authored
Merge pull request #27 from sonos/develop
Merge to main - v3.0.0
2 parents 38e6de1 + f54ef7a commit e221c2b

13 files changed

Lines changed: 184 additions & 41 deletions

File tree

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
flake8:
1515
runs-on: ubuntu-latest
1616
steps:
17-
- uses: actions/checkout@v3
17+
- uses: actions/checkout@v4
1818
- name: Lint the Python code
1919
uses: TrueBrain/actions-flake8@master
2020
with:

.github/workflows/test.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,22 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: ["3.8", "3.9", "3.10", "3.11"]
18+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
1919

2020
steps:
21-
- uses: actions/checkout@v3
21+
- uses: actions/checkout@v4
2222
- name: Install dependencies
2323
run: |
2424
sudo apt-get update -y
2525
sudo apt-get install libsndfile1
2626
- name: Set up Python ${{ matrix.python-version }}
27-
uses: actions/setup-python@v4
27+
uses: actions/setup-python@v5
2828
with:
2929
python-version: ${{ matrix.python-version }}
3030
- name: Install dependencies
3131
run: |
3232
python -m pip install --upgrade pip
33-
pip install coverage
33+
pip install coverage setuptools
3434
- name: Run tests
3535
run: coverage run setup.py test
3636
- name: Run coveralls
@@ -42,9 +42,9 @@ jobs:
4242
macos:
4343
runs-on: macos-latest
4444
steps:
45-
- uses: actions/checkout@v3
45+
- uses: actions/checkout@v4
4646
- name: Set up Python
47-
uses: actions/setup-python@v4
47+
uses: actions/setup-python@v5
4848
with:
4949
python-version: 3.11
5050
- name: Install dependencies
@@ -58,7 +58,7 @@ jobs:
5858
windows:
5959
runs-on: windows-latest
6060
steps:
61-
- uses: actions/checkout@v3
61+
- uses: actions/checkout@v4
6262
with:
6363
submodules: recursive
6464
- name: Check install

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
pyFLAC Changelog
22
----------------
33

4+
**v3.0.0**
5+
6+
* Fixed bug in the shutdown behaviour of the `StreamDecoder` (see #22 and #23).
7+
* Automatically detect bit depth of input data in the `FileEncoder`, and
8+
raise an error if not 16-bit or 32-bit PCM (see #24).
9+
* Added a new `OneShotDecoder` to decode a buffer of FLAC data in a single
10+
blocking operation, without the use of threads. Courtesy of @GOAE.
11+
412
**v2.2.0**
513

614
* Updated FLAC library to v1.4.3.

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ data directly from a file or process in real-time.
6868
:undoc-members:
6969
:inherited-members:
7070

71+
.. autoclass:: pyflac.OneShotDecoder
72+
7173
State
7274
-----
7375

examples/passthrough.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ def __init__(self, args):
2929
self.idx = 0
3030
self.total_bytes = 0
3131
self.queue = queue.SimpleQueue()
32-
self.data, self.sr = sf.read(args.input_file, dtype='int16', always_2d=True)
32+
33+
info = sf.info(str(args.input_file))
34+
if info.subtype == 'PCM_16':
35+
dtype = 'int16'
36+
elif info.subtype == 'PCM_32':
37+
dtype = 'int32'
38+
else:
39+
raise ValueError(f'WAV input data type must be either PCM_16 or PCM_32: Got {info.subtype}')
40+
41+
self.data, self.sr = sf.read(args.input_file, dtype=dtype, always_2d=True)
3342

3443
self.encoder = pyflac.StreamEncoder(
3544
write_callback=self.encoder_callback,

pyflac/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
#
55
# pyFLAC
66
#
7-
# Copyright (c) 2020-2021, Sonos, Inc.
7+
# Copyright (c) 2020-2024, Sonos, Inc.
88
# All rights reserved.
99
#
1010
# ------------------------------------------------------------------------------
1111

1212
__title__ = 'pyFLAC'
13-
__version__ = '2.2.0'
13+
__version__ = '3.0.0'
1414
__all__ = [
1515
'StreamEncoder',
1616
'FileEncoder',
@@ -19,6 +19,7 @@
1919
'EncoderProcessException',
2020
'StreamDecoder',
2121
'FileDecoder',
22+
'OneShotDecoder',
2223
'DecoderState',
2324
'DecoderInitException',
2425
'DecoderProcessException'
@@ -55,6 +56,7 @@
5556
from .decoder import (
5657
StreamDecoder,
5758
FileDecoder,
59+
OneShotDecoder,
5860
DecoderState,
5961
DecoderInitException,
6062
DecoderProcessException

pyflac/__main__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ def get_args():
2727
parser.add_argument('-c', '--compression-level', type=int, choices=range(0, 9), default=5,
2828
help='0 is the fastest compression, 5 is the default, 8 is the highest compression')
2929
parser.add_argument('-b', '--block-size', type=int, default=0, help='The block size')
30-
parser.add_argument('-d', '--dtype', default='int16', help='The encoded data type (int16 or int32)')
3130
parser.add_argument('-v', '--verify', action='store_false', default=True, help='Verify the compressed data')
3231
args = parser.parse_args()
3332
return args
@@ -45,7 +44,6 @@ def main():
4544
input_file=args.input_file,
4645
output_file=args.output_file,
4746
blocksize=args.block_size,
48-
dtype=args.dtype,
4947
compression_level=args.compression_level,
5048
verify=args.verify
5149
)

pyflac/decoder.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55
# pyFLAC decoder
66
#
7-
# Copyright (c) 2020-2021, Sonos, Inc.
7+
# Copyright (c) 2020-2024, Sonos, Inc.
88
# All rights reserved.
99
#
1010
# ------------------------------------------------------------------------------
@@ -93,7 +93,8 @@ def finish(self):
9393
Flushes the decoding buffer, releases resources, resets the decoder
9494
settings to their defaults, and returns the decoder state to `DecoderState.UNINITIALIZED`.
9595
96-
A well behaved program should always call this at the end.
96+
A well behaved program should always call this at the end, otherwise the processing
97+
thread will be left running, awaiting more data.
9798
"""
9899
_lib.FLAC__stream_decoder_finish(self._decoder)
99100

@@ -121,6 +122,9 @@ class StreamDecoder(_Decoder):
121122
blocks of raw uncompressed audio is passed back to the user via
122123
the `callback`.
123124
125+
The `finish` method must be called at the end of the decoding process,
126+
otherwise the processing thread will be left running.
127+
124128
Args:
125129
write_callback (fn): Function to call when there is uncompressed
126130
audio data ready, see the example below for more information.
@@ -159,6 +163,8 @@ def __init__(self,
159163

160164
self._done = False
161165
self._buffer = deque()
166+
self._event = threading.Event()
167+
self._lock = threading.Lock()
162168
self.write_callback = write_callback
163169

164170
rc = _lib.FLAC__stream_decoder_init_stream(
@@ -200,7 +206,10 @@ def process(self, data: bytes):
200206
Args:
201207
data (bytes): Bytes of FLAC data
202208
"""
209+
self._lock.acquire()
203210
self._buffer.append(data)
211+
self._lock.release()
212+
self._event.set()
204213

205214
def finish(self):
206215
"""
@@ -225,8 +234,9 @@ def finish(self):
225234
# Instruct the decoder to finish up and wait until it is done
226235
# --------------------------------------------------------------
227236
self._done = True
237+
self._event.set()
238+
self._thread.join()
228239
super().finish()
229-
self._thread.join(timeout=3)
230240
if self._error:
231241
raise DecoderProcessException(self._error)
232242

@@ -303,6 +313,84 @@ def _write_callback(self, data: np.ndarray, sample_rate: int, num_channels: int,
303313
self.__output.write(data)
304314

305315

316+
class OneShotDecoder(_Decoder):
317+
"""
318+
A pyFLAC one-shot decoder converts a buffer of FLAC encoded
319+
bytes back to raw audio data. Unlike the `StreamDecoder` class,
320+
the one-shot decoder operates on a single block of data, and
321+
runs in a blocking manner, as opposed to in a background thread.
322+
323+
The compressed data is passed in via the constructor, and
324+
blocks of raw uncompressed audio is passed back to the user via
325+
the `callback`.
326+
327+
Args:
328+
write_callback (fn): Function to call when there is uncompressed
329+
audio data ready, see the example below for more information.
330+
buffer (bytes): The FLAC encoded audio data
331+
332+
Examples:
333+
An example callback which writes the audio data to file
334+
using SoundFile.
335+
336+
.. code-block:: python
337+
:linenos:
338+
339+
import soundfile as sf
340+
341+
def callback(self,
342+
audio: np.ndarray,
343+
sample_rate: int,
344+
num_channels: int,
345+
num_samples: int):
346+
347+
# ------------------------------------------------------
348+
# Note: num_samples is the number of samples per channel
349+
# ------------------------------------------------------
350+
if self.output is None:
351+
self.output = sf.SoundFile(
352+
'output.wav', mode='w', channels=num_channels,
353+
samplerate=sample_rate
354+
)
355+
self.output.write(audio)
356+
357+
Raises:
358+
DecoderInitException: If initialisation of the decoder fails
359+
"""
360+
def __init__(self,
361+
write_callback: Callable[[np.ndarray, int, int, int], None],
362+
buffer: bytes):
363+
super().__init__()
364+
self._done = False
365+
self._buffer = deque()
366+
self._buffer.append(buffer)
367+
self._event = threading.Event()
368+
self._event.set()
369+
self._lock = threading.Lock()
370+
self.write_callback = write_callback
371+
372+
rc = _lib.FLAC__stream_decoder_init_stream(
373+
self._decoder,
374+
_lib._read_callback,
375+
_ffi.NULL,
376+
_ffi.NULL,
377+
_ffi.NULL,
378+
_ffi.NULL,
379+
_lib._write_callback,
380+
_ffi.NULL,
381+
_lib._error_callback,
382+
self._decoder_handle
383+
)
384+
if rc != _lib.FLAC__STREAM_DECODER_INIT_STATUS_OK:
385+
raise DecoderInitException(rc)
386+
387+
while len(self._buffer) > 0:
388+
_lib.FLAC__stream_decoder_process_single(self._decoder)
389+
390+
self._done = True
391+
super().finish()
392+
393+
306394
@_ffi.def_extern(error=_lib.FLAC__STREAM_DECODER_READ_STATUS_ABORT)
307395
def _read_callback(_decoder,
308396
byte_buffer,
@@ -314,6 +402,12 @@ def _read_callback(_decoder,
314402
If an exception is raised here, the abort status is returned.
315403
"""
316404
decoder = _ffi.from_handle(client_data)
405+
406+
# ----------------------------------------------------------
407+
# Wait until there is something in the buffer, or an error
408+
# occurs, or the end of the stream is reached.
409+
# ----------------------------------------------------------
410+
decoder._event.wait()
317411
if decoder._error:
318412
# ----------------------------------------------------------
319413
# If an error has been issued via the error callback, then
@@ -329,18 +423,13 @@ def _read_callback(_decoder,
329423
num_bytes[0] = 0
330424
return _lib.FLAC__STREAM_DECODER_READ_STATUS_END_OF_STREAM
331425

332-
maximum_bytes = int(num_bytes[0])
333-
while len(decoder._buffer) == 0:
334-
# ----------------------------------------------------------
335-
# Wait until there is something in the buffer
336-
# ----------------------------------------------------------
337-
time.sleep(0.01)
338-
339426
# --------------------------------------------------------------
340427
# Ensure only the maximum bytes or less is taken from
341428
# the thread safe queue.
342429
# --------------------------------------------------------------
343430
data = bytes()
431+
maximum_bytes = int(num_bytes[0])
432+
decoder._lock.acquire()
344433
if len(decoder._buffer[0]) <= maximum_bytes:
345434
data = decoder._buffer.popleft()
346435
maximum_bytes -= len(data)
@@ -349,6 +438,14 @@ def _read_callback(_decoder,
349438
data += decoder._buffer[0][0:maximum_bytes]
350439
decoder._buffer[0] = decoder._buffer[0][maximum_bytes:]
351440

441+
# --------------------------------------------------------------
442+
# If there is no more data to process from the buffer, then
443+
# clear the event, the thread will await more data to process.
444+
# --------------------------------------------------------------
445+
if len(decoder._buffer) == 0 or (len(decoder._buffer) > 0 and len(decoder._buffer[0]) == 0):
446+
decoder._event.clear()
447+
decoder._lock.release()
448+
352449
actual_bytes = len(data)
353450
num_bytes[0] = actual_bytes
354451
_ffi.memmove(byte_buffer, data, actual_bytes)
@@ -449,3 +546,4 @@ def _error_callback(_decoder,
449546
_lib.FLAC__StreamDecoderErrorStatusString[status]).decode()
450547
decoder.logger.error(f'Error in libFLAC decoder: {message}')
451548
decoder._error = message
549+
decoder._event.set()

pyflac/encoder.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55
# pyFLAC encoder
66
#
7-
# Copyright (c) 2020-2021, Sonos, Inc.
7+
# Copyright (c) 2020-2024, Sonos, Inc.
88
# All rights reserved.
99
#
1010
# ------------------------------------------------------------------------------
@@ -335,6 +335,8 @@ class FileEncoder(_Encoder):
335335
The pyFLAC file encoder reads the raw audio data from the WAV file and
336336
writes the encoded audio data to a FLAC file.
337337
338+
Note that the input WAV file must be either PCM_16 or PCM_32.
339+
338340
Args:
339341
input_file (pathlib.Path): Path to the input WAV file
340342
output_file (pathlib.Path): Path to the output FLAC file, a temporary
@@ -345,8 +347,6 @@ class FileEncoder(_Encoder):
345347
blocksize (int): The size of the block to be returned in the
346348
callback. The default is 0 which allows libFLAC to determine
347349
the best block size.
348-
dtype (str): The data type to use in the FLAC encoder, either int16 or int32,
349-
defaults to int16.
350350
streamable_subset (bool): Whether to use the streamable subset for encoding.
351351
If true the encoder will check settings for compatibility. If false,
352352
the settings may take advantage of the full range that the format allows.
@@ -365,13 +365,17 @@ def __init__(self,
365365
output_file: Path = None,
366366
compression_level: int = 5,
367367
blocksize: int = 0,
368-
dtype: str = 'int16',
369368
streamable_subset: bool = True,
370369
verify: bool = False):
371370
super().__init__()
372371

373-
if dtype not in ('int16', 'int32'):
374-
raise ValueError('FLAC encoding data type must be either int16 or int32')
372+
info = sf.info(str(input_file))
373+
if info.subtype == 'PCM_16':
374+
dtype = 'int16'
375+
elif info.subtype == 'PCM_32':
376+
dtype = 'int32'
377+
else:
378+
raise ValueError(f'WAV input data type must be either PCM_16 or PCM_32: Got {info.subtype}')
375379

376380
self.__raw_audio, sample_rate = sf.read(str(input_file), dtype=dtype)
377381
if output_file:

0 commit comments

Comments
 (0)