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 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
@@ -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
+ numpy>=1.19.0
@@ -0,0 +1 @@
1
+ pysoniq
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+