pysoniq 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.
- pysoniq-0.1.0/LICENSE +21 -0
- pysoniq-0.1.0/PKG-INFO +116 -0
- pysoniq-0.1.0/README.md +84 -0
- pysoniq-0.1.0/pyproject.toml +53 -0
- pysoniq-0.1.0/pysoniq/__init__.py +22 -0
- pysoniq-0.1.0/pysoniq/gain.py +184 -0
- pysoniq-0.1.0/pysoniq/io.py +105 -0
- pysoniq-0.1.0/pysoniq/loop.py +131 -0
- pysoniq-0.1.0/pysoniq/pause.py +95 -0
- pysoniq-0.1.0/pysoniq/play.py +221 -0
- pysoniq-0.1.0/pysoniq/stop.py +36 -0
- pysoniq-0.1.0/pysoniq/utils.py +93 -0
- pysoniq-0.1.0/pysoniq.egg-info/PKG-INFO +116 -0
- pysoniq-0.1.0/pysoniq.egg-info/SOURCES.txt +16 -0
- pysoniq-0.1.0/pysoniq.egg-info/dependency_links.txt +1 -0
- pysoniq-0.1.0/pysoniq.egg-info/requires.txt +1 -0
- pysoniq-0.1.0/pysoniq.egg-info/top_level.txt +1 -0
- pysoniq-0.1.0/setup.cfg +4 -0
pysoniq-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 laelume
|
|
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.
|
pysoniq-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysoniq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, pure-Python cross-platform audio playback library
|
|
5
|
+
Author: laelume
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/laelume/pysoniq
|
|
8
|
+
Project-URL: Repository, https://github.com/laelume/pysoniq
|
|
9
|
+
Project-URL: Issues, https://github.com/laelume/pysoniq/issues
|
|
10
|
+
Keywords: audio,sound,playback,music,cross-platform,pure-python,audio-playback,audio-player,sound-playback,wav
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
25
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: numpy>=1.19.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# pysoniq
|
|
34
|
+
|
|
35
|
+
Minimal, pure-Python cross-platform audio playback library.
|
|
36
|
+
|
|
37
|
+
- **Pure Python** - No compiled extensions
|
|
38
|
+
- **Cross-platform** - Windows, macOS, Linux
|
|
39
|
+
- **Minimal dependencies** - Only numpy
|
|
40
|
+
- **Simple API** - Play, pause, stop, loop
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
```bash
|
|
44
|
+
pip install pysoniq
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use in context
|
|
48
|
+
```python
|
|
49
|
+
import pysoniq as ps
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
# Play WAV file
|
|
53
|
+
ps.play('audio.wav')
|
|
54
|
+
|
|
55
|
+
# Play as numpy array
|
|
56
|
+
sr = 44100
|
|
57
|
+
t = np.linspace(0, 1.0, sr)
|
|
58
|
+
audio = 0.3 * np.sin(2 * np.pi * 440 * t)
|
|
59
|
+
ps.play(audio, samplerate=sr)
|
|
60
|
+
|
|
61
|
+
# Loop playback
|
|
62
|
+
ps.set_loop(True)
|
|
63
|
+
ps.play(audio, sr)
|
|
64
|
+
|
|
65
|
+
# Stop
|
|
66
|
+
ps.stop()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
**Playback**
|
|
72
|
+
```python
|
|
73
|
+
ps.play(data, samplerate) # Play audio
|
|
74
|
+
ps.stop() # Stop playback
|
|
75
|
+
ps.pause() # Pause
|
|
76
|
+
ps.resume() # Resume
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Looping**
|
|
80
|
+
```python
|
|
81
|
+
ps.set_loop(True) # Enable loop
|
|
82
|
+
ps.is_looping() # Check status
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Gain Control**
|
|
86
|
+
```python
|
|
87
|
+
ps.set_gain(0.5) # 50% volume
|
|
88
|
+
ps.set_volume_db(-6.0) # Set dB
|
|
89
|
+
audio = ps.adjust_gain_level(audio, 1.5)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Audio I/O**
|
|
93
|
+
```python
|
|
94
|
+
audio, sr = ps.load('file.wav')
|
|
95
|
+
ps.save('output.wav', audio, sr)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Platform Requirements
|
|
99
|
+
|
|
100
|
+
- **Windows**: Built-in (winsound)
|
|
101
|
+
- **macOS**: Built-in (afplay)
|
|
102
|
+
- **Linux**: ALSA (aplay) - usually pre-installed
|
|
103
|
+
|
|
104
|
+
## Limitations
|
|
105
|
+
|
|
106
|
+
- WAV format only (for now)
|
|
107
|
+
- Pause/resume uses time-based estimation
|
|
108
|
+
- Gain changes apply on next loop iteration
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
113
|
+
|
|
114
|
+
## Author
|
|
115
|
+
|
|
116
|
+
laelume
|
pysoniq-0.1.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pysoniq
|
|
2
|
+
|
|
3
|
+
Minimal, pure-Python cross-platform audio playback library.
|
|
4
|
+
|
|
5
|
+
- **Pure Python** - No compiled extensions
|
|
6
|
+
- **Cross-platform** - Windows, macOS, Linux
|
|
7
|
+
- **Minimal dependencies** - Only numpy
|
|
8
|
+
- **Simple API** - Play, pause, stop, loop
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
```bash
|
|
12
|
+
pip install pysoniq
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use in context
|
|
16
|
+
```python
|
|
17
|
+
import pysoniq as ps
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
# Play WAV file
|
|
21
|
+
ps.play('audio.wav')
|
|
22
|
+
|
|
23
|
+
# Play as numpy array
|
|
24
|
+
sr = 44100
|
|
25
|
+
t = np.linspace(0, 1.0, sr)
|
|
26
|
+
audio = 0.3 * np.sin(2 * np.pi * 440 * t)
|
|
27
|
+
ps.play(audio, samplerate=sr)
|
|
28
|
+
|
|
29
|
+
# Loop playback
|
|
30
|
+
ps.set_loop(True)
|
|
31
|
+
ps.play(audio, sr)
|
|
32
|
+
|
|
33
|
+
# Stop
|
|
34
|
+
ps.stop()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
**Playback**
|
|
40
|
+
```python
|
|
41
|
+
ps.play(data, samplerate) # Play audio
|
|
42
|
+
ps.stop() # Stop playback
|
|
43
|
+
ps.pause() # Pause
|
|
44
|
+
ps.resume() # Resume
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Looping**
|
|
48
|
+
```python
|
|
49
|
+
ps.set_loop(True) # Enable loop
|
|
50
|
+
ps.is_looping() # Check status
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Gain Control**
|
|
54
|
+
```python
|
|
55
|
+
ps.set_gain(0.5) # 50% volume
|
|
56
|
+
ps.set_volume_db(-6.0) # Set dB
|
|
57
|
+
audio = ps.adjust_gain_level(audio, 1.5)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Audio I/O**
|
|
61
|
+
```python
|
|
62
|
+
audio, sr = ps.load('file.wav')
|
|
63
|
+
ps.save('output.wav', audio, sr)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Platform Requirements
|
|
67
|
+
|
|
68
|
+
- **Windows**: Built-in (winsound)
|
|
69
|
+
- **macOS**: Built-in (afplay)
|
|
70
|
+
- **Linux**: ALSA (aplay) - usually pre-installed
|
|
71
|
+
|
|
72
|
+
## Limitations
|
|
73
|
+
|
|
74
|
+
- WAV format only (for now)
|
|
75
|
+
- Pause/resume uses time-based estimation
|
|
76
|
+
- Gain changes apply on next loop iteration
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
81
|
+
|
|
82
|
+
## Author
|
|
83
|
+
|
|
84
|
+
laelume
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pysoniq"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight, pure-Python cross-platform audio playback library"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "laelume"}
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"audio", "sound", "playback", "music",
|
|
17
|
+
"cross-platform", "pure-python", "audio-playback",
|
|
18
|
+
"audio-player", "sound-playback", "wav"
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Operating System :: Microsoft :: Windows",
|
|
26
|
+
"Operating System :: MacOS",
|
|
27
|
+
"Operating System :: POSIX :: Linux",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.8",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
35
|
+
"Topic :: Multimedia :: Sound/Audio :: Players",
|
|
36
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
dependencies = [
|
|
40
|
+
"numpy>=1.19.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/laelume/pysoniq"
|
|
45
|
+
Repository = "https://github.com/laelume/pysoniq"
|
|
46
|
+
Issues = "https://github.com/laelume/pysoniq/issues"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["."]
|
|
50
|
+
include = ["pysoniq*"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.package-data]
|
|
53
|
+
pysoniq = ["*.md"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""pysoniq - Lightweight cross-platform audio library"""
|
|
2
|
+
|
|
3
|
+
from .play import play
|
|
4
|
+
from .stop import stop
|
|
5
|
+
from .pause import pause, resume, is_paused
|
|
6
|
+
from .loop import set_loop, is_looping
|
|
7
|
+
from .io import load, save
|
|
8
|
+
from .gain import (
|
|
9
|
+
set_gain, get_gain,
|
|
10
|
+
set_volume_db, get_volume_db,
|
|
11
|
+
adjust_gain_level, normalize, compress, limiter,
|
|
12
|
+
db_to_linear, linear_to_db
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = '0.1.0'
|
|
16
|
+
__all__ = [
|
|
17
|
+
'play', 'stop', 'pause', 'resume', 'is_paused',
|
|
18
|
+
'load', 'save', 'set_loop', 'is_looping',
|
|
19
|
+
'set_gain', 'get_gain', 'set_volume_db', 'get_volume_db',
|
|
20
|
+
'adjust_gain_level', 'normalize', 'compress', 'limiter',
|
|
21
|
+
'db_to_linear', 'linear_to_db'
|
|
22
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Gain and volume control"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
# Module state
|
|
6
|
+
_global_gain = 1.0 # Linear gain (0.0 to 2.0+)
|
|
7
|
+
_global_volume_db = 0.0 # dB (-inf to +6 dB typical)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_gain(gain):
|
|
11
|
+
"""
|
|
12
|
+
Set global gain (linear)
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
gain: float, 0.0 to 2.0+ (1.0 = unity)
|
|
16
|
+
"""
|
|
17
|
+
global _global_gain
|
|
18
|
+
_global_gain = max(0.0, gain)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_gain():
|
|
22
|
+
"""Get current global gain (linear)"""
|
|
23
|
+
return _global_gain
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_volume_db(db):
|
|
27
|
+
"""
|
|
28
|
+
Set global volume in dB
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
db: float, typically -60 to +6 dB (0 dB = unity)
|
|
32
|
+
"""
|
|
33
|
+
global _global_volume_db, _global_gain
|
|
34
|
+
_global_volume_db = db
|
|
35
|
+
_global_gain = db_to_linear(db)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_volume_db():
|
|
39
|
+
"""Get current global volume in dB"""
|
|
40
|
+
return _global_volume_db
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def adjust_gain_level(audio, gain=None):
|
|
44
|
+
"""
|
|
45
|
+
Adjust audio gain level
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
audio: numpy array
|
|
49
|
+
gain: float, if None uses global gain (1.0 = unity, <1.0 = reduce, >1.0 = boost)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
audio with gain adjusted (clipped to -1, 1)
|
|
53
|
+
"""
|
|
54
|
+
if gain is None:
|
|
55
|
+
gain = _global_gain
|
|
56
|
+
|
|
57
|
+
return np.clip(audio * gain, -1.0, 1.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def db_to_linear(db):
|
|
61
|
+
"""
|
|
62
|
+
Convert dB to linear gain
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
db: float, decibels
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
linear gain
|
|
69
|
+
"""
|
|
70
|
+
return 10.0 ** (db / 20.0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def linear_to_db(gain):
|
|
74
|
+
"""
|
|
75
|
+
Convert linear gain to dB
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
gain: float, linear gain
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
dB value
|
|
82
|
+
"""
|
|
83
|
+
if gain <= 0:
|
|
84
|
+
return -np.inf
|
|
85
|
+
return 20.0 * np.log10(gain)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalize(audio, target_db=-3.0):
|
|
89
|
+
"""
|
|
90
|
+
Normalize audio to target dB level
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
audio: numpy array
|
|
94
|
+
target_db: float, target peak level in dB (e.g., -3.0)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
normalized audio
|
|
98
|
+
"""
|
|
99
|
+
peak = np.max(np.abs(audio))
|
|
100
|
+
if peak == 0:
|
|
101
|
+
return audio
|
|
102
|
+
|
|
103
|
+
target_linear = db_to_linear(target_db)
|
|
104
|
+
current_db = linear_to_db(peak)
|
|
105
|
+
gain_needed = target_linear / peak
|
|
106
|
+
|
|
107
|
+
return audio * gain_needed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compress(audio, threshold_db=-20.0, ratio=4.0, attack_ms=5.0, release_ms=50.0, sr=44100):
|
|
111
|
+
"""
|
|
112
|
+
Simple dynamics compression
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
audio: numpy array (mono)
|
|
116
|
+
threshold_db: float, compression threshold
|
|
117
|
+
ratio: float, compression ratio (e.g., 4.0 = 4:1)
|
|
118
|
+
attack_ms: float, attack time in milliseconds
|
|
119
|
+
release_ms: float, release time in milliseconds
|
|
120
|
+
sr: int, sample rate
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
compressed audio
|
|
124
|
+
"""
|
|
125
|
+
# Convert threshold to linear
|
|
126
|
+
threshold = db_to_linear(threshold_db)
|
|
127
|
+
|
|
128
|
+
# Calculate attack/release coefficients
|
|
129
|
+
attack_samples = int(attack_ms * sr / 1000.0)
|
|
130
|
+
release_samples = int(release_ms * sr / 1000.0)
|
|
131
|
+
|
|
132
|
+
# Simple envelope follower with compression
|
|
133
|
+
envelope = np.abs(audio)
|
|
134
|
+
gain_reduction = np.ones_like(audio)
|
|
135
|
+
|
|
136
|
+
for i in range(len(audio)):
|
|
137
|
+
# If above threshold, calculate gain reduction
|
|
138
|
+
if envelope[i] > threshold:
|
|
139
|
+
# Calculate how much over threshold
|
|
140
|
+
over = envelope[i] / threshold
|
|
141
|
+
# Apply ratio
|
|
142
|
+
gain_reduction[i] = 1.0 / (1.0 + (over - 1.0) * (ratio - 1.0) / ratio)
|
|
143
|
+
|
|
144
|
+
# Apply gain reduction with smoothing
|
|
145
|
+
smoothed_gain = np.copy(gain_reduction)
|
|
146
|
+
for i in range(1, len(smoothed_gain)):
|
|
147
|
+
if gain_reduction[i] < smoothed_gain[i-1]:
|
|
148
|
+
# Attack
|
|
149
|
+
alpha = 1.0 - np.exp(-1.0 / attack_samples)
|
|
150
|
+
else:
|
|
151
|
+
# Release
|
|
152
|
+
alpha = 1.0 - np.exp(-1.0 / release_samples)
|
|
153
|
+
|
|
154
|
+
smoothed_gain[i] = alpha * gain_reduction[i] + (1.0 - alpha) * smoothed_gain[i-1]
|
|
155
|
+
|
|
156
|
+
return audio * smoothed_gain
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def limiter(audio, threshold_db=-1.0, ceiling_db=-0.1):
|
|
160
|
+
"""
|
|
161
|
+
Hard limiter to prevent clipping
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
audio: numpy array
|
|
165
|
+
threshold_db: float, where limiting starts
|
|
166
|
+
ceiling_db: float, absolute maximum level
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
limited audio
|
|
170
|
+
"""
|
|
171
|
+
threshold = db_to_linear(threshold_db)
|
|
172
|
+
ceiling = db_to_linear(ceiling_db)
|
|
173
|
+
|
|
174
|
+
# Hard clip at ceiling
|
|
175
|
+
audio = np.clip(audio, -ceiling, ceiling)
|
|
176
|
+
|
|
177
|
+
# Soft limiting above threshold
|
|
178
|
+
mask = np.abs(audio) > threshold
|
|
179
|
+
if np.any(mask):
|
|
180
|
+
# Soft knee compression above threshold
|
|
181
|
+
over = np.abs(audio[mask]) - threshold
|
|
182
|
+
audio[mask] = np.sign(audio[mask]) * (threshold + over / (1.0 + over))
|
|
183
|
+
|
|
184
|
+
return audio
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Audio file I/O"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import wave
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def load(filepath):
|
|
8
|
+
"""
|
|
9
|
+
Load audio file
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
filepath: str or Path to audio file
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
audio: numpy array (normalized float32, -1 to 1)
|
|
16
|
+
samplerate: int, sample rate in Hz
|
|
17
|
+
|
|
18
|
+
Supports: .wav
|
|
19
|
+
"""
|
|
20
|
+
filepath = Path(filepath)
|
|
21
|
+
|
|
22
|
+
if filepath.suffix.lower() == '.wav':
|
|
23
|
+
return _load_wav(filepath)
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"Unsupported format: {filepath.suffix}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_wav(filepath):
|
|
29
|
+
"""Load WAV file using wave module"""
|
|
30
|
+
with wave.open(str(filepath), 'rb') as wav:
|
|
31
|
+
n_channels = wav.getnchannels()
|
|
32
|
+
sampwidth = wav.getsampwidth()
|
|
33
|
+
framerate = wav.getframerate()
|
|
34
|
+
n_frames = wav.getnframes()
|
|
35
|
+
|
|
36
|
+
# Read raw data
|
|
37
|
+
raw_data = wav.readframes(n_frames)
|
|
38
|
+
|
|
39
|
+
# Convert to numpy array based on sample width
|
|
40
|
+
if sampwidth == 1: # 8-bit unsigned
|
|
41
|
+
audio = np.frombuffer(raw_data, dtype=np.uint8)
|
|
42
|
+
audio = (audio.astype(np.float32) - 128) / 128.0
|
|
43
|
+
elif sampwidth == 2: # 16-bit signed
|
|
44
|
+
audio = np.frombuffer(raw_data, dtype=np.int16)
|
|
45
|
+
audio = audio.astype(np.float32) / 32768.0
|
|
46
|
+
elif sampwidth == 3: # 24-bit signed
|
|
47
|
+
# Expand 24-bit to 32-bit
|
|
48
|
+
audio_bytes = np.frombuffer(raw_data, dtype=np.uint8)
|
|
49
|
+
audio_int32 = np.zeros(len(audio_bytes) // 3, dtype=np.int32)
|
|
50
|
+
for i in range(len(audio_int32)):
|
|
51
|
+
audio_int32[i] = (audio_bytes[i*3] |
|
|
52
|
+
(audio_bytes[i*3+1] << 8) |
|
|
53
|
+
(audio_bytes[i*3+2] << 16))
|
|
54
|
+
# Sign extend
|
|
55
|
+
if audio_int32[i] & 0x800000:
|
|
56
|
+
audio_int32[i] |= 0xFF000000
|
|
57
|
+
audio = audio_int32.astype(np.float32) / 8388608.0
|
|
58
|
+
elif sampwidth == 4: # 32-bit signed
|
|
59
|
+
audio = np.frombuffer(raw_data, dtype=np.int32)
|
|
60
|
+
audio = audio.astype(np.float32) / 2147483648.0
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"Unsupported sample width: {sampwidth}")
|
|
63
|
+
|
|
64
|
+
# Reshape for multi-channel
|
|
65
|
+
if n_channels > 1:
|
|
66
|
+
audio = audio.reshape(-1, n_channels)
|
|
67
|
+
|
|
68
|
+
return audio, framerate
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save(filepath, audio, samplerate):
|
|
72
|
+
"""
|
|
73
|
+
Save audio to file
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
filepath: str or Path to output file
|
|
77
|
+
audio: numpy array (float32, -1 to 1)
|
|
78
|
+
samplerate: int, sample rate in Hz
|
|
79
|
+
|
|
80
|
+
Supports: .wav
|
|
81
|
+
"""
|
|
82
|
+
filepath = Path(filepath)
|
|
83
|
+
|
|
84
|
+
if filepath.suffix.lower() == '.wav':
|
|
85
|
+
_save_wav(filepath, audio, samplerate)
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(f"Unsupported format: {filepath.suffix}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _save_wav(filepath, audio, samplerate):
|
|
91
|
+
"""Save WAV file using wave module"""
|
|
92
|
+
from .utils import to_int16
|
|
93
|
+
|
|
94
|
+
# Convert to mono if needed
|
|
95
|
+
if audio.ndim > 1:
|
|
96
|
+
audio = np.mean(audio, axis=1)
|
|
97
|
+
|
|
98
|
+
# Convert to 16-bit PCM
|
|
99
|
+
audio_int16 = to_int16(audio)
|
|
100
|
+
|
|
101
|
+
with wave.open(str(filepath), 'wb') as wav:
|
|
102
|
+
wav.setnchannels(1)
|
|
103
|
+
wav.setsampwidth(2)
|
|
104
|
+
wav.setframerate(samplerate)
|
|
105
|
+
wav.writeframes(audio_int16.tobytes())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Loop control for audio playback"""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
# Module-level state
|
|
7
|
+
_loop_enabled = False
|
|
8
|
+
_stop_requested = False
|
|
9
|
+
_playback_thread = None
|
|
10
|
+
_current_audio = None
|
|
11
|
+
_current_samplerate = None
|
|
12
|
+
_play_function = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_loop(enabled):
|
|
16
|
+
"""Enable or disable looping"""
|
|
17
|
+
global _loop_enabled
|
|
18
|
+
_loop_enabled = enabled
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_looping():
|
|
22
|
+
"""Check if looping is enabled"""
|
|
23
|
+
return _loop_enabled
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stop():
|
|
27
|
+
"""Stop playback but preserve loop state"""
|
|
28
|
+
global _stop_requested
|
|
29
|
+
# Note: we do NOT touch _loop_enabled here
|
|
30
|
+
_stop_requested = True
|
|
31
|
+
|
|
32
|
+
print(f"DEBUG: loop.stop() called, loop_enabled={_loop_enabled} (preserved)")
|
|
33
|
+
|
|
34
|
+
# def stop():
|
|
35
|
+
# """Stop playback and looping"""
|
|
36
|
+
# global _stop_requested, _loop_enabled
|
|
37
|
+
|
|
38
|
+
# print(f"DEBUG: loop.stop() called from loop.py, was loop_enabled={_loop_enabled}")
|
|
39
|
+
|
|
40
|
+
# _stop_requested = True
|
|
41
|
+
# _loop_enabled = False
|
|
42
|
+
|
|
43
|
+
# # Platform-specific stop
|
|
44
|
+
# import sys
|
|
45
|
+
# if sys.platform == 'win32':
|
|
46
|
+
# print("DEBUG: Calling winsound.PlaySound(None, SND_PURGE)")
|
|
47
|
+
# try:
|
|
48
|
+
# import winsound
|
|
49
|
+
# winsound.PlaySound(None, winsound.SND_PURGE)
|
|
50
|
+
# print("DEBUG: winsound stop completed")
|
|
51
|
+
# except Exception as e:
|
|
52
|
+
# print(f"DEBUG: winsound stop failed: {e}")
|
|
53
|
+
# else:
|
|
54
|
+
# # Kill subprocess for macOS/Linux
|
|
55
|
+
# from . import playback
|
|
56
|
+
# if hasattr(playback, '_current_process') and playback._current_process:
|
|
57
|
+
# try:
|
|
58
|
+
# print(f"DEBUG: Terminating process {playback._current_process.pid}")
|
|
59
|
+
# playback._current_process.terminate()
|
|
60
|
+
# playback._current_process = None
|
|
61
|
+
# print("DEBUG: Process terminated")
|
|
62
|
+
# except Exception as e:
|
|
63
|
+
# print(f"DEBUG: Process terminate failed: {e}")
|
|
64
|
+
|
|
65
|
+
# print(f"DEBUG: loop.stop() completed, stop_requested={_stop_requested}, loop_enabled={_loop_enabled}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_stopped():
|
|
69
|
+
"""Check if stop was requested"""
|
|
70
|
+
return _stop_requested
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reset_stop():
|
|
74
|
+
"""Reset stop flag"""
|
|
75
|
+
global _stop_requested
|
|
76
|
+
_stop_requested = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def start_loop(audio_data, samplerate, play_func):
|
|
80
|
+
"""Start looping playback in background thread"""
|
|
81
|
+
global _playback_thread, _current_audio, _current_samplerate, _play_function
|
|
82
|
+
|
|
83
|
+
print(f"DEBUG: start_loop called, loop_enabled={_loop_enabled}")
|
|
84
|
+
|
|
85
|
+
_current_audio = audio_data
|
|
86
|
+
_current_samplerate = samplerate
|
|
87
|
+
_play_function = play_func
|
|
88
|
+
|
|
89
|
+
# Only stop existing playback thread if one is running
|
|
90
|
+
if _playback_thread is not None and _playback_thread.is_alive():
|
|
91
|
+
global _stop_requested
|
|
92
|
+
_stop_requested = True
|
|
93
|
+
time.sleep(0.1)
|
|
94
|
+
# Don't call stop() - just set the flag
|
|
95
|
+
|
|
96
|
+
# Reset stop flag for new playback
|
|
97
|
+
_stop_requested = False
|
|
98
|
+
|
|
99
|
+
print(f"DEBUG: Starting thread, loop_enabled={_loop_enabled}")
|
|
100
|
+
|
|
101
|
+
# Start loop thread
|
|
102
|
+
_playback_thread = threading.Thread(target=_loop_worker, daemon=True)
|
|
103
|
+
_playback_thread.start()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _loop_worker():
|
|
107
|
+
"""Worker thread for looping playback"""
|
|
108
|
+
global _loop_enabled, _stop_requested
|
|
109
|
+
|
|
110
|
+
print(f"DEBUG: Loop worker started, loop_enabled={_loop_enabled}") # Add debug
|
|
111
|
+
|
|
112
|
+
while _loop_enabled and not _stop_requested:
|
|
113
|
+
try:
|
|
114
|
+
# Play audio (blocking) - call the function directly, don't go through play()
|
|
115
|
+
_play_function(_current_audio, _current_samplerate, blocking=True)
|
|
116
|
+
|
|
117
|
+
print(f"DEBUG: Playback finished, loop_enabled={_loop_enabled}, stop={_stop_requested}") # Add debug
|
|
118
|
+
|
|
119
|
+
# Check if we should continue looping
|
|
120
|
+
if not _loop_enabled or _stop_requested:
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"Loop playback error: {e}")
|
|
125
|
+
import traceback
|
|
126
|
+
traceback.print_exc()
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
print("DEBUG: Loop worker exiting") # Add debug
|
|
130
|
+
# Cleanup
|
|
131
|
+
#_loop_enabled = False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pause/resume playback"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
# Module state
|
|
7
|
+
_paused = False
|
|
8
|
+
_pause_position = 0.0
|
|
9
|
+
_pause_audio = None
|
|
10
|
+
_pause_samplerate = None
|
|
11
|
+
_pause_start_time = None
|
|
12
|
+
_loop_was_enabled = False # Track if loop was on when paused
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pause():
|
|
16
|
+
"""Pause current playback"""
|
|
17
|
+
global _paused, _pause_start_time, _pause_position, _loop_was_enabled
|
|
18
|
+
|
|
19
|
+
if not _paused:
|
|
20
|
+
_paused = True
|
|
21
|
+
|
|
22
|
+
# Save loop state before stopping
|
|
23
|
+
from . import loop as loop_module
|
|
24
|
+
_loop_was_enabled = loop_module.is_looping()
|
|
25
|
+
|
|
26
|
+
# Estimate current position
|
|
27
|
+
if _pause_start_time is not None:
|
|
28
|
+
elapsed = time.time() - _pause_start_time
|
|
29
|
+
_pause_position += elapsed
|
|
30
|
+
|
|
31
|
+
# Stop playback (this will disable loop)
|
|
32
|
+
from .stop import stop as stop_func
|
|
33
|
+
stop_func()
|
|
34
|
+
|
|
35
|
+
print(f"Paused at {_pause_position:.2f}s (loop was {_loop_was_enabled})")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resume():
|
|
39
|
+
"""Resume playback from pause point"""
|
|
40
|
+
global _paused, _pause_position, _pause_start_time, _loop_was_enabled
|
|
41
|
+
|
|
42
|
+
if _paused and _pause_audio is not None:
|
|
43
|
+
_paused = False
|
|
44
|
+
|
|
45
|
+
# Restore loop state
|
|
46
|
+
from . import loop as loop_module
|
|
47
|
+
if _loop_was_enabled:
|
|
48
|
+
loop_module.set_loop(True)
|
|
49
|
+
|
|
50
|
+
# Calculate where to resume from
|
|
51
|
+
sample_position = int(_pause_position * _pause_samplerate)
|
|
52
|
+
|
|
53
|
+
# Trim audio to resume point
|
|
54
|
+
if sample_position < len(_pause_audio):
|
|
55
|
+
remaining_audio = _pause_audio[sample_position:]
|
|
56
|
+
|
|
57
|
+
# Play remaining audio
|
|
58
|
+
from . import play as play_module
|
|
59
|
+
_pause_start_time = time.time()
|
|
60
|
+
play_module.play(remaining_audio, _pause_samplerate)
|
|
61
|
+
|
|
62
|
+
print(f"Resumed from {_pause_position:.2f}s (loop={_loop_was_enabled})")
|
|
63
|
+
else:
|
|
64
|
+
# Already at end
|
|
65
|
+
reset()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_paused():
|
|
69
|
+
"""Check if paused"""
|
|
70
|
+
return _paused
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def was_looping():
|
|
74
|
+
"""Check if loop was enabled when paused"""
|
|
75
|
+
return _loop_was_enabled
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_playback_state(audio, samplerate):
|
|
79
|
+
"""Store current playback state for pause/resume"""
|
|
80
|
+
global _pause_audio, _pause_samplerate, _pause_position, _pause_start_time
|
|
81
|
+
_pause_audio = audio.copy() if audio is not None else None
|
|
82
|
+
_pause_samplerate = samplerate
|
|
83
|
+
_pause_position = 0.0
|
|
84
|
+
_pause_start_time = time.time()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def reset():
|
|
88
|
+
"""Reset pause state"""
|
|
89
|
+
global _paused, _pause_position, _pause_audio, _pause_samplerate, _pause_start_time, _loop_was_enabled
|
|
90
|
+
_paused = False
|
|
91
|
+
_pause_position = 0.0
|
|
92
|
+
_pause_audio = None
|
|
93
|
+
_pause_samplerate = None
|
|
94
|
+
_pause_start_time = None
|
|
95
|
+
_loop_was_enabled = False
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Audio playback functionality"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .io import load
|
|
7
|
+
from . import loop as loop_module
|
|
8
|
+
from .utils import to_pcm, int32_to_24bit_bytes
|
|
9
|
+
from . import pause as pause_module
|
|
10
|
+
|
|
11
|
+
# Track current playback process for stopping
|
|
12
|
+
_current_process = None
|
|
13
|
+
|
|
14
|
+
def play(data, samplerate=None, blocking=False):
|
|
15
|
+
"""
|
|
16
|
+
Play audio from file or array
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
data: str/Path (audio file) or numpy array (audio data)
|
|
20
|
+
samplerate: int, required if data is array
|
|
21
|
+
blocking: bool, if True wait for playback to finish
|
|
22
|
+
"""
|
|
23
|
+
# Reset stop flag when starting new playback
|
|
24
|
+
loop_module.reset_stop()
|
|
25
|
+
|
|
26
|
+
# If string/Path, load file first
|
|
27
|
+
if isinstance(data, (str, Path)):
|
|
28
|
+
audio_array, sr = load(data)
|
|
29
|
+
|
|
30
|
+
# Store state for pause/resume
|
|
31
|
+
pause_module.set_playback_state(audio_array, sr)
|
|
32
|
+
|
|
33
|
+
# If looping, start loop thread
|
|
34
|
+
if loop_module.is_looping():
|
|
35
|
+
loop_module.start_loop(audio_array, sr, _play_array)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
return _play_array(audio_array, sr, blocking)
|
|
39
|
+
|
|
40
|
+
# Otherwise assume numpy array
|
|
41
|
+
if samplerate is None:
|
|
42
|
+
raise ValueError("samplerate required when playing numpy array")
|
|
43
|
+
|
|
44
|
+
# Store state for pause/resume
|
|
45
|
+
pause_module.set_playback_state(data, samplerate)
|
|
46
|
+
|
|
47
|
+
# If looping, start loop thread
|
|
48
|
+
if loop_module.is_looping():
|
|
49
|
+
loop_module.start_loop(data, samplerate, _play_array)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
return _play_array(data, samplerate, blocking)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _play_array(data, samplerate, blocking):
|
|
56
|
+
"""Internal: play numpy array"""
|
|
57
|
+
# Check if stopped
|
|
58
|
+
if loop_module.is_stopped():
|
|
59
|
+
return
|
|
60
|
+
# Apply main gain from gain module
|
|
61
|
+
from . import gain as gain_module
|
|
62
|
+
data = gain_module.adjust_gain_level(data)
|
|
63
|
+
|
|
64
|
+
# Determine channels
|
|
65
|
+
n_channels = 1 if data.ndim == 1 else data.shape[1]
|
|
66
|
+
|
|
67
|
+
# Convert to mono if needed
|
|
68
|
+
if data.ndim > 1:
|
|
69
|
+
data = np.mean(data, axis=1)
|
|
70
|
+
|
|
71
|
+
# Convert to PCM
|
|
72
|
+
sampwidth, data_int = to_pcm(data)
|
|
73
|
+
|
|
74
|
+
# Platform-specific playback
|
|
75
|
+
if sys.platform == 'win32':
|
|
76
|
+
_play_windows(data_int, samplerate, sampwidth, n_channels, blocking)
|
|
77
|
+
elif sys.platform == 'darwin':
|
|
78
|
+
_play_macos(data_int, samplerate, sampwidth, n_channels, blocking)
|
|
79
|
+
else: # Linux
|
|
80
|
+
_play_linux(data_int, samplerate, sampwidth, n_channels, blocking)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _play_windows(data, samplerate, sampwidth, n_channels, blocking):
|
|
84
|
+
"""Windows playback using winsound"""
|
|
85
|
+
import winsound
|
|
86
|
+
import wave
|
|
87
|
+
import tempfile
|
|
88
|
+
import os
|
|
89
|
+
|
|
90
|
+
if loop_module.is_stopped():
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
|
94
|
+
temp_path = f.name
|
|
95
|
+
|
|
96
|
+
with wave.open(temp_path, 'wb') as wav:
|
|
97
|
+
wav.setnchannels(n_channels)
|
|
98
|
+
wav.setsampwidth(sampwidth)
|
|
99
|
+
wav.setframerate(samplerate)
|
|
100
|
+
|
|
101
|
+
if sampwidth == 3:
|
|
102
|
+
wav.writeframes(int32_to_24bit_bytes(data))
|
|
103
|
+
else:
|
|
104
|
+
wav.writeframes(data.tobytes())
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
flags = winsound.SND_FILENAME
|
|
108
|
+
if not blocking:
|
|
109
|
+
flags |= winsound.SND_ASYNC
|
|
110
|
+
|
|
111
|
+
winsound.PlaySound(temp_path, flags)
|
|
112
|
+
finally:
|
|
113
|
+
if not blocking:
|
|
114
|
+
import threading
|
|
115
|
+
def cleanup():
|
|
116
|
+
import time
|
|
117
|
+
time.sleep(len(data) / samplerate + 0.5)
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(temp_path)
|
|
120
|
+
except:
|
|
121
|
+
pass
|
|
122
|
+
threading.Thread(target=cleanup, daemon=True).start()
|
|
123
|
+
else:
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(temp_path)
|
|
126
|
+
except:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _play_macos(data, samplerate, sampwidth, n_channels, blocking):
|
|
131
|
+
"""macOS playback using afplay"""
|
|
132
|
+
import subprocess
|
|
133
|
+
import wave
|
|
134
|
+
import tempfile
|
|
135
|
+
import os
|
|
136
|
+
|
|
137
|
+
global _current_process
|
|
138
|
+
|
|
139
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
|
140
|
+
temp_path = f.name
|
|
141
|
+
|
|
142
|
+
with wave.open(temp_path, 'wb') as wav:
|
|
143
|
+
wav.setnchannels(n_channels)
|
|
144
|
+
wav.setsampwidth(sampwidth)
|
|
145
|
+
wav.setframerate(samplerate)
|
|
146
|
+
|
|
147
|
+
if sampwidth == 3:
|
|
148
|
+
wav.writeframes(int32_to_24bit_bytes(data))
|
|
149
|
+
else:
|
|
150
|
+
wav.writeframes(data.tobytes())
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
if blocking:
|
|
154
|
+
_current_process = subprocess.Popen(['afplay', temp_path])
|
|
155
|
+
_current_process.wait()
|
|
156
|
+
_current_process = None
|
|
157
|
+
os.unlink(temp_path)
|
|
158
|
+
else:
|
|
159
|
+
_current_process = subprocess.Popen(['afplay', temp_path])
|
|
160
|
+
import threading
|
|
161
|
+
def cleanup():
|
|
162
|
+
import time
|
|
163
|
+
time.sleep(len(data) / samplerate + 0.5)
|
|
164
|
+
try:
|
|
165
|
+
os.unlink(temp_path)
|
|
166
|
+
except:
|
|
167
|
+
pass
|
|
168
|
+
threading.Thread(target=cleanup, daemon=True).start()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
try:
|
|
171
|
+
os.unlink(temp_path)
|
|
172
|
+
except:
|
|
173
|
+
pass
|
|
174
|
+
raise e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _play_linux(data, samplerate, sampwidth, n_channels, blocking):
|
|
178
|
+
"""Linux playback using aplay"""
|
|
179
|
+
import subprocess
|
|
180
|
+
import wave
|
|
181
|
+
import tempfile
|
|
182
|
+
import os
|
|
183
|
+
|
|
184
|
+
global _current_process
|
|
185
|
+
|
|
186
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
|
187
|
+
temp_path = f.name
|
|
188
|
+
|
|
189
|
+
with wave.open(temp_path, 'wb') as wav:
|
|
190
|
+
wav.setnchannels(n_channels)
|
|
191
|
+
wav.setsampwidth(sampwidth)
|
|
192
|
+
wav.setframerate(samplerate)
|
|
193
|
+
|
|
194
|
+
if sampwidth == 3:
|
|
195
|
+
wav.writeframes(int32_to_24bit_bytes(data))
|
|
196
|
+
else:
|
|
197
|
+
wav.writeframes(data.tobytes())
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
if blocking:
|
|
201
|
+
_current_process = subprocess.Popen(['aplay', temp_path])
|
|
202
|
+
_current_process.wait()
|
|
203
|
+
_current_process = None
|
|
204
|
+
os.unlink(temp_path)
|
|
205
|
+
else:
|
|
206
|
+
_current_process = subprocess.Popen(['aplay', temp_path])
|
|
207
|
+
import threading
|
|
208
|
+
def cleanup():
|
|
209
|
+
import time
|
|
210
|
+
time.sleep(len(data) / samplerate + 0.5)
|
|
211
|
+
try:
|
|
212
|
+
os.unlink(temp_path)
|
|
213
|
+
except:
|
|
214
|
+
pass
|
|
215
|
+
threading.Thread(target=cleanup, daemon=True).start()
|
|
216
|
+
except Exception as e:
|
|
217
|
+
try:
|
|
218
|
+
os.unlink(temp_path)
|
|
219
|
+
except:
|
|
220
|
+
pass
|
|
221
|
+
raise e
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Stop playback functionality"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from . import loop as loop_module
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def stop():
|
|
8
|
+
"""Stop audio playback (preserves loop state)"""
|
|
9
|
+
# Stop playback but preserve loop setting
|
|
10
|
+
loop_module.stop()
|
|
11
|
+
|
|
12
|
+
# Platform-specific immediate stop
|
|
13
|
+
if sys.platform == 'win32':
|
|
14
|
+
_stop_windows()
|
|
15
|
+
else:
|
|
16
|
+
_stop_unix()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _stop_windows():
|
|
20
|
+
"""Stop Windows playback"""
|
|
21
|
+
try:
|
|
22
|
+
import winsound
|
|
23
|
+
winsound.PlaySound(None, winsound.SND_PURGE)
|
|
24
|
+
except:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _stop_unix():
|
|
29
|
+
"""Stop macOS/Linux playback"""
|
|
30
|
+
from . import play
|
|
31
|
+
if hasattr(play, '_current_process') and play._current_process:
|
|
32
|
+
try:
|
|
33
|
+
play._current_process.terminate()
|
|
34
|
+
play._current_process = None
|
|
35
|
+
except:
|
|
36
|
+
pass
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Utility functions"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def to_pcm(data, target_bits=16):
|
|
7
|
+
"""
|
|
8
|
+
Convert float audio to PCM with auto bit depth detection
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
data: numpy array, float
|
|
12
|
+
target_bits: 8, 16, 24, or 32
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
sampwidth: bytes per sample
|
|
16
|
+
data_int: integer array
|
|
17
|
+
"""
|
|
18
|
+
data_range = np.max(np.abs(data))
|
|
19
|
+
|
|
20
|
+
if data_range <= 1.0:
|
|
21
|
+
# Normalized float - convert to PCM
|
|
22
|
+
data = np.clip(data, -1.0, 1.0)
|
|
23
|
+
|
|
24
|
+
if target_bits == 8:
|
|
25
|
+
data_int = ((data + 1.0) * 127.5).astype(np.uint8)
|
|
26
|
+
sampwidth = 1
|
|
27
|
+
elif target_bits == 16:
|
|
28
|
+
data_int = (data * 32767).astype(np.int16)
|
|
29
|
+
sampwidth = 2
|
|
30
|
+
elif target_bits == 24:
|
|
31
|
+
data_int = (data * 8388607).astype(np.int32)
|
|
32
|
+
sampwidth = 3
|
|
33
|
+
elif target_bits == 32:
|
|
34
|
+
data_int = (data * 2147483647).astype(np.int32)
|
|
35
|
+
sampwidth = 4
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"Unsupported bit depth: {target_bits}")
|
|
38
|
+
else:
|
|
39
|
+
# Already integer - preserve
|
|
40
|
+
if data.dtype in [np.int16, np.uint8]:
|
|
41
|
+
sampwidth = 2 if data.dtype == np.int16 else 1
|
|
42
|
+
data_int = data.astype(np.int16) if data.dtype != np.int16 else data
|
|
43
|
+
elif data.dtype == np.int32:
|
|
44
|
+
sampwidth = 4
|
|
45
|
+
data_int = data
|
|
46
|
+
else:
|
|
47
|
+
# Unknown - normalize and convert
|
|
48
|
+
data_int = (np.clip(data, -1.0, 1.0) * 32767).astype(np.int16)
|
|
49
|
+
sampwidth = 2
|
|
50
|
+
|
|
51
|
+
return sampwidth, data_int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_int16(audio):
|
|
55
|
+
"""
|
|
56
|
+
Convert float audio to 16-bit PCM
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
audio: numpy array, float, -1 to 1
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
int16 array
|
|
63
|
+
"""
|
|
64
|
+
audio = np.clip(audio, -1.0, 1.0)
|
|
65
|
+
return (audio * 32767).astype(np.int16)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def int32_to_24bit_bytes(data_int32):
|
|
69
|
+
"""Convert int32 array to 24-bit byte array"""
|
|
70
|
+
data_bytes = bytearray()
|
|
71
|
+
for sample in data_int32:
|
|
72
|
+
data_bytes.extend(sample.to_bytes(4, byteorder='little', signed=True)[:3])
|
|
73
|
+
return bytes(data_bytes)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_audio(audio, target_level=-3.0):
|
|
77
|
+
"""
|
|
78
|
+
Normalize audio to target dB level
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
audio: numpy array
|
|
82
|
+
target_level: float, target dB level
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
normalized audio
|
|
86
|
+
"""
|
|
87
|
+
rms = np.sqrt(np.mean(audio**2))
|
|
88
|
+
if rms > 0:
|
|
89
|
+
target_amplitude = 10**(target_level / 20.0)
|
|
90
|
+
audio = audio * (target_amplitude / rms)
|
|
91
|
+
return np.clip(audio, -1.0, 1.0)
|
|
92
|
+
|
|
93
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysoniq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, pure-Python cross-platform audio playback library
|
|
5
|
+
Author: laelume
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/laelume/pysoniq
|
|
8
|
+
Project-URL: Repository, https://github.com/laelume/pysoniq
|
|
9
|
+
Project-URL: Issues, https://github.com/laelume/pysoniq/issues
|
|
10
|
+
Keywords: audio,sound,playback,music,cross-platform,pure-python,audio-playback,audio-player,sound-playback,wav
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
25
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: numpy>=1.19.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# pysoniq
|
|
34
|
+
|
|
35
|
+
Minimal, pure-Python cross-platform audio playback library.
|
|
36
|
+
|
|
37
|
+
- **Pure Python** - No compiled extensions
|
|
38
|
+
- **Cross-platform** - Windows, macOS, Linux
|
|
39
|
+
- **Minimal dependencies** - Only numpy
|
|
40
|
+
- **Simple API** - Play, pause, stop, loop
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
```bash
|
|
44
|
+
pip install pysoniq
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use in context
|
|
48
|
+
```python
|
|
49
|
+
import pysoniq as ps
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
# Play WAV file
|
|
53
|
+
ps.play('audio.wav')
|
|
54
|
+
|
|
55
|
+
# Play as numpy array
|
|
56
|
+
sr = 44100
|
|
57
|
+
t = np.linspace(0, 1.0, sr)
|
|
58
|
+
audio = 0.3 * np.sin(2 * np.pi * 440 * t)
|
|
59
|
+
ps.play(audio, samplerate=sr)
|
|
60
|
+
|
|
61
|
+
# Loop playback
|
|
62
|
+
ps.set_loop(True)
|
|
63
|
+
ps.play(audio, sr)
|
|
64
|
+
|
|
65
|
+
# Stop
|
|
66
|
+
ps.stop()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
**Playback**
|
|
72
|
+
```python
|
|
73
|
+
ps.play(data, samplerate) # Play audio
|
|
74
|
+
ps.stop() # Stop playback
|
|
75
|
+
ps.pause() # Pause
|
|
76
|
+
ps.resume() # Resume
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Looping**
|
|
80
|
+
```python
|
|
81
|
+
ps.set_loop(True) # Enable loop
|
|
82
|
+
ps.is_looping() # Check status
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Gain Control**
|
|
86
|
+
```python
|
|
87
|
+
ps.set_gain(0.5) # 50% volume
|
|
88
|
+
ps.set_volume_db(-6.0) # Set dB
|
|
89
|
+
audio = ps.adjust_gain_level(audio, 1.5)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Audio I/O**
|
|
93
|
+
```python
|
|
94
|
+
audio, sr = ps.load('file.wav')
|
|
95
|
+
ps.save('output.wav', audio, sr)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Platform Requirements
|
|
99
|
+
|
|
100
|
+
- **Windows**: Built-in (winsound)
|
|
101
|
+
- **macOS**: Built-in (afplay)
|
|
102
|
+
- **Linux**: ALSA (aplay) - usually pre-installed
|
|
103
|
+
|
|
104
|
+
## Limitations
|
|
105
|
+
|
|
106
|
+
- WAV format only (for now)
|
|
107
|
+
- Pause/resume uses time-based estimation
|
|
108
|
+
- Gain changes apply on next loop iteration
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
113
|
+
|
|
114
|
+
## Author
|
|
115
|
+
|
|
116
|
+
laelume
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pysoniq/__init__.py
|
|
5
|
+
pysoniq/gain.py
|
|
6
|
+
pysoniq/io.py
|
|
7
|
+
pysoniq/loop.py
|
|
8
|
+
pysoniq/pause.py
|
|
9
|
+
pysoniq/play.py
|
|
10
|
+
pysoniq/stop.py
|
|
11
|
+
pysoniq/utils.py
|
|
12
|
+
pysoniq.egg-info/PKG-INFO
|
|
13
|
+
pysoniq.egg-info/SOURCES.txt
|
|
14
|
+
pysoniq.egg-info/dependency_links.txt
|
|
15
|
+
pysoniq.egg-info/requires.txt
|
|
16
|
+
pysoniq.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
numpy>=1.19.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pysoniq
|
pysoniq-0.1.0/setup.cfg
ADDED