Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/sox_resampler_uniffi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
livekit-ffi: patch
---

# Export SoxResampler through UniFFI

`SoxResampler` is now a UniFFI object, so foreign hosts can construct one and
drive `push` / `flush` directly, receiving the resampled samples by value.

The FFI request surface is unchanged: `NewSoxResampler`, `PushSoxResampler` and
`FlushSoxResampler` still hand back a pointer into the resampler's own buffer,
readable until the next call on that resampler. Both paths are covered by new
tests that drive them with the same input and compare the results.
25 changes: 24 additions & 1 deletion livekit-ffi/src/conversion/resampler.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 LiveKit, Inc.
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -11,3 +11,26 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::{proto, server::resampler};

impl From<proto::SoxResamplerDataType> for resampler::SoxResamplerDataType {
fn from(value: proto::SoxResamplerDataType) -> Self {
match value {
proto::SoxResamplerDataType::SoxrDatatypeInt16i => Self::Interleaved,
proto::SoxResamplerDataType::SoxrDatatypeInt16s => Self::Split,
}
}
}

impl From<proto::SoxQualityRecipe> for resampler::SoxQualityRecipe {
fn from(value: proto::SoxQualityRecipe) -> Self {
match value {
proto::SoxQualityRecipe::SoxrQualityQuick => Self::Quick,
proto::SoxQualityRecipe::SoxrQualityLow => Self::Low,
proto::SoxQualityRecipe::SoxrQualityMedium => Self::Medium,
proto::SoxQualityRecipe::SoxrQualityHigh => Self::High,
proto::SoxQualityRecipe::SoxrQualityVeryhigh => Self::VeryHigh,
}
}
}
3 changes: 3 additions & 0 deletions livekit-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ use livekit::prelude::*;
use thiserror::Error;

mod conversion;
mod migration;
#[cfg(test)]
mod migration_tests;

pub mod build_info;
pub mod cabi;
Expand Down
100 changes: 100 additions & 0 deletions livekit-ffi/src/migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// This macro implements a bridge between the FFI handle system and the uniffi system.
///
/// It depends on migrating structs carrying a `handle_id: OnceLock<crate::FfiHandleId>` field.
/// Migrating structs then expose methods via `#[uniffi::export]`.
///
/// This macro generates foreign language methods:
/// 1) to get the `handle_id` so it can be passed via the old FFI system
/// 2) to construct an `Arc<Self>` from a `handle_id`, so we can migrate from the old FFI system
/// to the new uniffi system.
/// 3) to take ownership of a `&self` from the old FFI system, so the FFI side can release its
/// ownership of the handle but allow the uniffi side to continue use the struct.
///
/// The FFI server is a second owner: it holds an `Arc<Self>` from the first `ffi_handle_id()` call
/// until `livekit_ffi_drop_handle` or `take_ffi_handle_id` releases it. A struct that never publishes

Check warning on line 28 in livekit-ffi/src/migration.rs

View workflow job for this annotation

GitHub Actions / Check Formatting

