gstaudiocap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zcl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include LICENSE
3
+ include pyproject.toml
4
+ recursive-include src *.py
5
+ recursive-include defaults *.yaml
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: gstaudiocap
3
+ Version: 0.1.0
4
+ Summary: GStreamer-based audio capture and ring buffer for real-time audio processing
5
+ Author-email: alonsolee <lizhenchang@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alonsolee/gstaudiocap
8
+ Project-URL: Documentation, https://github.com/alonsolee/gstaudiocap#readme
9
+ Project-URL: Repository, https://github.com/alonsolee/gstaudiocap.git
10
+ Project-URL: Issues, https://github.com/alonsolee/gstaudiocap/issues
11
+ Keywords: audio,gstreamer,ring-buffer,real-time,capture
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Multimedia :: Sound/Audio
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: PyGObject>=3.42.0
26
+ Requires-Dist: PyYAML>=6.0
27
+ Dynamic: license-file
28
+
29
+ # gstaudiocap
30
+
31
+ GStreamer-based audio capture and ring buffer for real-time audio processing.
32
+
33
+ ## Features
34
+
35
+ - **Cross-platform audio capture**: Works on macOS, Linux, and Windows using GStreamer
36
+ - **Multi-consumer ring buffer**: Single-producer multi-consumer architecture
37
+ - **Automatic lifecycle management**: GStreamer pipeline starts/stops based on consumer registration
38
+ - **Flexible configuration**: Support for custom audio sources, sample rates, channels, and gain
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install gstaudiocap
44
+ ```
45
+
46
+ ## Dependencies
47
+
48
+ - Python 3.10+
49
+ - GStreamer 1.0 with appropriate plugins
50
+ - PyGObject
51
+ - PyYAML
52
+
53
+ ## Usage
54
+
55
+ ### Basic Usage
56
+
57
+ ```python
58
+ from gstaudiocap import AudioRingBuffer
59
+
60
+ # Create ring buffer
61
+ audio_buffer = AudioRingBuffer(
62
+ device="default", # macOS default, Linux: "hw:0"
63
+ sample_rate=16000, # Hz
64
+ channels=1, # Mono
65
+ gain=1.0, # No gain
66
+ )
67
+
68
+ # Register consumer
69
+ audio_buffer.register_consumer("my_consumer")
70
+
71
+ # Read audio (16-bit PCM, little-endian)
72
+ audio_chunk = audio_buffer.read("my_consumer")
73
+
74
+ # Unregister consumer (stops pipeline if last consumer)
75
+ audio_buffer.unregister_consumer("my_consumer")
76
+ ```
77
+
78
+ ### Audio Capture Only
79
+
80
+ ```python
81
+ from gstaudiocap import AudioCapture
82
+
83
+ # Create audio capture
84
+ capture = AudioCapture(
85
+ device="default",
86
+ sample_rate=16000,
87
+ channels=1,
88
+ gain=1.0,
89
+ audio_source="osxaudiosrc", # Custom GStreamer source
90
+ )
91
+
92
+ # Start capture
93
+ capture.start()
94
+
95
+ # Read audio
96
+ audio_chunk = capture.read()
97
+
98
+ # Stop capture
99
+ capture.stop()
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### AudioRingBuffer
105
+
106
+ ```python
107
+ class AudioRingBuffer:
108
+ def __init__(
109
+ self,
110
+ capacity: int = 50,
111
+ device: str = "default",
112
+ sample_rate: int = 16000,
113
+ channels: int = 1,
114
+ gain: float = 1.0,
115
+ audio_source: Optional[str] = None,
116
+ ) -> None
117
+
118
+ def register_consumer(self, consumer_id: str) -> None
119
+ def unregister_consumer(self, consumer_id: str) -> None
120
+ def read(self, consumer_id: str) -> Optional[bytes]
121
+ def read_exact(
122
+ self,
123
+ consumer_id: str,
124
+ frame_bytes: int,
125
+ timeout_ms: int = 40,
126
+ ) -> Optional[bytes]
127
+ ```
128
+
129
+ ### AudioCapture
130
+
131
+ ```python
132
+ class AudioCapture:
133
+ def __init__(
134
+ self,
135
+ device: str = "default",
136
+ sample_rate: int = 16000,
137
+ channels: int = 1,
138
+ gain: float = 1.0,
139
+ audio_source: Optional[str] = None,
140
+ ) -> None
141
+
142
+ def start(self) -> None
143
+ def stop(self) -> None
144
+ def read(self) -> Optional[bytes]
145
+ ```
146
+
147
+ ## Platform Support
148
+
149
+ ### macOS
150
+ - Default device: "default"
151
+ - Default source: "osxaudiosrc"
152
+
153
+ ### Linux
154
+ - Default device: "hw:0"
155
+ - Default source: "alsasrc"
156
+
157
+ ### Windows
158
+ - Default device: "default"
159
+ - Default source: "autoaudiosrc" or "directsoundsrc"
160
+
161
+ ## Audio Format
162
+
163
+ All audio is returned as:
164
+ - **Format**: 16-bit PCM, little-endian
165
+ - **Channels**: Mono (1) or Stereo (2)
166
+ - **Sample Rate**: Configurable (default: 16000 Hz)
167
+
168
+ ## License
169
+
170
+ MIT License
@@ -0,0 +1,142 @@
1
+ # gstaudiocap
2
+
3
+ GStreamer-based audio capture and ring buffer for real-time audio processing.
4
+
5
+ ## Features
6
+
7
+ - **Cross-platform audio capture**: Works on macOS, Linux, and Windows using GStreamer
8
+ - **Multi-consumer ring buffer**: Single-producer multi-consumer architecture
9
+ - **Automatic lifecycle management**: GStreamer pipeline starts/stops based on consumer registration
10
+ - **Flexible configuration**: Support for custom audio sources, sample rates, channels, and gain
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install gstaudiocap
16
+ ```
17
+
18
+ ## Dependencies
19
+
20
+ - Python 3.10+
21
+ - GStreamer 1.0 with appropriate plugins
22
+ - PyGObject
23
+ - PyYAML
24
+
25
+ ## Usage
26
+
27
+ ### Basic Usage
28
+
29
+ ```python
30
+ from gstaudiocap import AudioRingBuffer
31
+
32
+ # Create ring buffer
33
+ audio_buffer = AudioRingBuffer(
34
+ device="default", # macOS default, Linux: "hw:0"
35
+ sample_rate=16000, # Hz
36
+ channels=1, # Mono
37
+ gain=1.0, # No gain
38
+ )
39
+
40
+ # Register consumer
41
+ audio_buffer.register_consumer("my_consumer")
42
+
43
+ # Read audio (16-bit PCM, little-endian)
44
+ audio_chunk = audio_buffer.read("my_consumer")
45
+
46
+ # Unregister consumer (stops pipeline if last consumer)
47
+ audio_buffer.unregister_consumer("my_consumer")
48
+ ```
49
+
50
+ ### Audio Capture Only
51
+
52
+ ```python
53
+ from gstaudiocap import AudioCapture
54
+
55
+ # Create audio capture
56
+ capture = AudioCapture(
57
+ device="default",
58
+ sample_rate=16000,
59
+ channels=1,
60
+ gain=1.0,
61
+ audio_source="osxaudiosrc", # Custom GStreamer source
62
+ )
63
+
64
+ # Start capture
65
+ capture.start()
66
+
67
+ # Read audio
68
+ audio_chunk = capture.read()
69
+
70
+ # Stop capture
71
+ capture.stop()
72
+ ```
73
+
74
+ ## API
75
+
76
+ ### AudioRingBuffer
77
+
78
+ ```python
79
+ class AudioRingBuffer:
80
+ def __init__(
81
+ self,
82
+ capacity: int = 50,
83
+ device: str = "default",
84
+ sample_rate: int = 16000,
85
+ channels: int = 1,
86
+ gain: float = 1.0,
87
+ audio_source: Optional[str] = None,
88
+ ) -> None
89
+
90
+ def register_consumer(self, consumer_id: str) -> None
91
+ def unregister_consumer(self, consumer_id: str) -> None
92
+ def read(self, consumer_id: str) -> Optional[bytes]
93
+ def read_exact(
94
+ self,
95
+ consumer_id: str,
96
+ frame_bytes: int,
97
+ timeout_ms: int = 40,
98
+ ) -> Optional[bytes]
99
+ ```
100
+
101
+ ### AudioCapture
102
+
103
+ ```python
104
+ class AudioCapture:
105
+ def __init__(
106
+ self,
107
+ device: str = "default",
108
+ sample_rate: int = 16000,
109
+ channels: int = 1,
110
+ gain: float = 1.0,
111
+ audio_source: Optional[str] = None,
112
+ ) -> None
113
+
114
+ def start(self) -> None
115
+ def stop(self) -> None
116
+ def read(self) -> Optional[bytes]
117
+ ```
118
+
119
+ ## Platform Support
120
+
121
+ ### macOS
122
+ - Default device: "default"
123
+ - Default source: "osxaudiosrc"
124
+
125
+ ### Linux
126
+ - Default device: "hw:0"
127
+ - Default source: "alsasrc"
128
+
129
+ ### Windows
130
+ - Default device: "default"
131
+ - Default source: "autoaudiosrc" or "directsoundsrc"
132
+
133
+ ## Audio Format
134
+
135
+ All audio is returned as:
136
+ - **Format**: 16-bit PCM, little-endian
137
+ - **Channels**: Mono (1) or Stereo (2)
138
+ - **Sample Rate**: Configurable (default: 16000 Hz)
139
+
140
+ ## License
141
+
142
+ MIT License
@@ -0,0 +1,22 @@
1
+ audio:
2
+ # Enable audio capture
3
+ enabled: true
4
+
5
+ # Audio device (default for macOS, hw:0 for Linux)
6
+ device: "hw:0,0"
7
+
8
+ # Sample rate in Hz
9
+ sample_rate: 16000
10
+
11
+ # Number of audio channels (1=mono, 2=stereo)
12
+ channels: 1
13
+
14
+ # Audio gain multiplier (1.0 = original volume)
15
+ gain: 1.0
16
+
17
+ # Custom GStreamer audio source (null = auto-detect)
18
+ # Examples: "osxaudiosrc" (macOS), "alsasrc" (Linux), "autoaudiosrc"
19
+ audio_source: null
20
+
21
+ # Ring buffer capacity (number of chunks)
22
+ capacity: 50
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gstaudiocap"
7
+ version = "0.1.0"
8
+ description = "GStreamer-based audio capture and ring buffer for real-time audio processing"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "alonsolee", email = "lizhenchang@gmail.com"}
14
+ ]
15
+ keywords = ["audio", "gstreamer", "ring-buffer", "real-time", "capture"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Multimedia :: Sound/Audio",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+
29
+ dependencies = [
30
+ "PyGObject>=3.42.0",
31
+ "PyYAML>=6.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/alonsolee/gstaudiocap"
36
+ Documentation = "https://github.com/alonsolee/gstaudiocap#readme"
37
+ Repository = "https://github.com/alonsolee/gstaudiocap.git"
38
+ Issues = "https://github.com/alonsolee/gstaudiocap/issues"
39
+
40
+ [tool.setuptools]
41
+ zip-safe = false
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ "gstaudiocap" = ["*.yaml", "*.json"]
48
+
49
+ [tool.black]
50
+ line-length = 100
51
+ target-version = ['py310', 'py311', 'py312', 'py313']
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "N", "W"]
59
+ ignore = ["E501"]
60
+
61
+ [tool.mypy]
62
+ python_version = "3.10"
63
+ warn_return_any = true
64
+ warn_unused_configs = true
65
+ disallow_untyped_defs = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ """GStreamer-based audio capture and ring buffer for real-time audio processing.
2
+
3
+ This package provides:
4
+ - AudioConfig: Configuration for audio capture
5
+ - AudioCapture: Cross-platform audio capture using GStreamer
6
+ - AudioRingBuffer: Single-producer multi-consumer ring buffer for shared audio
7
+
8
+ Usage:
9
+ from gstaudiocap import AudioConfig, AudioCapture, AudioRingBuffer
10
+
11
+ # Load config from YAML
12
+ config = AudioConfig.from_yaml("config.yaml")
13
+
14
+ # Create ring buffer from config
15
+ audio_buffer = AudioRingBuffer(
16
+ device=config.device,
17
+ sample_rate=config.sample_rate,
18
+ channels=config.channels,
19
+ gain=config.gain,
20
+ )
21
+ """
22
+
23
+ from gstaudiocap.audio_capture import AudioCapture, gstreamer_available
24
+ from gstaudiocap.audio_buffer import AudioRingBuffer
25
+ from gstaudiocap.config import AudioConfig
26
+
27
+ __all__ = [
28
+ "AudioConfig",
29
+ "AudioCapture",
30
+ "AudioRingBuffer",
31
+ "gstreamer_available",
32
+ ]
33
+
34
+ __version__ = "0.1.0"
@@ -0,0 +1,177 @@
1
+ """SPMC ring buffer for shared audio consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import Optional
8
+
9
+ import yaml
10
+
11
+ from gstaudiocap.audio_capture import AudioCapture
12
+
13
+
14
+
15
+ class AudioRingBuffer:
16
+ """Fixed-capacity ring buffer for audio with multi-consumer support."""
17
+
18
+ def __init__(
19
+ self,
20
+ capacity: int = 50,
21
+ device: str = "hw:0",
22
+ sample_rate: int = 16000,
23
+ channels: int = 1,
24
+ gain: float = 1.0,
25
+ audio_source: Optional[str] = None,
26
+ ) -> None:
27
+ """Initialize audio ring buffer."""
28
+ self._capacity = capacity
29
+ self._slots: list[bytes | None] = [None] * capacity
30
+ self._write_pos = 0
31
+ self._cursors: dict[str, int] = {}
32
+ self._pending_bytes: dict[str, bytearray] = {}
33
+ self._lock = threading.Lock()
34
+ self._consumers: set[str] = set()
35
+ self._reference_count = 0
36
+ self._audio_capture = AudioCapture(
37
+ device=device,
38
+ sample_rate=sample_rate,
39
+ channels=channels,
40
+ gain=gain,
41
+ audio_source=audio_source,
42
+ )
43
+ self._running = False
44
+ self._thread: Optional[threading.Thread] = None
45
+
46
+ @classmethod
47
+ def from_yaml(cls, yaml_path: str, section: str = "audio") -> "AudioRingBuffer":
48
+ """Load ring buffer config from YAML."""
49
+ with open(yaml_path, "r", encoding="utf-8") as file:
50
+ raw = yaml.safe_load(file) or {}
51
+ payload = raw.get(section, raw)
52
+ return cls(
53
+ capacity=int(payload.get("capacity", 50)),
54
+ device=str(payload.get("device", "hw:0")),
55
+ sample_rate=int(payload.get("sample_rate", 16000)),
56
+ channels=int(payload.get("channels", 1)),
57
+ gain=float(payload.get("gain", 1.0)),
58
+ audio_source=payload.get("audio_source"),
59
+ )
60
+
61
+ def register_consumer(self, consumer_id: str) -> None:
62
+ """Register a consumer."""
63
+ with self._lock:
64
+ if consumer_id in self._consumers:
65
+ return
66
+ self._consumers.add(consumer_id)
67
+ self._cursors[consumer_id] = self._write_pos
68
+ self._pending_bytes[consumer_id] = bytearray()
69
+ self._reference_count += 1
70
+ if self._reference_count == 1:
71
+ self._start_pipeline()
72
+
73
+ def unregister_consumer(self, consumer_id: str) -> None:
74
+ """Unregister a consumer."""
75
+ with self._lock:
76
+ if consumer_id not in self._consumers:
77
+ return
78
+ self._consumers.remove(consumer_id)
79
+ self._cursors.pop(consumer_id, None)
80
+ self._pending_bytes.pop(consumer_id, None)
81
+ self._reference_count -= 1
82
+ if self._reference_count == 0:
83
+ self._stop_pipeline()
84
+
85
+ def _start_pipeline(self) -> None:
86
+ """Start audio capture pipeline and read loop."""
87
+ if self._running:
88
+ return
89
+ self._audio_capture.start()
90
+ self._running = True
91
+ self._thread = threading.Thread(target=self._read_loop, name="AudioRingBuffer", daemon=True)
92
+ self._thread.start()
93
+
94
+ def _stop_pipeline(self) -> None:
95
+ """Stop audio capture pipeline and read loop."""
96
+ if not self._running:
97
+ return
98
+ self._running = False
99
+ self._audio_capture.stop()
100
+ if self._thread is not None:
101
+ self._thread.join(timeout=1.0)
102
+ self._thread = None
103
+
104
+ def _read_loop(self) -> None:
105
+ """Read from capture and write into ring slots."""
106
+ while self._running:
107
+ audio_data = self._audio_capture.read()
108
+ if audio_data:
109
+ self._write(audio_data)
110
+ else:
111
+ time.sleep(0.001)
112
+
113
+ def _write(self, audio_data: bytes) -> None:
114
+ """Write chunk into ring."""
115
+ with self._lock:
116
+ self._slots[self._write_pos % self._capacity] = audio_data
117
+ self._write_pos += 1
118
+
119
+ def read(self, consumer_id: str) -> Optional[bytes]:
120
+ """Read next chunk for one consumer."""
121
+ with self._lock:
122
+ cursor = self._cursors.get(consumer_id)
123
+ if cursor is None:
124
+ return None
125
+ if cursor >= self._write_pos:
126
+ return None
127
+ behind = self._write_pos - cursor
128
+ if behind > self._capacity:
129
+ cursor = self._write_pos - self._capacity
130
+ audio_data = self._slots[cursor % self._capacity]
131
+ self._cursors[consumer_id] = cursor + 1
132
+ return audio_data
133
+
134
+ def read_exact(
135
+ self,
136
+ consumer_id: str,
137
+ frame_bytes: int,
138
+ timeout_ms: int = 40,
139
+ ) -> Optional[bytes]:
140
+ """Read fixed-size bytes for one consumer with bounded wait."""
141
+ if frame_bytes <= 0:
142
+ return None
143
+ deadline = time.monotonic() + (timeout_ms / 1000.0)
144
+ while True:
145
+ with self._lock:
146
+ pending = self._pending_bytes.get(consumer_id)
147
+ if pending is None:
148
+ return None
149
+ if len(pending) >= frame_bytes:
150
+ frame = bytes(pending[:frame_bytes])
151
+ del pending[:frame_bytes]
152
+ return frame
153
+ cursor = self._cursors.get(consumer_id)
154
+ if cursor is not None and cursor < self._write_pos:
155
+ behind = self._write_pos - cursor
156
+ if behind > self._capacity:
157
+ cursor = self._write_pos - self._capacity
158
+ audio_data = self._slots[cursor % self._capacity]
159
+ self._cursors[consumer_id] = cursor + 1
160
+ if audio_data:
161
+ pending.extend(audio_data)
162
+ continue
163
+ if time.monotonic() >= deadline:
164
+ with self._lock:
165
+ pending = self._pending_bytes.get(consumer_id)
166
+ if pending is None:
167
+ return None
168
+ available = bytes(pending)
169
+ pending.clear()
170
+ if available:
171
+ return available + (b"\x00" * (frame_bytes - len(available)))
172
+ return b"\x00" * frame_bytes
173
+ time.sleep(0.001)
174
+
175
+ def __del__(self):
176
+ """Cleanup on destruction."""
177
+ self._stop_pipeline()
@@ -0,0 +1,220 @@
1
+ """GStreamer audio capture module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import yaml
9
+
10
+ try:
11
+ import gi
12
+
13
+ gi.require_version("Gst", "1.0")
14
+ gi.require_version("GstApp", "1.0")
15
+ from gi.repository import Gst, GstApp
16
+
17
+ Gst.init(None)
18
+ _GST_AVAILABLE = True
19
+ except (ImportError, ValueError):
20
+ _GST_AVAILABLE = False
21
+
22
+
23
+ def gstreamer_available() -> bool:
24
+ """Return whether GStreamer bindings are available."""
25
+ return _GST_AVAILABLE
26
+
27
+
28
+ class AudioCapture:
29
+ """Audio capture using GStreamer."""
30
+
31
+ def __init__(
32
+ self,
33
+ device: str = "hw:0",
34
+ sample_rate: int = 16000,
35
+ channels: int = 1,
36
+ gain: float = 1.0,
37
+ max_buffers: int = 64,
38
+ audio_source: Optional[str] = None,
39
+ file_sources: Optional[list[str]] = None,
40
+ loop_file_sources: bool = False,
41
+ ) -> None:
42
+ """Initialize audio capture."""
43
+ if not _GST_AVAILABLE:
44
+ raise RuntimeError("GStreamer is not available")
45
+ self._device = device
46
+ self._sample_rate = sample_rate
47
+ self._channels = channels
48
+ self._gain = gain
49
+ self._max_buffers = max_buffers
50
+ self._audio_source = audio_source
51
+ self._file_sources = [str(Path(p).expanduser()) for p in (file_sources or [])]
52
+ self._loop_file_sources = loop_file_sources
53
+ self._pipeline: Optional[Gst.Pipeline] = None
54
+ self._appsink: Optional[GstApp.AppSink] = None
55
+ self._running = False
56
+ self._empty_read_count = 0
57
+
58
+ @classmethod
59
+ def from_yaml(cls, yaml_path: str, section: str = "audio") -> "AudioCapture":
60
+ """Load audio capture config from YAML."""
61
+ with open(yaml_path, "r", encoding="utf-8") as file:
62
+ raw = yaml.safe_load(file) or {}
63
+ payload = raw.get(section, raw)
64
+ return cls(
65
+ device=str(payload.get("device", "hw:0")),
66
+ sample_rate=int(payload.get("sample_rate", 16000)),
67
+ channels=int(payload.get("channels", 1)),
68
+ gain=float(payload.get("gain", 1.0)),
69
+ max_buffers=int(payload.get("max_buffers", 5)),
70
+ audio_source=payload.get("audio_source"),
71
+ file_sources=payload.get("file_sources"),
72
+ loop_file_sources=bool(payload.get("loop_file_sources", False)),
73
+ )
74
+
75
+ def start(self) -> None:
76
+ """Start audio capture pipeline."""
77
+ if self._running:
78
+ return
79
+ pipeline_desc = self._build_pipeline_desc()
80
+ pipeline = Gst.parse_launch(pipeline_desc)
81
+ if pipeline is None:
82
+ raise RuntimeError("Failed to create GStreamer pipeline")
83
+ self._pipeline = pipeline
84
+ self._appsink = pipeline.get_by_name("audio_sink")
85
+ pipeline.set_state(Gst.State.PLAYING)
86
+ self._running = True
87
+
88
+ def stop(self) -> None:
89
+ """Stop audio capture pipeline."""
90
+ if not self._running:
91
+ return
92
+ self._running = False
93
+ self._cleanup()
94
+
95
+ def read(self) -> Optional[bytes]:
96
+ """Read audio data from the pipeline."""
97
+ if not self._running or self._appsink is None:
98
+ return None
99
+ sample = self._appsink.try_pull_sample(100 * Gst.MSECOND)
100
+ if not sample:
101
+ self._empty_read_count += 1
102
+ if self._file_sources and self._loop_file_sources and self._empty_read_count >= 3:
103
+ self._restart_file_pipeline()
104
+ return None
105
+ self._empty_read_count = 0
106
+ buffer = sample.get_buffer()
107
+ success, map_info = buffer.map(Gst.MapFlags.READ)
108
+ if not success:
109
+ return None
110
+ audio_data = bytes(map_info.data)
111
+ buffer.unmap(map_info)
112
+ return audio_data
113
+
114
+ def _build_pipeline_desc(self) -> str:
115
+ """Build GStreamer pipeline description."""
116
+ if self._file_sources:
117
+ return self._build_file_pipeline_desc()
118
+ import platform
119
+
120
+ if self._audio_source:
121
+ audio_src = self._audio_source
122
+ else:
123
+ system = platform.system()
124
+ if system == "Darwin":
125
+ audio_src = "osxaudiosrc"
126
+ elif system == "Linux":
127
+ audio_src = "alsasrc"
128
+ else:
129
+ audio_src = "autoaudiosrc"
130
+ device_param = f"device={self._device}" if audio_src == "alsasrc" else ""
131
+ return (
132
+ f"{audio_src} {device_param} "
133
+ f"! audioconvert "
134
+ f"! audioresample "
135
+ f"! audio/x-raw,format=S16LE,rate={self._sample_rate},channels={self._channels} "
136
+ f"! volume name=volume volume={self._gain} "
137
+ f"! audioconvert "
138
+ f"! appsink name=audio_sink emit-signals=false max-buffers={self._max_buffers} drop=false sync=false"
139
+ )
140
+
141
+ def _build_file_pipeline_desc(self) -> str:
142
+ """Build pipeline that reads multiple local audio files."""
143
+ allowed_suffixes = {
144
+ ".wav",
145
+ ".mp3",
146
+ ".m4a",
147
+ ".aac",
148
+ ".flac",
149
+ ".ogg",
150
+ ".opus",
151
+ ".webm",
152
+ }
153
+ normalized_files: list[Path] = []
154
+ for file_source in self._file_sources:
155
+ candidate = Path(file_source).expanduser().resolve()
156
+ if not candidate.exists():
157
+ raise FileNotFoundError(f"Audio file not found: {candidate}")
158
+ if candidate.suffix.lower() not in allowed_suffixes:
159
+ raise ValueError(
160
+ f"Unsupported audio file type: {candidate}. "
161
+ f"Supported: {sorted(allowed_suffixes)}"
162
+ )
163
+ normalized_files.append(candidate)
164
+ if not normalized_files:
165
+ raise ValueError("file_sources is empty")
166
+
167
+ branches: list[str] = []
168
+ for file_path in normalized_files:
169
+ file_uri = file_path.as_uri()
170
+ branches.append(
171
+ f'uridecodebin uri="{file_uri}" ! audioconvert ! audioresample '
172
+ f'! audio/x-raw,format=S16LE,rate={self._sample_rate},channels={self._channels} '
173
+ f'! queue ! audio_concat.'
174
+ )
175
+ branches_str = " ".join(branches)
176
+
177
+ return (
178
+ f"{branches_str} "
179
+ f"concat name=audio_concat "
180
+ f"! audioconvert "
181
+ f"! audioresample "
182
+ f"! audio/x-raw,format=S16LE,rate={self._sample_rate},channels={self._channels} "
183
+ f"! volume name=volume volume={self._gain} "
184
+ f"! audioconvert "
185
+ f"! appsink name=audio_sink emit-signals=false max-buffers={self._max_buffers} drop=false sync=true"
186
+ )
187
+
188
+ def _restart_file_pipeline(self) -> None:
189
+ """Restart file-based pipeline to loop file sources."""
190
+ pipeline_desc = self._build_file_pipeline_desc()
191
+ self._cleanup()
192
+ pipeline = Gst.parse_launch(pipeline_desc)
193
+ if pipeline is None:
194
+ return
195
+ self._pipeline = pipeline
196
+ self._appsink = pipeline.get_by_name("audio_sink")
197
+ pipeline.set_state(Gst.State.PLAYING)
198
+ self._empty_read_count = 0
199
+
200
+ def _cleanup(self) -> None:
201
+ """Clean up GStreamer resources."""
202
+ if self._pipeline is None:
203
+ return
204
+ try:
205
+ self._pipeline.send_event(Gst.Event.new_eos())
206
+ bus = self._pipeline.get_bus()
207
+ if bus is not None:
208
+ bus.timed_pop_filtered(
209
+ Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR
210
+ )
211
+ except Exception:
212
+ pass
213
+ finally:
214
+ self._pipeline.set_state(Gst.State.NULL)
215
+ self._pipeline = None
216
+ self._appsink = None
217
+
218
+ def __del__(self):
219
+ """Cleanup on destruction."""
220
+ self.stop()
@@ -0,0 +1,52 @@
1
+ """Audio configuration for GStreamer capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class AudioConfig:
14
+ """Audio configuration for capture."""
15
+
16
+ enabled: bool = True
17
+ device: str = "hw:0,0"
18
+ sample_rate: int = 16000
19
+ channels: int = 1
20
+ gain: float = 1.0
21
+ audio_source: Optional[str] = None
22
+ capacity: int = 50
23
+
24
+ @classmethod
25
+ def from_yaml(cls, yaml_path: Optional[str] = None) -> AudioConfig:
26
+ """Load configuration from YAML file.
27
+
28
+ Args:
29
+ yaml_path: Path to YAML file. If None, returns default config.
30
+
31
+ Returns:
32
+ AudioConfig instance
33
+ """
34
+ if yaml_path is None:
35
+ return cls()
36
+
37
+ if not Path(yaml_path).exists():
38
+ return cls()
39
+
40
+ with open(yaml_path, "r", encoding="utf-8") as f:
41
+ raw = yaml.safe_load(f) or {}
42
+
43
+ audio_data = raw.get("audio", raw)
44
+ return cls(
45
+ enabled=bool(audio_data.get("enabled", True)),
46
+ device=str(audio_data.get("device", "default")),
47
+ sample_rate=int(audio_data.get("sample_rate", 16000)),
48
+ channels=int(audio_data.get("channels", 1)),
49
+ gain=float(audio_data.get("gain", 1.0)),
50
+ audio_source=audio_data.get("audio_source"),
51
+ capacity=int(audio_data.get("capacity", 50)),
52
+ )
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: gstaudiocap
3
+ Version: 0.1.0
4
+ Summary: GStreamer-based audio capture and ring buffer for real-time audio processing
5
+ Author-email: alonsolee <lizhenchang@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alonsolee/gstaudiocap
8
+ Project-URL: Documentation, https://github.com/alonsolee/gstaudiocap#readme
9
+ Project-URL: Repository, https://github.com/alonsolee/gstaudiocap.git
10
+ Project-URL: Issues, https://github.com/alonsolee/gstaudiocap/issues
11
+ Keywords: audio,gstreamer,ring-buffer,real-time,capture
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Multimedia :: Sound/Audio
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: PyGObject>=3.42.0
26
+ Requires-Dist: PyYAML>=6.0
27
+ Dynamic: license-file
28
+
29
+ # gstaudiocap
30
+
31
+ GStreamer-based audio capture and ring buffer for real-time audio processing.
32
+
33
+ ## Features
34
+
35
+ - **Cross-platform audio capture**: Works on macOS, Linux, and Windows using GStreamer
36
+ - **Multi-consumer ring buffer**: Single-producer multi-consumer architecture
37
+ - **Automatic lifecycle management**: GStreamer pipeline starts/stops based on consumer registration
38
+ - **Flexible configuration**: Support for custom audio sources, sample rates, channels, and gain
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install gstaudiocap
44
+ ```
45
+
46
+ ## Dependencies
47
+
48
+ - Python 3.10+
49
+ - GStreamer 1.0 with appropriate plugins
50
+ - PyGObject
51
+ - PyYAML
52
+
53
+ ## Usage
54
+
55
+ ### Basic Usage
56
+
57
+ ```python
58
+ from gstaudiocap import AudioRingBuffer
59
+
60
+ # Create ring buffer
61
+ audio_buffer = AudioRingBuffer(
62
+ device="default", # macOS default, Linux: "hw:0"
63
+ sample_rate=16000, # Hz
64
+ channels=1, # Mono
65
+ gain=1.0, # No gain
66
+ )
67
+
68
+ # Register consumer
69
+ audio_buffer.register_consumer("my_consumer")
70
+
71
+ # Read audio (16-bit PCM, little-endian)
72
+ audio_chunk = audio_buffer.read("my_consumer")
73
+
74
+ # Unregister consumer (stops pipeline if last consumer)
75
+ audio_buffer.unregister_consumer("my_consumer")
76
+ ```
77
+
78
+ ### Audio Capture Only
79
+
80
+ ```python
81
+ from gstaudiocap import AudioCapture
82
+
83
+ # Create audio capture
84
+ capture = AudioCapture(
85
+ device="default",
86
+ sample_rate=16000,
87
+ channels=1,
88
+ gain=1.0,
89
+ audio_source="osxaudiosrc", # Custom GStreamer source
90
+ )
91
+
92
+ # Start capture
93
+ capture.start()
94
+
95
+ # Read audio
96
+ audio_chunk = capture.read()
97
+
98
+ # Stop capture
99
+ capture.stop()
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### AudioRingBuffer
105
+
106
+ ```python
107
+ class AudioRingBuffer:
108
+ def __init__(
109
+ self,
110
+ capacity: int = 50,
111
+ device: str = "default",
112
+ sample_rate: int = 16000,
113
+ channels: int = 1,
114
+ gain: float = 1.0,
115
+ audio_source: Optional[str] = None,
116
+ ) -> None
117
+
118
+ def register_consumer(self, consumer_id: str) -> None
119
+ def unregister_consumer(self, consumer_id: str) -> None
120
+ def read(self, consumer_id: str) -> Optional[bytes]
121
+ def read_exact(
122
+ self,
123
+ consumer_id: str,
124
+ frame_bytes: int,
125
+ timeout_ms: int = 40,
126
+ ) -> Optional[bytes]
127
+ ```
128
+
129
+ ### AudioCapture
130
+
131
+ ```python
132
+ class AudioCapture:
133
+ def __init__(
134
+ self,
135
+ device: str = "default",
136
+ sample_rate: int = 16000,
137
+ channels: int = 1,
138
+ gain: float = 1.0,
139
+ audio_source: Optional[str] = None,
140
+ ) -> None
141
+
142
+ def start(self) -> None
143
+ def stop(self) -> None
144
+ def read(self) -> Optional[bytes]
145
+ ```
146
+
147
+ ## Platform Support
148
+
149
+ ### macOS
150
+ - Default device: "default"
151
+ - Default source: "osxaudiosrc"
152
+
153
+ ### Linux
154
+ - Default device: "hw:0"
155
+ - Default source: "alsasrc"
156
+
157
+ ### Windows
158
+ - Default device: "default"
159
+ - Default source: "autoaudiosrc" or "directsoundsrc"
160
+
161
+ ## Audio Format
162
+
163
+ All audio is returned as:
164
+ - **Format**: 16-bit PCM, little-endian
165
+ - **Channels**: Mono (1) or Stereo (2)
166
+ - **Sample Rate**: Configurable (default: 16000 Hz)
167
+
168
+ ## License
169
+
170
+ MIT License
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ defaults/gstaudiocap.yaml
6
+ src/gstaudiocap/__init__.py
7
+ src/gstaudiocap/audio_buffer.py
8
+ src/gstaudiocap/audio_capture.py
9
+ src/gstaudiocap/config.py
10
+ src/gstaudiocap.egg-info/PKG-INFO
11
+ src/gstaudiocap.egg-info/SOURCES.txt
12
+ src/gstaudiocap.egg-info/dependency_links.txt
13
+ src/gstaudiocap.egg-info/not-zip-safe
14
+ src/gstaudiocap.egg-info/requires.txt
15
+ src/gstaudiocap.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ PyGObject>=3.42.0
2
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ gstaudiocap