From b965ecbcb569de5ce25283a9ad20828465a8953d Mon Sep 17 00:00:00 2001 From: jij-work Date: Tue, 17 Jun 2025 13:15:58 +0200 Subject: [PATCH] Pipewire audio Co-authored-by: Jonas Holmberg Co-authored-by: Stiv Abdullwahed Co-authored-by: Johan Olsson --- .github/workflows/audio-capture.yml | 33 +++ .github/workflows/audio-playback.yml | 33 +++ README.md | 7 + audio-capture/Dockerfile | 12 + audio-capture/README.md | 184 ++++++++++++ audio-capture/app/LICENSE | 202 ++++++++++++++ audio-capture/app/Makefile | 27 ++ audio-capture/app/audiocapture.c | 404 +++++++++++++++++++++++++++ audio-capture/app/manifest.json | 20 ++ audio-playback/Dockerfile | 12 + audio-playback/README.md | 143 ++++++++++ audio-playback/app/LICENSE | 202 ++++++++++++++ audio-playback/app/Makefile | 27 ++ audio-playback/app/audioplayback.c | 367 ++++++++++++++++++++++++ audio-playback/app/manifest.json | 20 ++ 15 files changed, 1693 insertions(+) create mode 100644 .github/workflows/audio-capture.yml create mode 100644 .github/workflows/audio-playback.yml create mode 100644 audio-capture/Dockerfile create mode 100644 audio-capture/README.md create mode 100644 audio-capture/app/LICENSE create mode 100644 audio-capture/app/Makefile create mode 100644 audio-capture/app/audiocapture.c create mode 100644 audio-capture/app/manifest.json create mode 100644 audio-playback/Dockerfile create mode 100644 audio-playback/README.md create mode 100644 audio-playback/app/LICENSE create mode 100644 audio-playback/app/Makefile create mode 100644 audio-playback/app/audioplayback.c create mode 100644 audio-playback/app/manifest.json diff --git a/.github/workflows/audio-capture.yml b/.github/workflows/audio-capture.yml new file mode 100644 index 00000000..3c105338 --- /dev/null +++ b/.github/workflows/audio-capture.yml @@ -0,0 +1,33 @@ +name: Build audio-capture application +on: + workflow_dispatch: + push: + paths: + - 'audio-capture/**' + - '!audio-capture/README.md' + - '.github/workflows/audio-capture.yml' +jobs: + test-app: + name: Test app + runs-on: ubuntu-latest + strategy: + matrix: + axis-os: ["12.3.56"] + arch: ["armv7hf", "aarch64"] + env: + EXREPO: acap-native-examples + EXNAME: audio-capture + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Build ${{ env.EXNAME }} application + env: + imagetag: ${{ env.EXREPO }}_${{ env.EXNAME }}:${{ matrix.arch }} + run: | + docker image rm -f $imagetag + cd $EXNAME + docker build --no-cache --tag $imagetag --build-arg ARCH=${{ matrix.arch }} . + docker cp $(docker create $imagetag):/opt/app ./build + cd .. + docker image rm -f $imagetag diff --git a/.github/workflows/audio-playback.yml b/.github/workflows/audio-playback.yml new file mode 100644 index 00000000..14f09be2 --- /dev/null +++ b/.github/workflows/audio-playback.yml @@ -0,0 +1,33 @@ +name: Build audio-playback application +on: + workflow_dispatch: + push: + paths: + - 'audio-playback/**' + - '!audio-playback/README.md' + - '.github/workflows/audio-playback.yml' +jobs: + test-app: + name: Test app + runs-on: ubuntu-latest + strategy: + matrix: + axis-os: ["12.3.56"] + arch: ["armv7hf", "aarch64"] + env: + EXREPO: acap-native-examples + EXNAME: audio-playback + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Build ${{ env.EXNAME }} application + env: + imagetag: ${{ env.EXREPO }}_${{ env.EXNAME }}:${{ matrix.arch }} + run: | + docker image rm -f $imagetag + cd $EXNAME + docker build --no-cache --tag $imagetag --build-arg ARCH=${{ matrix.arch }} . + docker cp $(docker create $imagetag):/opt/app ./build + cd .. + docker image rm -f $imagetag diff --git a/README.md b/README.md index e1ddcdf6..91b28a1e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ The examples are organized into logical groups to help you find the most relevan - [vdostream](./vdostream/) - An example in C that starts a vdo stream and then illustrates how to continuously capture frames from the vdo service, access the received buffer contents as well as the frame metadata. +### Audio + +- [audio-capture](./audio-capture/) + - Example in C that illustrate how to capture audio. +- [audio-playback](./audio-playback/) + - Example in C that illustrate how to play audio. + ### Machine learning #### Object Detection diff --git a/audio-capture/Dockerfile b/audio-capture/Dockerfile new file mode 100644 index 00000000..09b006f6 --- /dev/null +++ b/audio-capture/Dockerfile @@ -0,0 +1,12 @@ +ARG ARCH=armv7hf +ARG VERSION=12.3.0 +ARG UBUNTU_VERSION=24.04 +ARG REPO=axisecp +ARG SDK=acap-native-sdk + +FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} + +# Building the ACAP application +COPY ./app /opt/app/ +WORKDIR /opt/app +RUN . /opt/axis/acapsdk/environment-setup* && acap-build . diff --git a/audio-capture/README.md b/audio-capture/README.md new file mode 100644 index 00000000..b551b3e8 --- /dev/null +++ b/audio-capture/README.md @@ -0,0 +1,184 @@ +*Copyright (C) 2025, Axis Communications AB, Lund, Sweden. All Rights Reserved.* + +# A pipewire stream based ACAP application on an edge device + +This README file explains how to build an ACAP application that uses the [pipewire stream API](https://docs.pipewire.org/page_streams.html#ssec_consume). It is achieved by using the containerized API and toolchain images. + +Together with this README file, you should be able to find a directory called app. That directory contains the "audiocapture" application source code which can easily be compiled and run with the help of the tools and step by step below. + +This example illustrates how to continuously capture audio samples from the pipewire service, access the received buffer contents as well as the audio metadata. Peak level is calculated from the captured samples and logged in the Application log. + +The naming convention of the audio nodes in pipewire is described in the [Native SDK API](https://developer.axis.com/acap/api/native-sdk-api/#pipewire) + +## Getting started + +These instructions will guide you on how to execute the code. Below is the structure and scripts used in the example: + +```sh +audio-capture +├── app +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ └── audiocapture.c +├── Dockerfile +└── README.md +``` + +- **app/LICENSE** - Text file which lists all open source licensed source code distributed with the application. +- **app/Makefile** - Makefile containing the build and link instructions for building the ACAP application. +- **app/manifest.json** - Defines the application and its configuration. +- **app/audiocapture.c** - Application to capture audio from the pipewire service in C. +- **Dockerfile** - Docker file with the specified Axis toolchain and API container to build the example specified. +- **README.md** - Step by step instructions on how to run the example. + +### How to run the code + +Below is the step by step instructions on how to execute the program. So basically starting with the generation of the .eap file to running it on a device: + +#### Build the application + +Standing in your working directory run the following commands: + +> [!NOTE] +> +> Depending on the network your local build machine is connected to, you may need to add proxy +> settings for Docker. See +> [Proxy in build time](https://developer.axis.com/acap/develop/proxy/#proxy-in-build-time). + +```sh +docker build --tag . +``` + + is the name to tag the image with, e.g., audiocapture:1.0 + +Default architecture is **armv7hf**. To build for **aarch64** it's possible to +update the *ARCH* variable in the Dockerfile or to set it in the docker build +command via build argument: + +```sh +docker build --build-arg ARCH=aarch64 --tag . +``` + +Copy the result from the container image to a local directory build: + +```sh +docker cp $(docker create ):/opt/app ./build +``` + +The working dir now contains a build folder with the following files: + +```sh +audio-capture +├── app +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ └── audiocapture.c +├── build +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ ├── package.conf +│ ├── package.conf.orig +│ ├── param.conf +│ ├── audiocapture* +│ ├── Audio_capture_1_0_0_armv7hf.eap +│ ├── Audio_capture_1_0_0_LICENSE.txt +│ └── audiocapture.c +├── Dockerfile +└── README.md +``` + +- **build/manifest.json** - Defines the application and its configuration. +- **build/package.conf** - Defines the application and its configuration. +- **build/package.conf.orig** - Defines the application and its configuration, original file. +- **build/param.conf** - File containing application parameters. +- **build/audiocapture*** - Application executable binary file. +- **build/Audio_capture_1_0_0_armv7hf.eap** - Application package .eap file. +- **build/Audio_capture_1_0_0_LICENSE.txt** - Copy of LICENSE file. + +#### Install and start the application + +Browse to the application page of the Axis device: + +```sh +http:///index.html#apps +``` + +- Click on the tab `Apps` in the device GUI +- Enable `Allow unsigned apps` toggle +- Click `(+ Add app)` button to upload the application file +- Browse to the newly built ACAP application, depending on architecture: + - `Audio_capture_1_0_0_aarch64.eap` + - `Audio_capture_1_0_0_armv7hf.eap` +- Click `Install` +- Run the application by enabling the `Start` switch + +#### The expected output + +Application log can be found directly at: + +```sh +http:///axis-cgi/admin/systemlog.cgi?appname=audiocapture +``` + +#### Output + +```sh +----- Contents of SYSTEM_LOG for 'audiocapture' ----- + +audiocapture[1346447]: I audiocapture [audiocapture.c:368:main]: Starting. +audiocapture[1346447]: I audiocapture [audiocapture.c:235:registry_event_global]: Found Audio/Source node AudioDevice0Input0.Unprocessed with id 90. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0.Unprocessed changed unconnected -> connecting +audiocapture[1346447]: I audiocapture [audiocapture.c:235:registry_event_global]: Found Audio/Source node AudioDevice0Input0 with id 134. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0 changed unconnected -> connecting +audiocapture[1346447]: I audiocapture [audiocapture.c:235:registry_event_global]: Found Audio/Sink node AudioDevice0Output0 with id 146. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Output0 changed unconnected -> connecting +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0.Unprocessed changed connecting -> paused +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0 changed connecting -> paused +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Output0 changed connecting -> paused +audiocapture[1346447]: I audiocapture [audiocapture.c:98:on_param_changed]: Capturing from node AudioDevice0Input0.Unprocessed, 2 channel(s), rate 48000. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0.Unprocessed changed paused -> streaming +audiocapture[1346447]: I audiocapture [audiocapture.c:98:on_param_changed]: Capturing from node AudioDevice0Input0, 1 channel(s), rate 48000. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Input0 changed paused -> streaming +audiocapture[1346447]: I audiocapture [audiocapture.c:98:on_param_changed]: Capturing from node AudioDevice0Output0, 1 channel(s), rate 48000. +audiocapture[1346447]: D audiocapture [audiocapture.c:114:on_state_changed]: State for stream from AudioDevice0Output0 changed paused -> streaming +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -42.1 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -19.8 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -56.8 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -6.8 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -68.4 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -18.4 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -66.2 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -16.2 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -58.9 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -8.9 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -69.3 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -19.3 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -68.8 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -18.8 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -69.6 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -19.6 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -67.0 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -17.0 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -39.5 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak 10.5 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0.Unprocessed, channel 0, peak -62.7 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Input0, channel 0, peak -12.7 dBFS. +audiocapture[1346447]: I audiocapture [audiocapture.c:184:on_timeout]: Node AudioDevice0Output0, channel 0, peak -inf dBFS. +``` + +## License + +**[Apache License 2.0](../LICENSE)** diff --git a/audio-capture/app/LICENSE b/audio-capture/app/LICENSE new file mode 100644 index 00000000..b68ecd06 --- /dev/null +++ b/audio-capture/app/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://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 2025 Axis Communications AB + + 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 + + https://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/audio-capture/app/Makefile b/audio-capture/app/Makefile new file mode 100644 index 00000000..25ed9674 --- /dev/null +++ b/audio-capture/app/Makefile @@ -0,0 +1,27 @@ +PROG1 = audiocapture +OBJS1 = $(PROG1).c +PROGS = $(PROG1) + +PKGS = libpipewire-0.3 + +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) -lm + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror + +all: $(PROGS) + +$(PROG1): $(OBJS1) + $(CC) $(CFLAGS) $(LDFLAGS) $^ $(LDLIBS) -o $@ + +clean: + rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp* diff --git a/audio-capture/app/audiocapture.c b/audio-capture/app/audiocapture.c new file mode 100644 index 00000000..fc29ff9d --- /dev/null +++ b/audio-capture/app/audiocapture.c @@ -0,0 +1,404 @@ +/** + * Copyright (C) 2025, Axis Communications AB, Lund, Sweden + * + * 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 + * + * https://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. + */ + +/** + * - audiocapture - + * + * This application is a basic pipewire application using a pipewire mainloop to + * process audio data. + * + * The application starts an audio stream and calculates the peak values for + * all of all samples for all channels over a 5 second interval and prints them + * to the system log. The log messages can be followed with the command: + * + * journalctl -t audiocapture -f + * + * The application expects one argument on the command line which is the name of + * the pipewire node to capture audio from. + * + * Suppose that you have gone through the steps of installation. Then you can + * also run it on your device like this: + * + * /usr/local/packages/audiocapture/audiocapture \ + * AudioDevice0Input0.Unprocessed + * + * and then the output will go to stderr instead of the system log. + */ + +#include +#include +#include + +#include +#include + +PW_LOG_TOPIC_STATIC(topic, "audiocapture"); +#define PW_LOG_TOPIC_DEFAULT topic + +/* The state of the application, to be shared between functions. */ +struct impl { + struct pw_main_loop* loop; + struct pw_core* core; + struct pw_context* context; + struct pw_registry* registry; + struct spa_hook registry_listener; + struct spa_list streams; + struct spa_source* timer_source; +}; + +struct stream_data { + struct spa_list link; + struct pw_stream* stream; + struct spa_hook stream_listener; + uint32_t target_id; + char target_name[64]; + struct spa_audio_info info; + float peaks[SPA_AUDIO_MAX_CHANNELS]; +}; + +/** + * A callback function that will be called from the mainloop when stream + * parameters have been set. + */ +static void on_param_changed(void* data, uint32_t id, const struct spa_pod* param) { + struct stream_data* stream_data = data; + int res; + + if (param == NULL || id != SPA_PARAM_Format) { + return; + } + + res = spa_format_parse(param, &stream_data->info.media_type, &stream_data->info.media_subtype); + if (res < 0) { + pw_log_warn("Failed to parse format from %s: %s", stream_data->target_name, strerror(-res)); + return; + } + + if (stream_data->info.media_type != SPA_MEDIA_TYPE_audio || + stream_data->info.media_subtype != SPA_MEDIA_SUBTYPE_raw) { + pw_log_warn("Format from %s is not raw audio.", stream_data->target_name); + return; + } + + spa_format_audio_raw_parse(param, &stream_data->info.info.raw); + + pw_log_info("Capturing from node %s, %d channel(s), rate %d.", + stream_data->target_name, + stream_data->info.info.raw.channels, + stream_data->info.info.raw.rate); +} + +/** + * A callback function that will be called from the mainloop when stream + * state has been changed. + */ +static void on_state_changed(void* data, + enum pw_stream_state old, + enum pw_stream_state state, + const char* error) { + struct stream_data* stream_data = data; + + pw_log_debug("State for stream from %s changed %s -> %s", + stream_data->target_name, + pw_stream_state_as_string(old), + pw_stream_state_as_string(state)); + + if (state == PW_STREAM_STATE_ERROR) { + pw_log_warn("Stream from %s got error: %s", stream_data->target_name, error); + } +} + +/** + * A process callback function that will be called from the mainloop when there + * are new audio samples to process. + */ +static void on_process(void* data) { + struct stream_data* stream_data = data; + struct pw_buffer* b; + struct spa_buffer* buf; + unsigned int c; + + b = pw_stream_dequeue_buffer(stream_data->stream); + if (b == NULL) { + pw_log_warn("Out of buffers from %s: %m", stream_data->target_name); + return; + } + buf = b->buffer; + + for (c = 0; c < stream_data->info.info.raw.channels; c++) { + const float* samples; + uint32_t n_samples; + float min; + float max; + unsigned int i; + float peak; + + samples = buf->datas[c].data; + if (samples == NULL) { + pw_log_warn("No data in buffer from %s, channel %u.", stream_data->target_name, c); + goto out; + } + n_samples = buf->datas[c].chunk->size / sizeof(float); + + min = max = samples[0]; + for (i = 0; i < n_samples; i++) { + if (samples[i] > max) + max = samples[i]; + if (samples[i] < min) + min = samples[i]; + } + peak = fmaxf(fabs(min), max); + if (peak > stream_data->peaks[c]) { + stream_data->peaks[c] = peak; + } + } + +out: + pw_stream_queue_buffer(stream_data->stream, b); +} + +/** + * A timer callback function that will be called from the mainloop periodically. + */ +static void on_timeout(void* data, uint64_t expirations) { + (void)expirations; + struct impl* impl = data; + struct stream_data* stream_data; + unsigned int c; + + spa_list_for_each(stream_data, &impl->streams, link) { + for (c = 0; c < stream_data->info.info.raw.channels; c++) { + pw_log_info("Node %s, channel %u, peak %.1f dBFS.", + stream_data->target_name, + c, + 20 * log10f(stream_data->peaks[c])); + stream_data->peaks[c] = 0; + } + } +} + +/** + * A signal callback function that will be called from the mainloop. + */ +static void on_signal(void* data, int signal_num) { + struct impl* impl = data; + + pw_log_info("Got signal %d, quit main loop.", signal_num); + pw_main_loop_quit(impl->loop); +} + +static const struct pw_stream_events stream_events = {PW_VERSION_STREAM_EVENTS, + .param_changed = on_param_changed, + .process = on_process, + .state_changed = on_state_changed}; + +/** + * A callback function that will be called from the mainloop when there are new + * global objects, such as nodes, in pipewire. It will be called for all + * existing objects when the context is connected. + */ +static void registry_event_global(void* data, + uint32_t id, + uint32_t permissions, + const char* type, + uint32_t version, + const struct spa_dict* props) { + (void)permissions; + (void)version; + struct impl* impl = data; + + if (spa_streq(type, PW_TYPE_INTERFACE_Node)) { + const char* media_class; + const char* name; + struct pw_properties* stream_props; + uint8_t buf[1024]; + struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + const struct spa_pod* params[1]; + struct stream_data* stream_data; + int res; + + media_class = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS); + name = spa_dict_lookup(props, PW_KEY_NODE_NAME); + pw_log_info("Found %s node %s with id %u.", media_class, name, id); + + stream_props = pw_properties_new(PW_KEY_MEDIA_TYPE, + "Audio", + PW_KEY_MEDIA_CATEGORY, + "Capture", + PW_KEY_TARGET_OBJECT, + name, + NULL); + if (stream_props == NULL) { + pw_log_warn("Could not create properties for %s.", name); + return; + } + + /* Set PW_KEY_STREAM_CAPTURE_SINK to monitor an output node. */ + if (media_class != NULL && strcmp(media_class, "Audio/Sink") == 0) { + int res; + + res = pw_properties_set(stream_props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + if (res < 0) { + pw_log_warn("Could not set property for %s: %s", name, strerror(-res)); + return; + } + } + + /* Create a stream. */ + stream_data = calloc(1, sizeof(struct stream_data)); + stream_data->target_id = id; + strncpy(stream_data->target_name, name, sizeof(stream_data->target_name) - 1); + stream_data->stream = pw_stream_new(impl->core, "Audio capture", stream_props); + if (stream_data->stream == NULL) { + pw_log_warn("Could not create stream for %s: %m", name); + return; + } + pw_stream_add_listener(stream_data->stream, + &stream_data->stream_listener, + &stream_events, + stream_data); + + /* Leave rate and channels empty to accept the native device format. */ + params[0] = + spa_format_audio_raw_build(&builder, + SPA_PARAM_EnumFormat, + &SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_F32P)); + + /* Connect to pipewire. */ + res = pw_stream_connect(stream_data->stream, + PW_DIRECTION_INPUT, + PW_ID_ANY, + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS, + params, + SPA_N_ELEMENTS(params)); + if (res < 0) { + pw_log_error("Could not connect stream for %s: %s", name, strerror(-res)); + return; + } + + spa_list_append(&impl->streams, &stream_data->link); + } +} + +/** + * A callback function that will be called from the mainloop when a global + * objects, such as nodes, has been removed. + */ +static void registry_event_global_remove(void* data, uint32_t id) { + struct impl* impl = data; + struct stream_data* stream_data; + + pw_log_debug("Removed pipewire object with id %u.", id); + + spa_list_for_each(stream_data, &impl->streams, link) { + if (stream_data->target_id == id) { + pw_log_info("Destroy stream from %s.", stream_data->target_name); + spa_hook_remove(&stream_data->stream_listener); + pw_stream_destroy(stream_data->stream); + spa_list_remove(&stream_data->link); + break; + } + } +} + +static const struct pw_registry_events registry_events = { + PW_VERSION_REGISTRY_EVENTS, + .global = registry_event_global, + .global_remove = registry_event_global_remove, +}; + +/** + * Main function that starts a stream with target node as argument. + */ +int main(int argc, char* argv[]) { + struct impl impl = {0}; + struct pw_loop* loop; + struct timespec ts; + int res; + struct stream_data* stream_data; + + /* Enable all messages from the audiocapture category plus warning and error + * level messages from all other categorys to be sent to the system log. */ + setenv("PIPEWIRE_DEBUG", "audiocapture:5,2", 1); + + pw_init(&argc, &argv); + + /* Create a main loop. */ + impl.loop = pw_main_loop_new(NULL); + if (impl.loop == NULL) { + pw_log_error("Could not create main loop: %m"); + return EXIT_FAILURE; + } + loop = pw_main_loop_get_loop(impl.loop); + + pw_loop_add_signal(loop, SIGINT, on_signal, &impl); + pw_loop_add_signal(loop, SIGTERM, on_signal, &impl); + + impl.context = pw_context_new(loop, NULL, 0); + if (impl.context == NULL) { + pw_log_error("Cannot get pipewire context."); + return EXIT_FAILURE; + } + + impl.core = pw_context_connect(impl.context, NULL, 0); + if (impl.core == NULL) { + pw_log_error("Cannot connect to pipewire."); + return EXIT_FAILURE; + } + + impl.registry = pw_core_get_registry(impl.core, PW_VERSION_REGISTRY, 0); + pw_registry_add_listener(impl.registry, &impl.registry_listener, ®istry_events, &impl); + + spa_list_init(&impl.streams); + + pw_log_info("Starting."); + + /* Print peaks to the system log periodically every 5 seconds. */ + impl.timer_source = pw_loop_add_timer(loop, on_timeout, &impl); + if (impl.timer_source == NULL) { + pw_log_error("Could not create timer source."); + return EXIT_FAILURE; + } + ts.tv_sec = 5; + ts.tv_nsec = 0; + res = pw_loop_update_timer(loop, impl.timer_source, NULL, &ts, false); + if (res < 0) { + pw_log_error("Could not update timer source: %s", strerror(-res)); + return EXIT_FAILURE; + } + + /* Start processing. */ + pw_main_loop_run(impl.loop); + + pw_loop_destroy_source(loop, impl.timer_source); + spa_hook_remove(&impl.registry_listener); + pw_proxy_destroy((struct pw_proxy*)impl.registry); + spa_list_consume(stream_data, &impl.streams, link) { + pw_log_debug("Destroy stream with target node %s.", stream_data->target_name); + spa_hook_remove(&stream_data->stream_listener); + pw_stream_destroy(stream_data->stream); + spa_list_remove(&stream_data->link); + } + pw_core_disconnect(impl.core); + pw_context_destroy(impl.context); + + pw_main_loop_destroy(impl.loop); + pw_deinit(); + + pw_log_info("Terminating."); + + return EXIT_SUCCESS; +} diff --git a/audio-capture/app/manifest.json b/audio-capture/app/manifest.json new file mode 100644 index 00000000..d788b742 --- /dev/null +++ b/audio-capture/app/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "1.7.3", + "resources": { + "linux": { + "user": { + "groups": ["pipewire"] + } + } + }, + "acapPackageConf": { + "setup": { + "friendlyName": "Audio capture", + "appName": "audiocapture", + "vendor": "Axis Communications", + "embeddedSdkVersion": "3.0", + "runMode": "never", + "version": "1.0.0" + } + } +} diff --git a/audio-playback/Dockerfile b/audio-playback/Dockerfile new file mode 100644 index 00000000..09b006f6 --- /dev/null +++ b/audio-playback/Dockerfile @@ -0,0 +1,12 @@ +ARG ARCH=armv7hf +ARG VERSION=12.3.0 +ARG UBUNTU_VERSION=24.04 +ARG REPO=axisecp +ARG SDK=acap-native-sdk + +FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} + +# Building the ACAP application +COPY ./app /opt/app/ +WORKDIR /opt/app +RUN . /opt/axis/acapsdk/environment-setup* && acap-build . diff --git a/audio-playback/README.md b/audio-playback/README.md new file mode 100644 index 00000000..fb5a9200 --- /dev/null +++ b/audio-playback/README.md @@ -0,0 +1,143 @@ +*Copyright (C) 2025, Axis Communications AB, Lund, Sweden. All Rights Reserved.* + +# A pipewire stream based ACAP application on an edge device + +This README file explains how to build an ACAP application that uses the [pipewire stream API](https://docs.pipewire.org/page_streams.html#ssec_produce). It is achieved by using the containerized API and toolchain images. + +Together with this README file, you should be able to find a directory called app. That directory contains the "audioplayback" application source code which can easily be compiled and run with the help of the tools and step by step below. + +This example illustrates how to continuously play audio samples to the pipewire service by filling pipewire buffers with samples of a sine wave. + +The naming convention of the audio nodes in pipewire is described in the [Native SDK API](https://developer.axis.com/acap/api/native-sdk-api/#pipewire) + +## Getting started + +These instructions will guide you on how to execute the code. Below is the structure and scripts used in the example: + +```sh +audio-playback +├── app +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ └── audioplayback.c +├── Dockerfile +└── README.md +``` + +- **app/LICENSE** - Text file which lists all open source licensed source code distributed with the application. +- **app/Makefile** - Makefile containing the build and link instructions for building the ACAP application. +- **app/manifest.json** - Defines the application and its configuration. +- **app/audioplayback.c** - Application to play audio to the pipewire service in C. +- **Dockerfile** - Docker file with the specified Axis toolchain and API container to build the example specified. +- **README.md** - Step by step instructions on how to run the example. + +### How to run the code + +Below is the step by step instructions on how to execute the program. So basically starting with the generation of the .eap file to running it on a device: + +#### Build the application + +Standing in your working directory run the following commands: + +> [!NOTE] +> +> Depending on the network your local build machine is connected to, you may need to add proxy +> settings for Docker. See +> [Proxy in build time](https://developer.axis.com/acap/develop/proxy/#proxy-in-build-time). + +```sh +docker build --tag . +``` + + is the name to tag the image with, e.g., audioplayback:1.0 + +Default architecture is **armv7hf**. To build for **aarch64** it's possible to +update the *ARCH* variable in the Dockerfile or to set it in the docker build +command via build argument: + +```sh +docker build --build-arg ARCH=aarch64 --tag . +``` + +Copy the result from the container image to a local directory build: + +```sh +docker cp $(docker create ):/opt/app ./build +``` + +The working dir now contains a build folder with the following files: + +```sh +audio-playback +├── app +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ └── audioplayback.c +├── build +│ ├── LICENSE +│ ├── Makefile +│ ├── manifest.json +│ ├── package.conf +│ ├── package.conf.orig +│ ├── param.conf +│ ├── audioplayback* +│ ├── Audio_playback_1_0_0_armv7hf.eap +│ ├── Audio_playback_1_0_0_LICENSE.txt +│ └── audioplayback.c +├── Dockerfile +└── README.md +``` + +- **build/manifest.json** - Defines the application and its configuration. +- **build/package.conf** - Defines the application and its configuration. +- **build/package.conf.orig** - Defines the application and its configuration, original file. +- **build/param.conf** - File containing application parameters. +- **build/audioplayback*** - Application executable binary file. +- **build/Audio_playback_1_0_0_armv7hf.eap** - Application package .eap file. +- **build/Audio_playback_1_0_0_LICENSE.txt** - Copy of LICENSE file. + +#### Install and start the application + +Browse to the application page of the Axis device: + +```sh +http:///index.html#apps +``` + +- Click on the tab `Apps` in the device GUI +- Enable `Allow unsigned apps` toggle +- Click `(+ Add app)` button to upload the application file +- Browse to the newly built ACAP application, depending on architecture: + - `Audio_playback_1_0_0_aarch64.eap` + - `Audio_playback_1_0_0_armv7hf.eap` +- Click `Install` +- Run the application by enabling the `Start` switch + +#### The expected output + +Application log can be found directly at: + +```sh +http:///axis-cgi/admin/systemlog.cgi?appname=audioplayback +``` + +#### Output + +```sh +----- Contents of SYSTEM_LOG for 'audioplayback' ----- + +audioplayback[91929]: I audioplayback [audioplayback.c:333:main]: Starting. +audioplayback[91929]: D audioplayback [audioplayback.c:203:registry_event_global]: Ignore node AudioDevice0Input0.Unprocessed with id 70. +audioplayback[91929]: I audioplayback [audioplayback.c:201:registry_event_global]: Found node AudioDevice0Output0 with id 81. +audioplayback[91929]: D audioplayback [audioplayback.c:105:on_state_changed]: State for stream from AudioDevice0Output0 changed unconnected -> connecting +audioplayback[91929]: D audioplayback [audioplayback.c:203:registry_event_global]: Ignore node AudioDevice0Input0 with id 114. +audioplayback[91929]: D audioplayback [audioplayback.c:105:on_state_changed]: State for stream from AudioDevice0Output0 changed connecting -> paused +audioplayback[91929]: I audioplayback [audioplayback.c:90:on_param_changed]: Playing to node AudioDevice0Output0, rate 48000. +audioplayback[91929]: D audioplayback [audioplayback.c:105:on_state_changed]: State for stream from AudioDevice0Output0 changed paused -> streaming +``` + +## License + +**[Apache License 2.0](../LICENSE)** diff --git a/audio-playback/app/LICENSE b/audio-playback/app/LICENSE new file mode 100644 index 00000000..b68ecd06 --- /dev/null +++ b/audio-playback/app/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://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 2025 Axis Communications AB + + 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 + + https://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/audio-playback/app/Makefile b/audio-playback/app/Makefile new file mode 100644 index 00000000..09108699 --- /dev/null +++ b/audio-playback/app/Makefile @@ -0,0 +1,27 @@ +PROG1 = audioplayback +OBJS1 = $(PROG1).c +PROGS = $(PROG1) + +PKGS = libpipewire-0.3 + +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) -lm + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror + +all: $(PROGS) + +$(PROG1): $(OBJS1) + $(CC) $(CFLAGS) $(LDFLAGS) $^ $(LDLIBS) -o $@ + +clean: + rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp* diff --git a/audio-playback/app/audioplayback.c b/audio-playback/app/audioplayback.c new file mode 100644 index 00000000..41662691 --- /dev/null +++ b/audio-playback/app/audioplayback.c @@ -0,0 +1,367 @@ +/** + * Copyright (C) 2025, Axis Communications AB, Lund, Sweden + * + * 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 + * + * https://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. + */ + +/** + * - audioplayback - + * + * This application is a basic pipewire application using a pipewire mainloop to + * process audio data. + * + * The application starts an audio stream that plays a sine tone. The log + * messages can be followed with the command: + * + * journalctl -t audioplayback -f + * + * The application expects one argument on the command line which is the name of + * the pipewire node to play audio to. + * + * Suppose that you have gone through the steps of installation. Then you can + * also run it on your device like this: + * + * /usr/local/packages/audioplayback/audioplayback \ + * AudioDevice0Output0 + * + * and then the output will go to stderr instead of the system log. + */ + +#include +#include +#include +#include + +#include +#include + +#define FREQUENCY 440 +#define VOLUME 0.5f + +PW_LOG_TOPIC_STATIC(topic, "audioplayback"); +#define PW_LOG_TOPIC_DEFAULT topic + +/* The state of the application, to be shared between functions. */ +struct impl { + struct pw_main_loop* loop; + struct pw_core* core; + struct pw_context* context; + struct pw_registry* registry; + struct spa_hook registry_listener; + regex_t node_name_regex; + struct spa_list streams; +}; + +struct stream_data { + struct spa_list link; + struct pw_stream* stream; + struct spa_hook stream_listener; + uint32_t target_id; + char target_name[64]; + struct spa_audio_info info; + float angle; +}; + +/** + * A callback function that will be called from the mainloop when stream + * parameters have been set. + */ +static void on_param_changed(void* data, uint32_t id, const struct spa_pod* param) { + struct stream_data* stream_data = data; + int res; + + if (param == NULL || id != SPA_PARAM_Format) { + return; + } + + res = spa_format_parse(param, &stream_data->info.media_type, &stream_data->info.media_subtype); + if (res < 0) { + pw_log_warn("Failed to parse format from %s: %s", stream_data->target_name, strerror(-res)); + return; + } + + if (stream_data->info.media_type != SPA_MEDIA_TYPE_audio || + stream_data->info.media_subtype != SPA_MEDIA_SUBTYPE_raw) { + pw_log_warn("Format from %s is not raw audio.", stream_data->target_name); + return; + } + + spa_format_audio_raw_parse(param, &stream_data->info.info.raw); + + pw_log_info("Playing to node %s, rate %d.", + stream_data->target_name, + stream_data->info.info.raw.rate); +} + +/** + * A callback function that will be called from the mainloop when stream + * state has been changed. + */ +static void on_state_changed(void* data, + enum pw_stream_state old, + enum pw_stream_state state, + const char* error) { + struct stream_data* stream_data = data; + + pw_log_debug("State for stream from %s changed %s -> %s", + stream_data->target_name, + pw_stream_state_as_string(old), + pw_stream_state_as_string(state)); + + if (state == PW_STREAM_STATE_ERROR) { + pw_log_warn("Stream from %s got error: %s", stream_data->target_name, error); + } +} + +/** + * A process callback function that will be called from the mainloop when there + * is a new buffer to fill with audio data. + */ +static void on_process(void* data) { + struct stream_data* stream_data = data; + struct pw_buffer* b; + struct spa_buffer* buf; + float* samples; + uint32_t n_samples; + unsigned int i; + + b = pw_stream_dequeue_buffer(stream_data->stream); + if (b == NULL) { + pw_log_warn("Out of buffers: %m"); + return; + } + buf = b->buffer; + + samples = buf->datas[0].data; + if (samples == NULL) { + pw_log_warn("No data in buffer."); + goto out; + } + n_samples = buf->datas[0].maxsize / sizeof(float); + + /* Fill the buffer with a sine wave. Remember the angle until next call. */ + for (i = 0; i < n_samples; i++) { + samples[i] = sinf(stream_data->angle) * VOLUME; + + stream_data->angle += (float)(2 * M_PI * FREQUENCY) / stream_data->info.info.raw.rate; + if (stream_data->angle >= 2 * M_PI) { + stream_data->angle -= 2 * M_PI; + } + } + + /* Set buffer metadata. */ + buf->datas[0].chunk->offset = 0; + buf->datas[0].chunk->stride = sizeof(float); + buf->datas[0].chunk->size = n_samples * sizeof(float); + +out: + pw_stream_queue_buffer(stream_data->stream, b); +} + +/** + * A signal callback function that will be called from the mainloop. + */ +static void on_signal(void* data, int signal_num) { + struct impl* impl = data; + + pw_log_info("Got signal %d, quit main loop.", signal_num); + pw_main_loop_quit(impl->loop); +} + +static const struct pw_stream_events stream_events = {PW_VERSION_STREAM_EVENTS, + .param_changed = on_param_changed, + .process = on_process, + .state_changed = on_state_changed}; + +/** + * A callback function that will be called from the mainloop when there are new + * global objects, such as nodes, in pipewire. It will be called for all + * existing objects when the context is connected. + */ +static void registry_event_global(void* data, + uint32_t id, + uint32_t permissions, + const char* type, + uint32_t version, + const struct spa_dict* props) { + (void)permissions; + (void)version; + struct impl* impl = data; + + if (spa_streq(type, PW_TYPE_INTERFACE_Node)) { + const char* name; + struct pw_properties* stream_props; + uint8_t buf[1024]; + struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + const struct spa_pod* params[1]; + struct stream_data* stream_data; + int res; + + name = spa_dict_lookup(props, PW_KEY_NODE_NAME); + if (regexec(&impl->node_name_regex, name, 0, NULL, 0) == 0) { + pw_log_info("Found node %s with id %u.", name, id); + } else { + pw_log_debug("Ignore node %s with id %u.", name, id); + return; + } + + stream_props = pw_properties_new(PW_KEY_MEDIA_TYPE, + "Audio", + PW_KEY_MEDIA_CATEGORY, + "Playback", + PW_KEY_TARGET_OBJECT, + name, + NULL); + if (stream_props == NULL) { + pw_log_warn("Could not create properties for %s.", name); + return; + } + + /* Create a stream. */ + stream_data = calloc(1, sizeof(struct stream_data)); + stream_data->target_id = id; + strncpy(stream_data->target_name, name, sizeof(stream_data->target_name) - 1); + stream_data->stream = pw_stream_new(impl->core, "Audio playback", stream_props); + if (stream_data->stream == NULL) { + pw_log_warn("Could not create stream for %s: %m", name); + return; + } + pw_stream_add_listener(stream_data->stream, + &stream_data->stream_listener, + &stream_events, + stream_data); + + /* Leave rate empty to accept the native device rate. */ + params[0] = spa_format_audio_raw_build( + &builder, + SPA_PARAM_EnumFormat, + &SPA_AUDIO_INFO_RAW_INIT(.channels = 1, .format = SPA_AUDIO_FORMAT_F32P)); + + /* Connect to pipewire. */ + res = pw_stream_connect(stream_data->stream, + PW_DIRECTION_OUTPUT, + PW_ID_ANY, + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS, + params, + SPA_N_ELEMENTS(params)); + if (res < 0) { + pw_log_error("Could not connect stream for %s: %s", name, strerror(-res)); + return; + } + + spa_list_append(&impl->streams, &stream_data->link); + } +} + +/** + * A callback function that will be called from the mainloop when a global + * objects, such as nodes, has been removed. + */ +static void registry_event_global_remove(void* data, uint32_t id) { + struct impl* impl = data; + struct stream_data* stream_data; + + pw_log_debug("Removed pipewire object with id %u.", id); + + spa_list_for_each(stream_data, &impl->streams, link) { + if (stream_data->target_id == id) { + pw_log_info("Destroy stream from %s.", stream_data->target_name); + spa_hook_remove(&stream_data->stream_listener); + pw_stream_destroy(stream_data->stream); + spa_list_remove(&stream_data->link); + break; + } + } +} + +static const struct pw_registry_events registry_events = { + PW_VERSION_REGISTRY_EVENTS, + .global = registry_event_global, + .global_remove = registry_event_global_remove, +}; + +/** + * Main function that starts a stream with target node as argument. + */ +int main(int argc, char* argv[]) { + struct impl impl = {0}; + int res; + struct pw_loop* loop; + struct stream_data* stream_data; + + /* Compile a regex for node names to match. */ + res = + regcomp(&impl.node_name_regex, "^AudioDevice[0-9]+Output[0-9]+$", REG_EXTENDED | REG_NOSUB); + if (res != 0) { + pw_log_error("Cannot compile regex: %d", res); + return EXIT_FAILURE; + } + + /* Enable all messages from the audioplayback category plus warning and + * error level messages from all other categorys to be sent to the system + * log. */ + setenv("PIPEWIRE_DEBUG", "audioplayback:5,2", 1); + + pw_init(&argc, &argv); + + /* Create a main loop. */ + impl.loop = pw_main_loop_new(NULL); + if (impl.loop == NULL) { + pw_log_error("Could not create main loop: %m"); + return EXIT_FAILURE; + } + loop = pw_main_loop_get_loop(impl.loop); + + pw_loop_add_signal(loop, SIGINT, on_signal, &impl); + pw_loop_add_signal(loop, SIGTERM, on_signal, &impl); + + impl.context = pw_context_new(loop, NULL, 0); + if (impl.context == NULL) { + pw_log_error("Cannot get pipewire context."); + return EXIT_FAILURE; + } + + impl.core = pw_context_connect(impl.context, NULL, 0); + if (impl.core == NULL) { + pw_log_error("Cannot connect to pipewire."); + return EXIT_FAILURE; + } + + impl.registry = pw_core_get_registry(impl.core, PW_VERSION_REGISTRY, 0); + pw_registry_add_listener(impl.registry, &impl.registry_listener, ®istry_events, &impl); + + spa_list_init(&impl.streams); + + pw_log_info("Starting."); + + /* Start processing. */ + pw_main_loop_run(impl.loop); + + spa_hook_remove(&impl.registry_listener); + pw_proxy_destroy((struct pw_proxy*)impl.registry); + spa_list_consume(stream_data, &impl.streams, link) { + pw_log_debug("Destroy stream with target node %s.", stream_data->target_name); + spa_hook_remove(&stream_data->stream_listener); + pw_stream_destroy(stream_data->stream); + spa_list_remove(&stream_data->link); + } + pw_core_disconnect(impl.core); + pw_context_destroy(impl.context); + pw_main_loop_destroy(impl.loop); + pw_deinit(); + regfree(&impl.node_name_regex); + + pw_log_info("Terminating."); + + return EXIT_SUCCESS; +} diff --git a/audio-playback/app/manifest.json b/audio-playback/app/manifest.json new file mode 100644 index 00000000..5862b15f --- /dev/null +++ b/audio-playback/app/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "1.7.3", + "resources": { + "linux": { + "user": { + "groups": ["pipewire"] + } + } + }, + "acapPackageConf": { + "setup": { + "friendlyName": "Audio playback", + "appName": "audioplayback", + "vendor": "Axis Communications", + "embeddedSdkVersion": "3.0", + "runMode": "never", + "version": "1.0.0" + } + } +}