Diff in /home/runner/work/rust-sdks/rust-sdks/livekit-ffi/src/migration.rs
/// a handle is owned by the uniffi side alone and drops with its last `Arc`.
///
/// The FFI system and uniffi system can be interchanged, however, once removed (via [take_ffi_handle_id] or
/// [livekit_ffi_drop_handle]), the FFI system cannot easily be used again for that handle id.
#[macro_export]
macro_rules! migrate_from_ffi {
($T:ty) => {
impl crate::server::FfiHandle for ::std::sync::Arc<$T> {}

#[::uniffi::export]
impl $T {
/// Publishes this struct to the FFI handle map and returns the id the FFI side
/// knows it by.
///
/// The first call hands the FFI side co-ownership; later calls return the same id
/// without republishing. An id whose handle the FFI side has already released is
/// stale, and passing it back over the FFI fails with "handle not found".
pub fn ffi_handle_id(self: ::std::sync::Arc<Self>) -> crate::FfiHandleId {
let handle_id = *self.handle_id.get_or_init(|| {
let handle_id = crate::FFI_SERVER.next_id();
// the FFI side co-owns from here; released by livekit_ffi_drop_handle
crate::FFI_SERVER.store_handle(handle_id, ::std::sync::Arc::clone(&self));
handle_id
});
handle_id
}

#[::uniffi::constructor]
pub fn from_ffi_handle_id(
handle_id: crate::FfiHandleId,
) -> ::std::result::Result<::std::sync::Arc<Self>, crate::migration::MigrationError>
{
match crate::FFI_SERVER.retrieve_handle::<::std::sync::Arc<Self>>(handle_id) {
Ok(arc) => Ok(arc.clone()),
Err(s) => Err(crate::migration::MigrationError::FromFfiHandleIdError(format!(
"MigrationError for handle_id {handle_id}: {s:?}"
))),
}
}

/// Takes the FFI side's ownership of this struct, leaving the uniffi side as the
/// only owner. The FFI side calls this when it is done with the handle.
///
/// The release is final: the handle id is not reusable, a second call errors, and
/// so does a call on a struct that was never published.
pub fn take_ffi_handle_id(
&self,
) -> ::std::result::Result<::std::sync::Arc<Self>, crate::migration::MigrationError>
{
let handle_id = self.handle_id.get().ok_or_else(|| {
crate::migration::MigrationError::TakeFfiHandleIdError(
"MigrationError for handle_id: handle_id not set yet".to_owned(),
)
})?;
match crate::FFI_SERVER.take_handle::<::std::sync::Arc<Self>>(*handle_id) {
Ok(arc) => Ok(arc.clone()),
Err(s) => Err(crate::migration::MigrationError::TakeFfiHandleIdError(format!(
"MigrationError for handle_id {handle_id}: {s:?}"
))),
}
}
}
};
}

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum MigrationError {
#[error("Invalid handle id: {0}")]
FromFfiHandleIdError(String),
#[error("Failed to take handle id: {0}")]
TakeFfiHandleIdError(String),
}
192 changes: 192 additions & 0 deletions livekit-ffi/src/migration_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Protobuf-driven tests for the types being migrated to uniffi.
//!
//! The FFI request surface is the contract that must not move while the types
//! behind it grow uniffi annotations, so these tests drive it the way the
//! bindings do — encoded requests in, handles and raw pointers out — and check
//! the result against the same work done through the Rust API directly. The
//! direct-call half of each pair lives next to the code it exercises, e.g.
//! `server::resampler::migration_tests`.
//!
//! They are written to compile and pass either side of the migration, so they
//! can be replayed on the commit before it.

use std::{mem::size_of, slice};

use crate::{proto, server::requests::handle_request, FfiHandleId, FFI_SERVER};

fn request(message: impl Into<proto::ffi_request::Message>) -> proto::ffi_response::Message {
let response = handle_request(&FFI_SERVER, proto::FfiRequest { message: Some(message.into()) })
.expect("request failed");
response.message.expect("response carried no message")
}

fn new_sox_resampler(
input_rate: f64,
output_rate: f64,
num_channels: u32,
quality: proto::SoxQualityRecipe,
) -> FfiHandleId {
let response = match request(proto::NewSoxResamplerRequest {
input_rate,
output_rate,
num_channels,
input_data_type: proto::SoxResamplerDataType::SoxrDatatypeInt16i as i32,
output_data_type: proto::SoxResamplerDataType::SoxrDatatypeInt16i as i32,
quality_recipe: quality as i32,
flags: None,
}) {
proto::ffi_response::Message::NewSoxResampler(response) => response,
_ => panic!("expected a NewSoxResampler response"),
};

match response.message.expect("response carried no message") {
proto::new_sox_resampler_response::Message::Resampler(resampler) => resampler.handle.id,
proto::new_sox_resampler_response::Message::Error(error) => panic!("{error}"),
}
}

fn push_sox_resampler(handle: FfiHandleId, input: &[i16]) -> Vec<i16> {
let response = match request(proto::PushSoxResamplerRequest {
resampler_handle: handle,
data_ptr: input.as_ptr() as u64,
size: (input.len() * size_of::<i16>()) as u32,
}) {
proto::ffi_response::Message::PushSoxResampler(response) => response,
_ => panic!("expected a PushSoxResampler response"),
};

assert_eq!(response.error, None, "push failed");
read_output(response.output_ptr, response.size)
}

fn flush_sox_resampler(handle: FfiHandleId) -> Vec<i16> {
let response = match request(proto::FlushSoxResamplerRequest { resampler_handle: handle }) {
proto::ffi_response::Message::FlushSoxResampler(response) => response,
_ => panic!("expected a FlushSoxResampler response"),
};

assert_eq!(response.error, None, "flush failed");
read_output(response.output_ptr, response.size)
}

