diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c88f83..5991f3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,15 +19,23 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build wheels + - name: Build wheels (skip bundled auditwheel) uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist -i python3.9 -i python3.10 -i python3.11 -i python3.12 -i python3.13 -i python3.14 + args: --release --out dist-pre --auditwheel skip -i python3.9 -i python3.10 -i python3.11 -i python3.12 -i python3.13 -i python3.14 manylinux: auto before-script-linux: | yum install -y alsa-lib-devel + - name: Repair wheels (exclude libasound for system PipeWire/Pulse) + run: | + pip install auditwheel + mkdir -p dist + for whl in dist-pre/*.whl; do + auditwheel repair --exclude libasound.so.2 -w dist "$whl" + done + - uses: actions/upload-artifact@v4 with: name: wheels-linux-${{ matrix.target }} diff --git a/README.md b/README.md index d520b77..0e5ff19 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Stream audio to any output device — including PipeWire/PulseAudio virtual sink - **Cross-platform**: Linux (ALSA/PipeWire/PulseAudio), Windows (WASAPI), macOS (CoreAudio) - **Auto-detect**: Sample rate and channels are detected from the device — zero config needed +- **Auto-negotiate**: If the device doesn't support your sample rate, it automatically resamples and upmixes (e.g. 22050Hz mono TTS → 48000Hz stereo device) - **Device selection**: List and select output devices by name, including PipeWire virtual sinks - **Streaming audio**: Write audio data in chunks via a lock-free ring buffer — ideal for real-time TTS, generative audio, live effects, etc. - **Unified `write()`**: Accepts `bytes`, `numpy` arrays (int16/int32/float32/float64), or `list[float]` — format is detected automatically @@ -24,13 +25,25 @@ Stream audio to any output device — including PipeWire/PulseAudio virtual sink ## Installation +### From PyPI + +```bash +pip install pyaudiocast +``` + +> **Note (Linux):** Pre-built wheels require ALSA. Install `libasound2` if not already present: +> ```bash +> sudo apt install libasound2 # Debian/Ubuntu +> sudo dnf install alsa-lib # Fedora +> ``` + ### From source (requires Rust toolchain) ```bash # Install Rust if needed curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -# Linux: install ALSA headers +# Linux: install ALSA development headers sudo apt install libasound2-dev # Debian/Ubuntu sudo dnf install alsa-lib-devel # Fedora @@ -64,7 +77,7 @@ with pyaudiocast.AudioPlayer() as player: player.write(audio_bytes) # bytes, numpy array, or list[float] player.drain() # wait for playback to finish -# Override sample rate if your source requires it +# Override sample rate if your source requires it (auto-resampled to device) with pyaudiocast.AudioPlayer(sample_rate=22050) as player: player.write(tts_audio) player.drain() @@ -117,7 +130,7 @@ def on_user_speech_detected(player): ```python import numpy as np -with pyaudiocast.AudioPlayer(sample_rate=44100) as player: +with pyaudiocast.AudioPlayer() as player: # All of these work with the same write() method player.write(b"\x00\x00" * 100) # bytes (int16 LE) player.write(np.zeros(100, dtype=np.int16)) # numpy int16 @@ -146,6 +159,8 @@ Streaming audio player with ring buffer. | `sample_rate` | `int \| None` | `None` | Sample rate in Hz, or None to auto-detect | | `channels` | `int \| None` | `None` | Number of audio channels, or None to auto-detect | +If `sample_rate` or `channels` don't match the device natively, pyaudiocast automatically resamples and/or upmixes to the device's supported configuration. + **Methods:** | Method | Description | @@ -155,7 +170,15 @@ Streaming audio player with ring buffer. | `clear()` | Discard buffer and unblock drain() immediately | | `stop()` | Stop playback and release resources | -**Properties:** `sample_rate`, `channels`, `is_active` +**Properties:** + +| Property | Type | Description | +|-----------------------|--------|----------------------------------------------| +| `sample_rate` | `int` | Requested sample rate (what you send) | +| `channels` | `int` | Requested channel count (what you send) | +| `device_sample_rate` | `int` | Actual device sample rate (what plays) | +| `device_channels` | `int` | Actual device channel count (what plays) | +| `is_active` | `bool` | Whether the player is active | **Context manager:** Supports `with` statement (calls `stop()` on exit). @@ -217,22 +240,27 @@ The audio engine (`cpal`) is fully cross-platform. PipeWire/PulseAudio virtual s ``` Python (pyaudiocast) - │ - ├─ write(data) → auto-detect format → convert to f32 - │ │ - │ ▼ - │ Lock-free Ring Buffer (ringbuf crate) - │ │ - │ ▼ - │ cpal audio callback (OS audio thread) - │ │ ▲ - │ ▼ │ - └─ Speaker / Virtual Sink clear() → discard + silence + | + +- write(data) -> auto-detect format -> convert to f32 + | | + | v + | [resample + upmix if needed] + | | + | v + | Lock-free Ring Buffer (ringbuf crate) + | | + | v + | cpal audio callback (OS audio thread) + | | ^ + | v | + +- Speaker / Virtual Sink clear() -> discard + silence ``` - **Ring buffer**: Lock-free producer/consumer. Python pushes samples, the OS audio callback pulls them — no locks in the audio path. - **GIL release**: `write()` and `drain()` release the Python GIL during blocking operations. - **Sample conversion**: All input formats are converted to float32 in Rust before entering the ring buffer. +- **Auto-resample**: If the device doesn't natively support the requested sample rate, linear interpolation resampling is applied transparently. +- **Auto-upmix**: Mono audio is automatically duplicated to stereo (or more channels) to match the device. - **Interruption**: `clear()` sets an atomic flag checked by the audio callback, which discards remaining samples and outputs silence. ## Running Tests diff --git a/examples/list_devices.py b/examples/list_devices.py index 194a0c4..adf4462 100644 --- a/examples/list_devices.py +++ b/examples/list_devices.py @@ -1,12 +1,12 @@ """List all available audio output devices.""" -import pyaudiocast as pyspeaker +import pyaudiocast -devices = pyspeaker.list_output_devices() +devices = pyaudiocast.list_output_devices() if not devices: print("No output devices found.") else: print(f"Found {len(devices)} output device(s):\n") for dev in devices: - print(f" [{dev['index']}] {dev['name']}") + print(f" [{dev['index']}] {dev['name']} ({dev['type']})") diff --git a/examples/play_sine.py b/examples/play_sine.py index d713b11..6e6f405 100644 --- a/examples/play_sine.py +++ b/examples/play_sine.py @@ -2,7 +2,7 @@ import math -import pyaudiocast as pyspeaker +import pyaudiocast SAMPLE_RATE = 44100 DURATION = 2.0 @@ -17,7 +17,9 @@ print(f"Playing {FREQUENCY}Hz sine wave for {DURATION}s...") -with pyspeaker.AudioPlayer(sample_rate=SAMPLE_RATE, channels=1) as player: +with pyaudiocast.AudioPlayer(sample_rate=SAMPLE_RATE, channels=1) as player: + print(f" Source: {player.sample_rate}Hz, {player.channels}ch") + print(f" Device: {player.device_sample_rate}Hz, {player.device_channels}ch") player.write(samples) player.drain() diff --git a/examples/play_wav.py b/examples/play_wav.py new file mode 100644 index 0000000..800d966 --- /dev/null +++ b/examples/play_wav.py @@ -0,0 +1,14 @@ +"""Play a WAV file using play_file().""" + +import sys + +import pyaudiocast + +if len(sys.argv) < 2: + print("Usage: python play_wav.py ") + sys.exit(1) + +path = sys.argv[1] +print(f"Playing {path}...") +pyaudiocast.play_file(path) +print("Done.") diff --git a/examples/stream_with_interrupt.py b/examples/stream_with_interrupt.py new file mode 100644 index 0000000..12bb788 --- /dev/null +++ b/examples/stream_with_interrupt.py @@ -0,0 +1,51 @@ +"""Demonstrate streaming audio with interruption (clear).""" + +import math +import threading +import time + +import pyaudiocast + +SAMPLE_RATE = 44100 +FREQUENCY = 440.0 + +# Generate 5 seconds of sine wave in chunks +CHUNK_DURATION = 0.5 +CHUNK_SAMPLES = int(SAMPLE_RATE * CHUNK_DURATION) +TOTAL_CHUNKS = 10 + + +def generate_chunk(freq): + return [ + 0.3 * math.sin(2.0 * math.pi * freq * i / SAMPLE_RATE) + for i in range(CHUNK_SAMPLES) + ] + + +with pyaudiocast.AudioPlayer(sample_rate=SAMPLE_RATE, channels=1) as player: + print(f"Streaming {TOTAL_CHUNKS * CHUNK_DURATION}s of audio...") + print(f" Source: {player.sample_rate}Hz, {player.channels}ch") + print(f" Device: {player.device_sample_rate}Hz, {player.device_channels}ch") + + # Simulate interruption after 2 seconds + def interrupt_later(): + time.sleep(2.0) + print("\n [!] Interrupting playback with clear()...") + player.clear() + + t = threading.Thread(target=interrupt_later) + t.start() + + for i in range(TOTAL_CHUNKS): + chunk = generate_chunk(FREQUENCY) + try: + player.write(chunk) + print(f" Wrote chunk {i + 1}/{TOTAL_CHUNKS}") + except RuntimeError: + print(" Player stopped.") + break + + player.drain() + t.join() + +print("Done.") diff --git a/pyaudiocast/__init__.py b/pyaudiocast/__init__.py index 20a84a1..39b3483 100644 --- a/pyaudiocast/__init__.py +++ b/pyaudiocast/__init__.py @@ -1,6 +1,8 @@ """PyAudioCast: Cross-platform audio output for Python, powered by Rust and CPAL.""" +from importlib.metadata import version + from pyaudiocast._pyaudiocast import AudioPlayer, list_output_devices, play_file __all__ = ["AudioPlayer", "list_output_devices", "play_file"] -__version__ = "0.1.0" +__version__ = version("pyaudiocast") diff --git a/pyaudiocast/_pyaudiocast.pyi b/pyaudiocast/_pyaudiocast.pyi index f829873..dcec7d5 100644 --- a/pyaudiocast/_pyaudiocast.pyi +++ b/pyaudiocast/_pyaudiocast.pyi @@ -1,6 +1,6 @@ -"""Type stubs for the native _pyspeaker module.""" +"""Type stubs for the native _pyaudiocast module.""" -from typing import Optional +from typing import Optional, Union import numpy as np import numpy.typing as npt @@ -8,7 +8,7 @@ import numpy.typing as npt def list_output_devices() -> list[dict[str, str | int]]: """List all available audio output devices. - Returns a list of dicts, each with "name" (str) and "index" (int). + Returns a list of dicts with "name" (str), "index" (int), and "type" (str). """ ... @@ -30,21 +30,32 @@ class AudioPlayer: def __init__( self, device: Optional[str] = None, - sample_rate: int = 22050, - channels: int = 1, + sample_rate: Optional[int] = None, + channels: Optional[int] = None, ) -> None: ... - def write(self, data: bytes) -> None: - """Write raw audio bytes (int16 little-endian) to the player.""" - ... - def write_array(self, data: npt.NDArray[np.int16]) -> None: - """Write a numpy int16 array to the player.""" - ... - def write_f32(self, data: list[float]) -> None: - """Write f32 samples directly (values should be in -1.0..1.0 range).""" + def write( + self, + data: Union[ + bytes, + npt.NDArray[np.int16], + npt.NDArray[np.int32], + npt.NDArray[np.float32], + npt.NDArray[np.float64], + list[float], + ], + ) -> None: + """Write audio data to the player. + + Accepts bytes (int16 LE), numpy arrays (int16/int32/float32/float64), + or list[float]. Format is auto-detected. + """ ... def drain(self) -> None: """Block until all buffered audio has been played.""" ... + def clear(self) -> None: + """Discard buffered audio and unblock drain() immediately.""" + ... def stop(self) -> None: """Stop the player and release resources.""" ... @@ -53,6 +64,10 @@ class AudioPlayer: @property def channels(self) -> int: ... @property + def device_sample_rate(self) -> int: ... + @property + def device_channels(self) -> int: ... + @property def is_active(self) -> bool: ... def __enter__(self) -> "AudioPlayer": ... def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> bool: ... diff --git a/src/player.rs b/src/player.rs index c02e5ef..ab6814d 100644 --- a/src/player.rs +++ b/src/player.rs @@ -32,10 +32,14 @@ pub struct AudioPlayer { interrupted: Arc, /// Whether the stream is active active: Arc, - /// Configured sample rate + /// User-requested sample rate (what Python sends) sample_rate: u32, - /// Configured channels + /// User-requested channels (what Python sends, e.g. 1 for mono) channels: u16, + /// Actual device sample rate (may differ from user-requested) + device_sample_rate: u32, + /// Actual device channels (may differ from user-requested) + device_channels: u16, } #[pymethods] @@ -67,105 +71,76 @@ impl AudioPlayer { .default_output_config() .map_err(SpeakerError::from)?; - let sample_rate = sample_rate.unwrap_or(default_config.sample_rate()); - let channels = channels.unwrap_or(default_config.channels()); + let user_sample_rate = sample_rate.unwrap_or(default_config.sample_rate()); + let user_channels = channels.unwrap_or(default_config.channels()); + let device_channels = default_config.channels(); + + // Find the best supported config: try user rate first, then fall back to device default + let supported_configs: Vec<_> = cpal_device + .supported_output_configs() + .map_err(SpeakerError::from)? + .collect(); + + let (device_sample_rate, use_f32) = Self::negotiate_config( + &supported_configs, + user_sample_rate, + device_channels, + default_config.sample_rate(), + )?; info!( - "Using config: {}Hz, {}ch (device default: {}Hz, {}ch)", - sample_rate, - channels, + "Config: user={}Hz {}ch, device={}Hz {}ch, format={} (default: {}Hz, {}ch)", + user_sample_rate, + user_channels, + device_sample_rate, + device_channels, + if use_f32 { "f32" } else { "i16" }, default_config.sample_rate(), default_config.channels() ); - let desired_config = StreamConfig { - channels, - sample_rate, - buffer_size: cpal::BufferSize::Default, - }; - - // Determine the best sample format to use - let supported_configs = cpal_device - .supported_output_configs() - .map_err(SpeakerError::from)?; - - let mut supports_f32 = false; - let mut supports_i16 = false; - for config in supported_configs { - if config.min_sample_rate() <= sample_rate - && config.max_sample_rate() >= sample_rate - && config.channels() >= channels - { - match config.sample_format() { - SampleFormat::F32 => supports_f32 = true, - SampleFormat::I16 => supports_i16 = true, - _ => {} - } - } + if device_sample_rate != user_sample_rate { + info!( + "Will resample: {}Hz -> {}Hz", + user_sample_rate, device_sample_rate + ); } - - debug!( - "Device supports: f32={}, i16={}", - supports_f32, supports_i16 - ); - - if !supports_f32 && !supports_i16 { - return Err(SpeakerError::ConfigError(format!( - "Device does not support {}Hz {}ch in f32 or i16", - sample_rate, channels - )) - .into()); + if user_channels != device_channels { + info!("Will upmix: {}ch -> {}ch", user_channels, device_channels); } - // Create ring buffer + let stream_config = StreamConfig { + channels: device_channels, + sample_rate: device_sample_rate, + buffer_size: cpal::BufferSize::Default, + }; + + // Create ring buffer (always f32 internally, converted to i16 in callback if needed) let rb = HeapRb::::new(RING_BUFFER_SIZE); - let (producer, mut consumer) = rb.split(); + let (producer, consumer) = rb.split(); debug!("Ring buffer created: {} samples", RING_BUFFER_SIZE); let drain_signal = Arc::new((Mutex::new(false), Condvar::new())); let interrupted = Arc::new(AtomicBool::new(false)); let active = Arc::new(AtomicBool::new(false)); - let drain_signal_clone = drain_signal.clone(); - let interrupted_clone = interrupted.clone(); - - // Build output stream - let stream = cpal_device - .build_output_stream( - &desired_config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - // If interrupted, discard all buffered samples and output silence - if interrupted_clone.load(Ordering::SeqCst) { - while consumer.try_pop().is_some() {} - for sample in data.iter_mut() { - *sample = 0.0; - } - return; - } - - let mut all_silence = true; - for sample in data.iter_mut() { - if let Some(s) = consumer.try_pop() { - *sample = s; - all_silence = false; - } else { - *sample = 0.0; - } - } - if all_silence && consumer.is_empty() { - let (lock, cvar) = &*drain_signal_clone; - if let Ok(mut drained) = lock.lock() { - *drained = true; - cvar.notify_all(); - } - } - }, - move |err| { - log::error!("Stream callback error: {}", err); - }, - None, - ) - .map_err(SpeakerError::from)?; + let stream = if use_f32 { + Self::build_f32_stream( + &cpal_device, + &stream_config, + consumer, + &drain_signal, + &interrupted, + )? + } else { + Self::build_i16_stream( + &cpal_device, + &stream_config, + consumer, + &drain_signal, + &interrupted, + )? + }; stream.play().map_err(SpeakerError::from)?; active.store(true, Ordering::SeqCst); @@ -177,8 +152,10 @@ impl AudioPlayer { drain_signal, interrupted, active, - sample_rate, - channels, + sample_rate: user_sample_rate, + channels: user_channels, + device_sample_rate, + device_channels, }) } @@ -202,9 +179,21 @@ impl AudioPlayer { } } - let samples = Self::extract_samples(data)?; + let mut samples = Self::extract_samples(data)?; debug!("write: {} f32 samples", samples.len()); + // Resample if device rate differs from user rate + if self.sample_rate != self.device_sample_rate { + let before = samples.len(); + samples = Self::resample(&samples, self.sample_rate, self.device_sample_rate); + debug!("resampled: {} -> {} samples", before, samples.len()); + } + + // Upmix channels if needed (e.g. mono -> stereo) + if self.channels < self.device_channels { + samples = Self::upmix(&samples, self.channels, self.device_channels); + } + py.detach(|| { let mut offset = 0; while offset < samples.len() { @@ -285,6 +274,18 @@ impl AudioPlayer { self.channels } + /// Returns the actual device sample rate. + #[getter] + fn device_sample_rate(&self) -> u32 { + self.device_sample_rate + } + + /// Returns the actual device channel count. + #[getter] + fn device_channels(&self) -> u16 { + self.device_channels + } + /// Returns whether the player is active. #[getter] fn is_active(&self) -> bool { @@ -309,6 +310,225 @@ impl AudioPlayer { } impl AudioPlayer { + /// Negotiate the best sample rate and format for the device. + /// Returns (device_sample_rate, use_f32). + fn negotiate_config( + supported_configs: &[cpal::SupportedStreamConfigRange], + user_rate: u32, + channels: u16, + default_rate: u32, + ) -> PyResult<(u32, bool)> { + // Check what formats/rates are supported + let mut f32_at_user = false; + let mut i16_at_user = false; + let mut f32_at_default = false; + let mut i16_at_default = false; + let mut any_f32_rate: Option = None; + let mut any_i16_rate: Option = None; + + for config in supported_configs { + if config.channels() < channels { + continue; + } + let supports_user = + config.min_sample_rate() <= user_rate && config.max_sample_rate() >= user_rate; + let supports_default = config.min_sample_rate() <= default_rate + && config.max_sample_rate() >= default_rate; + + match config.sample_format() { + SampleFormat::F32 => { + if supports_user { + f32_at_user = true; + } + if supports_default { + f32_at_default = true; + } + if any_f32_rate.is_none() { + any_f32_rate = Some( + config + .max_sample_rate() + .max(user_rate.min(config.max_sample_rate())), + ); + } + } + SampleFormat::I16 => { + if supports_user { + i16_at_user = true; + } + if supports_default { + i16_at_default = true; + } + if any_i16_rate.is_none() { + any_i16_rate = Some( + config + .max_sample_rate() + .max(user_rate.min(config.max_sample_rate())), + ); + } + } + _ => {} + } + } + + debug!( + "negotiate: f32@user={}, i16@user={}, f32@default={}, i16@default={}", + f32_at_user, i16_at_user, f32_at_default, i16_at_default + ); + + // Priority: f32 at user rate > i16 at user rate > f32 at default > i16 at default + if f32_at_user { + Ok((user_rate, true)) + } else if i16_at_user { + Ok((user_rate, false)) + } else if f32_at_default { + info!( + "Device doesn't support {}Hz, using default {}Hz (will resample)", + user_rate, default_rate + ); + Ok((default_rate, true)) + } else if i16_at_default { + info!( + "Device doesn't support {}Hz, using default {}Hz i16 (will resample)", + user_rate, default_rate + ); + Ok((default_rate, false)) + } else { + Err(SpeakerError::ConfigError(format!( + "Device does not support {}Hz or {}Hz in f32 or i16", + user_rate, default_rate + )) + .into()) + } + } + + /// Build an f32 output stream. + fn build_f32_stream( + device: &cpal::Device, + config: &StreamConfig, + mut consumer: ringbuf::HeapCons, + drain_signal: &Arc<(Mutex, Condvar)>, + interrupted: &Arc, + ) -> PyResult { + let drain_clone = drain_signal.clone(); + let int_clone = interrupted.clone(); + + let stream = device + .build_output_stream( + config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + if int_clone.load(Ordering::SeqCst) { + while consumer.try_pop().is_some() {} + data.fill(0.0); + return; + } + let mut all_silence = true; + for sample in data.iter_mut() { + if let Some(s) = consumer.try_pop() { + *sample = s; + all_silence = false; + } else { + *sample = 0.0; + } + } + if all_silence && consumer.is_empty() { + let (lock, cvar) = &*drain_clone; + if let Ok(mut drained) = lock.lock() { + *drained = true; + cvar.notify_all(); + } + } + }, + |err| log::error!("Stream error: {}", err), + None, + ) + .map_err(SpeakerError::from)?; + Ok(stream) + } + + /// Build an i16 output stream (reads f32 from ring buffer, converts to i16). + fn build_i16_stream( + device: &cpal::Device, + config: &StreamConfig, + mut consumer: ringbuf::HeapCons, + drain_signal: &Arc<(Mutex, Condvar)>, + interrupted: &Arc, + ) -> PyResult { + let drain_clone = drain_signal.clone(); + let int_clone = interrupted.clone(); + + let stream = device + .build_output_stream( + config, + move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + if int_clone.load(Ordering::SeqCst) { + while consumer.try_pop().is_some() {} + data.fill(0); + return; + } + let mut all_silence = true; + for sample in data.iter_mut() { + if let Some(s) = consumer.try_pop() { + *sample = (s * 32767.0).clamp(-32768.0, 32767.0) as i16; + all_silence = false; + } else { + *sample = 0; + } + } + if all_silence && consumer.is_empty() { + let (lock, cvar) = &*drain_clone; + if let Ok(mut drained) = lock.lock() { + *drained = true; + cvar.notify_all(); + } + } + }, + |err| log::error!("Stream error: {}", err), + None, + ) + .map_err(SpeakerError::from)?; + Ok(stream) + } + + /// Upmix audio from fewer channels to more (e.g. mono -> stereo). + /// For mono->stereo, duplicates each sample. For other cases, duplicates + /// each frame's samples to fill the target channel count. + fn upmix(samples: &[f32], src_channels: u16, dst_channels: u16) -> Vec { + if src_channels >= dst_channels { + return samples.to_vec(); + } + let src_ch = src_channels as usize; + let dst_ch = dst_channels as usize; + let num_frames = samples.len() / src_ch; + let mut output = Vec::with_capacity(num_frames * dst_ch); + for frame in 0..num_frames { + let base = frame * src_ch; + for ch in 0..dst_ch { + // Copy from source channel, wrapping around if dst > src + output.push(samples[base + (ch % src_ch)]); + } + } + output + } + + /// Linear interpolation resampling from src_rate to dst_rate. + fn resample(samples: &[f32], src_rate: u32, dst_rate: u32) -> Vec { + if src_rate == dst_rate { + return samples.to_vec(); + } + let ratio = src_rate as f64 / dst_rate as f64; + let out_len = ((samples.len() as f64) / ratio).ceil() as usize; + let mut output = Vec::with_capacity(out_len); + for i in 0..out_len { + let src_pos = i as f64 * ratio; + let idx = src_pos as usize; + let frac = (src_pos - idx as f64) as f32; + let s0 = samples[idx.min(samples.len() - 1)]; + let s1 = samples[(idx + 1).min(samples.len() - 1)]; + output.push(s0 + frac * (s1 - s0)); + } + output + } + /// Convert any supported Python audio data to Vec. fn extract_samples(data: &Bound<'_, pyo3::PyAny>) -> PyResult> { // bytes → int16 LE PCM