diff --git a/.github/workflows/python-codestyle.yml b/.github/workflows/python-codestyle.yml new file mode 100644 index 0000000..bed7f27 --- /dev/null +++ b/.github/workflows/python-codestyle.yml @@ -0,0 +1,32 @@ +name: Python Codestyle + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: + - 'binding/python/*.py' + - 'demo/python/*.py' + pull_request: + branches: [ main, 'v[0-9]+.[0-9]+' ] + paths: + - 'binding/python/*.py' + - 'demo/python/*.py' + +jobs: + check-python-codestyle: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install flake8 pep8-naming + + - name: Check python codestyle + run: flake8 --ignore=F401,F403,F405 --max-line-length=120 binding/python demo/python \ No newline at end of file diff --git a/.github/workflows/python-demos.yml b/.github/workflows/python-demos.yml new file mode 100644 index 0000000..7d29859 --- /dev/null +++ b/.github/workflows/python-demos.yml @@ -0,0 +1,68 @@ +name: Python Demos + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: + - '.github/workflows/python-demos.yml' + - 'demo/python/**' + - '!demo/python/README.md' + pull_request: + branches: [ main, 'v[0-9]+.[0-9]+' ] + paths: + - '.github/workflows/python-demos.yml' + - 'demo/python/**' + - '!demo/python/README.md' + +defaults: + run: + working-directory: demo/python + +jobs: + build-github-hosted: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Pre-build dependencies + run: python -m pip install --upgrade pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run demo + run: python3 pv_speaker_demo.py --show_audio_devices + + build-self-hosted: + runs-on: ${{ matrix.machine }} + + strategy: + matrix: + machine: [rpi3-32, rpi3-64, rpi4-32, rpi4-64, rpi5-32, rpi5-64] + + steps: + - uses: actions/checkout@v3 + + - name: Install dependencies + run: | + python3 -m venv .venv + source .venv/bin/activate + pip3 install -r requirements.txt + + - name: Run demo + run: | + python3 -m venv .venv + source .venv/bin/activate + python3 pv_speaker_demo.py --show_audio_devices \ No newline at end of file diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..d68eee5 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,70 @@ +name: Python + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: + - 'binding/python/**' + - '!binding/python/README.md' + - 'lib/linux/**' + - 'lib/mac/**' + - 'lib/raspberry-pi/**' + - 'lib/windows/**' + - '.github/workflows/python.yml' + pull_request: + branches: [ main, 'v[0-9]+.[0-9]+' ] + paths: + - 'binding/python/**' + - '!binding/python/README.md' + - 'lib/linux/**' + - 'lib/mac/**' + - 'lib/raspberry-pi/**' + - 'lib/windows/**' + - '.github/workflows/python.yml' + +defaults: + run: + working-directory: binding/python + +jobs: + build-github-hosted: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Test + run: python3 test_pv_speaker.py + + build-self-hosted: + runs-on: ${{ matrix.machine }} + + strategy: + matrix: + machine: [rpi3-32, rpi3-64, rpi4-32, rpi4-64, rpi5-32, rpi5-64, pv-windows, pv-ios] + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Test + run: python3 test_pv_speaker.py + if: ${{ matrix.machine != 'pv-windows' }} + + - name: Test + run: python test_pv_speaker.py + if: ${{ matrix.machine == 'pv-windows' }} \ No newline at end of file diff --git a/README.md b/README.md index 56f59f6..b208f16 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # PvSpeaker + +[![PyPI](https://img.shields.io/pypi/v/pvspeaker)](https://pypi.org/project/pvspeaker/) + + Made in Vancouver, Canada by [Picovoice](https://picovoice.ai) @@ -14,7 +18,10 @@ PvSpeaker is an easy-to-use, cross-platform audio player designed for real-time - [Table of Contents](#table-of-contents) - [Source Code](#source-code) - [Demos](#demos) - - [C](#c-demo) + - [Python](#python-demo) + - [C](#c-demo) + - [SDKs](#sdks) + - [Python](#python) ## Source Code @@ -35,6 +42,30 @@ If using HTTPS, clone the repository with: git clone --recurse-submodules https://github.com/Picovoice/pvspeaker.git ``` +### Python Demo + +Install the demo package: + +```console +pip3 install pvspeakerdemo +``` + +To show the available audio devices run: + +```console +pv_speaker_demo --show_audio_devices +``` + +With a working speaker connected to your device run the following in the terminal: + +```console +pv_speaker_demo --input_wav_path {INPUT_WAV_PATH} +``` + +Replace `{INPUT_WAV_PATH}` with the path to the pcm `wav` file you wish to play. + +For more information about the Python demos go to [demo/python](demo/python). + ### C Demo Run the following commands to build the demo app: @@ -60,4 +91,44 @@ Play from a single-channel PCM WAV file with a given audio device index: Hit `Ctrl+C` if you wish to stop audio playback before it completes. If no audio device index (`-d`) is provided, the demo will use the system's default audio player device. -For more information about the C demo, go to [demo/c](demo/c). \ No newline at end of file +For more information about the C demo, go to [demo/c](demo/c). + +## SDKs + +### Python + +To start playing audio, initialize an instance and run `start()`: + +```python +from pvspeaker import PvSpeaker + +speaker = PvSpeaker( + sample_rate=22050, + bits_per_sample=16, + device_index=0) + +speaker.start() +``` + +Write frames of audio: + +```python +def get_next_audio_frame(): + pass + +speaker.write(get_next_audio_frame()) +``` + +When all frames have been written, run `stop()` on the instance: + +```python +speaker.stop() +``` + +Once you are done, free the resources acquired by PvSpeaker. You do not have to call `stop()` before `delete()`: + +```python +speaker.delete() +``` + +For more information about the PvSpeaker Python SDK, go to [binding/python](binding/python). \ No newline at end of file diff --git a/binding/python/.gitignore b/binding/python/.gitignore new file mode 100644 index 0000000..288dbbe --- /dev/null +++ b/binding/python/.gitignore @@ -0,0 +1,7 @@ +venv +dist +MANIFEST.in +pvspeaker +pvspeaker.egg-info +build +__pycache__ \ No newline at end of file diff --git a/binding/python/README.md b/binding/python/README.md new file mode 100644 index 0000000..9eb992f --- /dev/null +++ b/binding/python/README.md @@ -0,0 +1,73 @@ +# PvSpeaker Binding for Python + +## PvSpeaker + +PvSpeaker is an easy-to-use, cross-platform audio player designed for real-time speech audio processing. It allows developers to send raw PCM frames to an audio device's output stream. + +## Compatibility + +- Python 3.8+ +- Runs on Linux (x86_64), macOS (x86_64 and arm64), Windows (x86_64), and Raspberry Pi (3, 4, 5). + +## Installation + +```shell +pip3 install pvspeaker +``` + +## Usage + +Initialize and start `PvSpeaker`: + +```python +from pvspeaker import PvSpeaker + +speaker = PvSpeaker( + sample_rate=22050, + bits_per_sample=16, + device_index=0) + +speaker.start() +``` + +(or) + +Use `get_available_devices()` to get a list of available devices and then initialize the instance based on the index of a device: + +```python +from pvspeaker import PvSpeaker + +devices = PvSpeaker.get_available_devices() + +speaker = PvSpeaker( + sample_rate=22050, + bits_per_sample=16, + device_index=0) + +speaker.start() +``` + +Write frames of audio: + +```python +def get_next_audio_frame(): + pass + +speaker.write(get_next_audio_frame()) +``` + +When all frames have been written, run `stop()` on the instance: + +```python +speaker.stop() +``` + +Once you are done, free the resources acquired by PvSpeaker. You do not have to call `stop()` before `delete()`: + +```python +speaker.delete() +``` + +## Demos + +[pvspeakerdemo](https://pypi.org/project/pvspeakerdemo/) provides command-line utilities for playing audio from a file. \ No newline at end of file diff --git a/binding/python/__init__.py b/binding/python/__init__.py new file mode 100644 index 0000000..474bb68 --- /dev/null +++ b/binding/python/__init__.py @@ -0,0 +1,12 @@ +# +# Copyright 2024 Picovoice Inc. +# +# You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE" +# file accompanying this source. +# +# 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. +# + +from ._pvspeaker import PvSpeaker diff --git a/binding/python/_pvspeaker.py b/binding/python/_pvspeaker.py new file mode 100644 index 0000000..1ec0950 --- /dev/null +++ b/binding/python/_pvspeaker.py @@ -0,0 +1,299 @@ +# +# Copyright 2024 Picovoice Inc. +# +# You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE" +# file accompanying this source. +# +# 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 os +import platform +import subprocess + +from ctypes import * +from enum import Enum +from struct import pack +from typing import * + +CALLBACK = CFUNCTYPE(None, POINTER(c_int16)) + + +def default_library_path(relative: str = ''): + """A helper function to get the library path.""" + + if platform.system() == "Windows": + script_path = os.path.join(os.path.dirname(__file__), relative, "resources", "scripts", "platform.bat") + else: + script_path = os.path.join(os.path.dirname(__file__), relative, "resources", "scripts", "platform.sh") + + command = subprocess.run(script_path, stdout=subprocess.PIPE) + + if command.returncode != 0: + raise RuntimeError("Current system is not supported.") + os_name, cpu = str(command.stdout.decode("utf-8")).split(" ") + + if os_name == "windows": + extension = "dll" + elif os_name == "mac": + extension = "dylib" + else: + extension = "so" + + return os.path.join(os.path.dirname(__file__), relative, "lib", os_name, cpu, "libpv_speaker.%s" % extension) + + +class PvSpeaker(object): + """ + A cross-platform Python SDK for PvSpeaker to play audio. It lists the available output devices. + Also given the audio device index, sample_rate, frame_length, and bits_per_sample, plays the + frame of audio to the device speakers. + """ + + class PvSpeakerStatuses(Enum): + SUCCESS = 0 + OUT_OF_MEMORY = 1 + INVALID_ARGUMENT = 2 + INVALID_STATE = 3 + BUFFER_OVERFLOW = 3 + BACKEND_ERROR = 4 + DEVICE_ALREADY_INITIALIZED = 5 + DEVICE_NOT_INITIALIZED = 6 + IO_ERROR = 7 + RUNTIME_ERROR = 8 + + _PVSPEAKER_STATUS_TO_EXCEPTION = { + PvSpeakerStatuses.OUT_OF_MEMORY: MemoryError, + PvSpeakerStatuses.INVALID_ARGUMENT: ValueError, + PvSpeakerStatuses.INVALID_STATE: ValueError, + PvSpeakerStatuses.BUFFER_OVERFLOW: IOError, + PvSpeakerStatuses.BACKEND_ERROR: SystemError, + PvSpeakerStatuses.DEVICE_ALREADY_INITIALIZED: ValueError, + PvSpeakerStatuses.DEVICE_NOT_INITIALIZED: ValueError, + PvSpeakerStatuses.IO_ERROR: IOError, + PvSpeakerStatuses.RUNTIME_ERROR: RuntimeError + } + + class CPvSpeaker(Structure): + pass + + _library = None + _relative_library_path = '' + + def __init__( + self, + sample_rate: int, + bits_per_sample: int, + device_index: int = -1, + frame_length: int = 512, + buffered_frames_count: int = 50): + """ + Constructor + + :param sample_rate: The sample rate of the audio to be played. + :param bits_per_sample: The number of bits per sample. + :param device_index: The index of the audio device to use. A value of (-1) will resort to default device. + :param frame_length: The maximum length of audio frame that will be passed to each write call. + :param buffered_frames_count: The number of audio frames buffered internally for writing - i.e. internal + circular buffer will be of size `frame_length` * `buffered_frames_count`. If this value is too low, + buffer overflows could occur audio frames could be dropped. A higher value will increase memory usage. + """ + + library = self._get_library() + + init_func = library.pv_speaker_init + init_func.argtypes = [ + c_int32, + c_int32, + c_int32, + c_int32, + c_int32, + POINTER(POINTER(self.CPvSpeaker)) + ] + init_func.restype = self.PvSpeakerStatuses + + self._handle = POINTER(self.CPvSpeaker)() + self._sample_rate = sample_rate + self._frame_length = frame_length + self._bits_per_sample = bits_per_sample + + status = init_func( + sample_rate, frame_length, bits_per_sample, device_index, buffered_frames_count, byref(self._handle)) + if status is not self.PvSpeakerStatuses.SUCCESS: + raise self._PVSPEAKER_STATUS_TO_EXCEPTION[status]("Failed to initialize PvSpeaker.") + + self._delete_func = library.pv_speaker_delete + self._delete_func.argtypes = [POINTER(self.CPvSpeaker)] + self._delete_func.restype = None + + self._start_func = library.pv_speaker_start + self._start_func.argtypes = [POINTER(self.CPvSpeaker)] + self._start_func.restype = self.PvSpeakerStatuses + + self._stop_func = library.pv_speaker_stop + self._stop_func.argtypes = [POINTER(self.CPvSpeaker)] + self._stop_func.restype = self.PvSpeakerStatuses + + self._set_debug_logging_func = library.pv_speaker_set_debug_logging + self._set_debug_logging_func.argtypes = [POINTER(self.CPvSpeaker), c_bool] + self._set_debug_logging_func.restype = None + + self._write_func = library.pv_speaker_write + self._write_func.argtypes = [POINTER(self.CPvSpeaker), c_int32, c_void_p] + self._write_func.restype = self.PvSpeakerStatuses + + self._get_is_started_func = library.pv_speaker_get_is_started + self._get_is_started_func.argtypes = [POINTER(self.CPvSpeaker)] + self._get_is_started_func.restype = c_bool + + self._get_selected_device_func = library.pv_speaker_get_selected_device + self._get_selected_device_func.argtypes = [POINTER(self.CPvSpeaker)] + self._get_selected_device_func.restype = c_char_p + + self._version_func = library.pv_speaker_version + self._version_func.argtypes = None + self._version_func.restype = c_char_p + + def delete(self) -> None: + """Releases any resources used by PvSpeaker.""" + + self._delete_func(self._handle) + + def start(self) -> None: + """Starts buffering audio frames.""" + + status = self._start_func(self._handle) + if status is not self.PvSpeakerStatuses.SUCCESS: + raise self._PVSPEAKER_STATUS_TO_EXCEPTION[status]("Failed to start device.") + + def stop(self) -> None: + """Stops playing audio.""" + + status = self._stop_func(self._handle) + if status is not self.PvSpeakerStatuses.SUCCESS: + raise self._PVSPEAKER_STATUS_TO_EXCEPTION[status]("Failed to stop device.") + + def write(self, pcm) -> None: + """Synchronous call to write pcm frames to selected device for audio playback.""" + + i = 0 + while i < len(pcm): + is_last_frame = i + self._frame_length >= len(pcm) + write_frame_length = len(pcm) - i if is_last_frame else self._frame_length + + start_index = i + end_index = i + write_frame_length + frame = pcm[start_index:end_index] + + byte_data = None + if self._bits_per_sample == 8: + byte_data = pack('B' * len(frame), *frame) + elif self._bits_per_sample == 16: + byte_data = pack('h' * len(frame), *frame) + elif self._bits_per_sample == 24: + byte_data = b''.join(pack(' None: + """ + Enable or disable debug logging for PvSpeaker. Debug logs will indicate when there are overflows + in the internal frame buffer. + + :param is_debug_logging_enabled: Boolean indicating whether the debug logging is enabled or disabled. + """ + + self._set_debug_logging_func(self._handle, is_debug_logging_enabled) + + @property + def is_started(self) -> bool: + """Gets whether the speaker has started and is available to receive pcm frames or not.""" + + return bool(self._get_is_started_func(self._handle)) + + @property + def selected_device(self) -> str: + """Gets the audio device that the given `PvSpeaker` instance is using.""" + + device_name = self._get_selected_device_func(self._handle) + return device_name.decode('utf-8') + + @property + def version(self) -> str: + """Gets the current version of PvSpeaker library.""" + + version = self._version_func() + return version.decode('utf-8') + + @property + def sample_rate(self) -> int: + """Gets the sample rate matching the value given to `__init__()`.""" + + return self._sample_rate + + @property + def frame_length(self) -> int: + """Gets the frame length matching the value given to `__init__()`.""" + + return self._frame_length + + @property + def bits_per_sample(self) -> int: + """Gets the bits per sample matching the value given to `__init__()`.""" + + return self._bits_per_sample + + @staticmethod + def get_available_devices() -> List[str]: + """Gets the list of available audio devices that can be used for playing. + + :return: A list of strings, indicating the names of audio devices. + """ + + get_available_devices_func = PvSpeaker._get_library().pv_speaker_get_available_devices + get_available_devices_func.argstype = [POINTER(c_int32), POINTER(POINTER(c_char_p))] + get_available_devices_func.restype = PvSpeaker.PvSpeakerStatuses + + free_available_devices_func = PvSpeaker._get_library().pv_speaker_free_available_devices + free_available_devices_func.argstype = [c_int32, POINTER(c_char_p)] + free_available_devices_func.restype = None + + count = c_int32() + devices = POINTER(c_char_p)() + + status = get_available_devices_func(byref(count), byref(devices)) + if status is not PvSpeaker.PvSpeakerStatuses.SUCCESS: + raise PvSpeaker._PVSPEAKER_STATUS_TO_EXCEPTION[status]("Failed to get device list") + + device_list = list() + for i in range(count.value): + device_list.append(devices[i].decode('utf-8')) + + free_available_devices_func(count, devices) + + return device_list + + @classmethod + def set_default_library_path(cls, relative: str): + cls._relative_library_path = default_library_path(relative) + + @classmethod + def _get_library(cls): + if len(cls._relative_library_path) == 0: + cls._relative_library_path = default_library_path() + if cls._library is None: + cls._library = cdll.LoadLibrary(cls._relative_library_path) + return cls._library + + +__all__ = [ + 'PvSpeaker', +] diff --git a/binding/python/setup.py b/binding/python/setup.py new file mode 100644 index 0000000..f8373b4 --- /dev/null +++ b/binding/python/setup.py @@ -0,0 +1,79 @@ +# +# Copyright 2024 Picovoice Inc. +# +# You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE" +# file accompanying this source. +# +# 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 os +import shutil + +import setuptools + +os.system('git clean -dfx') + +package_folder = os.path.join(os.path.dirname(__file__), 'pvspeaker') +os.mkdir(package_folder) + +shutil.copy(os.path.join(os.path.dirname(__file__), '../../LICENSE'), package_folder) + +shutil.copy(os.path.join(os.path.dirname(__file__), '__init__.py'), os.path.join(package_folder, '__init__.py')) +shutil.copy(os.path.join(os.path.dirname(__file__), '_pvspeaker.py'), os.path.join(package_folder, '_pvspeaker.py')) + +shutil.copytree( + os.path.join(os.path.dirname(__file__), '../../resources/scripts'), + os.path.join(package_folder, 'resources/scripts')) + +platforms = ('linux', 'mac', 'raspberry-pi', 'windows') + +os.mkdir(os.path.join(package_folder, 'lib')) +for platform in platforms: + shutil.copytree( + os.path.join(os.path.dirname(__file__), '../../lib', platform), + os.path.join(package_folder, 'lib', platform)) + +MANIFEST_IN = """ +include pvspeaker/LICENSE +include pvspeaker/__init__.py +include pvspeaker/_pv_speaker.py +include pvspeaker/lib/linux/x86_64/libpv_speaker.so +include pvspeaker/lib/mac/x86_64/libpv_speaker.dylib +include pvspeaker/lib/mac/arm64/libpv_speaker.dylib +recursive-include pvspeaker/lib/raspberry-pi * +include pvspeaker/lib/windows/amd64/libpv_speaker.dll +recursive-include pvspeaker/resources/scripts * +""" + +with open(os.path.join(os.path.dirname(__file__), 'MANIFEST.in'), 'w') as f: + f.write(MANIFEST_IN.strip('\n ')) + +with open(os.path.join(os.path.dirname(__file__), 'README.md'), 'r') as f: + long_description = f.read() + +setuptools.setup( + name="pvspeaker", + version="1.0.0", + author="Picovoice", + author_email="hello@picovoice.ai", + description="Speaker library for Picovoice.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Picovoice/pvspeaker", + packages=["pvspeaker"], + install_requires=[], + include_package_data=True, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio :: Speech" + ], + python_requires='>=3.8', + keywords="Audio Player", +) diff --git a/binding/python/test_pv_speaker.py b/binding/python/test_pv_speaker.py new file mode 100644 index 0000000..302b882 --- /dev/null +++ b/binding/python/test_pv_speaker.py @@ -0,0 +1,116 @@ +# +# Copyright 2024 Picovoice Inc. +# +# You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE" +# file accompanying this source. +# +# 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 os.path +import unittest + +from _pvspeaker import * + + +class PvSpeakerTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + PvSpeaker.set_default_library_path(os.path.join('..', '..')) + + def test_invalid_device_index(self): + with self.assertRaises(ValueError): + _ = PvSpeaker(16000, 16, -2) + + def test_invalid_frame_length(self): + with self.assertRaises(ValueError): + _ = PvSpeaker(16000, 16, 0, 0) + + def test_invalid_buffered_frame_count(self): + with self.assertRaises(ValueError): + _ = PvSpeaker(16000, 16, 0, 512, 0) + + def test_set_frame_length(self): + speaker = PvSpeaker(16000, 16, 0, 256) + frame_length = speaker.frame_length + self.assertEqual(frame_length, 256) + self.assertIsInstance(frame_length, int) + speaker.delete() + + def test_start_stop(self): + error = False + try: + speaker = PvSpeaker(16000, 16, 0) + speaker.start() + frame = [0] * (512 * 2) + speaker.write(frame) + speaker.stop() + speaker.delete() + except ValueError or IOError: + error = True + self.assertFalse(error) + + def test_set_debug_logging(self): + speaker = PvSpeaker(16000, 16, 0) + speaker.set_debug_logging(True) + speaker.set_debug_logging(False) + self.assertIsNotNone(speaker) + speaker.delete() + + def test_is_started(self): + speaker = PvSpeaker(16000, 16, 0) + speaker.start() + self.assertTrue(speaker.is_started) + speaker.stop() + self.assertFalse(speaker.is_started) + speaker.delete() + + def test_selected_device(self): + speaker = PvSpeaker(16000, 16, 0) + device = speaker.selected_device + self.assertIsNotNone(device) + self.assertIsInstance(device, str) + speaker.delete() + + def test_get_available_devices(self): + speaker = PvSpeaker(16000, 16, 0) + devices = speaker.get_available_devices() + self.assertIsNotNone(devices) + for device in devices: + self.assertIsNotNone(device) + self.assertIsInstance(device, str) + speaker.delete() + + def test_version(self): + speaker = PvSpeaker(16000, 16, 0) + version = speaker.version + self.assertGreater(len(version), 0) + self.assertIsInstance(version, str) + speaker.delete() + + def test_sample_rate(self): + speaker = PvSpeaker(16000, 16, 0) + sample_rate = speaker.sample_rate + self.assertEqual(sample_rate, 16000) + self.assertIsInstance(sample_rate, int) + speaker.delete() + + def test_frame_length(self): + speaker = PvSpeaker(16000, 16, 0) + frame_length = speaker.frame_length + self.assertEqual(frame_length, 512) + self.assertIsInstance(frame_length, int) + speaker.delete() + + def test_bits_per_sample(self): + speaker = PvSpeaker(16000, 16, 0) + bits_per_sample = speaker.bits_per_sample + self.assertEqual(bits_per_sample, 16) + self.assertIsInstance(bits_per_sample, int) + speaker.delete() + + +if __name__ == '__main__': + unittest.main() diff --git a/demo/python/.gitignore b/demo/python/.gitignore new file mode 100644 index 0000000..e6a7303 --- /dev/null +++ b/demo/python/.gitignore @@ -0,0 +1,7 @@ +venv +dist +MANIFEST.in +pvspeakerdemo +pvspeakerdemo.egg-info +build +__pycache__ \ No newline at end of file diff --git a/demo/python/README.md b/demo/python/README.md new file mode 100644 index 0000000..13f8b31 --- /dev/null +++ b/demo/python/README.md @@ -0,0 +1,36 @@ +# PvSpeaker Demo for Python + +This project contains a Python command-line demo for PvSpeaker that demonstrates how to use PvSpeaker to play audio from a WAV file. + +## PvSpeaker + +PvSpeaker is an easy-to-use, cross-platform audio player designed for real-time speech audio processing. It allows developers to send raw PCM frames to an audio device's output stream. + +## Compatibility + +- Python 3.8+ +- Runs on Linux (x86_64), macOS (x86_64 and arm64), Windows (x86_64), and Raspberry Pi (3, 4, 5). + +## Installation + +```console +pip3 install pvspeakerdemo +``` + +## Usage + +In the following instructions, we will refer to `{AUDIO_DEVICE_INDEX}` as the index of the audio device to use, and `{INPUT_WAV_PATH}` as the path to the pcm `wav` file that will be played. + +`{AUDIO_DEVICE_INDEX}` defaults to -1 and `{INPUT_WAV_PATH}` must not be empty. + +To show the available audio devices run: + +```console +pv_speaker_demo --show_audio_devices +``` + +To run PvSpeaker run: + +```console +pv_speaker_demo --audio_device_index {AUDIO_DEVICE_INDEX} --input_wav_path {INPUT_WAV_PATH} +``` \ No newline at end of file diff --git a/demo/python/pv_speaker_demo.py b/demo/python/pv_speaker_demo.py new file mode 100644 index 0000000..7871a52 --- /dev/null +++ b/demo/python/pv_speaker_demo.py @@ -0,0 +1,112 @@ +# +# Copyright 2024 Picovoice Inc. +# +# You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE" +# file accompanying this source. +# +# 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 wave +import array + +from pvspeaker import PvSpeaker + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument( + "--show_audio_devices", + help="List of audio devices currently available for use.", + action="store_true") + + parser.add_argument( + "--audio_device_index", + help="Index of input audio device.", + type=int, + default=-1) + + parser.add_argument( + "--input_wav_path", + help="Path to PCM WAV file to be played.", + default=None) + + args = parser.parse_args() + + if args.show_audio_devices: + devices = PvSpeaker.get_available_devices() + for i in range(len(devices)): + print("index: %d, device name: %s" % (i, devices[i])) + else: + device_index = args.audio_device_index + input_path = args.input_wav_path + + wavfile = None + speaker = None + + try: + if input_path is not None: + wavfile = wave.open(input_path, "rb") + + sample_rate = wavfile.getframerate() + bits_per_sample = wavfile.getsampwidth() * 8 + num_channels = wavfile.getnchannels() + num_samples = wavfile.getnframes() + + if bits_per_sample != 8 and bits_per_sample != 16 and bits_per_sample != 24 and bits_per_sample != 32: + print(f"Unsupported bits per sample: {bits_per_sample}") + wavfile.close() + exit() + + if num_channels != 1: + print("WAV file must have a single channel (MONO)") + wavfile.close() + exit() + + speaker = PvSpeaker( + sample_rate=sample_rate, + bits_per_sample=bits_per_sample, + device_index=device_index) + print("pvspeaker version: %s" % speaker.version) + print("Using device: %s" % speaker.selected_device) + + wav_bytes = wavfile.readframes(num_samples) + + pcm = None + if bits_per_sample == 8: + pcm = list(array.array('B', wav_bytes)) + elif bits_per_sample == 16: + pcm = list(array.array('h', wav_bytes)) + elif bits_per_sample == 24: + pcm = [] + for i in range(0, len(wav_bytes), 3): + sample = int.from_bytes(wav_bytes[i:i + 3], byteorder='little', signed=True) + pcm.append(sample) + elif bits_per_sample == 32: + pcm = list(array.array('i', wav_bytes)) + + speaker.start() + + print("Playing audio...") + speaker.write(pcm) + speaker.stop() + + print("Finished playing audio...") + wavfile.close() + + except KeyboardInterrupt: + print("Stopping...") + finally: + if speaker is not None: + speaker.delete() + if wavfile is not None: + wavfile.close() + + +if __name__ == "__main__": + main() diff --git a/demo/python/requirements.txt b/demo/python/requirements.txt new file mode 100644 index 0000000..3c5aeaa --- /dev/null +++ b/demo/python/requirements.txt @@ -0,0 +1 @@ +pvspeaker==1.0.0 \ No newline at end of file diff --git a/demo/python/setup.py b/demo/python/setup.py new file mode 100644 index 0000000..9e61692 --- /dev/null +++ b/demo/python/setup.py @@ -0,0 +1,51 @@ +import os +import shutil + +import setuptools + +os.system('git clean -dfx') + +package_folder = os.path.join(os.path.dirname(__file__), 'pvspeakerdemo') +os.mkdir(package_folder) + +shutil.copy(os.path.join(os.path.dirname(__file__), '../../LICENSE'), package_folder) + +shutil.copy( + os.path.join(os.path.dirname(__file__), 'pv_speaker_demo.py'), + os.path.join(package_folder, 'pv_speaker_demo.py')) + +with open(os.path.join(os.path.dirname(__file__), 'MANIFEST.in'), 'w') as f: + f.write('include pvspeakerdemo/LICENSE\n') + f.write('include pvspeakerdemo/pv_speaker_demo.py\n') + +with open(os.path.join(os.path.dirname(__file__), 'README.md'), 'r') as f: + long_description = f.read() + +setuptools.setup( + name="pvspeakerdemo", + version="1.0.0", + author="Picovoice", + author_email="hello@picovoice.ai", + description="Speaker library for Picovoice.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Picovoice/pvspeaker", + packages=["pvspeakerdemo"], + install_requires=["pvspeaker==1.0.0"], + include_package_data=True, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio :: Speech" + ], + entry_points=dict( + console_scripts=[ + 'pv_speaker_demo=pvspeakerdemo.pv_speaker_demo:main', + ], + ), + python_requires='>=3.8', + keywords="Audio Player", +) diff --git a/lib/linux/x86_64/libpv_speaker.so b/lib/linux/x86_64/libpv_speaker.so index c416626..500813e 100644 Binary files a/lib/linux/x86_64/libpv_speaker.so and b/lib/linux/x86_64/libpv_speaker.so differ diff --git a/lib/mac/arm64/libpv_speaker.dylib b/lib/mac/arm64/libpv_speaker.dylib index 798e4db..1d31380 100644 Binary files a/lib/mac/arm64/libpv_speaker.dylib and b/lib/mac/arm64/libpv_speaker.dylib differ diff --git a/lib/mac/x86_64/libpv_speaker.dylib b/lib/mac/x86_64/libpv_speaker.dylib index 0f605c3..0ed8d21 100644 Binary files a/lib/mac/x86_64/libpv_speaker.dylib and b/lib/mac/x86_64/libpv_speaker.dylib differ diff --git a/lib/raspberry-pi/cortex-a53-aarch64/libpv_speaker.so b/lib/raspberry-pi/cortex-a53-aarch64/libpv_speaker.so index 7bf5f2c..7fecb08 100644 Binary files a/lib/raspberry-pi/cortex-a53-aarch64/libpv_speaker.so and b/lib/raspberry-pi/cortex-a53-aarch64/libpv_speaker.so differ diff --git a/lib/raspberry-pi/cortex-a53/libpv_speaker.so b/lib/raspberry-pi/cortex-a53/libpv_speaker.so index 90e064c..6975727 100644 Binary files a/lib/raspberry-pi/cortex-a53/libpv_speaker.so and b/lib/raspberry-pi/cortex-a53/libpv_speaker.so differ diff --git a/lib/raspberry-pi/cortex-a72-aarch64/libpv_speaker.so b/lib/raspberry-pi/cortex-a72-aarch64/libpv_speaker.so index 839746d..57df94c 100644 Binary files a/lib/raspberry-pi/cortex-a72-aarch64/libpv_speaker.so and b/lib/raspberry-pi/cortex-a72-aarch64/libpv_speaker.so differ diff --git a/lib/raspberry-pi/cortex-a72/libpv_speaker.so b/lib/raspberry-pi/cortex-a72/libpv_speaker.so index eb98c5c..1163e4c 100644 Binary files a/lib/raspberry-pi/cortex-a72/libpv_speaker.so and b/lib/raspberry-pi/cortex-a72/libpv_speaker.so differ diff --git a/lib/raspberry-pi/cortex-a76-aarch64/libpv_speaker.so b/lib/raspberry-pi/cortex-a76-aarch64/libpv_speaker.so index d15de7a..fabbef3 100644 Binary files a/lib/raspberry-pi/cortex-a76-aarch64/libpv_speaker.so and b/lib/raspberry-pi/cortex-a76-aarch64/libpv_speaker.so differ diff --git a/lib/raspberry-pi/cortex-a76/libpv_speaker.so b/lib/raspberry-pi/cortex-a76/libpv_speaker.so index 55d83f3..63c0bf7 100644 Binary files a/lib/raspberry-pi/cortex-a76/libpv_speaker.so and b/lib/raspberry-pi/cortex-a76/libpv_speaker.so differ diff --git a/lib/windows/amd64/libpv_speaker.dll b/lib/windows/amd64/libpv_speaker.dll index 2fb3ee6..56a66b5 100644 Binary files a/lib/windows/amd64/libpv_speaker.dll and b/lib/windows/amd64/libpv_speaker.dll differ diff --git a/project/include/pv_speaker.h b/project/include/pv_speaker.h index 209b4bd..0b8af32 100644 --- a/project/include/pv_speaker.h +++ b/project/include/pv_speaker.h @@ -77,7 +77,7 @@ PV_API pv_speaker_status_t pv_speaker_init( PV_API void pv_speaker_delete(pv_speaker_t *object); /** -* Starts playing and buffering audio frames. +* Starts the audio output device. After starting, pcm frames can be sent to the audio output device via `pv_speaker_write`. * * @param object PvSpeaker object. * @returnStatus Status Code. Returns PV_SPEAKER_STATUS_INVALID_ARGUMENT, PV_SPEAKER_STATUS_DEVICE_NOT_INITIALIZED @@ -118,23 +118,23 @@ PV_API void pv_speaker_set_debug_logging( bool is_debug_logging_enabled); /** -* Gets whether the given `pv_speaker_t` instance is currently playing audio or not. +* Gets whether the given `pv_speaker_t` instance has started and available to receive pcm frames or not. * * @param object PvSpeaker object. -* @returns A boolean indicating whether PvSpeaker is currently playing audio or not. +* @returns A boolean indicating whether PvSpeaker has started and available to receive pcm frames or not. */ -PV_API bool pv_speaker_get_is_playing(pv_speaker_t *object); +PV_API bool pv_speaker_get_is_started(pv_speaker_t *object); /** * Gets the audio device that the given `pv_speaker_t` instance is using. * * @param object PvSpeaker object. -* @return A string containing the name of the current playing device. +* @return A string containing the name of the current audio output device. */ PV_API const char *pv_speaker_get_selected_device(pv_speaker_t *object); /** -* Gets the list of available audio devices that can be used for playing. +* Gets the list of available audio devices that can be used for playing audio. * Free the returned `device_list` array using `pv_speaker_free_device_list()`. * * @param[out] device_list_length The number of available audio devices. @@ -164,13 +164,6 @@ PV_API void pv_speaker_free_available_devices( */ PV_API const char *pv_speaker_status_to_string(pv_speaker_status_t status); -/** -* Gets the audio sample rate used by PvSpeaker. -* -* @return Sample rate. -*/ -PV_API int32_t pv_speaker_sample_rate(void); - /** * Gets the PvSpeaker version. * diff --git a/project/src/pv_speaker.c b/project/src/pv_speaker.c index d9608b1..3cb086c 100644 --- a/project/src/pv_speaker.c +++ b/project/src/pv_speaker.c @@ -323,7 +323,7 @@ PV_API void pv_speaker_set_debug_logging( object->is_debug_logging_enabled = is_debug_logging_enabled; } -PV_API bool pv_speaker_get_is_playing(pv_speaker_t *object) { +PV_API bool pv_speaker_get_is_started(pv_speaker_t *object) { if (!object) { return false; } @@ -419,6 +419,7 @@ PV_API const char *pv_speaker_status_to_string(pv_speaker_status_t status) { "OUT_OF_MEMORY", "INVALID_ARGUMENT", "INVALID_STATE", + "BUFFER_OVERFLOW", "BACKEND_ERROR", "DEVICE_INITIALIZED", "DEVICE_NOT_INITIALIZED", diff --git a/project/test/test_pv_speaker.c b/project/test/test_pv_speaker.c index a147869..915d06d 100644 --- a/project/test/test_pv_speaker.c +++ b/project/test/test_pv_speaker.c @@ -99,21 +99,21 @@ static void test_pv_speaker_start_stop(void) { pv_speaker_status_to_string(status), pv_speaker_status_to_string(PV_SPEAKER_STATUS_SUCCESS)); - printf("Check is_playing on NULL\n"); - bool is_playing = pv_speaker_get_is_playing(NULL); + printf("Check is_started on NULL\n"); + bool is_started = pv_speaker_get_is_started(NULL); check_condition( - is_playing == false, + is_started == false, __FUNCTION__, __LINE__, - "get_is_playing returned true on a NULL object."); + "get_is_started returned true on a NULL object."); - printf("Check is_playing on before start\n"); - is_playing = pv_speaker_get_is_playing(speaker); + printf("Check is_started on before start\n"); + is_started = pv_speaker_get_is_started(speaker); check_condition( - is_playing == false, + is_started == false, __FUNCTION__, __LINE__, - "get_is_playing returned true - expected false."); + "get_is_started returned true - expected false."); printf("Call start on null object\n"); status = pv_speaker_start(NULL); @@ -175,13 +175,13 @@ static void test_pv_speaker_start_stop(void) { pv_speaker_status_to_string(status), pv_speaker_status_to_string(PV_SPEAKER_STATUS_SUCCESS)); - printf("Check is_playing on started speaker\n"); - is_playing = pv_speaker_get_is_playing(speaker); + printf("Check is_started on started speaker\n"); + is_started = pv_speaker_get_is_started(speaker); check_condition( - is_playing == true, + is_started == true, __FUNCTION__, __LINE__, - "get_is_playing returned false - expected true."); + "get_is_started returned false - expected true."); printf("Call stop on null speaker object\n"); status = pv_speaker_stop(NULL); @@ -203,13 +203,13 @@ static void test_pv_speaker_start_stop(void) { pv_speaker_status_to_string(status), pv_speaker_status_to_string(PV_SPEAKER_STATUS_SUCCESS)); - printf("Check is_playing on stopped speaker\n"); - is_playing = pv_speaker_get_is_playing(speaker); + printf("Check is_started on stopped speaker\n"); + is_started = pv_speaker_get_is_started(speaker); check_condition( - is_playing == false, + is_started == false, __FUNCTION__, __LINE__, - "get_is_playing returned true - expected false."); + "get_is_started returned true - expected false."); pv_speaker_delete(speaker); }