diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf30482..f2ecb83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Strict workspace Clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + - name: Strict PipeWire-only Clippy + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features pipewire-capture -- -D warnings + - name: Cross-document public facade on the configured docs.rs target run: cargo doc --locked --no-deps -p pocketstation --no-default-features --target x86_64-unknown-linux-gnu @@ -52,7 +55,7 @@ jobs: run: | if cargo tree --locked -e normal -p pocketstation \ --no-default-features --features native-capture --prefix none | \ - grep -E '^(opus|audiopus_sys) v'; then + grep -E '^(opus|audiopus_sys|opusic-sys) v'; then echo "capture-only builds must not include Opus" >&2 exit 1 fi @@ -84,6 +87,7 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.95.0 + components: clippy - name: Install Python 3.13 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 @@ -100,5 +104,19 @@ jobs: ninja --version cargo check --release --locked -p pocketstation + - name: Link the default debug library + if: runner.os == 'Windows' + env: + CMAKE_GENERATOR: Ninja + run: cargo build --locked -p pocketstation + - name: Link the capture-only debug library without Opus run: cargo build --locked -p pocketstation --no-default-features --features native-capture + + - name: Strict Core Audio-only Clippy + if: runner.os == 'macOS' + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features coreaudio-capture -- -D warnings + + - name: Strict WASAPI-only Clippy + if: runner.os == 'Windows' + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features wasapi-capture -- -D warnings diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e3fb1cf..80cc5f7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,6 +115,9 @@ jobs: - name: Strict workspace Clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + - name: Strict PipeWire-only Clippy + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features pipewire-capture -- -D warnings + - name: Cross-document public facade on the configured docs.rs target run: cargo doc --locked --no-deps -p pocketstation --no-default-features --target x86_64-unknown-linux-gnu @@ -122,7 +125,7 @@ jobs: run: | if cargo tree --locked -e normal -p pocketstation \ --no-default-features --features native-capture --prefix none | \ - grep -E '^(opus|audiopus_sys) v'; then + grep -E '^(opus|audiopus_sys|opusic-sys) v'; then echo "capture-only builds must not include Opus" >&2 exit 1 fi @@ -164,6 +167,7 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.95.0 + components: clippy - name: Install Python 3.13 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 @@ -180,9 +184,23 @@ jobs: ninja --version cargo check --release --locked -p pocketstation + - name: Link the default debug library + if: runner.os == 'Windows' + env: + CMAKE_GENERATOR: Ninja + run: cargo build --locked -p pocketstation + - name: Link the capture-only debug library without Opus run: cargo build --locked -p pocketstation --no-default-features --features native-capture + - name: Strict Core Audio-only Clippy + if: runner.os == 'macOS' + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features coreaudio-capture -- -D warnings + + - name: Strict WASAPI-only Clippy + if: runner.os == 'Windows' + run: cargo clippy -p pocketstation --lib --locked --no-default-features --features wasapi-capture -- -D warnings + publish: if: >- ( diff --git a/Cargo.lock b/Cargo.lock index 497dbcc..cc1e6fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,17 +67,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55ca83137a482d61d916ceb1eba52a684f98004f18e0cafea230fe5579c178a3" -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -941,11 +930,20 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opus" -version = "0.3.1" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" +dependencies = [ + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" dependencies = [ - "audiopus_sys", + "cmake", ] [[package]] @@ -1015,7 +1013,7 @@ dependencies = [ [[package]] name = "pocketstation" -version = "1.1.9" +version = "1.1.10" dependencies = [ "alsa", "assert_no_alloc", diff --git a/Cargo.toml b/Cargo.toml index 9f65fcd..8e1e9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pocketstation" -version = "1.1.9" +version = "1.1.10" edition = "2021" rust-version = "1.95.0" license = "MIT OR Apache-2.0" @@ -60,14 +60,21 @@ crate-type = ["rlib", "cdylib", "staticlib"] [features] default = ["native-capture", "opus-codec"] native-capture = [ - "dep:cpal", - "dep:pipewire", - "dep:alsa", - "dep:libc", + "coreaudio-capture", + "wasapi-capture", + "pipewire-capture", + "alsa-fallback", +] +native-capture-runtime = [] +coreaudio-capture = ["native-capture-runtime", "dep:cpal"] +wasapi-capture = [ + "native-capture-runtime", "dep:wasapi", "dep:windows", "dep:windows-core", ] +pipewire-capture = ["native-capture-runtime", "dep:pipewire", "dep:libc"] +alsa-fallback = ["native-capture-runtime", "dep:alsa", "dep:libc"] opus-codec = ["dep:opus"] conformance-fixtures = [] internal-testing = [] @@ -80,7 +87,7 @@ thiserror = "1" hound = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" -opus = { version = "0.3", optional = true } +opus = { version = "0.4", optional = true } tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } [dev-dependencies] diff --git a/README.md b/README.md index b07eab3..717da4d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ You need Rust 1.95 or newer and one supported desktop application producing audio. ```bash -cargo add pocketstation@1.1.9 +cargo add pocketstation@1.1.10 ``` ```rust,no_run @@ -209,7 +209,7 @@ Native prerequisites: Disable native capture when a tool only needs PocketStation types or docs: ```toml -pocketstation = { version = "1.1.9", default-features = false } +pocketstation = { version = "1.1.10", default-features = false } ``` ## Continue from the task you have diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bd9fba7..f431c8a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,6 +4,27 @@ This page covers user-visible changes in the PocketStation 1.x release line. ## Unreleased +## 1.1.10 — 2026-09-04 + +Windows applications can now use PocketStation's default features in debug +builds. The Opus dependency uses the current libopus bindings, which link the +same Microsoft C runtime as the Rust application. + +Applications that already own part of the host audio stack can select only the +PocketStation driver they need: + +```toml +pocketstation = { + version = "1.1.10", + default-features = false, + features = ["pipewire-capture"] +} +``` + +The available driver features are `coreaudio-capture`, `wasapi-capture`, +`pipewire-capture`, and `alsa-fallback`. The normal `native-capture` feature +still enables all drivers for the current platform. + ## 1.1.9 — 2026-09-04 Capture the complete desktop output mix through the same Session API used for diff --git a/build.rs b/build.rs index 30ff25d..fee74b0 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,7 @@ fn main() { #[cfg(target_os = "macos")] { - if std::env::var_os("CARGO_FEATURE_NATIVE_CAPTURE").is_some() { + if std::env::var_os("CARGO_FEATURE_COREAUDIO_CAPTURE").is_some() { build_macos_capture_bridge(); } if std::env::var_os("CARGO_FEATURE_MACOS_ASP_DRIVER_ARTIFACT").is_some() { diff --git a/docs/compatibility/README.md b/docs/compatibility/README.md index 0ec2957..704c7cc 100644 --- a/docs/compatibility/README.md +++ b/docs/compatibility/README.md @@ -13,12 +13,28 @@ The default feature set includes desktop capture and the Opus codec. A native application that only captures PCM can omit the codec and its native library: ```toml -pocketstation = { version = "1.1.9", default-features = false, features = ["native-capture"] } +pocketstation = { version = "1.1.10", default-features = false, features = ["native-capture"] } ``` Use the `opus-codec` feature when the application calls the Rust Opus API or links the C Opus functions declared in `pocketstation.h`. +An application that already owns part of the host audio stack can select one +PocketStation driver directly. For example, a Linux application that already +links an incompatible ALSA crate can enable only PipeWire: + +```toml +pocketstation = { + version = "1.1.10", + default-features = false, + features = ["pipewire-capture"] +} +``` + +The corresponding macOS and Windows features are `coreaudio-capture` and +`wasapi-capture`. Use the normal `native-capture` feature when PocketStation +should select the platform driver and include the Linux `snd-aloop` fallback. + Use the repository checks before changing a public API or protocol: ```bash diff --git a/docs/getting-started/rust-quickstart.md b/docs/getting-started/rust-quickstart.md index eeb74b1..958d522 100644 --- a/docs/getting-started/rust-quickstart.md +++ b/docs/getting-started/rust-quickstart.md @@ -23,7 +23,7 @@ Native build requirements: ## Add PocketStation ```bash -cargo add pocketstation@1.1.9 +cargo add pocketstation@1.1.10 ``` ## Capture in three lines diff --git a/examples/operator-consumer/Cargo.lock b/examples/operator-consumer/Cargo.lock index 03b4c60..6d64421 100644 --- a/examples/operator-consumer/Cargo.lock +++ b/examples/operator-consumer/Cargo.lock @@ -49,17 +49,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -719,11 +708,20 @@ dependencies = [ [[package]] name = "opus" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" dependencies = [ - "audiopus_sys", + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" +dependencies = [ + "cmake", ] [[package]] @@ -765,13 +763,14 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pocketstation" -version = "0.1.2" +version = "1.1.10" dependencies = [ "alsa", "cc", "cpal", "hound", "libc", + "libloading", "opus", "pipewire", "rtrb", diff --git a/examples/whisper-transcribe/Cargo.lock b/examples/whisper-transcribe/Cargo.lock index 3bb94d9..c0bcaea 100644 --- a/examples/whisper-transcribe/Cargo.lock +++ b/examples/whisper-transcribe/Cargo.lock @@ -49,17 +49,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -711,11 +700,20 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opus" -version = "0.3.1" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" +dependencies = [ + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" dependencies = [ - "audiopus_sys", + "cmake", ] [[package]] @@ -757,7 +755,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pocketstation" -version = "1.1.6" +version = "1.1.10" dependencies = [ "alsa", "cc", diff --git a/src/capture/capture_owner.rs b/src/capture/capture_owner.rs index 19ce528..feb2598 100644 --- a/src/capture/capture_owner.rs +++ b/src/capture/capture_owner.rs @@ -330,7 +330,7 @@ pub fn prepare_capture_with_start_gate( /// /// This is a control-thread operation. Callers with multiple workers must call /// it for every owned handle before returning the first failure. -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any(test, feature = "internal-testing", feature = "native-capture-runtime"))] pub fn join_capture_worker( worker_thread: std::thread::JoinHandle<()>, worker: &'static str, diff --git a/src/capture/events.rs b/src/capture/events.rs index 00bbbe1..29e3250 100644 --- a/src/capture/events.rs +++ b/src/capture/events.rs @@ -276,7 +276,11 @@ impl SourceRuntimeEventSender { /// Publishes one exact post-open backend failure without introducing another /// event queue or worker. -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture") +))] pub fn publish_backend_failure( sender: &SourceRuntimeEventSender, stable_id: StableSourceId, diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 69d3e46..90e1c20 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -12,7 +12,15 @@ mod authorization; mod capture_owner; mod events; -#[cfg(any(test, feature = "native-capture"))] +#[cfg(any( + test, + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] mod frame_normalizer; mod frame_stream; mod identity; @@ -25,7 +33,16 @@ mod selection; mod timeline; pub use authorization::*; -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] pub use capture_owner::join_capture_worker; #[cfg(any(test, feature = "internal-testing"))] pub use capture_owner::{ @@ -50,17 +67,43 @@ pub use frame_stream::{ pub use identity::*; #[cfg(feature = "internal-testing")] pub use lifecycle_registry::*; -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] pub use observations::CaptureObservationCounters; pub use observations::{CaptureObservationHandle, CaptureObservations}; pub use query::*; pub use selection::*; +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] +pub use timeline::CaptureSampleTimeline; #[cfg(any(test, feature = "internal-testing"))] pub use timeline::CaptureSampleTimelineError; -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] -pub use timeline::{ - initialize_monotonic_timestamp_domain, monotonic_timestamp_ns, CaptureSampleTimeline, -}; +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] +pub use timeline::{initialize_monotonic_timestamp_domain, monotonic_timestamp_ns}; #[cfg(test)] mod tests; diff --git a/src/capture/observations.rs b/src/capture/observations.rs index e30922f..ae0e2cf 100644 --- a/src/capture/observations.rs +++ b/src/capture/observations.rs @@ -42,12 +42,12 @@ impl CaptureObservationHandle { /// Setup-time cloneable handle; every observation is one relaxed atomic /// operation and remains allocation-free, lock-free, and log-free. #[derive(Clone, Debug, Default)] -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any(test, feature = "internal-testing", feature = "native-capture-runtime"))] pub struct CaptureObservationCounters { values: Arc, } -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any(test, feature = "internal-testing", feature = "native-capture-runtime"))] impl CaptureObservationCounters { pub fn observe_callback_buffer(&self) { self.values @@ -97,6 +97,11 @@ impl CaptureObservationCounters { .fetch_add(1, Ordering::Relaxed); } + #[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture") + ))] pub fn observe_timestamp_epoch_clamp(&self) { self.values .timestamp_epoch_clamps_total diff --git a/src/capture/platform/linux/mod.rs b/src/capture/platform/linux/mod.rs index 192db6b..0a55842 100644 --- a/src/capture/platform/linux/mod.rs +++ b/src/capture/platform/linux/mod.rs @@ -3,7 +3,9 @@ mod pipewire; #[cfg(target_os = "linux")] mod session_backend; +#[cfg(all(target_os = "linux", any(test, feature = "internal-testing")))] +pub use pipewire::SystemLoopbackSource; #[cfg(target_os = "linux")] -pub use pipewire::{discover_sources_linux, DesktopCaptureSource, SystemLoopbackSource}; +pub use pipewire::{discover_sources_linux, DesktopCaptureSource}; #[cfg(target_os = "linux")] pub use session_backend::DesktopCaptureBackend; diff --git a/src/capture/platform/linux/pipewire.rs b/src/capture/platform/linux/pipewire.rs index c1036eb..6398f34 100644 --- a/src/capture/platform/linux/pipewire.rs +++ b/src/capture/platform/linux/pipewire.rs @@ -92,6 +92,7 @@ const PW_NODE_DONT_RECONNECT: &str = "node.dont-reconnect"; /// ALSA loopback capture device (snd-aloop module, subdevice 0 of card 1). /// Requires: sudo modprobe snd-aloop +#[cfg(feature = "alsa-fallback")] const ALSA_LOOPBACK_DEVICE: &str = "hw:Loopback,1,0"; #[derive(Debug, Clone)] @@ -755,6 +756,7 @@ pub struct DesktopCaptureSource { } impl DesktopCaptureSource { + #[cfg(any(test, feature = "internal-testing"))] pub fn capture_mode(mode: CaptureMode, callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -804,6 +806,7 @@ impl DesktopCaptureSource { impl SystemLoopbackSource { /// Start capturing the system-wide audio mix. + #[cfg(any(test, feature = "internal-testing"))] pub fn capture(callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -812,6 +815,7 @@ impl SystemLoopbackSource { } /// Start capturing in the given mode. + #[cfg(any(test, feature = "internal-testing"))] pub fn capture_mode(mode: CaptureMode, callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -937,7 +941,17 @@ impl SystemLoopbackSource { if pipewire_available() { run_pipewire(mode, audio_frame_duration, callback, runtime_event_sender) } else { - run_alsa(audio_frame_duration, callback, runtime_event_sender) + #[cfg(feature = "alsa-fallback")] + { + run_alsa(audio_frame_duration, callback, runtime_event_sender) + } + #[cfg(not(feature = "alsa-fallback"))] + { + Err(LoopbackError::BackendInit( + "PipeWire is unavailable and the ALSA fallback is not enabled" + .to_owned(), + )) + } } } CaptureMode::InputDevice(selector) => { @@ -1792,6 +1806,7 @@ where }) } +#[cfg(feature = "alsa-fallback")] fn run_alsa( audio_frame_duration: AudioFrameDuration, mut callback: F, diff --git a/src/capture/platform/mod.rs b/src/capture/platform/mod.rs index ab3a1d4..4834361 100644 --- a/src/capture/platform/mod.rs +++ b/src/capture/platform/mod.rs @@ -1,6 +1,9 @@ -#[cfg(all(target_os = "linux", feature = "native-capture"))] +#[cfg(all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") +))] pub(crate) mod linux; -#[cfg(all(target_os = "macos", feature = "native-capture"))] +#[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] pub(crate) mod macos; -#[cfg(all(target_os = "windows", feature = "native-capture"))] +#[cfg(all(target_os = "windows", feature = "wasapi-capture"))] pub(crate) mod windows; diff --git a/src/capture/platform/windows/mod.rs b/src/capture/platform/windows/mod.rs index 475387e..704f8bc 100644 --- a/src/capture/platform/windows/mod.rs +++ b/src/capture/platform/windows/mod.rs @@ -12,14 +12,13 @@ mod runtime_lifecycle; #[cfg(target_os = "windows")] mod session_backend; #[cfg(target_os = "windows")] -mod windows; +mod source; #[cfg(target_os = "windows")] pub(crate) use authorization::microphone_permission_observation; #[cfg(target_os = "windows")] pub use session_backend::DesktopCaptureBackend; +#[cfg(any(test, feature = "internal-testing"))] +pub use source::SystemLoopbackSource; #[cfg(target_os = "windows")] -pub use windows::{ - discover_sources_windows, DesktopCaptureSource, SystemLoopbackSource, - WindowsAudioThreadPriorityGuard, WASAPI_PROCESS_LOOPBACK_PERIOD_100NS, -}; +pub use source::{discover_sources_windows, DesktopCaptureSource}; diff --git a/src/capture/platform/windows/windows.rs b/src/capture/platform/windows/source.rs similarity index 98% rename from src/capture/platform/windows/windows.rs rename to src/capture/platform/windows/source.rs index 8bc9151..81f4f34 100644 --- a/src/capture/platform/windows/windows.rs +++ b/src/capture/platform/windows/source.rs @@ -53,13 +53,15 @@ use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerforma use crate::capture::frame_normalizer::CaptureFrameNormalizer; use crate::capture::{ - initialize_monotonic_timestamp_domain, monotonic_timestamp_ns, source_runtime_event_channel, - CaptureError as LoopbackError, CaptureMode, CaptureObservationCounters, - CaptureObservationHandle, CaptureObservations, CaptureRuntimeFailure, - CaptureRuntimeFailureClass, InputDeviceSelector, SourceGeneration, SourceKind, - SourceRecoveryRequirement, SourceRuntimeEvent, SourceRuntimeEventObservations, - SourceRuntimeEventReceive, SourceRuntimeEventReceiver, SourceRuntimeEventSender, - StableSourceId, + initialize_monotonic_timestamp_domain, monotonic_timestamp_ns, CaptureError as LoopbackError, + CaptureMode, CaptureObservationCounters, CaptureObservationHandle, CaptureObservations, + CaptureRuntimeFailure, CaptureRuntimeFailureClass, InputDeviceSelector, SourceGeneration, + SourceKind, SourceRecoveryRequirement, SourceRuntimeEvent, SourceRuntimeEventReceiver, + SourceRuntimeEventSender, StableSourceId, +}; +#[cfg(any(test, feature = "internal-testing"))] +use crate::capture::{ + source_runtime_event_channel, SourceRuntimeEventObservations, SourceRuntimeEventReceive, }; const CAPTURE_CHANNEL_COUNT: u8 = 2; @@ -100,6 +102,7 @@ const OPEN_WORKER_CANCELLATION_GRACE_DURATION: Duration = Duration::from_millis( // input channel and 16-frame graph ring, this remains below the source pool's // 64-slot ownership bound with room for callback and dispatch in-flight frames. const DISPATCH_QUEUE_CAPACITY_FRAMES: usize = 24; +#[cfg(any(test, feature = "internal-testing"))] const RUNTIME_EVENT_CHANNEL_CAPACITY_EVENTS: usize = 8; const _: () = assert!(CAPTURE_POOL_CAPACITY_FRAMES >= DISPATCH_QUEUE_CAPACITY_FRAMES + 2); @@ -156,6 +159,7 @@ pub struct SystemLoopbackSource { dispatch_thread: Option>, stop_tx: std::sync::mpsc::SyncSender<()>, counters: CaptureObservationCounters, + #[cfg(any(test, feature = "internal-testing"))] runtime_event_rx: Option, source_id: SourceId, } @@ -165,6 +169,7 @@ pub struct DesktopCaptureSource { } impl DesktopCaptureSource { + #[cfg(any(test, feature = "internal-testing"))] pub fn capture_mode(mode: CaptureMode, callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -202,10 +207,12 @@ impl DesktopCaptureSource { self.source.observation_handle() } + #[cfg(any(test, feature = "internal-testing"))] pub fn try_recv_runtime_event(&self) -> SourceRuntimeEventReceive { self.source.try_recv_runtime_event() } + #[cfg(any(test, feature = "internal-testing"))] pub fn runtime_event_observations(&self) -> SourceRuntimeEventObservations { self.source.runtime_event_observations() } @@ -642,6 +649,7 @@ fn activate_process_loopback_client( } impl SystemLoopbackSource { + #[cfg(any(test, feature = "internal-testing"))] pub fn capture(callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -649,6 +657,7 @@ impl SystemLoopbackSource { Self::capture_mode(CaptureMode::SystemMix, callback) } + #[cfg(any(test, feature = "internal-testing"))] pub fn capture_mode(mode: CaptureMode, callback: F) -> Result where F: FnMut(AudioFrame) + Send + 'static, @@ -692,6 +701,8 @@ impl SystemLoopbackSource { where F: FnMut(AudioFrame) + Send + 'static, { + #[cfg(not(any(test, feature = "internal-testing")))] + let _ = runtime_event_rx; let (stop_tx, stop_rx) = std::sync::mpsc::sync_channel::<()>(1); let (open_tx, open_rx) = std::sync::mpsc::sync_channel::>(1); @@ -908,6 +919,7 @@ impl SystemLoopbackSource { dispatch_thread: Some(dispatch_thread), stop_tx, counters, + #[cfg(any(test, feature = "internal-testing"))] runtime_event_rx, source_id, }) @@ -925,6 +937,7 @@ impl SystemLoopbackSource { self.source_id } + #[cfg(any(test, feature = "internal-testing"))] pub fn try_recv_runtime_event(&self) -> SourceRuntimeEventReceive { self.runtime_event_rx .as_ref() @@ -933,6 +946,7 @@ impl SystemLoopbackSource { }) } + #[cfg(any(test, feature = "internal-testing"))] pub fn runtime_event_observations(&self) -> SourceRuntimeEventObservations { self.runtime_event_rx .as_ref() @@ -1179,37 +1193,34 @@ fn target_wave_format() -> WaveFormat { fn deliver_packet( callback_samples: &[f32], callback_timestamp_ns: u64, - source_id: SourceId, - pool: &Arc, - sequence_number: &Arc, - counters: &CaptureObservationCounters, + state: &CaptureLoopState, frame_normalizer: &mut CaptureFrameNormalizer, callback: &mut (impl FnMut(AudioFrame) + Send + 'static), ) { - counters.observe_callback_buffer(); + state.counters.observe_callback_buffer(); if callback_samples.is_empty() { - counters.observe_invalid_buffer(); + state.counters.observe_invalid_buffer(); return; } let normalized = frame_normalizer.push( callback_samples, callback_timestamp_ns, |timestamp_ns, samples| { - let frame_sequence_number = sequence_number.fetch_add(1, Ordering::Relaxed); - let mut handle = match pool.acquire() { + let frame_sequence_number = state.sequence.fetch_add(1, Ordering::Relaxed); + let mut handle = match state.pool.acquire() { Some(handle) => handle, None => { - counters.observe_pool_exhaustion(); + state.counters.observe_pool_exhaustion(); return; } }; if handle.try_copy_from_slice(samples).is_err() { - counters.observe_oversized_buffer(); + state.counters.observe_oversized_buffer(); return; } let mut frame = AudioFrame::new( StreamId(0), - source_id, + state.source_id, frame_sequence_number, timestamp_ns, CAPTURE_CHANNEL_COUNT, @@ -1220,7 +1231,7 @@ fn deliver_packet( }, ); if !normalized { - counters.observe_invalid_buffer(); + state.counters.observe_invalid_buffer(); } } @@ -1527,10 +1538,7 @@ fn capture_loop( deliver_packet( &callback_samples[..sample_count], callback_timestamp_ns, - state.source_id, - &state.pool, - &state.sequence, - &state.counters, + &state, &mut frame_normalizer, &mut callback, ); @@ -1824,10 +1832,7 @@ fn capture_process_loopback( deliver_packet( &callback_samples[..sample_count], callback_timestamp_ns, - state.source_id, - &state.pool, - &state.sequence, - &state.counters, + &state, &mut frame_normalizer, &mut callback, ); diff --git a/src/capture/query.rs b/src/capture/query.rs index f29c85b..9c5c25f 100644 --- a/src/capture/query.rs +++ b/src/capture/query.rs @@ -1,12 +1,12 @@ //! Control-plane source discovery queries used by the first-party CLI. -#[cfg(feature = "native-capture")] +#[cfg(feature = "native-capture-runtime")] use std::collections::HashSet; -#[cfg(feature = "native-capture")] +#[cfg(feature = "native-capture-runtime")] use super::StableSourceId; use super::{CaptureSource, SourceKind, SourceState}; -#[cfg(feature = "native-capture")] +#[cfg(feature = "native-capture-runtime")] use crate::frame::Platform; #[derive(Debug, Clone, PartialEq, Eq)] @@ -62,20 +62,21 @@ impl SourceProvider for LocalSourceProvider { /// This is a control-plane capability query. It does not open a device, prompt /// for permission, or create a capture/runtime owner. pub fn application_capture_available() -> bool { - #[cfg(all(target_os = "macos", feature = "native-capture"))] + #[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] { super::platform::macos::tap_available() } - #[cfg(all( - feature = "native-capture", - any(target_os = "windows", target_os = "linux") + #[cfg(any( + all(target_os = "windows", feature = "wasapi-capture"), + all(target_os = "linux", feature = "pipewire-capture") ))] { true } - #[cfg(not(all( - feature = "native-capture", - any(target_os = "macos", target_os = "windows", target_os = "linux") + #[cfg(not(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all(target_os = "linux", feature = "pipewire-capture") )))] { false @@ -83,10 +84,24 @@ pub fn application_capture_available() -> bool { } pub fn discover_sources() -> Vec { - #[cfg(not(feature = "native-capture"))] + #[cfg(not(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) + )))] return Vec::new(); - #[cfg(feature = "native-capture")] + #[cfg(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) + ))] { #[cfg(target_os = "macos")] let platform = Platform::Macos; @@ -108,16 +123,19 @@ pub fn discover_sources() -> Vec { channels: 2, }]; - #[cfg(all(target_os = "macos", feature = "native-capture"))] + #[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] { sources.extend(super::platform::macos::discover_input_sources_native()); sources.extend(super::platform::macos::discover_sources_native()); } - #[cfg(all(target_os = "windows", feature = "native-capture"))] + #[cfg(all(target_os = "windows", feature = "wasapi-capture"))] sources.extend(super::platform::windows::discover_sources_windows()); - #[cfg(all(target_os = "linux", feature = "native-capture"))] + #[cfg(all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ))] sources.extend(super::platform::linux::discover_sources_linux()); let mut seen = HashSet::new(); diff --git a/src/capture/timeline.rs b/src/capture/timeline.rs index c7dbd70..dda8ced 100644 --- a/src/capture/timeline.rs +++ b/src/capture/timeline.rs @@ -1,13 +1,30 @@ //! Source-sample time mapped into PocketStation's monotonic clock domain. -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] use std::num::NonZeroU32; /// Initializes the process-wide capture timestamp domain from a setup thread. /// /// Capture backends call this before starting a realtime callback so the /// callback only reads the initialized monotonic origin. -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] pub fn initialize_monotonic_timestamp_domain() { let _ = crate::timing::monotonic_timestamp_ns(); } @@ -27,7 +44,15 @@ pub fn monotonic_timestamp_ns() -> u64 { /// and advances only by represented sample count. Callers must advance it even /// when a captured buffer is dropped so downstream gaps remain observable. #[derive(Debug)] -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] pub struct CaptureSampleTimeline { sample_rate_hz: NonZeroU32, origin_timestamp_ns: Option, @@ -37,7 +62,15 @@ pub struct CaptureSampleTimeline { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] pub enum CaptureSampleTimelineError { MixedAdvanceModes, SourcePositionOverflow, @@ -47,7 +80,15 @@ pub enum CaptureSampleTimelineError { }, } -#[cfg(any(test, feature = "internal-testing", feature = "native-capture"))] +#[cfg(any( + test, + feature = "internal-testing", + all(target_os = "macos", feature = "coreaudio-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) +))] impl CaptureSampleTimeline { pub fn new(sample_rate_hz: NonZeroU32) -> Self { Self { diff --git a/src/frame/audio.rs b/src/frame/audio.rs index f9420d7..bfdd0fc 100644 --- a/src/frame/audio.rs +++ b/src/frame/audio.rs @@ -6,7 +6,7 @@ use crate::frame::{FrameLineage, OutputGeneration, SourceId, StreamId}; pub const SAMPLE_RATE_HZ: u32 = 48_000; #[cfg(test)] const FRAME_DURATION_MS: u32 = 20; -#[cfg(any(test, feature = "internal-testing", target_os = "linux"))] +#[cfg(any(test, feature = "internal-testing"))] pub const POOL_SLOT_SAMPLES: usize = 960; // 20ms × 48kHz #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/lib.rs b/src/lib.rs index e880c4a..abee879 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,18 +54,18 @@ pub use crate::capture::{ /// reinterpret that value as allowed or denied. Permission prompting remains /// an explicit host-application action. pub fn microphone_permission_observation() -> PermissionObservation { - #[cfg(all(target_os = "macos", feature = "native-capture"))] + #[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] { crate::capture::platform::macos::microphone_permission_observation() } - #[cfg(all(target_os = "windows", feature = "native-capture"))] + #[cfg(all(target_os = "windows", feature = "wasapi-capture"))] { crate::capture::platform::windows::microphone_permission_observation() } - #[cfg(not(all( - feature = "native-capture", - any(target_os = "macos", target_os = "windows") - )))] + #[cfg(not(all(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture") + ))))] { PermissionObservation::NotObservable } @@ -155,11 +155,14 @@ pub use crate::runtime::{ pub mod internal { pub mod capture { pub use crate::capture::*; - #[cfg(all(target_os = "linux", feature = "native-capture"))] + #[cfg(all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ))] pub mod linux { pub use crate::capture::platform::linux::*; } - #[cfg(all(target_os = "macos", feature = "native-capture"))] + #[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] pub mod macos { pub use crate::capture::platform::macos::{ discover_input_sources_native, discover_sources_native, tap_available, @@ -168,7 +171,7 @@ pub mod internal { InternalSystemLoopbackSource as SystemLoopbackSource, MacosInputSource, }; } - #[cfg(all(target_os = "windows", feature = "native-capture"))] + #[cfg(all(target_os = "windows", feature = "wasapi-capture"))] pub mod windows { pub use crate::capture::platform::windows::*; } diff --git a/src/session/lifecycle/host.rs b/src/session/lifecycle/host.rs index 0311875..203dc46 100644 --- a/src/session/lifecycle/host.rs +++ b/src/session/lifecycle/host.rs @@ -9,11 +9,14 @@ use crate::graph::{ }; use crate::runtime::SidecarProcessSpec; -#[cfg(all(target_os = "linux", feature = "native-capture"))] +#[cfg(all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") +))] use crate::capture::platform::linux::DesktopCaptureBackend as NativeDesktopCaptureBackend; -#[cfg(all(target_os = "macos", feature = "native-capture"))] +#[cfg(all(target_os = "macos", feature = "coreaudio-capture"))] use crate::capture::platform::macos::DesktopCaptureBackend as NativeDesktopCaptureBackend; -#[cfg(all(target_os = "windows", feature = "native-capture"))] +#[cfg(all(target_os = "windows", feature = "wasapi-capture"))] use crate::capture::platform::windows::DesktopCaptureBackend as NativeDesktopCaptureBackend; use crate::session::{ @@ -233,9 +236,13 @@ impl SessionEngineHostBuilder { /// Creates the production host builder with the platform's native capture /// backend, leaving endpoint registration open to the owning application. - #[cfg(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") + #[cfg(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) ))] pub fn native( options: NativeSessionEngineHostOptions, @@ -243,9 +250,13 @@ impl SessionEngineHostBuilder { Self::native_with_audio_frame_duration(options, AudioFrameDuration::default()) } - #[cfg(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") + #[cfg(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) ))] pub fn native_with_audio_frame_duration( options: NativeSessionEngineHostOptions, @@ -263,9 +274,13 @@ impl SessionEngineHostBuilder { /// Returns a typed unsupported-platform error on targets without a native /// PocketStation capture backend. - #[cfg(not(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") + #[cfg(not(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) )))] pub fn native( _options: NativeSessionEngineHostOptions, @@ -273,9 +288,13 @@ impl SessionEngineHostBuilder { Err(SessionEngineHostBuildError::UnsupportedPlatform) } - #[cfg(not(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") + #[cfg(not(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) )))] pub fn native_with_audio_frame_duration( _options: NativeSessionEngineHostOptions, @@ -438,9 +457,13 @@ pub enum SessionEngineHostBuildError { UnsupportedPlatform, } -#[cfg(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") +#[cfg(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) ))] fn build_native_host( options: NativeSessionEngineHostOptions, @@ -454,9 +477,13 @@ fn build_native_host( builder.build() } -#[cfg(not(all( - feature = "native-capture", - any(target_os = "linux", target_os = "macos", target_os = "windows") +#[cfg(not(any( + all(target_os = "macos", feature = "coreaudio-capture"), + all(target_os = "windows", feature = "wasapi-capture"), + all( + target_os = "linux", + any(feature = "pipewire-capture", feature = "alsa-fallback") + ) )))] fn build_native_host( _options: NativeSessionEngineHostOptions, diff --git a/tests/capture_callback_source_contract.rs b/tests/capture_callback_source_contract.rs index d782c94..4f1eeca 100644 --- a/tests/capture_callback_source_contract.rs +++ b/tests/capture_callback_source_contract.rs @@ -1,6 +1,6 @@ const MACOS_INPUT: &str = include_str!("../src/capture/platform/macos/input.rs"); const LINUX_CAPTURE: &str = include_str!("../src/capture/platform/linux/pipewire.rs"); -const WINDOWS_CAPTURE: &str = include_str!("../src/capture/platform/windows/windows.rs"); +const WINDOWS_CAPTURE: &str = include_str!("../src/capture/platform/windows/source.rs"); const MACOS_NATIVE_TAP: &str = include_str!("../native/macos/asp/source_discovery.m"); const MACOS_ASP_RING: &str = include_str!("../native/macos/asp/SharedRing.h"); const FRAME_OWNERSHIP: &str = include_str!("../src/frame/pool.rs"); diff --git a/tools/check-api-compatibility.sh b/tools/check-api-compatibility.sh index e2fc7f9..9847d2c 100755 --- a/tools/check-api-compatibility.sh +++ b/tools/check-api-compatibility.sh @@ -2,8 +2,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -baseline_ref="${PKS_API_BASELINE_REF:-pocketstation-v1.1.7}" -expected_baseline_commit="${PKS_API_BASELINE_COMMIT:-172e2f270999b8880c98362d329117f676d666f2}" +baseline_ref="${PKS_API_BASELINE_REF:-pocketstation-v1.1.9}" +expected_baseline_commit="${PKS_API_BASELINE_COMMIT:-fa435235448012040a4dbd769bd2e932d84ad0f6}" required_tool_version="cargo-semver-checks 0.48.0" actual_baseline_commit="$(git -C "${repo_root}" rev-parse "${baseline_ref}^{commit}")"