Skip to content
Merged
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
58 changes: 43 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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).

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions examples/list_devices.py
Original file line number Diff line number Diff line change
@@ -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']})")
6 changes: 4 additions & 2 deletions examples/play_sine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import math

import pyaudiocast as pyspeaker
import pyaudiocast

SAMPLE_RATE = 44100
DURATION = 2.0
Expand All @@ -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()

Expand Down
14 changes: 14 additions & 0 deletions examples/play_wav.py
Original file line number Diff line number Diff line change
@@ -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 <path-to-wav>")
sys.exit(1)

path = sys.argv[1]
print(f"Playing {path}...")
pyaudiocast.play_file(path)
print("Done.")
51 changes: 51 additions & 0 deletions examples/stream_with_interrupt.py
Original file line number Diff line number Diff line change
@@ -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.")
4 changes: 3 additions & 1 deletion pyaudiocast/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
41 changes: 28 additions & 13 deletions pyaudiocast/_pyaudiocast.pyi
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""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

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).
"""
...

Expand All @@ -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."""
...
Expand All @@ -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: ...
Loading