├── assets ├── app-capture.png └── device-capture.png ├── .gitignore ├── .editorconfig ├── data └── locale │ ├── pt-BR.ini │ ├── en-US.ini │ ├── de-DE.ini │ ├── fr-FR.ini │ └── es-ES.ini ├── CMakeLists.txt ├── src ├── linux-pipewire-audio.c ├── pipewire-audio.h ├── pipewire-audio-capture-device.c ├── pipewire-audio.c └── pipewire-audio-capture-app.c ├── README.md ├── .github └── workflows │ └── main.yml ├── cmake └── FindPipeWire.cmake ├── .clang-format └── LICENSE /assets/app-capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtpap/obs-pipewire-audio-capture/HEAD/assets/app-capture.png -------------------------------------------------------------------------------- /assets/device-capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtpap/obs-pipewire-audio-capture/HEAD/assets/device-capture.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | CMakeLists.txt.user 2 | CMakeCache.txt 3 | CMakeFiles 4 | CMakeScripts 5 | Testing 6 | Makefile 7 | cmake_install.cmake 8 | install_manifest.txt 9 | compile_commands.json 10 | CTestTestfile.cmake 11 | _deps 12 | /build 13 | 14 | .vscode -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | charset = utf-8 7 | indent_style = tab 8 | indent_size = 8 9 | 10 | [CMakeLists.txt] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [**/CMakeLists.txt] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [cmake/**/*.cmake] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /data/locale/pt-BR.ini: -------------------------------------------------------------------------------- 1 | PipeWireAudioCaptureInput="Captura de entrada de áudio (PipeWire)" 2 | PipeWireAudioCaptureOutput="Captura de saída de áudio (PipeWire)" 3 | PipeWireAudioCaptureApplication="Captura de áudio de aplicativo (PipeWire)" 4 | MatchPriority="Prioridade da Correspondência" 5 | MatchBinaryFirst="Corresponder ao nome do binário, se falhar, ao nome do app" 6 | MatchAppNameFirst="Corresponder pelo nome do app, se falhar, ao nome do binário" 7 | Device="Dispositivo" 8 | Default="Padrão" 9 | Application="Aplicativo" 10 | ExceptApp="Capturar todos os apps, exceto o selecionado" 11 | -------------------------------------------------------------------------------- /data/locale/en-US.ini: -------------------------------------------------------------------------------- 1 | PipeWireAudioCaptureInput="Audio Input Capture (PipeWire)" 2 | PipeWireAudioCaptureOutput="Audio Output Capture (PipeWire)" 3 | PipeWireAudioCaptureApplication="Application Audio Capture (PipeWire)" 4 | AppCaptureMode="Capture Mode" 5 | SingleApp="Single application" 6 | MultipleApps="Multiple applications" 7 | MatchPriority="Match Priority" 8 | MatchBinaryFirst="Match by executable name, fallback to app name" 9 | MatchAppNameFirst="Match by app name, fallback to executable name" 10 | Device="Device" 11 | Default="Default" 12 | Application="Application" 13 | Applications="Applications" 14 | ExceptApp="Capture all apps except selected" 15 | SelectedApps="Selected Apps" 16 | AddToSelected="Add selection" 17 | -------------------------------------------------------------------------------- /data/locale/de-DE.ini: -------------------------------------------------------------------------------- 1 | PipeWireAudioCaptureInput="Audioeingabeaufnahme (PipeWire)" 2 | PipeWireAudioCaptureOutput="Audioausgabeaufnahme (PipeWire)" 3 | PipeWireAudioCaptureApplication="Anwendungsaudioaufnahme (PipeWire)" 4 | AppCaptureMode="Aufnahmemodus" 5 | SingleApp="Einzelne Anwendung" 6 | MultipleApps="Mehrere Anwendungen" 7 | MatchPriority="Übereinstimmungspriorität" 8 | MatchBinaryFirst="Nach Ausführungsdatei abgleichen, dann Anwendungsname" 9 | MatchAppNameFirst="Nach Anwendungsname abgleichen, dann Ausführungsdatei" 10 | Device="Gerät" 11 | Default="Standard" 12 | Application="Anwendung" 13 | Applications="Anwendungen" 14 | ExceptApp="Alle Anwendungen außer den ausgewählten aufnehmen" 15 | SelectedApps="Ausgewählte Anwendungen" 16 | AddToSelected="Zur Auswahl hinzufügen" 17 | -------------------------------------------------------------------------------- /data/locale/fr-FR.ini: -------------------------------------------------------------------------------- 1 | PipeWireAudioCaptureInput="Capture de l'entrée audio (PipeWire)" 2 | PipeWireAudioCaptureOutput="Capture de la sortie audio (PipeWire)" 3 | PipeWireAudioCaptureApplication="Capture audio de l'application (PipeWire)" 4 | AppCaptureMode="Mode de capture" 5 | SingleApp="Application unique" 6 | MultipleApps="Applications multiples" 7 | MatchPriority="Priorité de correspondance" 8 | MatchBinaryFirst="Correspondance par nom d'executable, repli par nom d'application" 9 | MatchAppNameFirst="Correspondance par nom d'application, repli par nom d'executable" 10 | Device="Appareil" 11 | Default="Par défaut" 12 | Application="Application" 13 | Applications="Applications" 14 | ExceptApp="Capturer toutes les applications sauf celles sélectionnées" 15 | SelectedApps="Applications sélectionnées" 16 | AddToSelected="Ajouter à la sélection" 17 | -------------------------------------------------------------------------------- /data/locale/es-ES.ini: -------------------------------------------------------------------------------- 1 | PipeWireAudioCaptureInput="Captura de entrada de audio (PipeWire)" 2 | PipeWireAudioCaptureOutput="Captura de salida de audio (PipeWire)" 3 | PipeWireAudioCaptureApplication="Captura de audio de la aplicación (PipeWire)" 4 | AppCaptureMode="Modo de captura" 5 | SingleApp="Aplicación única" 6 | MultipleApps="Múltiples aplicaciones" 7 | MatchPriority="Prioridad de coincidencia" 8 | MatchBinaryFirst="Coincidir por nombre del ejecutable, luego por nombre de la aplicación" 9 | MatchAppNameFirst="Coincidir por nombre de la aplicación, luego por nombre del ejecutable" 10 | Device="Dispositivo" 11 | Default="Por defecto" 12 | Application="Aplicación" 13 | Applications="Aplicaciones" 14 | ExceptApp="Capturar todas las aplicaciones excepto las seleccionadas" 15 | SelectedApps="Aplicaciones seleccionadas" 16 | AddToSelected="Agregar a la selección" 17 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project(linux-pipewire-audio) 3 | 4 | include(GNUInstallDirs) 5 | 6 | set(linux-pipewire-audio_SOURCES 7 | src/linux-pipewire-audio.c 8 | src/pipewire-audio.h 9 | src/pipewire-audio.c 10 | src/pipewire-audio-capture-device.c 11 | src/pipewire-audio-capture-app.c 12 | ) 13 | 14 | add_library(linux-pipewire-audio MODULE ${linux-pipewire-audio_SOURCES}) 15 | 16 | list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake") 17 | 18 | find_package(libobs REQUIRED) 19 | find_package(PipeWire REQUIRED) 20 | 21 | set(linux-pipewire-audio_INCLUDES 22 | ${PIPEWIRE_INCLUDE_DIRS} 23 | ${SPA_INCLUDE_DIRS} 24 | ) 25 | 26 | add_definitions( 27 | ${PIPEWIRE_DEFINITIONS} 28 | ) 29 | 30 | set(linux-pipewire-audio_LIBRARIES 31 | OBS::libobs 32 | ${PIPEWIRE_LIBRARIES} 33 | ) 34 | 35 | target_link_libraries(linux-pipewire-audio ${linux-pipewire-audio_LIBRARIES}) 36 | target_compile_options(linux-pipewire-audio PRIVATE -Wall) 37 | 38 | include_directories(SYSTEM 39 | ${linux-pipewire-audio_INCLUDES} 40 | ) 41 | 42 | set_target_properties(linux-pipewire-audio PROPERTIES PREFIX "") 43 | 44 | install(TARGETS linux-pipewire-audio LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/obs-plugins) 45 | install(DIRECTORY data/locale DESTINATION ${CMAKE_INSTALL_DATADIR}/obs/obs-plugins/linux-pipewire-audio) 46 | -------------------------------------------------------------------------------- /src/linux-pipewire-audio.c: -------------------------------------------------------------------------------- 1 | /* linux-pipewire-audio.c 2 | * 3 | * Copyright 2022-2025 Dimitris Papaioannou 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 2 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-2.0-or-later 19 | */ 20 | 21 | #include 22 | 23 | #include 24 | 25 | #include "pipewire-audio.h" 26 | 27 | OBS_DECLARE_MODULE() 28 | OBS_MODULE_USE_DEFAULT_LOCALE("linux-pipewire-audio", "en-US") 29 | MODULE_EXPORT const char *obs_module_description(void) 30 | { 31 | return "PipeWire input, output and application audio capture"; 32 | } 33 | 34 | bool obs_module_load(void) 35 | { 36 | pw_init(NULL, NULL); 37 | 38 | pipewire_audio_capture_load(); 39 | pipewire_audio_capture_app_load(); 40 | return true; 41 | } 42 | 43 | void obs_module_unload(void) 44 | { 45 | #if PW_CHECK_VERSION(0, 3, 49) 46 | pw_deinit(); 47 | #endif 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audio device and application capture for OBS Studio using PipeWire 2 | 3 | This plugin adds 3 sources for capturing audio outputs, inputs and applications using [PipeWire](https://pipewire.org) 4 | ![Device capture properties](assets/device-capture.png) 5 | ![App capture properties](assets/app-capture.png) 6 | 7 | ## Usage 8 | ### Requirements 9 | - OBS Studio 28.0 or later 10 | - [WirePlumber](https://pipewire.pages.freedesktop.org/wireplumber/) 11 | 12 | PipeWire 0.3.62 or later is highly recommended ([#17](https://github.com/dimtpap/obs-pipewire-audio-capture/issues/17), [PipeWire#2874](https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/2874)) 13 | 14 | For the plugin to be able to capture applications, PipeWire should be set up to handle audio on your system. 15 | For most applications, the [`pipewire-pulse`](https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/FAQ#should-i-uninstall-everything-pulseaudio) 16 | compatibility layer should be enough, but there are also `pipewire-jack` and `pipewire-alsa`. 17 | If applications aren't showing up in the plugin, your system may be missing one of those components. 18 | See the [PipeWire wiki](https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/home) for more info. 19 | 20 | ### Installation 21 | 1. Get the `linux-pipewire-audio-(version).tar.gz` archive from the [latest release](https://github.com/dimtpap/obs-pipewire-audio-capture/releases/latest) 22 | 2. In OBS Studio, go to **File**, then click **Show Settings Folder** 23 | 3. In the folder that opens, create a folder called `plugins` if it doesn't already exist 24 | 4. Extract the archive you downloaded in the `plugins` folder 25 | 5. Restart OBS Studio 26 | 6. If you're using the Flatpak and the sources aren't working, run `flatpak override --filesystem=xdg-run/pipewire-0 com.obsproject.Studio` and restart OBS Studio 27 | 28 | Your files should look like this 29 | ``` 30 | .../obs-studio/plugins 31 | ├── linux-pipewire-audio 32 | │ ├── bin 33 | │ │ └── 64bit 34 | │ │ └── linux-pipewire-audio.so 35 | │ └── data 36 | │ └── locale 37 | │ ... 38 | ``` 39 | > [!IMPORTANT] 40 | > ## Flatpak users note 41 | > ***THIS INSTALLATION METHOD IS UNSUPPORTED BY THE OBS STUDIO TEAM AND CAN BREAK AT ANY TIME*** 42 | > This plugin relies on a Flatpak permission that OBS Studio could remove at any time, so it can't be on Flathub. 43 | > If after updating OBS Studio the plugin stops working, check the latest release for a new version, or build the plugin yourself 44 | > against the latest OBS Studio. 45 | > 46 | > Note that native OBS Studio packages do not have this problem. 47 | 48 | ### Building (for development) 49 | Ensure you have CMake, PipeWire and OBS Studio/libobs development packages, then in the repo's root: 50 | ```sh 51 | cmake -B build -DCMAKE_INSTALL_PREFIX="/usr" -DCMAKE_BUILD_TYPE=RelWithDebInfo 52 | cmake --build build 53 | # To install it system-wide: 54 | cmake --install build 55 | ``` 56 | ## Inclusion in upstream OBS Studio 57 | This plugin is currently in the process of being worked on to merge into upstream OBS Studio. See https://github.com/obsproject/obs-studio/pull/6207 58 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | paths: 6 | - .github/workflows/main.yml 7 | - 'src/**' 8 | - 'cmake/**' 9 | - 'CMakeLists.txt' 10 | tags: 11 | - '*' 12 | branches: 13 | - '**' 14 | 15 | jobs: 16 | build-plugin: 17 | strategy: 18 | matrix: 19 | obs-version: ['28.0.0', '30.2.0'] 20 | name: 'Build Plugin' 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Restore OBS from cache 24 | uses: actions/cache@v4 25 | id: cache-obs 26 | with: 27 | path: ${{ github.workspace }}/obs/ 28 | key: ${{ matrix.obs-version }} 29 | - name: Checkout OBS 30 | if: steps.cache-obs.outputs.cache-hit != 'true' 31 | uses: actions/checkout@v4 32 | with: 33 | repository: 'obsproject/obs-studio' 34 | path: 'obs-src' 35 | ref: ${{ matrix.obs-version }} 36 | submodules: 'recursive' 37 | - name: 'Install system dependencies' 38 | run: | 39 | sudo apt update 40 | sudo apt install cmake ninja-build pkg-config clang clang-format build-essential curl ccache git zsh\ 41 | libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev libswresample-dev libswscale-dev\ 42 | libcurl4-openssl-dev\ 43 | libxcb1-dev libx11-xcb-dev\ 44 | libgl1-mesa-dev\ 45 | libglvnd-dev\ 46 | libgles2-mesa-dev\ 47 | libpipewire-0.3-dev\ 48 | uuid-dev\ 49 | uthash-dev libjansson-dev 50 | - name: 'Configure OBS' 51 | if: steps.cache-obs.outputs.cache-hit != 'true' 52 | run: cmake -B obs-src/build -S obs-src -DOBS_CMAKE_VERSION=3 -DENABLE_BROWSER=OFF -DENABLE_UI=OFF -DENABLE_SCRIPTING=OFF -DENABLE_PULSEAUDIO=OFF -DENABLE_WAYLAND=OFF -DENABLE_PLUGINS=OFF 53 | - name: 'Build OBS' 54 | if: steps.cache-obs.outputs.cache-hit != 'true' 55 | run: cmake --build obs-src/build -j4 56 | - name: 'Install OBS' 57 | if: steps.cache-obs.outputs.cache-hit != 'true' 58 | run: cmake --install obs-src/build --prefix obs 59 | - name: 'Checkout' 60 | uses: actions/checkout@v4 61 | with: 62 | path: 'plugin' 63 | - name: 'Configure' 64 | run: cmake -B ./plugin/build -S ./plugin -DCMAKE_BUILD_TYPE=RelWithDebInfo -Dlibobs_DIR="$GITHUB_WORKSPACE/obs/lib/cmake/libobs/" 65 | - name: 'Build' 66 | run: cmake --build ./plugin/build -j4 67 | - name: 'Package' 68 | run: | 69 | mkdir -p ./linux-pipewire-audio/bin/64bit 70 | cp ./plugin/build/linux-pipewire-audio.so ./linux-pipewire-audio/bin/64bit/linux-pipewire-audio.so 71 | cp -r ./plugin/data/ ./linux-pipewire-audio/data/ 72 | tar -zcvf linux-pipewire-audio-$OBS_VERSION.tar.gz linux-pipewire-audio 73 | env: 74 | OBS_VERSION: ${{ matrix.obs-version }} 75 | - name: 'Upload' 76 | uses: actions/upload-artifact@v4 77 | with: 78 | path: linux-pipewire-audio-${{ matrix.obs-version }}.tar.gz 79 | name: linux-pipewire-audio-${{ matrix.obs-version }} 80 | -------------------------------------------------------------------------------- /cmake/FindPipeWire.cmake: -------------------------------------------------------------------------------- 1 | # .rst: FindPipeWire 2 | # ------- 3 | # 4 | # Try to find PipeWire on a Unix system. 5 | # 6 | # This will define the following variables: 7 | # 8 | # ``PIPEWIRE_FOUND`` True if (the requested version of) PipeWire is available 9 | # ``PIPEWIRE_VERSION`` The version of PipeWire ``PIPEWIRE_LIBRARIES`` This can 10 | # be passed to target_link_libraries() instead of the ``PipeWire::PipeWire`` 11 | # target ``PIPEWIRE_INCLUDE_DIRS`` This should be passed to 12 | # target_include_directories() if the target is not used for linking 13 | # ``PIPEWIRE_COMPILE_FLAGS`` This should be passed to target_compile_options() 14 | # if the target is not used for linking 15 | # 16 | # If ``PIPEWIRE_FOUND`` is TRUE, it will also define the following imported 17 | # target: 18 | # 19 | # ``PipeWire::PipeWire`` The PipeWire library 20 | # 21 | # In general we recommend using the imported target, as it is easier to use. 22 | # Bear in mind, however, that if the target is in the link interface of an 23 | # exported library, it must be made available by the package config file. 24 | 25 | # ============================================================================= 26 | # Copyright 2014 Alex Merry Copyright 2014 Martin Gräßlin 27 | # Copyright 2018-2020 Jan Grulich 28 | # 29 | # Redistribution and use in source and binary forms, with or without 30 | # modification, are permitted provided that the following conditions are met: 31 | # 32 | # 1. Redistributions of source code must retain the copyright notice, this list 33 | # of conditions and the following disclaimer. 34 | # 2. Redistributions in binary form must reproduce the copyright notice, this 35 | # list of conditions and the following disclaimer in the documentation and/or 36 | # other materials provided with the distribution. 37 | # 3. The name of the author may not be used to endorse or promote products 38 | # derived from this software without specific prior written permission. 39 | # 40 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED 41 | # WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 42 | # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 43 | # EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 44 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 45 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 46 | # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 47 | # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 48 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 49 | # POSSIBILITY OF SUCH DAMAGE. 50 | # ============================================================================= 51 | 52 | # Use pkg-config to get the directories and then use these values in the 53 | # FIND_PATH() and FIND_LIBRARY() calls 54 | find_package(PkgConfig QUIET) 55 | 56 | pkg_search_module(PKG_PIPEWIRE QUIET libpipewire-0.3) 57 | pkg_search_module(PKG_SPA QUIET libspa-0.2) 58 | 59 | set(PIPEWIRE_COMPILE_FLAGS "${PKG_PIPEWIRE_CFLAGS}" "${PKG_SPA_CFLAGS}") 60 | set(PIPEWIRE_VERSION "${PKG_PIPEWIRE_VERSION}") 61 | 62 | find_path( 63 | PIPEWIRE_INCLUDE_DIRS 64 | NAMES pipewire/pipewire.h 65 | HINTS ${PKG_PIPEWIRE_INCLUDE_DIRS} ${PKG_PIPEWIRE_INCLUDE_DIRS}/pipewire-0.3) 66 | 67 | find_path( 68 | SPA_INCLUDE_DIRS 69 | NAMES spa/param/props.h 70 | HINTS ${PKG_SPA_INCLUDE_DIRS} ${PKG_SPA_INCLUDE_DIRS}/spa-0.2) 71 | 72 | find_library( 73 | PIPEWIRE_LIBRARIES 74 | NAMES pipewire-0.3 75 | HINTS ${PKG_PIPEWIRE_LIBRARY_DIRS}) 76 | 77 | include(FindPackageHandleStandardArgs) 78 | find_package_handle_standard_args( 79 | PipeWire 80 | FOUND_VAR PIPEWIRE_FOUND 81 | REQUIRED_VARS PIPEWIRE_LIBRARIES PIPEWIRE_INCLUDE_DIRS SPA_INCLUDE_DIRS 82 | VERSION_VAR PIPEWIRE_VERSION) 83 | 84 | if(PIPEWIRE_FOUND AND NOT TARGET PipeWire::PipeWire) 85 | add_library(PipeWire::PipeWire UNKNOWN IMPORTED) 86 | set_target_properties( 87 | PipeWire::PipeWire 88 | PROPERTIES IMPORTED_LOCATION "${PIPEWIRE_LIBRARIES}" 89 | INTERFACE_COMPILE_OPTIONS "${PIPEWIRE_COMPILE_FLAGS}" 90 | INTERFACE_INCLUDE_DIRECTORIES 91 | "${PIPEWIRE_INCLUDE_DIRS};${SPA_INCLUDE_DIRS}") 92 | endif() 93 | 94 | mark_as_advanced(PIPEWIRE_LIBRARIES PIPEWIRE_INCLUDE_DIRS) 95 | 96 | include(FeatureSummary) 97 | set_package_properties( 98 | PipeWire PROPERTIES 99 | URL "https://www.pipewire.org" 100 | DESCRIPTION "PipeWire - multimedia processing") 101 | -------------------------------------------------------------------------------- /src/pipewire-audio.h: -------------------------------------------------------------------------------- 1 | /* pipewire-audio.h 2 | * 3 | * Copyright 2022-2025 Dimitris Papaioannou 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 2 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-2.0-or-later 19 | */ 20 | 21 | /* Stuff used by the PipeWire audio capture sources */ 22 | 23 | #pragma once 24 | 25 | #include 26 | 27 | #include 28 | #include 29 | #include 30 | 31 | /* PipeWire Stream wrapper */ 32 | 33 | /** 34 | * Audio metadata 35 | */ 36 | struct obs_pw_audio_info { 37 | uint32_t sample_rate; 38 | enum audio_format format; 39 | enum speaker_layout speakers; 40 | }; 41 | 42 | /** 43 | * PipeWire stream wrapper that outputs to an OBS source 44 | */ 45 | struct obs_pw_audio_stream { 46 | struct pw_stream *stream; 47 | struct spa_hook stream_listener; 48 | struct obs_pw_audio_info info; 49 | struct spa_io_position *pos; 50 | 51 | obs_source_t *output; 52 | }; 53 | 54 | /** 55 | * Connect a stream with the default params 56 | * @return 0 on success, < 0 on error 57 | */ 58 | int obs_pw_audio_stream_connect(struct obs_pw_audio_stream *s, uint32_t target_id, uint32_t target_serial, 59 | uint32_t channels); 60 | /* ------------------------------------------------- */ 61 | 62 | /** 63 | * Common PipeWire components 64 | */ 65 | struct obs_pw_audio_instance { 66 | struct pw_thread_loop *thread_loop; 67 | struct pw_context *context; 68 | 69 | struct pw_core *core; 70 | struct spa_hook core_listener; 71 | int seq; 72 | 73 | struct pw_registry *registry; 74 | struct spa_hook registry_listener; 75 | 76 | struct obs_pw_audio_stream audio; 77 | }; 78 | 79 | /** 80 | * Initialize a PipeWire instance 81 | * @warning The thread loop is left locked 82 | * @return true on success, false on error 83 | */ 84 | bool obs_pw_audio_instance_init(struct obs_pw_audio_instance *pw, const struct pw_registry_events *registry_events, 85 | void *registry_cb_data, bool stream_capture_sink, bool stream_want_driver, 86 | obs_source_t *stream_output); 87 | 88 | /** 89 | * Destroy a PipeWire instance 90 | * @warning Call with the thread loop locked 91 | */ 92 | void obs_pw_audio_instance_destroy(struct obs_pw_audio_instance *pw); 93 | 94 | /** 95 | * Trigger a PipeWire core sync 96 | */ 97 | void obs_pw_audio_instance_sync(struct obs_pw_audio_instance *pw); 98 | /* ------------------------------------------------- */ 99 | 100 | /** 101 | * PipeWire metadata 102 | */ 103 | struct obs_pw_audio_default_node_metadata { 104 | struct pw_proxy *proxy; 105 | struct spa_hook proxy_listener; 106 | struct spa_hook metadata_listener; 107 | 108 | bool wants_sink; 109 | 110 | void (*default_node_callback)(void *data, const char *name); 111 | void *data; 112 | }; 113 | 114 | /** 115 | * Add listeners to the metadata 116 | * @return true on success, false on error 117 | */ 118 | bool obs_pw_audio_default_node_metadata_listen(struct obs_pw_audio_default_node_metadata *metadata, 119 | struct obs_pw_audio_instance *pw, uint32_t global_id, bool wants_sink, 120 | void (*default_node_callback)(void *data, const char *name), void *data); 121 | /* ------------------------------------------------- */ 122 | 123 | /* Helpers for storing remote PipeWire objects */ 124 | 125 | /** 126 | * Wrapper over a PipeWire proxy that's a member of a spa_list. 127 | * Automatically handles adding and removing itself from the list. 128 | */ 129 | struct obs_pw_audio_proxied_object; 130 | 131 | /** 132 | * Get the user data of a proxied object 133 | */ 134 | void *obs_pw_audio_proxied_object_get_user_data(struct obs_pw_audio_proxied_object *obj); 135 | 136 | /** 137 | * Convenience wrapper over spa_lists that holds proxied objects 138 | */ 139 | struct obs_pw_audio_proxy_list { 140 | struct spa_list list; 141 | void (*bound_callback)(void *data, uint32_t global_id); 142 | void (*destroy_callback)(void *data); 143 | }; 144 | 145 | void obs_pw_audio_proxy_list_init(struct obs_pw_audio_proxy_list *list, 146 | void (*bound_callback)(void *data, uint32_t global_id), 147 | void (*destroy_callback)(void *data)); 148 | 149 | void obs_pw_audio_proxy_list_append(struct obs_pw_audio_proxy_list *list, struct pw_proxy *proxy); 150 | 151 | /** 152 | * Destroy all stored proxies. 153 | */ 154 | void obs_pw_audio_proxy_list_clear(struct obs_pw_audio_proxy_list *list); 155 | 156 | /** 157 | * Iterator over all user data of the proxies in the list 158 | */ 159 | struct obs_pw_audio_proxy_list_iter { 160 | struct obs_pw_audio_proxy_list *proxy_list; 161 | struct obs_pw_audio_proxied_object *current; 162 | }; 163 | 164 | void obs_pw_audio_proxy_list_iter_init(struct obs_pw_audio_proxy_list_iter *iter, struct obs_pw_audio_proxy_list *list); 165 | 166 | /** 167 | * @return true when there are more items to process, false otherwise 168 | */ 169 | bool obs_pw_audio_proxy_list_iter_next(struct obs_pw_audio_proxy_list_iter *iter, void **proxy_user_data); 170 | /* ------------------------------------------------- */ 171 | 172 | /* Sources */ 173 | void pipewire_audio_capture_load(void); 174 | void pipewire_audio_capture_app_load(void); 175 | /* ------------------------------------------------- */ 176 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | # please use clang-format version 16 or later 2 | 3 | Standard: c++17 4 | AccessModifierOffset: -8 5 | AlignAfterOpenBracket: Align 6 | AlignConsecutiveAssignments: false 7 | AlignConsecutiveDeclarations: false 8 | AlignEscapedNewlines: Left 9 | AlignOperands: true 10 | AlignTrailingComments: true 11 | AllowAllArgumentsOnNextLine: false 12 | AllowAllConstructorInitializersOnNextLine: false 13 | AllowAllParametersOfDeclarationOnNextLine: false 14 | AllowShortBlocksOnASingleLine: false 15 | AllowShortCaseLabelsOnASingleLine: false 16 | AllowShortFunctionsOnASingleLine: Inline 17 | AllowShortIfStatementsOnASingleLine: false 18 | AllowShortLambdasOnASingleLine: Inline 19 | AllowShortLoopsOnASingleLine: false 20 | AlwaysBreakAfterDefinitionReturnType: None 21 | AlwaysBreakAfterReturnType: None 22 | AlwaysBreakBeforeMultilineStrings: false 23 | AlwaysBreakTemplateDeclarations: false 24 | BinPackArguments: true 25 | BinPackParameters: true 26 | BraceWrapping: 27 | AfterClass: false 28 | AfterControlStatement: false 29 | AfterEnum: false 30 | AfterFunction: true 31 | AfterNamespace: false 32 | AfterObjCDeclaration: false 33 | AfterStruct: false 34 | AfterUnion: false 35 | AfterExternBlock: false 36 | BeforeCatch: false 37 | BeforeElse: false 38 | IndentBraces: false 39 | SplitEmptyFunction: true 40 | SplitEmptyRecord: true 41 | SplitEmptyNamespace: true 42 | BreakBeforeBinaryOperators: None 43 | BreakBeforeBraces: Custom 44 | BreakBeforeTernaryOperators: true 45 | BreakConstructorInitializers: BeforeColon 46 | BreakStringLiterals: false # apparently unpredictable 47 | ColumnLimit: 120 48 | CompactNamespaces: false 49 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 50 | ConstructorInitializerIndentWidth: 8 51 | ContinuationIndentWidth: 8 52 | Cpp11BracedListStyle: true 53 | DerivePointerAlignment: false 54 | DisableFormat: false 55 | FixNamespaceComments: true 56 | ForEachMacros: 57 | - 'json_object_foreach' 58 | - 'json_object_foreach_safe' 59 | - 'json_array_foreach' 60 | - 'HASH_ITER' 61 | IncludeBlocks: Preserve 62 | IndentCaseLabels: false 63 | IndentPPDirectives: None 64 | IndentWidth: 8 65 | IndentWrappedFunctionNames: false 66 | KeepEmptyLinesAtTheStartOfBlocks: true 67 | MaxEmptyLinesToKeep: 1 68 | NamespaceIndentation: None 69 | ObjCBinPackProtocolList: Auto 70 | ObjCBlockIndentWidth: 8 71 | ObjCSpaceAfterProperty: true 72 | ObjCSpaceBeforeProtocolList: true 73 | 74 | PenaltyBreakAssignment: 10 75 | PenaltyBreakBeforeFirstCallParameter: 30 76 | PenaltyBreakComment: 10 77 | PenaltyBreakFirstLessLess: 0 78 | PenaltyBreakString: 10 79 | PenaltyExcessCharacter: 100 80 | PenaltyReturnTypeOnItsOwnLine: 60 81 | 82 | PointerAlignment: Right 83 | ReflowComments: false 84 | SkipMacroDefinitionBody: true 85 | SortIncludes: false 86 | SortUsingDeclarations: false 87 | SpaceAfterCStyleCast: false 88 | SpaceAfterLogicalNot: false 89 | SpaceAfterTemplateKeyword: false 90 | SpaceBeforeAssignmentOperators: true 91 | SpaceBeforeCtorInitializerColon: true 92 | SpaceBeforeInheritanceColon: true 93 | SpaceBeforeParens: ControlStatements 94 | SpaceBeforeRangeBasedForLoopColon: true 95 | SpaceInEmptyParentheses: false 96 | SpacesBeforeTrailingComments: 1 97 | SpacesInAngles: false 98 | SpacesInCStyleCastParentheses: false 99 | SpacesInContainerLiterals: false 100 | SpacesInParentheses: false 101 | SpacesInSquareBrackets: false 102 | StatementMacros: 103 | - 'Q_OBJECT' 104 | TabWidth: 8 105 | TypenameMacros: 106 | - 'DARRAY' 107 | UseTab: ForContinuationAndIndentation 108 | --- 109 | Language: ObjC 110 | AccessModifierOffset: 2 111 | AlignArrayOfStructures: Right 112 | AlignConsecutiveAssignments: None 113 | AlignConsecutiveBitFields: None 114 | AlignConsecutiveDeclarations: None 115 | AlignConsecutiveMacros: 116 | Enabled: true 117 | AcrossEmptyLines: false 118 | AcrossComments: true 119 | AllowShortBlocksOnASingleLine: Never 120 | AllowShortEnumsOnASingleLine: false 121 | AllowShortFunctionsOnASingleLine: Empty 122 | AllowShortIfStatementsOnASingleLine: Never 123 | AllowShortLambdasOnASingleLine: None 124 | AttributeMacros: ['__unused', '__autoreleasing', '_Nonnull', '__bridge'] 125 | BitFieldColonSpacing: Both 126 | #BreakBeforeBraces: Webkit 127 | BreakBeforeBraces: Custom 128 | BraceWrapping: 129 | AfterCaseLabel: false 130 | AfterClass: true 131 | AfterControlStatement: Never 132 | AfterEnum: false 133 | AfterFunction: true 134 | AfterNamespace: false 135 | AfterObjCDeclaration: false 136 | AfterStruct: false 137 | AfterUnion: false 138 | AfterExternBlock: false 139 | BeforeCatch: false 140 | BeforeElse: false 141 | BeforeLambdaBody: false 142 | BeforeWhile: false 143 | IndentBraces: false 144 | SplitEmptyFunction: false 145 | SplitEmptyRecord: false 146 | SplitEmptyNamespace: true 147 | BreakAfterAttributes: Never 148 | BreakArrays: false 149 | BreakBeforeConceptDeclarations: Allowed 150 | BreakBeforeInlineASMColon: OnlyMultiline 151 | BreakConstructorInitializers: AfterColon 152 | BreakInheritanceList: AfterComma 153 | ColumnLimit: 120 154 | ConstructorInitializerIndentWidth: 4 155 | ContinuationIndentWidth: 4 156 | EmptyLineAfterAccessModifier: Never 157 | EmptyLineBeforeAccessModifier: LogicalBlock 158 | ExperimentalAutoDetectBinPacking: false 159 | FixNamespaceComments: true 160 | IndentAccessModifiers: false 161 | IndentCaseBlocks: false 162 | IndentCaseLabels: true 163 | IndentExternBlock: Indent 164 | IndentGotoLabels: false 165 | IndentRequiresClause: true 166 | IndentWidth: 4 167 | IndentWrappedFunctionNames: true 168 | InsertBraces: false 169 | InsertNewlineAtEOF: true 170 | KeepEmptyLinesAtTheStartOfBlocks: false 171 | LambdaBodyIndentation: Signature 172 | NamespaceIndentation: All 173 | ObjCBinPackProtocolList: Auto 174 | ObjCBlockIndentWidth: 4 175 | ObjCBreakBeforeNestedBlockParam: false 176 | ObjCSpaceAfterProperty: true 177 | ObjCSpaceBeforeProtocolList: true 178 | PPIndentWidth: -1 179 | PackConstructorInitializers: NextLine 180 | QualifierAlignment: Leave 181 | ReferenceAlignment: Right 182 | RemoveSemicolon: false 183 | RequiresClausePosition: WithPreceding 184 | RequiresExpressionIndentation: OuterScope 185 | SeparateDefinitionBlocks: Leave 186 | ShortNamespaceLines: 1 187 | SortIncludes: false 188 | #SortUsingDeclarations: LexicographicNumeric 189 | SortUsingDeclarations: true 190 | SpaceAfterCStyleCast: true 191 | SpaceAfterLogicalNot: false 192 | SpaceAroundPointerQualifiers: Default 193 | SpaceBeforeCaseColon: false 194 | SpaceBeforeCpp11BracedList: true 195 | SpaceBeforeCtorInitializerColon: true 196 | SpaceBeforeInheritanceColon: true 197 | SpaceBeforeParens: ControlStatements 198 | SpaceBeforeRangeBasedForLoopColon: true 199 | SpaceBeforeSquareBrackets: false 200 | SpaceInEmptyBlock: false 201 | SpaceInEmptyParentheses: false 202 | SpacesBeforeTrailingComments: 2 203 | SpacesInConditionalStatement: false 204 | SpacesInLineCommentPrefix: 205 | Minimum: 1 206 | Maximum: -1 207 | Standard: c++17 208 | TabWidth: 4 209 | UseTab: Never 210 | -------------------------------------------------------------------------------- /src/pipewire-audio-capture-device.c: -------------------------------------------------------------------------------- 1 | /* pipewire-audio-capture-device.c 2 | * 3 | * Copyright 2022-2025 Dimitris Papaioannou 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 2 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-2.0-or-later 19 | */ 20 | 21 | #include "pipewire-audio.h" 22 | 23 | #include 24 | 25 | /* Source for capturing device audio using PipeWire */ 26 | 27 | struct target_node { 28 | const char *friendly_name; 29 | const char *name; 30 | uint32_t serial; 31 | uint32_t id; 32 | uint32_t channels; 33 | 34 | struct spa_hook node_listener; 35 | 36 | struct obs_pw_audio_capture_device *pwac; 37 | }; 38 | 39 | enum capture_type { 40 | CAPTURE_TYPE_INPUT, 41 | CAPTURE_TYPE_OUTPUT, 42 | }; 43 | 44 | #define SETTING_TARGET_SERIAL "TargetId" 45 | #define SETTING_TARGET_NAME "TargetName" 46 | 47 | struct obs_pw_audio_capture_device { 48 | obs_source_t *source; 49 | 50 | enum capture_type capture_type; 51 | 52 | struct obs_pw_audio_instance pw; 53 | 54 | struct { 55 | struct obs_pw_audio_default_node_metadata metadata; 56 | bool autoconnect; 57 | uint32_t node_serial; 58 | struct dstr name; 59 | } default_info; 60 | 61 | struct obs_pw_audio_proxy_list targets; 62 | 63 | struct dstr target_name; 64 | uint32_t connected_serial; 65 | }; 66 | 67 | static void start_streaming(struct obs_pw_audio_capture_device *pwac, struct target_node *node) 68 | { 69 | dstr_copy(&pwac->target_name, node->name); 70 | 71 | if (pw_stream_get_state(pwac->pw.audio.stream, NULL) != PW_STREAM_STATE_UNCONNECTED) { 72 | if (node->serial == pwac->connected_serial) { 73 | /* Already connected to this node */ 74 | return; 75 | } 76 | 77 | pw_stream_disconnect(pwac->pw.audio.stream); 78 | pwac->connected_serial = SPA_ID_INVALID; 79 | } 80 | 81 | if (obs_pw_audio_stream_connect(&pwac->pw.audio, node->id, node->serial, node->channels) == 0) { 82 | pwac->connected_serial = node->serial; 83 | blog(LOG_INFO, "[pipewire-audio] %p streaming from %u", pwac->pw.audio.stream, node->serial); 84 | } else { 85 | pwac->connected_serial = SPA_ID_INVALID; 86 | blog(LOG_WARNING, "[pipewire-audio] Error connecting stream %p", pwac->pw.audio.stream); 87 | } 88 | 89 | pw_stream_set_active(pwac->pw.audio.stream, obs_source_active(pwac->source)); 90 | } 91 | 92 | struct target_node *get_node_by_name(struct obs_pw_audio_capture_device *pwac, const char *name) 93 | { 94 | struct obs_pw_audio_proxy_list_iter iter; 95 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->targets); 96 | 97 | struct target_node *node; 98 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&node)) { 99 | if (strcmp(node->name, name) == 0) { 100 | return node; 101 | } 102 | } 103 | 104 | return NULL; 105 | } 106 | 107 | struct target_node *get_node_by_serial(struct obs_pw_audio_capture_device *pwac, uint32_t serial) 108 | { 109 | struct obs_pw_audio_proxy_list_iter iter; 110 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->targets); 111 | 112 | struct target_node *node; 113 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&node)) { 114 | if (node->serial == serial) { 115 | return node; 116 | } 117 | } 118 | 119 | return NULL; 120 | } 121 | 122 | /* Target node */ 123 | static void on_node_param_cb(void *data, int seq, uint32_t id, uint32_t index, uint32_t next, 124 | const struct spa_pod *param) 125 | { 126 | UNUSED_PARAMETER(seq); 127 | UNUSED_PARAMETER(index); 128 | UNUSED_PARAMETER(next); 129 | 130 | if (id != SPA_PARAM_EnumFormat) { 131 | return; 132 | } 133 | 134 | struct target_node *n = data; 135 | 136 | struct spa_pod_parser p; 137 | spa_pod_parser_pod(&p, param); 138 | 139 | uint32_t parsed_id = 0, channels = 0, media_type = 0; 140 | 141 | spa_pod_parser_get_object(&p, SPA_TYPE_OBJECT_Format, &parsed_id, SPA_FORMAT_mediaType, SPA_POD_Id(&media_type), 142 | SPA_FORMAT_AUDIO_channels, SPA_POD_OPT_Int(&channels)); 143 | 144 | if (media_type != SPA_MEDIA_TYPE_audio) { 145 | blog(LOG_WARNING, 146 | "[pipewire-audio] Could not parse target node format. Channels may be mapped incorrectly."); 147 | } 148 | 149 | n->channels = channels; 150 | 151 | struct obs_pw_audio_capture_device *pwac = n->pwac; 152 | 153 | bool not_streamed = pwac->connected_serial != n->serial; 154 | bool has_default_node_name = !dstr_is_empty(&pwac->default_info.name) && 155 | dstr_cmp(&pwac->default_info.name, n->name) == 0; 156 | bool is_new_default_node = not_streamed && has_default_node_name; 157 | 158 | bool stream_is_unconnected = pw_stream_get_state(pwac->pw.audio.stream, NULL) == PW_STREAM_STATE_UNCONNECTED; 159 | bool node_has_target_name = !dstr_is_empty(&pwac->target_name) && dstr_cmp(&pwac->target_name, n->name) == 0; 160 | 161 | if ((pwac->default_info.autoconnect && is_new_default_node) || 162 | (stream_is_unconnected && node_has_target_name)) { 163 | start_streaming(pwac, n); 164 | } 165 | } 166 | 167 | static const struct pw_node_events node_events = { 168 | PW_VERSION_NODE_EVENTS, 169 | .param = on_node_param_cb, 170 | }; 171 | 172 | static void node_destroy_cb(void *data) 173 | { 174 | struct target_node *n = data; 175 | 176 | struct obs_pw_audio_capture_device *pwac = n->pwac; 177 | if (n->serial == pwac->connected_serial) { 178 | if (pw_stream_get_state(pwac->pw.audio.stream, NULL) != PW_STREAM_STATE_UNCONNECTED) { 179 | pw_stream_disconnect(pwac->pw.audio.stream); 180 | } 181 | pwac->connected_serial = SPA_ID_INVALID; 182 | } 183 | 184 | spa_hook_remove(&n->node_listener); 185 | 186 | bfree((void *)n->friendly_name); 187 | bfree((void *)n->name); 188 | } 189 | 190 | static void register_target_node(struct obs_pw_audio_capture_device *pwac, const char *friendly_name, const char *name, 191 | uint32_t object_serial, uint32_t global_id) 192 | { 193 | struct pw_proxy *node_proxy = pw_registry_bind(pwac->pw.registry, global_id, PW_TYPE_INTERFACE_Node, 194 | PW_VERSION_NODE, sizeof(struct target_node)); 195 | if (!node_proxy) { 196 | return; 197 | } 198 | 199 | struct target_node *n = pw_proxy_get_user_data(node_proxy); 200 | n->friendly_name = bstrdup(friendly_name); 201 | n->name = bstrdup(name); 202 | n->id = global_id; 203 | n->serial = object_serial; 204 | n->channels = 0; 205 | n->pwac = pwac; 206 | 207 | obs_pw_audio_proxy_list_append(&pwac->targets, node_proxy); 208 | 209 | spa_zero(n->node_listener); 210 | pw_proxy_add_object_listener(node_proxy, &n->node_listener, &node_events, n); 211 | 212 | pw_node_subscribe_params((struct pw_node *)node_proxy, (uint32_t[]){SPA_PARAM_EnumFormat}, 1); 213 | } 214 | /* ------------------------------------------------- */ 215 | 216 | /* Default device metadata */ 217 | static void default_node_cb(void *data, const char *name) 218 | { 219 | struct obs_pw_audio_capture_device *pwac = data; 220 | 221 | blog(LOG_DEBUG, "[pipewire-audio] New default device %s", name); 222 | 223 | dstr_copy(&pwac->default_info.name, name); 224 | 225 | struct target_node *n = get_node_by_name(pwac, name); 226 | if (n) { 227 | pwac->default_info.node_serial = n->serial; 228 | // Connect now or wait for the param ballback to connect this 229 | if (pwac->default_info.autoconnect && n->channels) { 230 | start_streaming(pwac, n); 231 | } 232 | } 233 | } 234 | /* ------------------------------------------------- */ 235 | 236 | /* Registry */ 237 | static void on_global_cb(void *data, uint32_t id, uint32_t permissions, const char *type, uint32_t version, 238 | const struct spa_dict *props) 239 | { 240 | UNUSED_PARAMETER(permissions); 241 | UNUSED_PARAMETER(version); 242 | 243 | struct obs_pw_audio_capture_device *pwac = data; 244 | 245 | if (!props || !type) { 246 | return; 247 | } 248 | 249 | if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) { 250 | const char *node_name, *media_class; 251 | if (!(node_name = spa_dict_lookup(props, PW_KEY_NODE_NAME)) || 252 | !(media_class = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS))) { 253 | return; 254 | } 255 | 256 | /* Target device */ 257 | if ((pwac->capture_type == CAPTURE_TYPE_INPUT && 258 | (strcmp(media_class, "Audio/Source") == 0 || strcmp(media_class, "Audio/Source/Virtual") == 0)) || 259 | (pwac->capture_type == CAPTURE_TYPE_OUTPUT && 260 | (strcmp(media_class, "Audio/Sink") == 0 || strcmp(media_class, "Audio/Duplex") == 0))) { 261 | 262 | const char *ser = spa_dict_lookup(props, PW_KEY_OBJECT_SERIAL); 263 | if (!ser) { 264 | blog(LOG_WARNING, "[pipewire-audio] No object serial found on node %u", id); 265 | return; 266 | } 267 | uint32_t object_serial = strtoul(ser, NULL, 10); 268 | 269 | const char *node_friendly_name = spa_dict_lookup(props, PW_KEY_NODE_NICK); 270 | if (!node_friendly_name) { 271 | node_friendly_name = spa_dict_lookup(props, PW_KEY_NODE_DESCRIPTION); 272 | if (!node_friendly_name) { 273 | node_friendly_name = node_name; 274 | } 275 | } 276 | 277 | register_target_node(pwac, node_friendly_name, node_name, object_serial, id); 278 | } 279 | } else if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) { 280 | const char *name = spa_dict_lookup(props, PW_KEY_METADATA_NAME); 281 | if (!name || strcmp(name, "default") != 0) { 282 | return; 283 | } 284 | 285 | if (!obs_pw_audio_default_node_metadata_listen(&pwac->default_info.metadata, &pwac->pw, id, 286 | pwac->capture_type == CAPTURE_TYPE_OUTPUT, 287 | default_node_cb, pwac)) { 288 | blog(LOG_WARNING, 289 | "[pipewire-audio] Failed to get default metadata, cannot detect default audio devices"); 290 | } 291 | } 292 | } 293 | 294 | static const struct pw_registry_events registry_events = { 295 | PW_VERSION_REGISTRY_EVENTS, 296 | .global = on_global_cb, 297 | }; 298 | /* ------------------------------------------------- */ 299 | 300 | /* Source */ 301 | static void *pipewire_audio_capture_create(obs_data_t *settings, obs_source_t *source, enum capture_type capture_type) 302 | { 303 | struct obs_pw_audio_capture_device *pwac = bzalloc(sizeof(struct obs_pw_audio_capture_device)); 304 | 305 | if (!obs_pw_audio_instance_init(&pwac->pw, ®istry_events, pwac, capture_type == CAPTURE_TYPE_OUTPUT, true, 306 | source)) { 307 | obs_pw_audio_instance_destroy(&pwac->pw); 308 | 309 | bfree(pwac); 310 | return NULL; 311 | } 312 | 313 | pwac->source = source; 314 | pwac->capture_type = capture_type; 315 | pwac->default_info.node_serial = SPA_ID_INVALID; 316 | pwac->connected_serial = SPA_ID_INVALID; 317 | 318 | obs_pw_audio_proxy_list_init(&pwac->targets, NULL, node_destroy_cb); 319 | 320 | if (obs_data_get_int(settings, SETTING_TARGET_SERIAL) != PW_ID_ANY) { 321 | /** Reset id setting, PipeWire node ids may not persist between sessions. 322 | * Connecting to saved target will happen based on the TargetName setting 323 | * once target has connected */ 324 | obs_data_set_int(settings, SETTING_TARGET_SERIAL, 0); 325 | } else { 326 | pwac->default_info.autoconnect = true; 327 | } 328 | 329 | dstr_init_copy(&pwac->target_name, obs_data_get_string(settings, SETTING_TARGET_NAME)); 330 | 331 | pw_thread_loop_unlock(pwac->pw.thread_loop); 332 | 333 | return pwac; 334 | } 335 | 336 | static void *pipewire_audio_capture_input_create(obs_data_t *settings, obs_source_t *source) 337 | { 338 | return pipewire_audio_capture_create(settings, source, CAPTURE_TYPE_INPUT); 339 | } 340 | 341 | static void *pipewire_audio_capture_output_create(obs_data_t *settings, obs_source_t *source) 342 | { 343 | return pipewire_audio_capture_create(settings, source, CAPTURE_TYPE_OUTPUT); 344 | } 345 | 346 | static void pipewire_audio_capture_defaults(obs_data_t *settings) 347 | { 348 | obs_data_set_default_int(settings, SETTING_TARGET_SERIAL, PW_ID_ANY); 349 | } 350 | 351 | static obs_properties_t *pipewire_audio_capture_properties(void *data) 352 | { 353 | struct obs_pw_audio_capture_device *pwac = data; 354 | 355 | obs_properties_t *p = obs_properties_create(); 356 | 357 | obs_property_t *targets_list = obs_properties_add_list(p, SETTING_TARGET_SERIAL, obs_module_text("Device"), 358 | OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT); 359 | 360 | obs_property_list_add_int(targets_list, obs_module_text("Default"), PW_ID_ANY); 361 | 362 | if (!pwac->default_info.autoconnect) { 363 | obs_data_t *settings = obs_source_get_settings(pwac->source); 364 | /* Saved target serial may be different from connected because a previously connected 365 | node may have been replaced by one with the same name */ 366 | obs_data_set_int(settings, SETTING_TARGET_SERIAL, pwac->connected_serial); 367 | obs_data_release(settings); 368 | } 369 | 370 | pw_thread_loop_lock(pwac->pw.thread_loop); 371 | 372 | struct obs_pw_audio_proxy_list_iter iter; 373 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->targets); 374 | 375 | struct target_node *node; 376 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&node)) { 377 | obs_property_list_add_int(targets_list, node->friendly_name, node->serial); 378 | } 379 | 380 | pw_thread_loop_unlock(pwac->pw.thread_loop); 381 | 382 | return p; 383 | } 384 | 385 | static void pipewire_audio_capture_update(void *data, obs_data_t *settings) 386 | { 387 | struct obs_pw_audio_capture_device *pwac = data; 388 | 389 | uint32_t new_node_serial = obs_data_get_int(settings, SETTING_TARGET_SERIAL); 390 | 391 | pw_thread_loop_lock(pwac->pw.thread_loop); 392 | 393 | if ((pwac->default_info.autoconnect = new_node_serial == PW_ID_ANY)) { 394 | if (pwac->default_info.node_serial != SPA_ID_INVALID) { 395 | start_streaming(pwac, get_node_by_serial(pwac, pwac->default_info.node_serial)); 396 | } 397 | } else { 398 | struct target_node *new_node = get_node_by_serial(pwac, new_node_serial); 399 | if (new_node) { 400 | start_streaming(pwac, new_node); 401 | 402 | obs_data_set_string(settings, SETTING_TARGET_NAME, pwac->target_name.array); 403 | } 404 | } 405 | 406 | pw_thread_loop_unlock(pwac->pw.thread_loop); 407 | } 408 | 409 | static void pipewire_audio_capture_show(void *data) 410 | { 411 | struct obs_pw_audio_capture_device *pwac = data; 412 | 413 | pw_thread_loop_lock(pwac->pw.thread_loop); 414 | pw_stream_set_active(pwac->pw.audio.stream, true); 415 | pw_thread_loop_unlock(pwac->pw.thread_loop); 416 | } 417 | 418 | static void pipewire_audio_capture_hide(void *data) 419 | { 420 | struct obs_pw_audio_capture_device *pwac = data; 421 | pw_thread_loop_lock(pwac->pw.thread_loop); 422 | pw_stream_set_active(pwac->pw.audio.stream, false); 423 | pw_thread_loop_unlock(pwac->pw.thread_loop); 424 | } 425 | 426 | static void pipewire_audio_capture_destroy(void *data) 427 | { 428 | struct obs_pw_audio_capture_device *pwac = data; 429 | 430 | pw_thread_loop_lock(pwac->pw.thread_loop); 431 | 432 | obs_pw_audio_proxy_list_clear(&pwac->targets); 433 | 434 | if (pwac->default_info.metadata.proxy) { 435 | pw_proxy_destroy(pwac->default_info.metadata.proxy); 436 | } 437 | 438 | obs_pw_audio_instance_destroy(&pwac->pw); 439 | 440 | dstr_free(&pwac->default_info.name); 441 | dstr_free(&pwac->target_name); 442 | 443 | bfree(pwac); 444 | } 445 | 446 | static const char *pipewire_audio_capture_input_name(void *data) 447 | { 448 | UNUSED_PARAMETER(data); 449 | return obs_module_text("PipeWireAudioCaptureInput"); 450 | } 451 | 452 | static const char *pipewire_audio_capture_output_name(void *data) 453 | { 454 | UNUSED_PARAMETER(data); 455 | return obs_module_text("PipeWireAudioCaptureOutput"); 456 | } 457 | 458 | void pipewire_audio_capture_load(void) 459 | { 460 | const struct obs_source_info pipewire_audio_capture_input = { 461 | .id = "pipewire_audio_input_capture", 462 | .type = OBS_SOURCE_TYPE_INPUT, 463 | .output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE, 464 | .get_name = pipewire_audio_capture_input_name, 465 | .create = pipewire_audio_capture_input_create, 466 | .get_defaults = pipewire_audio_capture_defaults, 467 | .get_properties = pipewire_audio_capture_properties, 468 | .update = pipewire_audio_capture_update, 469 | .show = pipewire_audio_capture_show, 470 | .hide = pipewire_audio_capture_hide, 471 | .destroy = pipewire_audio_capture_destroy, 472 | .icon_type = OBS_ICON_TYPE_AUDIO_INPUT, 473 | }; 474 | const struct obs_source_info pipewire_audio_capture_output = { 475 | .id = "pipewire_audio_output_capture", 476 | .type = OBS_SOURCE_TYPE_INPUT, 477 | .output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE | OBS_SOURCE_DO_NOT_SELF_MONITOR, 478 | .get_name = pipewire_audio_capture_output_name, 479 | .create = pipewire_audio_capture_output_create, 480 | .get_defaults = pipewire_audio_capture_defaults, 481 | .get_properties = pipewire_audio_capture_properties, 482 | .update = pipewire_audio_capture_update, 483 | .show = pipewire_audio_capture_show, 484 | .hide = pipewire_audio_capture_hide, 485 | .destroy = pipewire_audio_capture_destroy, 486 | .icon_type = OBS_ICON_TYPE_AUDIO_OUTPUT, 487 | }; 488 | 489 | obs_register_source(&pipewire_audio_capture_input); 490 | obs_register_source(&pipewire_audio_capture_output); 491 | } 492 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /src/pipewire-audio.c: -------------------------------------------------------------------------------- 1 | /* pipewire-audio.c 2 | * 3 | * Copyright 2022-2025 Dimitris Papaioannou 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 2 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-2.0-or-later 19 | */ 20 | 21 | #include "pipewire-audio.h" 22 | 23 | #include 24 | 25 | #include 26 | 27 | /* Utilities */ 28 | bool json_object_find(const char *obj, const char *key, char *value, size_t len) 29 | { 30 | /* From PipeWire's source */ 31 | 32 | struct spa_json it[2]; 33 | const char *v; 34 | char k[128]; 35 | 36 | spa_json_init(&it[0], obj, strlen(obj)); 37 | if (spa_json_enter_object(&it[0], &it[1]) <= 0) { 38 | return false; 39 | } 40 | 41 | while (spa_json_get_string(&it[1], k, sizeof(k)) > 0) { 42 | if (spa_streq(k, key)) { 43 | if (spa_json_get_string(&it[1], value, len) > 0) { 44 | return true; 45 | } 46 | } else if (spa_json_next(&it[1], &v) <= 0) { 47 | break; 48 | } 49 | } 50 | return false; 51 | } 52 | /* ------------------------------------------------- */ 53 | 54 | /* PipeWire stream wrapper */ 55 | void obs_channels_to_spa_audio_position(enum spa_audio_channel *position, uint32_t channels) 56 | { 57 | switch (channels) { 58 | case 1: 59 | position[0] = SPA_AUDIO_CHANNEL_MONO; 60 | break; 61 | case 2: 62 | position[0] = SPA_AUDIO_CHANNEL_FL; 63 | position[1] = SPA_AUDIO_CHANNEL_FR; 64 | break; 65 | case 3: 66 | position[0] = SPA_AUDIO_CHANNEL_FL; 67 | position[1] = SPA_AUDIO_CHANNEL_FR; 68 | position[2] = SPA_AUDIO_CHANNEL_LFE; 69 | break; 70 | case 4: 71 | position[0] = SPA_AUDIO_CHANNEL_FL; 72 | position[1] = SPA_AUDIO_CHANNEL_FR; 73 | position[2] = SPA_AUDIO_CHANNEL_FC; 74 | position[3] = SPA_AUDIO_CHANNEL_RC; 75 | break; 76 | case 5: 77 | position[0] = SPA_AUDIO_CHANNEL_FL; 78 | position[1] = SPA_AUDIO_CHANNEL_FR; 79 | position[2] = SPA_AUDIO_CHANNEL_FC; 80 | position[3] = SPA_AUDIO_CHANNEL_LFE; 81 | position[4] = SPA_AUDIO_CHANNEL_RC; 82 | break; 83 | case 6: 84 | position[0] = SPA_AUDIO_CHANNEL_FL; 85 | position[1] = SPA_AUDIO_CHANNEL_FR; 86 | position[2] = SPA_AUDIO_CHANNEL_FC; 87 | position[3] = SPA_AUDIO_CHANNEL_LFE; 88 | position[4] = SPA_AUDIO_CHANNEL_RL; 89 | position[5] = SPA_AUDIO_CHANNEL_RR; 90 | break; 91 | case 8: 92 | position[0] = SPA_AUDIO_CHANNEL_FL; 93 | position[1] = SPA_AUDIO_CHANNEL_FR; 94 | position[2] = SPA_AUDIO_CHANNEL_FC; 95 | position[3] = SPA_AUDIO_CHANNEL_LFE; 96 | position[4] = SPA_AUDIO_CHANNEL_RL; 97 | position[5] = SPA_AUDIO_CHANNEL_RR; 98 | position[6] = SPA_AUDIO_CHANNEL_SL; 99 | position[7] = SPA_AUDIO_CHANNEL_SR; 100 | break; 101 | default: 102 | for (size_t i = 0; i < channels; i++) { 103 | position[i] = SPA_AUDIO_CHANNEL_UNKNOWN; 104 | } 105 | break; 106 | } 107 | } 108 | 109 | enum audio_format spa_to_obs_audio_format(enum spa_audio_format format) 110 | { 111 | switch (format) { 112 | case SPA_AUDIO_FORMAT_U8: 113 | return AUDIO_FORMAT_U8BIT; 114 | case SPA_AUDIO_FORMAT_S16_LE: 115 | return AUDIO_FORMAT_16BIT; 116 | case SPA_AUDIO_FORMAT_S32_LE: 117 | return AUDIO_FORMAT_32BIT; 118 | case SPA_AUDIO_FORMAT_F32_LE: 119 | return AUDIO_FORMAT_FLOAT; 120 | case SPA_AUDIO_FORMAT_U8P: 121 | return AUDIO_FORMAT_U8BIT_PLANAR; 122 | case SPA_AUDIO_FORMAT_S16P: 123 | return AUDIO_FORMAT_16BIT_PLANAR; 124 | case SPA_AUDIO_FORMAT_S32P: 125 | return AUDIO_FORMAT_32BIT_PLANAR; 126 | case SPA_AUDIO_FORMAT_F32P: 127 | return AUDIO_FORMAT_FLOAT_PLANAR; 128 | default: 129 | return AUDIO_FORMAT_UNKNOWN; 130 | } 131 | } 132 | 133 | enum speaker_layout spa_to_obs_speakers(uint32_t channels) 134 | { 135 | switch (channels) { 136 | case 1: 137 | return SPEAKERS_MONO; 138 | case 2: 139 | return SPEAKERS_STEREO; 140 | case 3: 141 | return SPEAKERS_2POINT1; 142 | case 4: 143 | return SPEAKERS_4POINT0; 144 | case 5: 145 | return SPEAKERS_4POINT1; 146 | case 6: 147 | return SPEAKERS_5POINT1; 148 | case 8: 149 | return SPEAKERS_7POINT1; 150 | default: 151 | return SPEAKERS_UNKNOWN; 152 | } 153 | } 154 | 155 | bool spa_to_obs_pw_audio_info(struct obs_pw_audio_info *info, const struct spa_pod *param) 156 | { 157 | struct spa_audio_info_raw audio_info; 158 | 159 | if (spa_format_audio_raw_parse(param, &audio_info) < 0) { 160 | info->sample_rate = 0; 161 | info->format = AUDIO_FORMAT_UNKNOWN; 162 | info->speakers = SPEAKERS_UNKNOWN; 163 | 164 | return false; 165 | } 166 | 167 | info->sample_rate = audio_info.rate; 168 | info->speakers = spa_to_obs_speakers(audio_info.channels); 169 | info->format = spa_to_obs_audio_format(audio_info.format); 170 | 171 | return true; 172 | } 173 | 174 | static void on_process_cb(void *data) 175 | { 176 | uint64_t now = os_gettime_ns(); 177 | 178 | struct obs_pw_audio_stream *s = data; 179 | 180 | struct pw_buffer *b = pw_stream_dequeue_buffer(s->stream); 181 | 182 | if (!b) { 183 | return; 184 | } 185 | 186 | struct spa_buffer *buf = b->buffer; 187 | 188 | if (!s->info.sample_rate || buf->n_datas == 0 || buf->datas[0].chunk->stride == 0 || 189 | buf->datas[0].type != SPA_DATA_MemPtr) { 190 | goto queue; 191 | } 192 | 193 | struct obs_source_audio out = { 194 | .frames = buf->datas[0].chunk->size / buf->datas[0].chunk->stride, 195 | .speakers = s->info.speakers, 196 | .format = s->info.format, 197 | .samples_per_sec = s->info.sample_rate, 198 | }; 199 | 200 | for (size_t i = 0; i < buf->n_datas && i < MAX_AV_PLANES; i++) { 201 | out.data[i] = buf->datas[i].data; 202 | } 203 | 204 | if (s->info.sample_rate && s->pos->clock.rate_diff) { 205 | /** Taken from PipeWire's implementation of JACK's jack_get_cycle_times 206 | * (https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/0.3.52/pipewire-jack/src/pipewire-jack.c#L5639) 207 | * which is used in the linux-jack plugin to correctly set the timestamp 208 | * (https://github.com/obsproject/obs-studio/blob/27.2.4/plugins/linux-jack/jack-wrapper.c#L87) */ 209 | double period_nsecs = s->pos->clock.duration * (double)SPA_NSEC_PER_SEC / 210 | (s->info.sample_rate * s->pos->clock.rate_diff); 211 | 212 | out.timestamp = now - (uint64_t)period_nsecs; 213 | } else { 214 | out.timestamp = now - audio_frames_to_ns(s->info.sample_rate, out.frames); 215 | } 216 | 217 | obs_source_output_audio(s->output, &out); 218 | 219 | queue: 220 | pw_stream_queue_buffer(s->stream, b); 221 | } 222 | 223 | static void on_state_changed_cb(void *data, enum pw_stream_state old, enum pw_stream_state state, const char *error) 224 | { 225 | UNUSED_PARAMETER(old); 226 | 227 | struct obs_pw_audio_stream *s = data; 228 | 229 | blog(LOG_DEBUG, "[pipewire-audio] Stream %p state: \"%s\" (error: %s)", s->stream, 230 | pw_stream_state_as_string(state), error ? error : "none"); 231 | } 232 | 233 | static void on_param_changed_cb(void *data, uint32_t id, const struct spa_pod *param) 234 | { 235 | if (!param || id != SPA_PARAM_Format) { 236 | return; 237 | } 238 | 239 | struct obs_pw_audio_stream *s = data; 240 | 241 | if (!spa_to_obs_pw_audio_info(&s->info, param)) { 242 | blog(LOG_WARNING, "[pipewire-audio] Stream %p failed to parse audio format info", s->stream); 243 | } else { 244 | blog(LOG_INFO, "[pipewire-audio] %p Got format: rate %u - channels %u - format %u", s->stream, 245 | s->info.sample_rate, s->info.speakers, s->info.format); 246 | } 247 | } 248 | 249 | static void on_io_changed_cb(void *data, uint32_t id, void *area, uint32_t size) 250 | { 251 | UNUSED_PARAMETER(size); 252 | 253 | struct obs_pw_audio_stream *s = data; 254 | 255 | if (id == SPA_IO_Position) { 256 | s->pos = area; 257 | } 258 | } 259 | 260 | static const struct pw_stream_events stream_events = { 261 | PW_VERSION_STREAM_EVENTS, 262 | .process = on_process_cb, 263 | .state_changed = on_state_changed_cb, 264 | .param_changed = on_param_changed_cb, 265 | .io_changed = on_io_changed_cb, 266 | }; 267 | 268 | int obs_pw_audio_stream_connect(struct obs_pw_audio_stream *s, uint32_t target_id, uint32_t target_serial, 269 | uint32_t audio_channels) 270 | { 271 | if (audio_channels == 0) { 272 | blog(LOG_WARNING, 273 | "[pipewire-audio] Stream %p connecting without channel info. Channels may be mapped incorrectly.", 274 | s); 275 | } 276 | 277 | if (audio_channels > 8) { 278 | blog(LOG_WARNING, 279 | "[pipewire-audio] Stream %p cannot use %u > 8 channels. This is likely a Pro Audio node, will use stereo instead.", 280 | s, audio_channels); 281 | audio_channels = 2; 282 | } 283 | 284 | enum spa_audio_channel pos[8]; 285 | 286 | uint8_t buffer[2048]; 287 | struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); 288 | const struct spa_pod *params[1]; 289 | 290 | if (audio_channels) { 291 | obs_channels_to_spa_audio_position(pos, audio_channels); 292 | 293 | params[0] = spa_pod_builder_add_object( 294 | &b, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat, SPA_FORMAT_mediaType, 295 | SPA_POD_Id(SPA_MEDIA_TYPE_audio), SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw), 296 | SPA_FORMAT_AUDIO_channels, SPA_POD_Int(audio_channels), SPA_FORMAT_AUDIO_position, 297 | SPA_POD_Array(sizeof(enum spa_audio_channel), SPA_TYPE_Id, audio_channels, pos), 298 | SPA_FORMAT_AUDIO_format, 299 | SPA_POD_CHOICE_ENUM_Id(9, SPA_AUDIO_FORMAT_F32P, SPA_AUDIO_FORMAT_U8, SPA_AUDIO_FORMAT_S16_LE, 300 | SPA_AUDIO_FORMAT_S32_LE, SPA_AUDIO_FORMAT_F32_LE, SPA_AUDIO_FORMAT_U8P, 301 | SPA_AUDIO_FORMAT_S16P, SPA_AUDIO_FORMAT_S32P, SPA_AUDIO_FORMAT_F32P)); 302 | } else { 303 | params[0] = spa_pod_builder_add_object( 304 | &b, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat, SPA_FORMAT_mediaType, 305 | SPA_POD_Id(SPA_MEDIA_TYPE_audio), SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw), 306 | SPA_FORMAT_AUDIO_format, 307 | SPA_POD_CHOICE_ENUM_Id(9, SPA_AUDIO_FORMAT_F32P, SPA_AUDIO_FORMAT_U8, SPA_AUDIO_FORMAT_S16_LE, 308 | SPA_AUDIO_FORMAT_S32_LE, SPA_AUDIO_FORMAT_F32_LE, SPA_AUDIO_FORMAT_U8P, 309 | SPA_AUDIO_FORMAT_S16P, SPA_AUDIO_FORMAT_S32P, SPA_AUDIO_FORMAT_F32P)); 310 | } 311 | 312 | struct pw_properties *stream_props = pw_properties_new(NULL, NULL); 313 | pw_properties_setf(stream_props, PW_KEY_TARGET_OBJECT, "%u", target_serial); 314 | pw_stream_update_properties(s->stream, &stream_props->dict); 315 | pw_properties_free(stream_props); 316 | 317 | return pw_stream_connect( 318 | s->stream, PW_DIRECTION_INPUT, target_id, 319 | PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_DONT_RECONNECT, params, 1); 320 | } 321 | /* ------------------------------------------------- */ 322 | 323 | /* Common PipeWire components */ 324 | static void on_core_done_cb(void *data, uint32_t id, int seq) 325 | { 326 | struct obs_pw_audio_instance *pw = data; 327 | 328 | if (id == PW_ID_CORE && pw->seq == seq) { 329 | pw_thread_loop_signal(pw->thread_loop, false); 330 | } 331 | } 332 | 333 | static void on_core_error_cb(void *data, uint32_t id, int seq, int res, const char *message) 334 | { 335 | struct obs_pw_audio_instance *pw = data; 336 | 337 | blog(LOG_ERROR, "[pipewire-audio] Error id:%u seq:%d res:%d :%s", id, seq, res, message); 338 | 339 | pw_thread_loop_signal(pw->thread_loop, false); 340 | } 341 | 342 | static const struct pw_core_events core_events = { 343 | PW_VERSION_CORE_EVENTS, 344 | .done = on_core_done_cb, 345 | .error = on_core_error_cb, 346 | }; 347 | 348 | bool obs_pw_audio_instance_init(struct obs_pw_audio_instance *pw, const struct pw_registry_events *registry_events, 349 | void *registry_cb_data, bool stream_capture_sink, bool stream_want_driver, 350 | obs_source_t *stream_output) 351 | { 352 | pw->thread_loop = pw_thread_loop_new("PipeWire thread loop", NULL); 353 | pw->context = pw_context_new(pw_thread_loop_get_loop(pw->thread_loop), NULL, 0); 354 | 355 | pw_thread_loop_lock(pw->thread_loop); 356 | 357 | if (pw_thread_loop_start(pw->thread_loop) < 0) { 358 | blog(LOG_WARNING, "[pipewire-audio] Error starting threaded mainloop"); 359 | return false; 360 | } 361 | 362 | pw->core = pw_context_connect(pw->context, NULL, 0); 363 | if (!pw->core) { 364 | blog(LOG_WARNING, "[pipewire-audio] Error creating PipeWire core"); 365 | return false; 366 | } 367 | 368 | pw_core_add_listener(pw->core, &pw->core_listener, &core_events, pw); 369 | 370 | pw->registry = pw_core_get_registry(pw->core, PW_VERSION_REGISTRY, 0); 371 | if (!pw->registry) { 372 | return false; 373 | } 374 | pw_registry_add_listener(pw->registry, &pw->registry_listener, registry_events, registry_cb_data); 375 | 376 | struct pw_properties *stream_props = 377 | pw_properties_new(PW_KEY_MEDIA_NAME, obs_source_get_name(stream_output), PW_KEY_MEDIA_TYPE, "Audio", 378 | PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Production", 379 | PW_KEY_NODE_WANT_DRIVER, stream_want_driver ? "true" : "false", 380 | PW_KEY_STREAM_CAPTURE_SINK, stream_capture_sink ? "true" : "false", NULL); 381 | 382 | pw_properties_setf(stream_props, PW_KEY_NODE_NAME, "OBS: %s", obs_source_get_name(stream_output)); 383 | 384 | pw->audio.output = stream_output; 385 | pw->audio.stream = pw_stream_new(pw->core, obs_source_get_name(stream_output), stream_props); 386 | 387 | if (!pw->audio.stream) { 388 | blog(LOG_WARNING, "[pipewire-audio] Failed to create stream"); 389 | return false; 390 | } 391 | blog(LOG_INFO, "[pipewire-audio] Created stream %p", pw->audio.stream); 392 | 393 | pw_stream_add_listener(pw->audio.stream, &pw->audio.stream_listener, &stream_events, &pw->audio); 394 | 395 | return true; 396 | } 397 | 398 | void obs_pw_audio_instance_destroy(struct obs_pw_audio_instance *pw) 399 | { 400 | if (pw->audio.stream) { 401 | spa_hook_remove(&pw->audio.stream_listener); 402 | if (pw_stream_get_state(pw->audio.stream, NULL) != PW_STREAM_STATE_UNCONNECTED) { 403 | pw_stream_disconnect(pw->audio.stream); 404 | } 405 | pw_stream_destroy(pw->audio.stream); 406 | } 407 | 408 | if (pw->registry) { 409 | spa_hook_remove(&pw->registry_listener); 410 | spa_zero(pw->registry_listener); 411 | pw_proxy_destroy((struct pw_proxy *)pw->registry); 412 | } 413 | 414 | pw_thread_loop_unlock(pw->thread_loop); 415 | pw_thread_loop_stop(pw->thread_loop); 416 | 417 | if (pw->core) { 418 | spa_hook_remove(&pw->core_listener); 419 | spa_zero(pw->core_listener); 420 | pw_core_disconnect(pw->core); 421 | } 422 | 423 | if (pw->context) { 424 | pw_context_destroy(pw->context); 425 | } 426 | 427 | pw_thread_loop_destroy(pw->thread_loop); 428 | } 429 | 430 | void obs_pw_audio_instance_sync(struct obs_pw_audio_instance *pw) 431 | { 432 | pw->seq = pw_core_sync(pw->core, PW_ID_CORE, pw->seq); 433 | } 434 | /* ------------------------------------------------- */ 435 | 436 | /* PipeWire metadata */ 437 | static int on_metadata_property_cb(void *data, uint32_t id, const char *key, const char *type, const char *value) 438 | { 439 | UNUSED_PARAMETER(type); 440 | 441 | struct obs_pw_audio_default_node_metadata *metadata = data; 442 | 443 | if (id == PW_ID_CORE && key && value && 444 | strcmp(key, metadata->wants_sink ? "default.audio.sink" : "default.audio.source") == 0) { 445 | char val[128]; 446 | if (json_object_find(value, "name", val, sizeof(val)) && *val) { 447 | metadata->default_node_callback(metadata->data, val); 448 | } 449 | } 450 | 451 | return 0; 452 | } 453 | 454 | static const struct pw_metadata_events metadata_events = { 455 | PW_VERSION_METADATA_EVENTS, 456 | .property = on_metadata_property_cb, 457 | }; 458 | 459 | static void on_metadata_proxy_removed_cb(void *data) 460 | { 461 | struct obs_pw_audio_default_node_metadata *metadata = data; 462 | pw_proxy_destroy(metadata->proxy); 463 | } 464 | 465 | static void on_metadata_proxy_destroy_cb(void *data) 466 | { 467 | struct obs_pw_audio_default_node_metadata *metadata = data; 468 | 469 | spa_hook_remove(&metadata->metadata_listener); 470 | spa_hook_remove(&metadata->proxy_listener); 471 | spa_zero(metadata->metadata_listener); 472 | spa_zero(metadata->proxy_listener); 473 | 474 | metadata->proxy = NULL; 475 | } 476 | 477 | static const struct pw_proxy_events metadata_proxy_events = { 478 | PW_VERSION_PROXY_EVENTS, 479 | .removed = on_metadata_proxy_removed_cb, 480 | .destroy = on_metadata_proxy_destroy_cb, 481 | }; 482 | 483 | bool obs_pw_audio_default_node_metadata_listen(struct obs_pw_audio_default_node_metadata *metadata, 484 | struct obs_pw_audio_instance *pw, uint32_t global_id, bool wants_sink, 485 | void (*default_node_callback)(void *data, const char *name), void *data) 486 | { 487 | if (metadata->proxy) { 488 | pw_proxy_destroy(metadata->proxy); 489 | } 490 | 491 | struct pw_proxy *metadata_proxy = 492 | pw_registry_bind(pw->registry, global_id, PW_TYPE_INTERFACE_Metadata, PW_VERSION_METADATA, 0); 493 | if (!metadata_proxy) { 494 | return false; 495 | } 496 | 497 | metadata->proxy = metadata_proxy; 498 | 499 | metadata->wants_sink = wants_sink; 500 | 501 | metadata->default_node_callback = default_node_callback; 502 | metadata->data = data; 503 | 504 | pw_proxy_add_object_listener(metadata->proxy, &metadata->metadata_listener, &metadata_events, metadata); 505 | pw_proxy_add_listener(metadata->proxy, &metadata->proxy_listener, &metadata_proxy_events, metadata); 506 | 507 | return true; 508 | } 509 | /* ------------------------------------------------- */ 510 | 511 | /* Proxied objects */ 512 | struct obs_pw_audio_proxied_object { 513 | void (*bound_callback)(void *data, uint32_t global_id); 514 | void (*destroy_callback)(void *data); 515 | 516 | struct pw_proxy *proxy; 517 | struct spa_hook proxy_listener; 518 | 519 | struct spa_list link; 520 | }; 521 | 522 | static void on_proxy_bound_cb(void *data, uint32_t global_id) 523 | { 524 | struct obs_pw_audio_proxied_object *obj = data; 525 | if (obj->bound_callback) { 526 | obj->bound_callback(pw_proxy_get_user_data(obj->proxy), global_id); 527 | } 528 | } 529 | 530 | static void on_proxy_removed_cb(void *data) 531 | { 532 | struct obs_pw_audio_proxied_object *obj = data; 533 | pw_proxy_destroy(obj->proxy); 534 | } 535 | 536 | static void on_proxy_destroy_cb(void *data) 537 | { 538 | struct obs_pw_audio_proxied_object *obj = data; 539 | spa_hook_remove(&obj->proxy_listener); 540 | 541 | spa_list_remove(&obj->link); 542 | 543 | if (obj->destroy_callback) { 544 | obj->destroy_callback(pw_proxy_get_user_data(obj->proxy)); 545 | } 546 | 547 | bfree(data); 548 | } 549 | 550 | static const struct pw_proxy_events proxy_events = { 551 | PW_VERSION_PROXY_EVENTS, 552 | .bound = on_proxy_bound_cb, 553 | .removed = on_proxy_removed_cb, 554 | .destroy = on_proxy_destroy_cb, 555 | }; 556 | 557 | void obs_pw_audio_proxied_object_new(struct pw_proxy *proxy, struct spa_list *list, 558 | void (*bound_callback)(void *data, uint32_t global_id), 559 | void (*destroy_callback)(void *data)) 560 | { 561 | struct obs_pw_audio_proxied_object *obj = bmalloc(sizeof(struct obs_pw_audio_proxied_object)); 562 | 563 | obj->proxy = proxy; 564 | obj->bound_callback = bound_callback; 565 | obj->destroy_callback = destroy_callback; 566 | 567 | spa_list_append(list, &obj->link); 568 | 569 | spa_zero(obj->proxy_listener); 570 | pw_proxy_add_listener(obj->proxy, &obj->proxy_listener, &proxy_events, obj); 571 | } 572 | 573 | void *obs_pw_audio_proxied_object_get_user_data(struct obs_pw_audio_proxied_object *obj) 574 | { 575 | return pw_proxy_get_user_data(obj->proxy); 576 | } 577 | 578 | void obs_pw_audio_proxy_list_init(struct obs_pw_audio_proxy_list *list, 579 | void (*bound_callback)(void *data, uint32_t global_id), 580 | void (*destroy_callback)(void *data)) 581 | { 582 | spa_list_init(&list->list); 583 | 584 | list->bound_callback = bound_callback; 585 | list->destroy_callback = destroy_callback; 586 | } 587 | 588 | void obs_pw_audio_proxy_list_append(struct obs_pw_audio_proxy_list *list, struct pw_proxy *proxy) 589 | { 590 | obs_pw_audio_proxied_object_new(proxy, &list->list, list->bound_callback, list->destroy_callback); 591 | } 592 | 593 | void obs_pw_audio_proxy_list_clear(struct obs_pw_audio_proxy_list *list) 594 | { 595 | struct obs_pw_audio_proxied_object *obj, *temp; 596 | spa_list_for_each_safe(obj, temp, &list->list, link) 597 | { 598 | pw_proxy_destroy(obj->proxy); 599 | } 600 | } 601 | 602 | void obs_pw_audio_proxy_list_iter_init(struct obs_pw_audio_proxy_list_iter *iter, struct obs_pw_audio_proxy_list *list) 603 | { 604 | iter->proxy_list = list; 605 | iter->current = spa_list_first(&list->list, struct obs_pw_audio_proxied_object, link); 606 | } 607 | 608 | bool obs_pw_audio_proxy_list_iter_next(struct obs_pw_audio_proxy_list_iter *iter, void **proxy_user_data) 609 | { 610 | if (spa_list_is_empty(&iter->proxy_list->list)) { 611 | return false; 612 | } 613 | 614 | if (spa_list_is_end(iter->current, &iter->proxy_list->list, link)) { 615 | return false; 616 | } 617 | 618 | *proxy_user_data = obs_pw_audio_proxied_object_get_user_data(iter->current); 619 | iter->current = spa_list_next(iter->current, link); 620 | 621 | return true; 622 | } 623 | /* ------------------------------------------------- */ 624 | -------------------------------------------------------------------------------- /src/pipewire-audio-capture-app.c: -------------------------------------------------------------------------------- 1 | /* pipewire-audio-capture-app.c 2 | * 3 | * Copyright 2022-2025 Dimitris Papaioannou 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 2 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-2.0-or-later 19 | */ 20 | 21 | #include "pipewire-audio.h" 22 | 23 | #include 24 | 25 | #include 26 | 27 | /* Source for capturing applciation audio using PipeWire */ 28 | 29 | struct target_node_port { 30 | const char *channel; 31 | uint32_t id; 32 | }; 33 | 34 | struct target_node { 35 | const char *name; 36 | const char *app_name; 37 | const char *binary; 38 | uint32_t client_id; 39 | uint32_t id; 40 | struct obs_pw_audio_proxy_list ports; 41 | uint32_t *p_n_nodes; 42 | 43 | struct spa_hook node_listener; 44 | }; 45 | 46 | struct target_client { 47 | const char *app_name; 48 | const char *binary; 49 | uint32_t id; 50 | 51 | struct spa_hook client_listener; 52 | }; 53 | 54 | struct system_sink { 55 | const char *name; 56 | uint32_t id; 57 | }; 58 | 59 | struct capture_sink_link { 60 | uint32_t id; 61 | }; 62 | 63 | struct capture_sink_port { 64 | const char *channel; 65 | uint32_t id; 66 | }; 67 | 68 | enum capture_mode { CAPTURE_MODE_SINGLE, CAPTURE_MODE_MULTIPLE }; 69 | enum match_priority { MATCH_PRIORITY_BINARY_NAME, MATCH_PRIORITY_APP_NAME }; 70 | 71 | #define SETTING_CAPTURE_MODE "CaptureMode" 72 | #define SETTING_MATCH_PRIORITY "MatchPriorty" 73 | #define SETTING_EXCLUDE_SELECTIONS "ExceptApp" 74 | #define SETTING_SELECTION_SINGLE "TargetName" 75 | #define SETTING_SELECTION_MULTIPLE "apps" 76 | #define SETTING_AVAILABLE_APPS "AppToAdd" 77 | #define SETTING_ADD_TO_SELECTIONS "AddToSelected" 78 | 79 | /** This source basically works like this: 80 | - Keep track of output streams and their ports, system sinks and the default sink 81 | 82 | - Keep track of the channels of the default system sink and create a new virtual sink, 83 | destroying the previously made one, with the same channels, then connect the stream to it 84 | 85 | - Connect any registered or new stream ports to the sink 86 | */ 87 | struct obs_pw_audio_capture_app { 88 | obs_source_t *source; 89 | 90 | struct obs_pw_audio_instance pw; 91 | 92 | /** The app capture sink automatically mixes 93 | * the audio of all the app streams */ 94 | struct { 95 | struct pw_proxy *proxy; 96 | struct spa_hook proxy_listener; 97 | bool autoconnect_targets; 98 | uint32_t id; 99 | uint32_t serial; 100 | uint32_t channels; 101 | struct dstr position; 102 | DARRAY(struct capture_sink_port) ports; 103 | 104 | /* Links between app streams and the capture sink */ 105 | struct obs_pw_audio_proxy_list links; 106 | } sink; 107 | 108 | /** Need the default system sink to create 109 | * the app capture sink with the same audio channels */ 110 | struct obs_pw_audio_proxy_list system_sinks; 111 | struct { 112 | struct obs_pw_audio_default_node_metadata metadata; 113 | struct pw_proxy *proxy; 114 | struct spa_hook node_listener; 115 | struct spa_hook proxy_listener; 116 | } default_sink; 117 | 118 | struct obs_pw_audio_proxy_list clients; 119 | 120 | struct obs_pw_audio_proxy_list nodes; 121 | uint32_t n_nodes; 122 | 123 | enum capture_mode capture_mode; 124 | enum match_priority match_priority; 125 | bool except; 126 | DARRAY(const char *) selections; 127 | }; 128 | 129 | /* System sinks */ 130 | static void system_sink_destroy_cb(void *data) 131 | { 132 | struct system_sink *s = data; 133 | bfree((void *)s->name); 134 | } 135 | 136 | static void register_system_sink(struct obs_pw_audio_capture_app *pwac, uint32_t global_id, const char *name) 137 | { 138 | struct pw_proxy *sink_proxy = pw_registry_bind(pwac->pw.registry, global_id, PW_TYPE_INTERFACE_Node, 139 | PW_VERSION_NODE, sizeof(struct system_sink)); 140 | if (!sink_proxy) { 141 | return; 142 | } 143 | 144 | struct system_sink *sink = pw_proxy_get_user_data(sink_proxy); 145 | sink->name = bstrdup(name); 146 | sink->id = global_id; 147 | 148 | obs_pw_audio_proxy_list_append(&pwac->system_sinks, sink_proxy); 149 | } 150 | /* ------------------------------------------------- */ 151 | 152 | /* Target clients */ 153 | static void client_destroy_cb(void *data) 154 | { 155 | struct target_client *client = data; 156 | bfree((void *)client->app_name); 157 | bfree((void *)client->binary); 158 | 159 | spa_hook_remove(&client->client_listener); 160 | } 161 | 162 | static void on_client_info_cb(void *data, const struct pw_client_info *info) 163 | { 164 | if ((info->change_mask & PW_CLIENT_CHANGE_MASK_PROPS) == 0 || !info->props || !info->props->n_items) { 165 | return; 166 | } 167 | 168 | const char *binary = spa_dict_lookup(info->props, PW_KEY_APP_PROCESS_BINARY); 169 | if (!binary) { 170 | return; 171 | } 172 | 173 | struct target_client *client = data; 174 | bfree((void *)client->binary); 175 | client->binary = bstrdup(binary); 176 | } 177 | 178 | static const struct pw_client_events client_events = { 179 | PW_VERSION_CLIENT_EVENTS, 180 | .info = on_client_info_cb, 181 | }; 182 | 183 | static void register_target_client(struct obs_pw_audio_capture_app *pwac, uint32_t global_id, const char *app_name) 184 | { 185 | struct pw_proxy *client_proxy = pw_registry_bind(pwac->pw.registry, global_id, PW_TYPE_INTERFACE_Client, 186 | PW_VERSION_CLIENT, sizeof(struct target_client)); 187 | if (!client_proxy) { 188 | return; 189 | } 190 | 191 | struct target_client *client = pw_proxy_get_user_data(client_proxy); 192 | client->binary = NULL; 193 | client->app_name = bstrdup(app_name); 194 | client->id = global_id; 195 | 196 | obs_pw_audio_proxy_list_append(&pwac->clients, client_proxy); 197 | pw_proxy_add_object_listener(client_proxy, &client->client_listener, &client_events, client); 198 | } 199 | 200 | /* Target nodes and ports */ 201 | static void port_destroy_cb(void *data) 202 | { 203 | struct target_node_port *p = data; 204 | bfree((void *)p->channel); 205 | } 206 | 207 | static void node_destroy_cb(void *data) 208 | { 209 | struct target_node *node = data; 210 | 211 | spa_hook_remove(&node->node_listener); 212 | 213 | obs_pw_audio_proxy_list_clear(&node->ports); 214 | 215 | (*node->p_n_nodes)--; 216 | 217 | bfree((void *)node->binary); 218 | bfree((void *)node->app_name); 219 | bfree((void *)node->name); 220 | } 221 | 222 | static struct target_node_port *node_register_port(struct target_node *node, uint32_t global_id, 223 | struct pw_registry *registry, const char *channel) 224 | { 225 | struct pw_proxy *port_proxy = pw_registry_bind(registry, global_id, PW_TYPE_INTERFACE_Port, PW_VERSION_PORT, 226 | sizeof(struct target_node_port)); 227 | if (!port_proxy) { 228 | return NULL; 229 | } 230 | 231 | struct target_node_port *port = pw_proxy_get_user_data(port_proxy); 232 | port->channel = bstrdup(channel); 233 | port->id = global_id; 234 | 235 | obs_pw_audio_proxy_list_append(&node->ports, port_proxy); 236 | 237 | return port; 238 | } 239 | 240 | static void on_node_info_cb(void *data, const struct pw_node_info *info) 241 | { 242 | if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) == 0 || !info->props || !info->props->n_items) { 243 | return; 244 | } 245 | 246 | const char *binary = spa_dict_lookup(info->props, PW_KEY_APP_PROCESS_BINARY); 247 | if (!binary) { 248 | return; 249 | } 250 | 251 | struct target_node *node = data; 252 | bfree((void *)node->binary); 253 | node->binary = bstrdup(binary); 254 | } 255 | 256 | static const struct pw_node_events node_events = { 257 | PW_VERSION_NODE_EVENTS, 258 | .info = on_node_info_cb, 259 | }; 260 | 261 | static void register_target_node(struct obs_pw_audio_capture_app *pwac, uint32_t global_id, uint32_t client_id, 262 | const char *app_name, const char *name) 263 | { 264 | struct pw_proxy *node_proxy = pw_registry_bind(pwac->pw.registry, global_id, PW_TYPE_INTERFACE_Node, 265 | PW_VERSION_NODE, sizeof(struct target_node)); 266 | if (!node_proxy) { 267 | return; 268 | } 269 | 270 | struct target_node *node = pw_proxy_get_user_data(node_proxy); 271 | node->name = bstrdup(name); 272 | node->app_name = bstrdup(app_name); 273 | node->binary = NULL; 274 | node->id = global_id; 275 | node->client_id = client_id; 276 | node->p_n_nodes = &pwac->n_nodes; 277 | obs_pw_audio_proxy_list_init(&node->ports, NULL, port_destroy_cb); 278 | 279 | pwac->n_nodes++; 280 | 281 | obs_pw_audio_proxy_list_append(&pwac->nodes, node_proxy); 282 | pw_proxy_add_object_listener(node_proxy, &node->node_listener, &node_events, node); 283 | } 284 | 285 | static bool node_is_targeted(struct obs_pw_audio_capture_app *pwac, struct target_node *node) 286 | { 287 | bool targeted = false; 288 | for (size_t i = 0; i < pwac->selections.num && !targeted; i++) { 289 | const char *selection = pwac->selections.array[i]; 290 | 291 | targeted = (astrcmpi(selection, node->binary) == 0 || astrcmpi(selection, node->app_name) == 0 || 292 | astrcmpi(selection, node->name) == 0); 293 | 294 | if (!targeted && node->client_id) { 295 | struct obs_pw_audio_proxy_list_iter iter; 296 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->clients); 297 | 298 | struct target_client *client; 299 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&client)) { 300 | if (client->id == node->client_id) { 301 | targeted = (astrcmpi(selection, client->binary) == 0 || 302 | astrcmpi(selection, client->app_name) == 0); 303 | break; 304 | } 305 | } 306 | } 307 | } 308 | 309 | return targeted ^ pwac->except; 310 | } 311 | /* ------------------------------------------------- */ 312 | 313 | /* App streams <-> Capture sink links */ 314 | static void link_bound_cb(void *data, uint32_t global_id) 315 | { 316 | struct capture_sink_link *link = data; 317 | link->id = global_id; 318 | } 319 | 320 | static void link_destroy_cb(void *data) 321 | { 322 | struct capture_sink_link *link = data; 323 | blog(LOG_DEBUG, "[pipewire-audio] Link %u destroyed", link->id); 324 | } 325 | 326 | static void link_port_to_sink(struct obs_pw_audio_capture_app *pwac, struct target_node_port *port, uint32_t node_id) 327 | { 328 | blog(LOG_DEBUG, "[pipewire-audio] Connecting port %u of node %u to app capture sink", port->id, node_id); 329 | 330 | uint32_t p = 0; 331 | if (pwac->sink.channels == 1 && /* Mono capture sink */ 332 | pwac->sink.ports.num >= 1) { 333 | p = pwac->sink.ports.array[0].id; 334 | } else { 335 | for (size_t i = 0; i < pwac->sink.ports.num; i++) { 336 | if (astrcmpi(pwac->sink.ports.array[i].channel, port->channel) == 0) { 337 | p = pwac->sink.ports.array[i].id; 338 | break; 339 | } 340 | } 341 | } 342 | 343 | if (!p) { 344 | blog(LOG_WARNING, 345 | "[pipewire-audio] Could not connect port %u of node %u to app capture sink. No port of app capture sink has channel %s", 346 | port->id, node_id, port->channel); 347 | return; 348 | } 349 | 350 | struct pw_properties *link_props = pw_properties_new(PW_KEY_OBJECT_LINGER, "false", NULL); 351 | 352 | pw_properties_setf(link_props, PW_KEY_LINK_OUTPUT_NODE, "%u", node_id); 353 | pw_properties_setf(link_props, PW_KEY_LINK_OUTPUT_PORT, "%u", port->id); 354 | 355 | pw_properties_setf(link_props, PW_KEY_LINK_INPUT_NODE, "%u", pwac->sink.id); 356 | pw_properties_setf(link_props, PW_KEY_LINK_INPUT_PORT, "%u", p); 357 | 358 | struct pw_proxy *link_proxy = pw_core_create_object(pwac->pw.core, "link-factory", PW_TYPE_INTERFACE_Link, 359 | PW_VERSION_LINK, &link_props->dict, 360 | sizeof(struct capture_sink_link)); 361 | 362 | pw_properties_free(link_props); 363 | 364 | if (!link_proxy) { 365 | blog(LOG_WARNING, "[pipewire-audio] Could not connect port %u of node %u to app capture sink", port->id, 366 | node_id); 367 | return; 368 | } 369 | 370 | struct capture_sink_link *link = pw_proxy_get_user_data(link_proxy); 371 | link->id = SPA_ID_INVALID; 372 | 373 | obs_pw_audio_proxy_list_append(&pwac->sink.links, link_proxy); 374 | } 375 | 376 | static void link_node_to_sink(struct obs_pw_audio_capture_app *pwac, struct target_node *node) 377 | { 378 | struct obs_pw_audio_proxy_list_iter iter; 379 | obs_pw_audio_proxy_list_iter_init(&iter, &node->ports); 380 | 381 | struct target_node_port *port; 382 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&port)) { 383 | link_port_to_sink(pwac, port, node->id); 384 | } 385 | } 386 | /* ------------------------------------------------- */ 387 | 388 | /* App capture sink */ 389 | 390 | /** The app capture sink is created when there 391 | * is info about the system's default sink. 392 | * See the on_metadata and on_default_sink callbacks */ 393 | static void destroy_sink_links(struct obs_pw_audio_capture_app *pwac) 394 | { 395 | obs_pw_audio_proxy_list_clear(&pwac->sink.links); 396 | } 397 | 398 | static void connect_targets(struct obs_pw_audio_capture_app *pwac) 399 | { 400 | if (!pwac->sink.proxy) { 401 | return; 402 | } 403 | 404 | destroy_sink_links(pwac); 405 | 406 | if (pwac->selections.num == 0) { 407 | return; 408 | } 409 | 410 | struct obs_pw_audio_proxy_list_iter iter; 411 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->nodes); 412 | 413 | struct target_node *node; 414 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&node)) { 415 | if (node_is_targeted(pwac, node)) { 416 | link_node_to_sink(pwac, node); 417 | } 418 | } 419 | } 420 | 421 | static void finalize_capture_sink(struct obs_pw_audio_capture_app *pwac) 422 | { 423 | if (!pwac->sink.proxy || pwac->sink.id == SPA_ID_INVALID || pwac->sink.serial == SPA_ID_INVALID || 424 | pwac->sink.ports.num != pwac->sink.channels) { 425 | return; 426 | } 427 | 428 | blog(LOG_DEBUG, "[pipewire-audio] App capture sink ready"); 429 | 430 | connect_targets(pwac); 431 | 432 | pwac->sink.autoconnect_targets = true; 433 | 434 | if (obs_pw_audio_stream_connect(&pwac->pw.audio, pwac->sink.id, pwac->sink.serial, pwac->sink.channels) < 0) { 435 | blog(LOG_WARNING, "[pipewire-audio] Error connecting stream %p to app capture sink %u", 436 | pwac->pw.audio.stream, pwac->sink.id); 437 | } 438 | } 439 | 440 | static void on_sink_proxy_bound_cb(void *data, uint32_t global_id) 441 | { 442 | struct obs_pw_audio_capture_app *pwac = data; 443 | pwac->sink.id = global_id; 444 | da_init(pwac->sink.ports); 445 | } 446 | 447 | static void on_sink_proxy_removed_cb(void *data) 448 | { 449 | struct obs_pw_audio_capture_app *pwac = data; 450 | blog(LOG_WARNING, "[pipewire-audio] App capture sink %u has been destroyed by the PipeWire remote", 451 | pwac->sink.id); 452 | pw_proxy_destroy(pwac->sink.proxy); 453 | } 454 | 455 | static void on_sink_proxy_destroy_cb(void *data) 456 | { 457 | struct obs_pw_audio_capture_app *pwac = data; 458 | 459 | spa_hook_remove(&pwac->sink.proxy_listener); 460 | spa_zero(pwac->sink.proxy_listener); 461 | 462 | for (size_t i = 0; i < pwac->sink.ports.num; i++) { 463 | struct capture_sink_port *p = &pwac->sink.ports.array[i]; 464 | bfree((void *)p->channel); 465 | } 466 | da_free(pwac->sink.ports); 467 | 468 | pwac->sink.channels = 0; 469 | dstr_free(&pwac->sink.position); 470 | 471 | pwac->sink.autoconnect_targets = false; 472 | pwac->sink.proxy = NULL; 473 | 474 | blog(LOG_DEBUG, "[pipewire-audio] App capture sink %u destroyed", pwac->sink.id); 475 | 476 | pwac->sink.id = SPA_ID_INVALID; 477 | } 478 | 479 | static void on_sink_proxy_error_cb(void *data, int seq, int res, const char *message) 480 | { 481 | UNUSED_PARAMETER(data); 482 | blog(LOG_ERROR, "[pipewire-audio] App capture sink error: seq:%d res:%d :%s", seq, res, message); 483 | } 484 | 485 | static const struct pw_proxy_events sink_proxy_events = { 486 | PW_VERSION_PROXY_EVENTS, 487 | .bound = on_sink_proxy_bound_cb, 488 | .removed = on_sink_proxy_removed_cb, 489 | .destroy = on_sink_proxy_destroy_cb, 490 | .error = on_sink_proxy_error_cb, 491 | }; 492 | 493 | static void register_capture_sink_port(struct obs_pw_audio_capture_app *pwac, uint32_t global_id, const char *channel) 494 | { 495 | blog(LOG_DEBUG, "[pipewire-audio] Registering app capture sink port %u", global_id); 496 | 497 | struct capture_sink_port *port = da_push_back_new(pwac->sink.ports); 498 | port->channel = bstrdup(channel); 499 | port->id = global_id; 500 | 501 | finalize_capture_sink(pwac); 502 | } 503 | 504 | static void make_capture_sink(struct obs_pw_audio_capture_app *pwac, uint32_t channels, const char *position) 505 | { 506 | struct pw_properties *sink_props = pw_properties_new(PW_KEY_FACTORY_NAME, "support.null-audio-sink", 507 | PW_KEY_MEDIA_CLASS, "Stream/Input/Audio", 508 | PW_KEY_NODE_VIRTUAL, "true", SPA_KEY_AUDIO_POSITION, 509 | position, NULL); 510 | 511 | pw_properties_setf(sink_props, PW_KEY_NODE_NAME, "OBS: %s", obs_source_get_name(pwac->source)); 512 | 513 | pw_properties_setf(sink_props, PW_KEY_AUDIO_CHANNELS, "%u", channels); 514 | 515 | pwac->sink.proxy = pw_core_create_object(pwac->pw.core, "adapter", PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 516 | &sink_props->dict, 0); 517 | 518 | pw_properties_free(sink_props); 519 | 520 | if (!pwac->sink.proxy) { 521 | blog(LOG_WARNING, "[pipewire-audio] Failed to create app capture sink"); 522 | return; 523 | } 524 | 525 | pwac->sink.channels = channels; 526 | dstr_copy(&pwac->sink.position, position); 527 | 528 | pwac->sink.id = SPA_ID_INVALID; 529 | pwac->sink.serial = SPA_ID_INVALID; 530 | 531 | pw_proxy_add_listener(pwac->sink.proxy, &pwac->sink.proxy_listener, &sink_proxy_events, pwac); 532 | 533 | blog(LOG_DEBUG, "[pipewire-audio] Created app capture sink"); 534 | } 535 | 536 | static void destroy_capture_sink(struct obs_pw_audio_capture_app *pwac) 537 | { 538 | /* Links are automatically destroyed by PipeWire */ 539 | 540 | if (!pwac->sink.proxy) { 541 | return; 542 | } 543 | 544 | if (pw_stream_get_state(pwac->pw.audio.stream, NULL) != PW_STREAM_STATE_UNCONNECTED) { 545 | pw_stream_disconnect(pwac->pw.audio.stream); 546 | } 547 | 548 | pwac->sink.autoconnect_targets = false; 549 | pw_proxy_destroy(pwac->sink.proxy); 550 | } 551 | /* ------------------------------------------------- */ 552 | 553 | /* Default system sink */ 554 | static void on_default_sink_param_cb(void *data, int seq, uint32_t id, uint32_t index, uint32_t next, 555 | const struct spa_pod *param) 556 | { 557 | UNUSED_PARAMETER(seq); 558 | UNUSED_PARAMETER(index); 559 | UNUSED_PARAMETER(next); 560 | 561 | if (id != SPA_PARAM_EnumFormat) { 562 | return; 563 | } 564 | 565 | struct obs_pw_audio_capture_app *pwac = data; 566 | 567 | uint32_t media_type = 0, parsed_id = 0, channels = 0; 568 | struct spa_pod *position_pod = NULL; 569 | 570 | struct spa_pod_parser p; 571 | spa_pod_parser_pod(&p, param); 572 | 573 | spa_pod_parser_get_object(&p, SPA_TYPE_OBJECT_Format, &parsed_id, SPA_FORMAT_mediaType, SPA_POD_Id(&media_type), 574 | SPA_FORMAT_AUDIO_channels, SPA_POD_OPT_Int(&channels), SPA_FORMAT_AUDIO_position, 575 | SPA_POD_OPT_Pod(&position_pod)); 576 | 577 | if (parsed_id != SPA_PARAM_EnumFormat || media_type != SPA_MEDIA_TYPE_audio || !channels || !position_pod) { 578 | goto stereo_fallback; 579 | } 580 | 581 | uint32_t position_n = 0; 582 | uint32_t *position_arr = spa_pod_get_array(position_pod, &position_n); 583 | 584 | struct dstr position_str; 585 | dstr_init(&position_str); 586 | 587 | for (size_t i = 0; i < position_n; i++) { 588 | const char *chn = spa_debug_type_find_short_name(spa_type_audio_channel, position_arr[i]); 589 | 590 | if (strstr(chn, "AUX") != NULL) { 591 | // Sink is configured for pro audio, use stereo 592 | // https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/FAQ#what-is-the-pro-audio-profile 593 | channels = 2; 594 | dstr_copy(&position_str, "FL,FR"); 595 | break; 596 | } 597 | 598 | dstr_cat(&position_str, chn); 599 | 600 | if (position_n - 1 != i) { 601 | dstr_cat_ch(&position_str, ','); 602 | } 603 | } 604 | 605 | if (channels != pwac->sink.channels || dstr_cmpi(&position_str, pwac->sink.position.array) != 0) { 606 | destroy_capture_sink(pwac); 607 | make_capture_sink(pwac, channels, position_str.array); 608 | } 609 | 610 | dstr_free(&position_str); 611 | return; 612 | 613 | stereo_fallback: 614 | if (pwac->sink.proxy) { 615 | return; 616 | } 617 | 618 | blog(LOG_WARNING, "[pipewire-audio] Could not parse format of default sink. Falling back to stereo."); 619 | 620 | destroy_capture_sink(pwac); 621 | make_capture_sink(pwac, 2, "[FL,FR]"); 622 | } 623 | 624 | static const struct pw_node_events default_sink_events = { 625 | PW_VERSION_NODE_EVENTS, 626 | .param = on_default_sink_param_cb, 627 | }; 628 | 629 | static void on_default_sink_proxy_removed_cb(void *data) 630 | { 631 | struct obs_pw_audio_capture_app *pwac = data; 632 | pw_proxy_destroy(pwac->default_sink.proxy); 633 | } 634 | 635 | static void on_default_sink_proxy_destroy_cb(void *data) 636 | { 637 | struct obs_pw_audio_capture_app *pwac = data; 638 | spa_hook_remove(&pwac->default_sink.node_listener); 639 | spa_zero(pwac->default_sink.node_listener); 640 | 641 | spa_hook_remove(&pwac->default_sink.proxy_listener); 642 | spa_zero(pwac->default_sink.proxy_listener); 643 | 644 | pwac->default_sink.proxy = NULL; 645 | } 646 | 647 | static const struct pw_proxy_events default_sink_proxy_events = { 648 | PW_VERSION_PROXY_EVENTS, 649 | .removed = on_default_sink_proxy_removed_cb, 650 | .destroy = on_default_sink_proxy_destroy_cb, 651 | }; 652 | 653 | static void default_node_cb(void *data, const char *name) 654 | { 655 | struct obs_pw_audio_capture_app *pwac = data; 656 | 657 | blog(LOG_DEBUG, "[pipewire-audio] New default sink %s", name); 658 | 659 | /* Find the new default sink and bind to it to get its channel info */ 660 | struct obs_pw_audio_proxy_list_iter iter; 661 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->system_sinks); 662 | 663 | struct system_sink *temp, *default_sink = NULL; 664 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&temp)) { 665 | if (strcmp(name, temp->name) == 0) { 666 | default_sink = temp; 667 | break; 668 | } 669 | } 670 | if (!default_sink) { 671 | return; 672 | } 673 | 674 | if (pwac->default_sink.proxy) { 675 | pw_proxy_destroy(pwac->default_sink.proxy); 676 | } 677 | 678 | pwac->default_sink.proxy = 679 | pw_registry_bind(pwac->pw.registry, default_sink->id, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0); 680 | if (!pwac->default_sink.proxy) { 681 | if (!pwac->sink.proxy) { 682 | blog(LOG_WARNING, 683 | "[pipewire-audio] Failed to get default sink info, app capture sink defaulting to stereo"); 684 | make_capture_sink(pwac, 2, "FL,FR"); 685 | } 686 | return; 687 | } 688 | 689 | pw_proxy_add_object_listener(pwac->default_sink.proxy, &pwac->default_sink.node_listener, &default_sink_events, 690 | pwac); 691 | pw_proxy_add_listener(pwac->default_sink.proxy, &pwac->default_sink.proxy_listener, &default_sink_proxy_events, 692 | pwac); 693 | 694 | pw_node_subscribe_params((struct pw_node *)pwac->default_sink.proxy, (uint32_t[]){SPA_PARAM_EnumFormat}, 1); 695 | } 696 | /* ------------------------------------------------- */ 697 | 698 | /* Registry */ 699 | static void on_global_cb(void *data, uint32_t id, uint32_t permissions, const char *type, uint32_t version, 700 | const struct spa_dict *props) 701 | { 702 | UNUSED_PARAMETER(permissions); 703 | UNUSED_PARAMETER(version); 704 | 705 | if (!props || !type) { 706 | return; 707 | } 708 | 709 | struct obs_pw_audio_capture_app *pwac = data; 710 | 711 | if (id == pwac->sink.id) { 712 | const char *ser = spa_dict_lookup(props, PW_KEY_OBJECT_SERIAL); 713 | if (!ser) { 714 | blog(LOG_ERROR, "[pipewire-audio] No object serial found on app capture sink %u", id); 715 | pwac->sink.serial = 0; 716 | } else { 717 | pwac->sink.serial = strtoul(ser, NULL, 10); 718 | finalize_capture_sink(pwac); 719 | } 720 | } 721 | 722 | if (strcmp(type, PW_TYPE_INTERFACE_Port) == 0) { 723 | const char *nid, *dir, *chn; 724 | if (!(nid = spa_dict_lookup(props, PW_KEY_NODE_ID)) || 725 | !(dir = spa_dict_lookup(props, PW_KEY_PORT_DIRECTION)) || 726 | !(chn = spa_dict_lookup(props, PW_KEY_AUDIO_CHANNEL))) { 727 | return; 728 | } 729 | 730 | uint32_t node_id = strtoul(nid, NULL, 10); 731 | 732 | if (astrcmpi(dir, "in") == 0 && node_id == pwac->sink.id) { 733 | register_capture_sink_port(pwac, id, chn); 734 | } else if (astrcmpi(dir, "out") == 0) { 735 | /* Possibly a target port */ 736 | struct obs_pw_audio_proxy_list_iter iter; 737 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->nodes); 738 | 739 | struct target_node *temp, *node = NULL; 740 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&temp)) { 741 | if (temp->id == node_id) { 742 | node = temp; 743 | break; 744 | } 745 | } 746 | if (!node) { 747 | return; 748 | } 749 | 750 | struct target_node_port *port = node_register_port(node, id, pwac->pw.registry, chn); 751 | 752 | if (port && pwac->sink.autoconnect_targets && node_is_targeted(pwac, node)) { 753 | link_port_to_sink(pwac, port, node->id); 754 | } 755 | } 756 | } else if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) { 757 | const char *node_name, *media_class; 758 | if (!(node_name = spa_dict_lookup(props, PW_KEY_NODE_NAME)) || 759 | !(media_class = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS))) { 760 | return; 761 | } 762 | 763 | if (strcmp(media_class, "Stream/Output/Audio") == 0) { 764 | /* Target node */ 765 | const char *node_app_name = spa_dict_lookup(props, PW_KEY_APP_NAME); 766 | if (!node_app_name) { 767 | node_app_name = node_name; 768 | } 769 | 770 | uint32_t client_id = 0; 771 | const char *client_id_str = spa_dict_lookup(props, PW_KEY_CLIENT_ID); 772 | if (client_id_str) { 773 | client_id = strtoul(client_id_str, NULL, 10); 774 | } 775 | 776 | register_target_node(pwac, id, client_id, node_app_name, node_name); 777 | } else if (strcmp(media_class, "Audio/Sink") == 0) { 778 | register_system_sink(pwac, id, node_name); 779 | } 780 | 781 | } else if (strcmp(type, PW_TYPE_INTERFACE_Client) == 0) { 782 | const char *client_app_name = spa_dict_lookup(props, PW_KEY_APP_NAME); 783 | register_target_client(pwac, id, client_app_name); 784 | } else if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) { 785 | const char *name = spa_dict_lookup(props, PW_KEY_METADATA_NAME); 786 | if (!name || strcmp(name, "default") != 0) { 787 | return; 788 | } 789 | 790 | if (!obs_pw_audio_default_node_metadata_listen(&pwac->default_sink.metadata, &pwac->pw, id, true, 791 | default_node_cb, pwac) && 792 | !pwac->sink.proxy) { 793 | blog(LOG_WARNING, 794 | "[pipewire-audio] Failed to get default metadata, app capture sink defaulting to stereo"); 795 | make_capture_sink(pwac, 2, "FL,FR"); 796 | } 797 | } 798 | } 799 | 800 | static const struct pw_registry_events registry_events = { 801 | PW_VERSION_REGISTRY_EVENTS, 802 | .global = on_global_cb, 803 | }; 804 | /* ------------------------------------------------- */ 805 | 806 | /* Source */ 807 | static bool add_app_clicked(obs_properties_t *properties, obs_property_t *property, void *data) 808 | { 809 | UNUSED_PARAMETER(properties); 810 | UNUSED_PARAMETER(property); 811 | 812 | obs_source_t *source = data; 813 | 814 | obs_data_t *settings = obs_source_get_settings(source); 815 | const char *app_to_add = obs_data_get_string(settings, SETTING_AVAILABLE_APPS); 816 | 817 | obs_data_array_t *selections = obs_data_get_array(settings, SETTING_SELECTION_MULTIPLE); 818 | if (obs_data_array_count(selections) == 0) { 819 | obs_data_array_release(selections); 820 | 821 | selections = obs_data_array_create(); 822 | obs_data_set_array(settings, SETTING_SELECTION_MULTIPLE, selections); 823 | } 824 | 825 | /* Don't add if selection is already in the list */ 826 | 827 | bool should_add = true; 828 | for (size_t i = 0; i < obs_data_array_count(selections) && should_add; i++) { 829 | obs_data_t *item = obs_data_array_item(selections, i); 830 | 831 | should_add = astrcmpi(obs_data_get_string(item, "value"), app_to_add) != 0; 832 | 833 | obs_data_release(item); 834 | } 835 | 836 | if (should_add) { 837 | obs_data_t *new_entry = obs_data_create(); 838 | obs_data_set_bool(new_entry, "hidden", false); 839 | obs_data_set_bool(new_entry, "selected", false); 840 | obs_data_set_string(new_entry, "value", app_to_add); 841 | 842 | obs_data_array_push_back(selections, new_entry); 843 | 844 | obs_data_release(new_entry); 845 | 846 | obs_source_update(source, settings); 847 | } 848 | 849 | obs_data_array_release(selections); 850 | obs_data_release(settings); 851 | 852 | return should_add; 853 | } 854 | 855 | static int cmp_targets(const void *a, const void *b) 856 | { 857 | const char *a_str = *(char **)a; 858 | const char *b_str = *(char **)b; 859 | return strcmp(a_str, b_str); 860 | } 861 | 862 | static const char *choose_display_string(struct obs_pw_audio_capture_app *pwac, const char *binary, 863 | const char *app_name) 864 | { 865 | switch (pwac->match_priority) { 866 | case MATCH_PRIORITY_BINARY_NAME: 867 | return binary ? binary : app_name; 868 | case MATCH_PRIORITY_APP_NAME: 869 | return app_name ? app_name : binary; 870 | default: 871 | return NULL; 872 | } 873 | } 874 | 875 | static void populate_avaiable_apps_list(obs_property_t *list, struct obs_pw_audio_capture_app *pwac) 876 | { 877 | DARRAY(const char *) targets; 878 | da_init(targets); 879 | 880 | pw_thread_loop_lock(pwac->pw.thread_loop); 881 | 882 | da_reserve(targets, pwac->n_nodes); 883 | 884 | struct obs_pw_audio_proxy_list_iter iter; 885 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->nodes); 886 | 887 | struct target_node *node; 888 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&node)) { 889 | const char *display = choose_display_string(pwac, node->binary, node->app_name); 890 | 891 | if (!display) { 892 | display = node->name; 893 | } 894 | 895 | da_push_back(targets, &display); 896 | } 897 | 898 | obs_pw_audio_proxy_list_iter_init(&iter, &pwac->clients); 899 | 900 | struct target_client *client; 901 | while (obs_pw_audio_proxy_list_iter_next(&iter, (void **)&client)) { 902 | const char *display = choose_display_string(pwac, client->binary, client->app_name); 903 | 904 | if (display) { 905 | da_push_back(targets, &display); 906 | } 907 | } 908 | 909 | /* Show just one entry per target */ 910 | 911 | qsort(targets.array, targets.num, sizeof(const char *), cmp_targets); 912 | 913 | for (size_t i = 0; i < targets.num; i++) { 914 | if (i == 0 || strcmp(targets.array[i - 1], targets.array[i]) != 0) { 915 | obs_property_list_add_string(list, targets.array[i], targets.array[i]); 916 | } 917 | } 918 | 919 | pw_thread_loop_unlock(pwac->pw.thread_loop); 920 | 921 | da_free(targets); 922 | } 923 | 924 | static bool capture_mode_modified(void *data, obs_properties_t *properties, obs_property_t *property, 925 | obs_data_t *settings) 926 | { 927 | UNUSED_PARAMETER(property); 928 | 929 | struct obs_pw_audio_capture_app *pwac = data; 930 | 931 | enum capture_mode mode = obs_data_get_int(settings, SETTING_CAPTURE_MODE); 932 | 933 | switch (mode) { 934 | case CAPTURE_MODE_SINGLE: { 935 | obs_properties_remove_by_name(properties, SETTING_SELECTION_MULTIPLE); 936 | obs_properties_remove_by_name(properties, SETTING_AVAILABLE_APPS); 937 | obs_properties_remove_by_name(properties, SETTING_ADD_TO_SELECTIONS); 938 | 939 | obs_property_t *available_apps = 940 | obs_properties_add_list(properties, SETTING_SELECTION_SINGLE, obs_module_text("Application"), 941 | OBS_COMBO_TYPE_EDITABLE, OBS_COMBO_FORMAT_STRING); 942 | 943 | populate_avaiable_apps_list(available_apps, pwac); 944 | 945 | break; 946 | } 947 | case CAPTURE_MODE_MULTIPLE: { 948 | obs_properties_remove_by_name(properties, SETTING_SELECTION_SINGLE); 949 | 950 | obs_properties_add_editable_list(properties, SETTING_SELECTION_MULTIPLE, 951 | obs_module_text("SelectedApps"), OBS_EDITABLE_LIST_TYPE_STRINGS, NULL, 952 | NULL); 953 | 954 | obs_property_t *available_apps = obs_properties_add_list(properties, SETTING_AVAILABLE_APPS, 955 | obs_module_text("Applications"), 956 | OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); 957 | 958 | populate_avaiable_apps_list(available_apps, pwac); 959 | 960 | obs_properties_add_button2(properties, SETTING_ADD_TO_SELECTIONS, obs_module_text("AddToSelected"), 961 | add_app_clicked, pwac->source); 962 | 963 | break; 964 | } 965 | } 966 | 967 | return true; 968 | } 969 | 970 | static bool match_priority_modified(void *data, obs_properties_t *properties, obs_property_t *property, 971 | obs_data_t *settings) 972 | { 973 | UNUSED_PARAMETER(property); 974 | 975 | struct obs_pw_audio_capture_app *pwac = data; 976 | 977 | enum capture_mode mode = obs_data_get_int(settings, SETTING_CAPTURE_MODE); 978 | 979 | obs_property_t *targets = NULL; 980 | switch (mode) { 981 | default: 982 | case CAPTURE_MODE_SINGLE: 983 | targets = obs_properties_get(properties, SETTING_SELECTION_SINGLE); 984 | break; 985 | case CAPTURE_MODE_MULTIPLE: 986 | targets = obs_properties_get(properties, SETTING_AVAILABLE_APPS); 987 | break; 988 | } 989 | 990 | if (targets == NULL) { 991 | return false; 992 | } 993 | 994 | obs_property_list_clear(targets); 995 | 996 | populate_avaiable_apps_list(targets, pwac); 997 | 998 | return true; 999 | } 1000 | 1001 | static void build_selections(struct obs_pw_audio_capture_app *pwac, obs_data_t *settings) 1002 | { 1003 | switch (pwac->capture_mode) { 1004 | case CAPTURE_MODE_SINGLE: { 1005 | const char *selection = bstrdup(obs_data_get_string(settings, SETTING_SELECTION_SINGLE)); 1006 | da_push_back(pwac->selections, &selection); 1007 | break; 1008 | } 1009 | case CAPTURE_MODE_MULTIPLE: { 1010 | obs_data_array_t *selections = obs_data_get_array(settings, SETTING_SELECTION_MULTIPLE); 1011 | for (size_t i = 0; i < obs_data_array_count(selections); i++) { 1012 | obs_data_t *item = obs_data_array_item(selections, i); 1013 | 1014 | const char *selection = bstrdup(obs_data_get_string(item, "value")); 1015 | da_push_back(pwac->selections, &selection); 1016 | 1017 | obs_data_release(item); 1018 | } 1019 | obs_data_array_release(selections); 1020 | break; 1021 | } 1022 | } 1023 | } 1024 | 1025 | static void clear_selections(struct obs_pw_audio_capture_app *pwac) 1026 | { 1027 | for (size_t i = 0; i < pwac->selections.num; i++) { 1028 | const char *selection = pwac->selections.array[i]; 1029 | bfree((void *)selection); 1030 | } 1031 | 1032 | pwac->selections.num = 0; 1033 | } 1034 | 1035 | static void *pipewire_audio_capture_app_create(obs_data_t *settings, obs_source_t *source) 1036 | { 1037 | struct obs_pw_audio_capture_app *pwac = bzalloc(sizeof(struct obs_pw_audio_capture_app)); 1038 | 1039 | if (!obs_pw_audio_instance_init(&pwac->pw, ®istry_events, pwac, true, false, source)) { 1040 | obs_pw_audio_instance_destroy(&pwac->pw); 1041 | 1042 | bfree(pwac); 1043 | return NULL; 1044 | } 1045 | 1046 | pwac->source = source; 1047 | 1048 | obs_pw_audio_proxy_list_init(&pwac->nodes, NULL, node_destroy_cb); 1049 | obs_pw_audio_proxy_list_init(&pwac->clients, NULL, client_destroy_cb); 1050 | obs_pw_audio_proxy_list_init(&pwac->sink.links, link_bound_cb, link_destroy_cb); 1051 | obs_pw_audio_proxy_list_init(&pwac->system_sinks, NULL, system_sink_destroy_cb); 1052 | 1053 | pwac->sink.id = SPA_ID_INVALID; 1054 | dstr_init(&pwac->sink.position); 1055 | 1056 | pwac->capture_mode = obs_data_get_int(settings, SETTING_CAPTURE_MODE); 1057 | pwac->match_priority = obs_data_get_int(settings, SETTING_MATCH_PRIORITY); 1058 | pwac->except = obs_data_get_bool(settings, SETTING_EXCLUDE_SELECTIONS); 1059 | 1060 | da_init(pwac->selections); 1061 | build_selections(pwac, settings); 1062 | 1063 | pw_thread_loop_unlock(pwac->pw.thread_loop); 1064 | 1065 | return pwac; 1066 | } 1067 | 1068 | static void pipewire_audio_capture_app_defaults(obs_data_t *settings) 1069 | { 1070 | obs_data_set_default_int(settings, SETTING_CAPTURE_MODE, CAPTURE_MODE_SINGLE); 1071 | obs_data_set_default_int(settings, SETTING_MATCH_PRIORITY, MATCH_PRIORITY_BINARY_NAME); 1072 | obs_data_set_default_bool(settings, SETTING_EXCLUDE_SELECTIONS, false); 1073 | 1074 | obs_data_array_t *arr = obs_data_array_create(); 1075 | obs_data_set_default_array(settings, SETTING_SELECTION_MULTIPLE, arr); 1076 | obs_data_array_release(arr); 1077 | } 1078 | 1079 | static obs_properties_t *pipewire_audio_capture_app_properties(void *data) 1080 | { 1081 | struct obs_pw_audio_capture_app *pwac = data; 1082 | 1083 | obs_properties_t *p = obs_properties_create(); 1084 | 1085 | obs_property_t *capture_mode = obs_properties_add_list( 1086 | p, SETTING_CAPTURE_MODE, obs_module_text("AppCaptureMode"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT); 1087 | obs_property_list_add_int(capture_mode, obs_module_text("SingleApp"), CAPTURE_MODE_SINGLE); 1088 | obs_property_list_add_int(capture_mode, obs_module_text("MultipleApps"), CAPTURE_MODE_MULTIPLE); 1089 | obs_property_set_modified_callback2(capture_mode, capture_mode_modified, pwac); 1090 | 1091 | obs_property_t *match_priority = obs_properties_add_list( 1092 | p, SETTING_MATCH_PRIORITY, obs_module_text("MatchPriority"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT); 1093 | obs_property_list_add_int(match_priority, obs_module_text("MatchBinaryFirst"), MATCH_PRIORITY_BINARY_NAME); 1094 | obs_property_list_add_int(match_priority, obs_module_text("MatchAppNameFirst"), MATCH_PRIORITY_APP_NAME); 1095 | obs_property_set_modified_callback2(match_priority, match_priority_modified, pwac); 1096 | 1097 | obs_properties_add_bool(p, SETTING_EXCLUDE_SELECTIONS, obs_module_text("ExceptApp")); 1098 | 1099 | return p; 1100 | } 1101 | 1102 | static void pipewire_audio_capture_app_update(void *data, obs_data_t *settings) 1103 | { 1104 | struct obs_pw_audio_capture_app *pwac = data; 1105 | 1106 | pw_thread_loop_lock(pwac->pw.thread_loop); 1107 | 1108 | pwac->capture_mode = obs_data_get_int(settings, SETTING_CAPTURE_MODE); 1109 | pwac->match_priority = obs_data_get_int(settings, SETTING_MATCH_PRIORITY); 1110 | pwac->except = obs_data_get_bool(settings, SETTING_EXCLUDE_SELECTIONS); 1111 | 1112 | clear_selections(pwac); 1113 | build_selections(pwac, settings); 1114 | 1115 | connect_targets(pwac); 1116 | 1117 | pw_thread_loop_unlock(pwac->pw.thread_loop); 1118 | } 1119 | 1120 | static void pipewire_audio_capture_app_show(void *data) 1121 | { 1122 | struct obs_pw_audio_capture_app *pwac = data; 1123 | 1124 | pw_thread_loop_lock(pwac->pw.thread_loop); 1125 | pw_stream_set_active(pwac->pw.audio.stream, true); 1126 | pw_thread_loop_unlock(pwac->pw.thread_loop); 1127 | } 1128 | 1129 | static void pipewire_audio_capture_app_hide(void *data) 1130 | { 1131 | struct obs_pw_audio_capture_app *pwac = data; 1132 | 1133 | pw_thread_loop_lock(pwac->pw.thread_loop); 1134 | pw_stream_set_active(pwac->pw.audio.stream, false); 1135 | pw_thread_loop_unlock(pwac->pw.thread_loop); 1136 | } 1137 | 1138 | static void pipewire_audio_capture_app_destroy(void *data) 1139 | { 1140 | struct obs_pw_audio_capture_app *pwac = data; 1141 | 1142 | pw_thread_loop_lock(pwac->pw.thread_loop); 1143 | 1144 | obs_pw_audio_proxy_list_clear(&pwac->nodes); 1145 | obs_pw_audio_proxy_list_clear(&pwac->system_sinks); 1146 | 1147 | obs_pw_audio_proxy_list_clear(&pwac->clients); 1148 | 1149 | destroy_capture_sink(pwac); 1150 | 1151 | if (pwac->default_sink.proxy) { 1152 | pw_proxy_destroy(pwac->default_sink.proxy); 1153 | } 1154 | if (pwac->default_sink.metadata.proxy) { 1155 | pw_proxy_destroy(pwac->default_sink.metadata.proxy); 1156 | } 1157 | 1158 | obs_pw_audio_instance_destroy(&pwac->pw); 1159 | 1160 | dstr_free(&pwac->sink.position); 1161 | 1162 | clear_selections(pwac); 1163 | da_free(pwac->selections); 1164 | 1165 | bfree(pwac); 1166 | } 1167 | 1168 | static const char *pipewire_audio_capture_app_name(void *data) 1169 | { 1170 | UNUSED_PARAMETER(data); 1171 | return obs_module_text("PipeWireAudioCaptureApplication"); 1172 | } 1173 | 1174 | void pipewire_audio_capture_app_load(void) 1175 | { 1176 | const struct obs_source_info pipewire_audio_capture_application = { 1177 | .id = "pipewire_audio_application_capture", 1178 | .type = OBS_SOURCE_TYPE_INPUT, 1179 | .output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE, 1180 | .get_name = pipewire_audio_capture_app_name, 1181 | .create = pipewire_audio_capture_app_create, 1182 | .get_defaults = pipewire_audio_capture_app_defaults, 1183 | .get_properties = pipewire_audio_capture_app_properties, 1184 | .update = pipewire_audio_capture_app_update, 1185 | .show = pipewire_audio_capture_app_show, 1186 | .hide = pipewire_audio_capture_app_hide, 1187 | .destroy = pipewire_audio_capture_app_destroy, 1188 | .icon_type = OBS_ICON_TYPE_PROCESS_AUDIO_OUTPUT, 1189 | }; 1190 | 1191 | obs_register_source(&pipewire_audio_capture_application); 1192 | } 1193 | --------------------------------------------------------------------------------