/// The pointer/size pair the bindings dereference: `size` counts bytes, the
/// samples are `i16`, a run that produced nothing is a null pointer, and the
/// samples stay readable until the next call on the same resampler.
fn read_output(output_ptr: u64, size: u32) -> Vec<i16> {
assert_eq!(size as usize % size_of::<i16>(), 0, "size {size} is not whole i16 samples");
if output_ptr == 0 || size == 0 {
return Vec::new();
}
unsafe { slice::from_raw_parts(output_ptr as *const i16, size as usize / size_of::<i16>()) }
.to_vec()
}

/// soxr dithers its int16 output, so two instances fed identical input agree to
/// within a LSB or so rather than bit for bit.
fn assert_samples_match(actual: &[i16], expected: &[i16], context: &str) {
assert_eq!(actual.len(), expected.len(), "{context}: output length");
for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert!(
(*actual as i32 - *expected as i32).abs() <= 2,
"{context}: sample {i} is {actual}, expected ~{expected}"
);
}
}

/// Every knob of `NewSoxResamplerRequest` reaches the resampler: the same input
/// pushed through the FFI request surface and through the Rust API must come
/// back as the same audio, for each rate, channel count and quality recipe.
#[test]
fn proto_push_matches_direct_push() {
let cases = [
(48000.0, 16000.0, 1, proto::SoxQualityRecipe::SoxrQualityQuick),
(48000.0, 16000.0, 1, proto::SoxQualityRecipe::SoxrQualityVeryhigh),
(16000.0, 48000.0, 1, proto::SoxQualityRecipe::SoxrQualityMedium),
(48000.0, 24000.0, 2, proto::SoxQualityRecipe::SoxrQualityHigh),
];

for (input_rate, output_rate, num_channels, quality) in cases {
let input: Vec<i16> = (0..960 * num_channels as i16)
.map(|i| (i % 97) * 300 - 14000) // a repeating sawtooth, loud enough to compare
.collect();

let handle = new_sox_resampler(input_rate, output_rate, num_channels, quality);
#[allow(unused_mut)]
let mut direct = crate::sox_resampler!(input_rate, output_rate, num_channels, quality);

let context = format!("{input_rate} -> {output_rate}, {num_channels}ch, {quality:?}");
for _ in 0..3 {
assert_samples_match(
&push_sox_resampler(handle, &input),
&direct.push(&input).unwrap(),
&format!("push at {context}"),
);
}
assert_samples_match(
&flush_sox_resampler(handle),
&direct.flush().unwrap(),
&format!("flush at {context}"),
);

FFI_SERVER.drop_handle(handle);
}
}

/// 30ms in at 48kHz is 30ms out at 16kHz, counted in bytes off the wire.
#[test]
fn proto_push_then_flush_conserves_duration() {
let handle = new_sox_resampler(48000.0, 16000.0, 1, proto::SoxQualityRecipe::SoxrQualityQuick);
let frame = vec![1000i16; 480]; // 10ms @ 48kHz

let mut frames = 0;
for _ in 0..3 {
frames += push_sox_resampler(handle, &frame).len();
}
frames += flush_sox_resampler(handle).len();

assert!((frames as i64 - 480).abs() <= 8, "got {frames} frames, expected ~480");

FFI_SERVER.drop_handle(handle);
}

/// The FFI handle map is a *second* owner of a migrated object, and it only
/// takes that ownership when `ffi_handle_id()` publishes an id. A resampler
/// that never crosses into the FFI path must die with its last `Arc`.
#[test]
fn the_handle_map_only_owns_published_resamplers() {
use crate::{server::resampler::SoxResampler, sox_resampler};
use std::sync::Arc;

let resampler = sox_resampler!(48000.0, 16000.0, 1, proto::SoxQualityRecipe::SoxrQualityQuick);
assert_eq!(Arc::strong_count(&resampler), 1, "an unpublished resampler has no other owner");

let handle = resampler.clone().ffi_handle_id();
assert_eq!(
Arc::strong_count(&resampler),
2,
"publishing an id makes the FFI server a co-owner"
);

drop(resampler);
assert!(
FFI_SERVER.retrieve_handle::<Arc<SoxResampler>>(handle).is_ok(),
"the FFI server holds the resampler until its handle is dropped"
);

FFI_SERVER.drop_handle(handle);
assert!(FFI_SERVER.retrieve_handle::<Arc<SoxResampler>>(handle).is_err());
}
Loading
Loading