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.
- gstaudiocap-0.1.0/LICENSE +21 -0
- gstaudiocap-0.1.0/MANIFEST.in +5 -0
- gstaudiocap-0.1.0/PKG-INFO +170 -0
- gstaudiocap-0.1.0/README.md +142 -0
- gstaudiocap-0.1.0/defaults/gstaudiocap.yaml +22 -0
- gstaudiocap-0.1.0/pyproject.toml +65 -0
- gstaudiocap-0.1.0/setup.cfg +4 -0
- gstaudiocap-0.1.0/src/gstaudiocap/__init__.py +34 -0
- gstaudiocap-0.1.0/src/gstaudiocap/audio_buffer.py +177 -0
- gstaudiocap-0.1.0/src/gstaudiocap/audio_capture.py +220 -0
- gstaudiocap-0.1.0/src/gstaudiocap/config.py +52 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/PKG-INFO +170 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/SOURCES.txt +15 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/dependency_links.txt +1 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/not-zip-safe +1 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/requires.txt +2 -0
- gstaudiocap-0.1.0/src/gstaudiocap.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gstaudiocap
|