diff options
author | Antoine SOULIER <103120622+asoulier@users.noreply.github.com> | 2024-01-30 14:09:32 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-30 14:09:32 -0800 |
commit | 8b1619706e4c9bd6cc95a1722585ace0adec76f1 (patch) | |
tree | 40253e57aa2e5a9d511006ef3560f37d96fb1dc6 | |
parent | 10999c6c58f38f941ce192d6466405e0d7f8c19c (diff) | |
parent | 8e0bd81fe483c6c3f47d1f0d970904ff726d6179 (diff) | |
download | liblc3-8b1619706e4c9bd6cc95a1722585ace0adec76f1.tar.gz |
Merge pull request #41 from google/libpython
Python library wrapper
-rw-r--r-- | README.md | 19 | ||||
-rw-r--r-- | python/LICENSE | 202 | ||||
-rw-r--r-- | python/lc3.py | 393 | ||||
-rw-r--r-- | python/pyproject.toml | 16 | ||||
-rwxr-xr-x | python/tools/decoder.py | 96 | ||||
-rwxr-xr-x | python/tools/encoder.py | 88 | ||||
-rwxr-xr-x | python/tools/specgram.py | 92 | ||||
-rw-r--r--[-rwxr-xr-x] | test/decoder.py | 81 | ||||
-rw-r--r--[-rwxr-xr-x] | test/encoder.py | 88 |
9 files changed, 905 insertions, 170 deletions
@@ -17,6 +17,7 @@ The directory layout is as follows : - include: Library interface - src: Source files - tools: Standalone encoder/decoder tools +- python: Python wrapper - test: Unit testing framework - fuzz: Roundtrip fuzz testing harness - build: Building outputs @@ -62,7 +63,7 @@ Compiled library will be found in `bin` directory. ## Tools -Tools can be all compiled, while involking `make` as follows : +Tools can be all compiled, while invoking `make` as follows : ```sh $ make tools @@ -138,6 +139,7 @@ The conformance reports can be found [here](conformance/README.md) The codec was [_here_](https://hydrogenaud.io/index.php/topic,122575.0.html) subjectively evaluated in a blind listening test. + ## Meson build system Meson build system is also available to build and install lc3 codec in Linux @@ -148,3 +150,18 @@ $ meson build $ cd build && meson install ``` +## Python wrapper + +A python wrapper, installed as follows, is available in the `python` directory. + +```sh +$ python3 -m pip install ./python +``` + +Decoding and encoding tools are provided in `python/tools`, like C tools, +you can easly test encoding / decoding loop with : + +```sh +$ python3 ./python/tools/encoder.py <in.wav> --bitrate <bitrate> | \ + python3 ./python/tools/decoder.py > <out.wav> +``` diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python/lc3.py b/python/lc3.py new file mode 100644 index 0000000..4245b23 --- /dev/null +++ b/python/lc3.py @@ -0,0 +1,393 @@ +# +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import array +import ctypes +import enum + +from ctypes import c_bool, c_byte, c_int, c_uint, c_size_t, c_void_p +from ctypes.util import find_library + + +class _Base: + + def __init__(self, frame_duration, samplerate, nchannels, **kwargs): + + self.hrmode = False + self.dt_us = int(frame_duration * 1000) + self.sr_hz = int(samplerate) + self.sr_pcm_hz = self.sr_hz + self.nchannels = nchannels + + libpath = None + + for k in kwargs.keys(): + if k == 'hrmode': + self.hrmode = bool(kwargs[k]) + elif k == 'pcm_samplerate': + self.sr_pcm_hz = int(kwargs[k]) + elif k == 'libpath': + libpath = kwargs[k] + else: + raise ValueError("Invalid keyword argument: " + k) + + if self.dt_us not in [2500, 5000, 7500, 10000]: + raise ValueError( + "Invalid frame duration: %.1f ms" % frame_duration) + + allowed_samplerate = [8000, 16000, 24000, 32000, 48000] \ + if not self.hrmode else [48000, 96000] + + if self.sr_hz not in allowed_samplerate: + raise ValueError("Invalid sample rate: %d Hz" % samplerate) + + if libpath is None: + libpath = find_library("lc3") + if not libpath: + raise Exception("LC3 library not found") + + lib = ctypes.cdll.LoadLibrary(libpath) + + try: + lib.lc3_hr_frame_samples \ + and lib.lc3_hr_frame_block_bytes \ + and lib.lc3_hr_resolve_bitrate \ + and lib.lc3_hr_delay_samples + + except AttributeError: + + if self.hrmode: + raise Exception('High-Resolution interface not available') + + lib.lc3_hr_frame_samples = \ + lambda hrmode, dt_us, sr_hz: \ + lib.lc3_frame_samples(dt_us, sr_hz) + + lib.lc3_hr_frame_block_bytes = \ + lambda hrmode, dt_us, sr_hz, nchannels, bitrate: \ + nchannels * lib.lc3_frame_bytes(dt_us, bitrate // 2) + + lib.lc3_hr_resolve_bitrate = \ + lambda hrmode, dt_us, sr_hz, nbytes: \ + lib.lc3_resolve_bitrate(dt_us, nbytes) + + lib.lc3_hr_delay_samples = \ + lambda hrmode, dt_us, sr_hz: \ + lib.lc3_delay_samples(dt_us, sr_hz) + + lib.lc3_hr_frame_samples.argtypes = [c_bool, c_int, c_int] + lib.lc3_hr_frame_block_bytes.argtypes = \ + [c_bool, c_int, c_int, c_int, c_int] + lib.lc3_hr_resolve_bitrate.argtypes = [c_bool, c_int, c_int, c_int] + lib.lc3_hr_delay_samples.argtypes = [c_bool, c_int, c_int] + self.lib = lib + + libc = ctypes.cdll.LoadLibrary(find_library("c")) + + self.malloc = libc.malloc + self.malloc.argtypes = [c_size_t] + self.malloc.restype = c_void_p + + self.free = libc.free + self.free.argtypes = [c_void_p] + + def get_frame_samples(self): + """ + Returns the number of PCM samples in an LC3 frame + """ + ret = self.lib.lc3_hr_frame_samples( + self.hrmode, self.dt_us, self.sr_pcm_hz) + if ret < 0: + raise ValueError("Bad parameters") + return ret + + def get_frame_bytes(self, bitrate): + """ + Returns the size of LC3 frame blocks, from bitrate in bit per seconds. + A target `bitrate` equals 0 or `INT32_MAX` returns respectively + the minimum and maximum allowed size. + """ + ret = self.lib.lc3_hr_frame_block_bytes( + self.hrmode, self.dt_us, self.sr_hz, self.nchannels, bitrate) + if ret < 0: + raise ValueError("Bad parameters") + return ret + + def resolve_bitrate(self, nbytes): + """ + Returns the bitrate in bits per seconds, from the size of LC3 frames. + """ + ret = self.lib.lc3_hr_resolve_bitrate( + self.hrmode, self.dt_us, self.sr_hz, nbytes) + if ret < 0: + raise ValueError("Bad parameters") + return ret + + def get_delay_samples(self): + """ + Returns the algorithmic delay, as a number of samples. + """ + ret = self.lib.lc3_hr_delay_samples( + self.hrmode, self.dt_us, self.sr_pcm_hz) + if ret < 0: + raise ValueError("Bad parameters") + return ret + + @staticmethod + def _resolve_pcm_format(bitdepth): + PCM_FORMAT_S16 = 0 + PCM_FORMAT_S24 = 1 + PCM_FORMAT_S24_3LE = 2 + PCM_FORMAT_FLOAT = 3 + + match bitdepth: + case 16: return (PCM_FORMAT_S16, ctypes.c_int16) + case 24: return (PCM_FORMAT_S24_3LE, 3 * ctypes.c_byte) + case None: return (PCM_FORMAT_FLOAT, ctypes.c_float) + case _: raise ValueError("Could not interpret PCM bitdepth") + + +class Encoder(_Base): + """ + LC3 Encoder wrapper + + The `frame_duration` expressed in milliseconds is any of 2.5, 5.0, 7.5 + or 10.0. The `samplerate`, in Hertz, is any of 8000, 16000, 24000, 32000 + or 48000, unless High-Resolution mode is enabled. In High-Resolution mode, + the `samplerate` is 48000 or 96000. + + By default, one channel is processed. When `nchannels` is greater than one, + the PCM input stream is read interleaved and consecutives LC3 frames are + output, for each channel. + + Keyword arguments: + hrmode : Enable High-Resolution mode, default is `False`. + sr_pcm_hz : Input PCM samplerate, enable downsampling of input. + libpath : LC3 library path and name + """ + + class c_encoder_t(c_void_p): + pass + + def __init__(self, frame_duration, samplerate, nchannels=1, **kwargs): + + super().__init__(frame_duration, samplerate, nchannels, **kwargs) + + lib = self.lib + + try: + lib.lc3_hr_encoder_size \ + and lib.lc3_hr_setup_encoder + + except AttributeError: + + assert not self.hrmode + + lib.lc3_hr_encoder_size = \ + lambda hrmode, dt_us, sr_hz: \ + lib.lc3_encoder_size(dt_us, sr_hz) + + lib.lc3_hr_setup_encoder = \ + lambda hrmode, dt_us, sr_hz, sr_pcm_hz, mem: \ + lib.lc3_setup_encoder(dt_us, sr_hz, sr_pcm_hz, mem) + + lib.lc3_hr_encoder_size.argtypes = [c_bool, c_int, c_int] + lib.lc3_hr_encoder_size.restype = c_uint + + lib.lc3_hr_setup_encoder.argtypes = \ + [c_bool, c_int, c_int, c_int, c_void_p] + lib.lc3_hr_setup_encoder.restype = self.c_encoder_t + + lib.lc3_encode.argtypes = \ + [self.c_encoder_t, c_int, c_void_p, c_int, c_int, c_void_p] + + def new_encoder(): return lib.lc3_hr_setup_encoder( + self.hrmode, self.dt_us, self.sr_hz, self.sr_pcm_hz, + self.malloc(lib.lc3_hr_encoder_size( + self.hrmode, self.dt_us, self.sr_pcm_hz))) + + self.__encoders = [new_encoder() for i in range(nchannels)] + + def __del__(self): + + try: + (self.free(encoder) for encoder in self.__encoders) + finally: + return + + def encode(self, pcm, nbytes, bitdepth=None): + """ + Encode LC3 frame(s), for each channel. + + The `pcm` input is given in two ways. When no `bitdepth` is defined, + it's a vector of floating point values from -1 to 1, coding the sample + levels. When `bitdepth` is defined, `pcm` is interpreted as a byte-like + object, each sample coded on `bitdepth` bits (16 or 24). + The machine endianness, or little endian, is used for 16 or 24 bits + width, respectively. + In both cases, the `pcm` vector data is padded with zeros when + its length is less than the required input samples for the encoder. + Channels concatenation of encoded LC3 frames, of `nbytes`, is returned. + """ + + nchannels = self.nchannels + frame_samples = self.get_frame_samples() + + (pcm_fmt, pcm_t) = self._resolve_pcm_format(bitdepth) + pcm_len = nchannels * frame_samples + + if bitdepth is None: + pcm_buffer = array.array('f', pcm) + + # Invert test to catch NaN + if not abs(sum(pcm)) / frame_samples < 2: + raise ValueError("Out of range PCM input") + + padding = max(pcm_len - frame_samples, 0) + pcm_buffer.extend(array.array('f', [0] * padding)) + + else: + padding = max(pcm_len * ctypes.sizeof(pcm_t) - len(pcm), 0) + pcm_buffer = bytearray(pcm) + bytearray(padding) + + data_buffer = (c_byte * nbytes)() + data_offset = 0 + + for (ich, encoder) in enumerate(self.__encoders): + + pcm_offset = ich * ctypes.sizeof(pcm_t) + pcm = (pcm_t * (pcm_len - ich)).from_buffer(pcm_buffer, pcm_offset) + + data_size = nbytes // nchannels + int(ich < nbytes % nchannels) + data = (c_byte * data_size).from_buffer(data_buffer, data_offset) + data_offset += data_size + + ret = self.lib.lc3_encode( + encoder, pcm_fmt, pcm, nchannels, len(data), data) + if ret < 0: + raise ValueError("Bad parameters") + + return bytes(data_buffer) + + +class Decoder(_Base): + """ + LC3 Decoder wrapper + + The `frame_duration` expressed in milliseconds is any of 2.5, 5.0, 7.5 + or 10.0. The `samplerate`, in Hertz, is any of 8000, 16000, 24000, 32000 + or 48000, unless High-Resolution mode is enabled. In High-Resolution + mode, the `samplerate` is 48000 or 96000. + + By default, one channel is processed. When `nchannels` is greater than one, + the PCM input stream is read interleaved and consecutives LC3 frames are + output, for each channel. + + Keyword arguments: + hrmode : Enable High-Resolution mode, default is `False`. + sr_pcm_hz : Output PCM samplerate, enable upsampling of output. + libpath : LC3 library path and name + """ + + class c_decoder_t(c_void_p): + pass + + def __init__(self, frame_duration, samplerate, nchannels=1, **kwargs): + + super().__init__(frame_duration, samplerate, nchannels, **kwargs) + + lib = self.lib + + try: + lib.lc3_hr_decoder_size \ + and lib.lc3_hr_setup_decoder + + except AttributeError: + + assert not self.hrmode + + lib.lc3_hr_decoder_size = \ + lambda hrmode, dt_us, sr_hz: \ + lib.lc3_decoder_size(dt_us, sr_hz) + + lib.lc3_hr_setup_decoder = \ + lambda hrmode, dt_us, sr_hz, sr_pcm_hz, mem: \ + lib.lc3_setup_decoder(dt_us, sr_hz, sr_pcm_hz, mem) + + lib.lc3_hr_decoder_size.argtypes = [c_bool, c_int, c_int] + lib.lc3_hr_decoder_size.restype = c_uint + + lib.lc3_hr_setup_decoder.argtypes = \ + [c_bool, c_int, c_int, c_int, c_void_p] + lib.lc3_hr_setup_decoder.restype = self.c_decoder_t + + lib.lc3_decode.argtypes = \ + [self.c_decoder_t, c_void_p, c_int, c_int, c_void_p, c_int] + + def new_decoder(): return lib.lc3_hr_setup_decoder( + self.hrmode, self.dt_us, self.sr_hz, self.sr_pcm_hz, + self.malloc(lib.lc3_hr_decoder_size( + self.hrmode, self.dt_us, self.sr_pcm_hz))) + + self.__decoders = [new_decoder() for i in range(nchannels)] + + def __del__(self): + + try: + (self.free(decoder) for decoder in self.__decoders) + finally: + return + + def decode(self, data, bitdepth=None): + """ + Decode an LC3 frame + + The input `data` is the channels concatenation of LC3 frames in a + byte-like object. Interleaved PCM samples are returned according to + the `bitdepth` indication. + When no `bitdepth` is defined, it's a vector of floating point values + from -1 to 1, coding the sample levels. When `bitdepth` is defined, + it returns a byte array, each sample coded on `bitdepth` bits. + The machine endianness, or little endian, is used for 16 or 24 bits + width, respectively. + """ + + nchannels = self.nchannels + frame_samples = self.get_frame_samples() + + (pcm_fmt, pcm_t) = self._resolve_pcm_format(bitdepth) + pcm_len = nchannels * self.get_frame_samples() + pcm_buffer = (pcm_t * pcm_len)() + + data_buffer = bytearray(data) + data_offset = 0 + + for (ich, decoder) in enumerate(self.__decoders): + pcm_offset = ich * ctypes.sizeof(pcm_t) + pcm = (pcm_t * (pcm_len - ich)).from_buffer(pcm_buffer, pcm_offset) + + data_size = len(data_buffer) // nchannels + \ + int(ich < len(data_buffer) % nchannels) + data = (c_byte * data_size).from_buffer(data_buffer, data_offset) + data_offset += data_size + + ret = self.lib.lc3_decode( + decoder, data, len(data), pcm_fmt, pcm, self.nchannels) + if ret < 0: + raise ValueError("Bad parameters") + + return array.array('f', pcm_buffer) \ + if bitdepth is None else bytes(pcm_buffer) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..c1bb77c --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lc3" +version = "0.0.1" +license = "Apache-2.0" +authors = [ + { name="Antoine Soulier", email="asoulier@google.com" }, +] +description = "LC3 Codec library wrapper" +requires-python = ">=3.10" + +[project.urls] +Homepage = "https://github.com/google/liblc3" diff --git a/python/tools/decoder.py b/python/tools/decoder.py new file mode 100755 index 0000000..c10c1e4 --- /dev/null +++ b/python/tools/decoder.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import lc3 +import struct +import sys +import wave + +parser = argparse.ArgumentParser(description='LC3 Decoder') + +parser.add_argument( + 'lc3_file', nargs='?', + help='Input bitstream file, default is stdin', + type=argparse.FileType('rb'), default=sys.stdin.buffer) + +parser.add_argument( + 'wav_file', nargs='?', + help='Output wave file, default is stdout', + type=argparse.FileType('wb'), default=sys.stdout.buffer) + +parser.add_argument( + '--bitdepth', + help='Output bitdepth, default is 16 bits', + type=int, choices=[16, 24], default=16) + +parser.add_argument( + '--libpath', help='LC3 Library path') + +args = parser.parse_args() + +# --- LC3 File input --- + +f_lc3 = args.lc3_file + +header = struct.unpack('=HHHHHHHI', f_lc3.read(18)) +if header[0] != 0xcc1c: + raise ValueError('Invalid bitstream file') + +samplerate = header[2] * 100 +nchannels = header[4] +frame_duration = header[5] / 100 +stream_length = header[7] + +# --- Setup output --- + +bitdepth = args.bitdepth +pcm_size = nchannels * (bitdepth // 8) + +f_wav = args.wav_file +wavfile = wave.open(f_wav) +wavfile.setnchannels(nchannels) +wavfile.setsampwidth(bitdepth // 8) +wavfile.setframerate(samplerate) +wavfile.setnframes(stream_length) + +# --- Setup decoder --- + +dec = lc3.Decoder( + frame_duration, samplerate, nchannels, libpath=args.libpath) +frame_length = dec.get_frame_samples() +encoded_length = stream_length + dec.get_delay_samples() + +# --- Decoding loop --- + +for i in range(0, encoded_length, frame_length): + + lc3_frame_size = struct.unpack('=H', f_lc3.read(2))[0] + pcm = dec.decode(f_lc3.read(lc3_frame_size), bitdepth=bitdepth) + + pcm = pcm[max(encoded_length - stream_length - i, 0) * pcm_size: + min(encoded_length - i, frame_length) * pcm_size] + + wavfile.writeframesraw(pcm) + +# --- Cleanup --- + +wavfile.close() + +for f in (f_lc3, f_wav): + if f is not sys.stdout.buffer: + f.close() diff --git a/python/tools/encoder.py b/python/tools/encoder.py new file mode 100755 index 0000000..9f291ff --- /dev/null +++ b/python/tools/encoder.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import lc3 +import struct +import sys +import wave + +parser = argparse.ArgumentParser(description='LC3 Encoder') + +parser.add_argument( + 'wav_file', nargs='?', + help='Input wave file, default is stdin', + type=argparse.FileType('rb'), default=sys.stdin.buffer) + +parser.add_argument( + 'lc3_file', nargs='?', + help='Output bitstream file, default is stdout', + type=argparse.FileType('wb'), default=sys.stdout.buffer) + +parser.add_argument( + '--bitrate', help='Bitrate in bps', type=int, required=True) + +parser.add_argument( + '--frame_duration', help='Frame duration in ms', type=float, default=10) + +parser.add_argument( + '--libpath', help='LC3 Library path') + +args = parser.parse_args() + +# --- WAV File input --- + +f_wav = args.wav_file +wavfile = wave.open(f_wav, 'rb') + +samplerate = wavfile.getframerate() +nchannels = wavfile.getnchannels() +bitdepth = wavfile.getsampwidth() * 8 +stream_length = wavfile.getnframes() + +# --- Setup encoder --- + +enc = lc3.Encoder( + args.frame_duration, samplerate, nchannels, libpath=args.libpath) +frame_size = enc.get_frame_bytes(args.bitrate) +frame_length = enc.get_frame_samples() +bitrate = enc.resolve_bitrate(frame_size) + +# --- Setup output --- + +f_lc3 = args.lc3_file +f_lc3.write(struct.pack( + '=HHHHHHHI', 0xcc1c, 18, + samplerate // 100, bitrate // 100, nchannels, + int(args.frame_duration * 100), 0, stream_length)) + +# --- Encoding loop --- + +for i in range(0, stream_length, frame_length): + + f_lc3.write(struct.pack('=H', frame_size)) + + pcm = wavfile.readframes(frame_length) + f_lc3.write(enc.encode(pcm, frame_size, bitdepth=bitdepth)) + +# --- Cleanup --- + +wavfile.close() + +for f in (f_wav, f_lc3): + if f is not sys.stdout.buffer: + f.close() diff --git a/python/tools/specgram.py b/python/tools/specgram.py new file mode 100755 index 0000000..42781d4 --- /dev/null +++ b/python/tools/specgram.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import lc3 +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +import scipy.signal as signal + +matplotlib.use('QtAgg') + +parser = argparse.ArgumentParser(description='LC3 Encoder') + +parser.add_argument( + '--frame_duration', help='Frame duration (ms)', type=float, default=10) + +parser.add_argument( + '--sample_rate', help='Sampling frequency (Hz)', type=float, default=48000) + +parser.add_argument( + '--hrmode', help='Enable high-resolution mode', action='store_true') + +parser.add_argument( + '--bitrate', help='Bitrate (bps)', type=int, default=96000) + +parser.add_argument( + '--libpath', help='LC3 Library path') + +args = parser.parse_args() + +# --- Setup encoder + decoder --- + +fs = args.sample_rate + +enc = lc3.Encoder( + args.frame_duration, fs, hrmode=args.hrmode, libpath=args.libpath) +dec = lc3.Decoder( + args.frame_duration, fs, hrmode=args.hrmode, libpath=args.libpath) + +frame_len = enc.get_frame_samples() +delay_len = enc.get_delay_samples() + +# --- Generate 10 seconds chirp --- + +n = 10 * int(fs) +t = np.arange(n - (n % frame_len)) / fs +s = signal.chirp(t, f0=10, f1=fs/2, t1=t[-1], phi=-90, method='linear') + +# --- Encoding / decoding loop --- + +frame_size = enc.get_frame_bytes(args.bitrate) +bitrate = enc.resolve_bitrate(frame_size) + +y = np.empty(len(s) + frame_len) + +for i in range(0, len(s), frame_len): + y[i:i+frame_len] = dec.decode(enc.encode(s[i:i+frame_len], frame_size)) + +y[len(s):] = dec.decode(enc.encode(np.zeros(frame_len), frame_size)) +y = y[delay_len:len(s)+delay_len] + +# --- Plot spectrograms --- + +fig, (ax1, ax2) = plt.subplots(nrows=2, sharex=True) + +NFFT = 512 +for (ax, s) in [(ax1, s), (ax2, y)]: + ax.specgram(s, Fs=fs, NFFT=NFFT, pad_to=4*NFFT, noverlap=NFFT//2, + vmin=-160, vmax=0) + +ax1.set_title('Input signal') +ax1.set_ylabel('Frequency (Hz)') + +ax2.set_title(('Processed signal (%.1f kbps)' % (bitrate/1000))) +ax2.set_ylabel('Frequency (Hz)') + +plt.show() diff --git a/test/decoder.py b/test/decoder.py index 26cdccc..388fc62 100755..100644 --- a/test/decoder.py +++ b/test/decoder.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # # Copyright 2022 Google LLC # @@ -27,8 +26,6 @@ import tables as T, appendix_c as C import mdct, energy, bwdet, sns, tns, spec, ltpf import bitstream -### ------------------------------------------------------------------------ ### - class Decoder: def __init__(self, dt_ms, sr_hz): @@ -96,8 +93,6 @@ class Decoder: return x -### ------------------------------------------------------------------------ ### - def check_appendix_c(dt): i0 = dt - T.DT_7M5 @@ -120,79 +115,3 @@ def check(): ok = ok and check_appendix_c(dt) return ok - -### ------------------------------------------------------------------------ ### - -if __name__ == "__main__": - - parser = argparse.ArgumentParser(description='LC3 Decoder Test Framework') - parser.add_argument('lc3_file', - help='Input bitstream file', type=argparse.FileType('r')) - parser.add_argument('--pyout', - help='Python output file', type=argparse.FileType('w')) - parser.add_argument('--cout', - help='C output file', type=argparse.FileType('w')) - args = parser.parse_args() - - ### File Header ### - - f_lc3 = open(args.lc3_file.name, 'rb') - - header = struct.unpack('=HHHHHHHI', f_lc3.read(18)) - - if header[0] != 0xcc1c: - raise ValueError('Invalid bitstream file') - - if header[4] != 1: - raise ValueError('Unsupported number of channels') - - sr_hz = header[2] * 100 - bitrate = header[3] * 100 - nchannels = header[4] - dt_ms = header[5] / 100 - - f_lc3.seek(header[1]) - - ### Setup ### - - dec = Decoder(dt_ms, sr_hz) - dec_c = lc3.setup_decoder(int(dt_ms * 1000), sr_hz) - - pcm_c = np.empty(0).astype(np.int16) - pcm_py = np.empty(0).astype(np.int16) - - ### Decoding loop ### - - nframes = 0 - - while True: - - data = f_lc3.read(2) - if len(data) != 2: - break - - (frame_nbytes,) = struct.unpack('=H', data) - - print('Decoding frame %d' % nframes, end='\r') - - data = f_lc3.read(frame_nbytes) - - x = dec.run(data) - pcm_py = np.append(pcm_py, - np.clip(np.round(x), -32768, 32767).astype(np.int16)) - - x_c = lc3.decode(dec_c, data) - pcm_c = np.append(pcm_c, x_c) - - nframes += 1 - - print('done ! %16s' % '') - - ### Terminate ### - - if args.pyout: - wavfile.write(args.pyout.name, sr_hz, pcm_py) - if args.cout: - wavfile.write(args.cout.name, sr_hz, pcm_c) - -### ------------------------------------------------------------------------ ### diff --git a/test/encoder.py b/test/encoder.py index f687d4b..913420e 100755..100644 --- a/test/encoder.py +++ b/test/encoder.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # # Copyright 2022 Google LLC # @@ -28,8 +27,6 @@ import attdet, ltpf import mdct, energy, bwdet, sns, tns, spec import bitstream -### ------------------------------------------------------------------------ ### - class Encoder: def __init__(self, dt_ms, sr_hz): @@ -104,8 +101,6 @@ class Encoder: return data -### ------------------------------------------------------------------------ ### - def check_appendix_c(dt): i0 = dt - T.DT_7M5 @@ -128,86 +123,3 @@ def check(): ok = ok and check_appendix_c(dt) return ok - -### ------------------------------------------------------------------------ ### - -def dump(data): - for i in range(0, len(data), 20): - print(''.join('{:02x} '.format(x) - for x in data[i:min(i+20, len(data))] )) - -if __name__ == "__main__": - - parser = argparse.ArgumentParser(description='LC3 Encoder Test Framework') - parser.add_argument('wav_file', - help='Input wave file', type=argparse.FileType('r')) - parser.add_argument('--bitrate', - help='Bitrate in bps', type=int, required=True) - parser.add_argument('--dt', - help='Frame duration in ms', type=float, default=10) - parser.add_argument('--pyout', - help='Python output file', type=argparse.FileType('w')) - parser.add_argument('--cout', - help='C output file', type=argparse.FileType('w')) - args = parser.parse_args() - - if args.bitrate < 16000 or args.bitrate > 320000: - raise ValueError('Invalid bitate %d bps' % args.bitrate) - - if args.dt not in (7.5, 10): - raise ValueError('Invalid frame duration %.1f ms' % args.dt) - - (sr_hz, pcm) = wavfile.read(args.wav_file.name) - if sr_hz not in (8000, 16000, 24000, 320000, 48000): - raise ValueError('Unsupported input samplerate: %d' % sr_hz) - if pcm.ndim != 1: - raise ValueError('Only single channel wav file supported') - - ### Setup ### - - enc = Encoder(args.dt, sr_hz) - enc_c = lc3.setup_encoder(int(args.dt * 1000), sr_hz) - - frame_samples = int((args.dt * sr_hz) / 1000) - frame_nbytes = int((args.bitrate * args.dt) / (1000 * 8)) - - ### File Header ### - - f_py = open(args.pyout.name, 'wb') if args.pyout else None - f_c = open(args.cout.name , 'wb') if args.cout else None - - header = struct.pack('=HHHHHHHI', 0xcc1c, 18, - sr_hz // 100, args.bitrate // 100, 1, int(args.dt * 100), 0, len(pcm)) - - for f in (f_py, f_c): - if f: f.write(header) - - ### Encoding loop ### - - if len(pcm) % frame_samples > 0: - pcm = np.append(pcm, np.zeros(frame_samples - (len(pcm) % frame_samples))) - - for i in range(0, len(pcm), frame_samples): - - print('Encoding frame %d' % (i // frame_samples), end='\r') - - frame_pcm = pcm[i:i+frame_samples] - - data = enc.run(frame_pcm, frame_nbytes) - data_c = lc3.encode(enc_c, frame_pcm, frame_nbytes) - - for f in (f_py, f_c): - if f: f.write(struct.pack('=H', frame_nbytes)) - - if f_py: f_py.write(data) - if f_c: f_c.write(data_c) - - print('done ! %16s' % '') - - ### Terminate ### - - for f in (f_py, f_c): - if f: f.close() - - -### ------------------------------------------------------------------------ ### |