cysox 0.1.5__cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl

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.
cysox/audio.py ADDED
@@ -0,0 +1,528 @@
1
+ """High-level audio processing API for cysox.
2
+
3
+ This module provides a simplified, Pythonic interface for audio processing.
4
+ It handles initialization automatically and provides convenient functions
5
+ for common operations.
6
+
7
+ Example:
8
+ >>> import cysox
9
+ >>> from cysox import fx
10
+ >>>
11
+ >>> # Get file info
12
+ >>> info = cysox.info('audio.wav')
13
+ >>> print(f"Duration: {info['duration']:.2f}s")
14
+ >>>
15
+ >>> # Convert with effects
16
+ >>> cysox.convert('input.wav', 'output.mp3', effects=[
17
+ ... fx.Normalize(),
18
+ ... fx.Fade(fade_in=0.5),
19
+ ... ])
20
+ """
21
+
22
+ import atexit
23
+ from pathlib import Path
24
+ from typing import Dict, Iterator, List, Optional, Union
25
+
26
+ from . import sox
27
+ from .fx.base import Effect, CompositeEffect, PythonEffect
28
+
29
+ # Module state
30
+ _initialized = False
31
+
32
+
33
+ def _ensure_init() -> None:
34
+ """Ensure sox is initialized (called automatically)."""
35
+ global _initialized
36
+ if not _initialized:
37
+ sox.init()
38
+ atexit.register(_cleanup)
39
+ _initialized = True
40
+
41
+
42
+ def _cleanup() -> None:
43
+ """Cleanup sox on exit (called automatically via atexit)."""
44
+ global _initialized
45
+ if _initialized:
46
+ try:
47
+ sox._force_quit() # Use internal function for actual cleanup
48
+ except Exception:
49
+ pass # Ignore errors during cleanup
50
+ _initialized = False
51
+
52
+
53
+ def _expand_effects(effects: List[Effect]) -> List[Effect]:
54
+ """Expand CompositeEffects into their constituent effects."""
55
+ expanded = []
56
+ for effect in effects:
57
+ if isinstance(effect, CompositeEffect):
58
+ expanded.extend(_expand_effects(effect.effects))
59
+ else:
60
+ expanded.append(effect)
61
+ return expanded
62
+
63
+
64
+ def info(path: Union[str, Path]) -> Dict:
65
+ """Get audio file metadata.
66
+
67
+ Args:
68
+ path: Path to audio file.
69
+
70
+ Returns:
71
+ Dictionary with file information:
72
+ - path: Original path
73
+ - format: File format (e.g., 'wav', 'mp3')
74
+ - duration: Duration in seconds
75
+ - sample_rate: Sample rate in Hz
76
+ - channels: Number of channels
77
+ - bits_per_sample: Bits per sample
78
+ - samples: Total number of samples
79
+ - encoding: Encoding type
80
+
81
+ Example:
82
+ >>> info = cysox.info('audio.wav')
83
+ >>> print(f"Duration: {info['duration']:.2f}s")
84
+ >>> print(f"Sample rate: {info['sample_rate']} Hz")
85
+ """
86
+ _ensure_init()
87
+
88
+ path = str(path)
89
+ with sox.Format(path) as f:
90
+ signal = f.signal
91
+ encoding = f.encoding
92
+
93
+ # Calculate duration
94
+ if signal.length and signal.rate and signal.channels:
95
+ duration = signal.length / (signal.rate * signal.channels)
96
+ else:
97
+ duration = 0.0
98
+
99
+ return {
100
+ "path": path,
101
+ "format": f.filetype or "",
102
+ "duration": duration,
103
+ "sample_rate": int(signal.rate) if signal.rate else 0,
104
+ "channels": signal.channels or 0,
105
+ "bits_per_sample": encoding.bits_per_sample if encoding else 0,
106
+ "samples": signal.length or 0,
107
+ "encoding": _encoding_name(encoding.encoding) if encoding else "",
108
+ }
109
+
110
+
111
+ def _encoding_name(encoding_type: int) -> str:
112
+ """Convert encoding type constant to string."""
113
+ encoding_names = {
114
+ 0: "unknown",
115
+ 1: "signed-integer",
116
+ 2: "unsigned-integer",
117
+ 3: "float",
118
+ 4: "float-text",
119
+ 5: "flac",
120
+ 6: "hcom",
121
+ 7: "wavpack",
122
+ 8: "wavpackf",
123
+ 9: "ulaw",
124
+ 10: "alaw",
125
+ 11: "g721",
126
+ 12: "g723",
127
+ 13: "cl-adpcm",
128
+ 14: "cl-adpcm16",
129
+ 15: "ms-adpcm",
130
+ 16: "ima-adpcm",
131
+ 17: "oki-adpcm",
132
+ 18: "dpcm",
133
+ 19: "dwvw",
134
+ 20: "dwvwn",
135
+ 21: "gsm",
136
+ 22: "mp3",
137
+ 23: "vorbis",
138
+ 24: "amr-wb",
139
+ 25: "amr-nb",
140
+ 26: "cvsd",
141
+ 27: "lpc10",
142
+ 28: "opus",
143
+ }
144
+ return encoding_names.get(encoding_type, "unknown")
145
+
146
+
147
+ def convert(
148
+ input_path: Union[str, Path],
149
+ output_path: Union[str, Path],
150
+ effects: Optional[List[Effect]] = None,
151
+ *,
152
+ sample_rate: Optional[int] = None,
153
+ channels: Optional[int] = None,
154
+ bits: Optional[int] = None,
155
+ ) -> None:
156
+ """Convert audio file with optional effects.
157
+
158
+ Args:
159
+ input_path: Path to input audio file.
160
+ output_path: Path for output audio file. Format determined by extension.
161
+ effects: List of effect objects to apply (from cysox.fx).
162
+ sample_rate: Target sample rate in Hz (optional).
163
+ channels: Target number of channels (optional).
164
+ bits: Target bits per sample (optional).
165
+
166
+ Example:
167
+ >>> # Simple conversion
168
+ >>> cysox.convert('input.wav', 'output.mp3')
169
+ >>>
170
+ >>> # With effects
171
+ >>> cysox.convert('input.wav', 'output.wav', effects=[
172
+ ... fx.Volume(db=3),
173
+ ... fx.Reverb(),
174
+ ... ])
175
+ >>>
176
+ >>> # With format options
177
+ >>> cysox.convert('input.wav', 'output.wav',
178
+ ... sample_rate=48000,
179
+ ... channels=1,
180
+ ... )
181
+ """
182
+ _ensure_init()
183
+
184
+ input_path = str(input_path)
185
+ output_path = str(output_path)
186
+
187
+ # Open input
188
+ input_fmt = sox.Format(input_path)
189
+
190
+ # Build output signal info
191
+ out_signal = sox.SignalInfo(
192
+ rate=sample_rate or input_fmt.signal.rate,
193
+ channels=channels or input_fmt.signal.channels,
194
+ precision=bits or input_fmt.signal.precision,
195
+ )
196
+
197
+ # Open output
198
+ output_fmt = sox.Format(output_path, signal=out_signal, mode='w')
199
+
200
+ # Create effects chain
201
+ chain = sox.EffectsChain(input_fmt.encoding, output_fmt.encoding)
202
+
203
+ # Save original input properties (before any mutation)
204
+ original_rate = input_fmt.signal.rate
205
+
206
+ # Track current signal - use same object pattern to allow libsox in-place updates
207
+ current_signal = input_fmt.signal
208
+
209
+ # Target output rate
210
+ target_rate = sample_rate or original_rate
211
+
212
+ # Add input effect
213
+ e = sox.Effect(sox.find_effect("input"))
214
+ e.set_options([input_fmt])
215
+ chain.add_effect(e, current_signal, current_signal)
216
+
217
+ # Process effects
218
+ if effects:
219
+ expanded = _expand_effects(effects)
220
+
221
+ for effect in expanded:
222
+ if isinstance(effect, PythonEffect):
223
+ raise NotImplementedError(
224
+ "PythonEffect not yet supported in convert(). "
225
+ "Use stream() for custom Python processing."
226
+ )
227
+
228
+ handler = sox.find_effect(effect.name)
229
+ if handler is None:
230
+ raise ValueError(f"Unknown effect: {effect.name}")
231
+
232
+ e = sox.Effect(handler)
233
+ e.set_options(effect.to_args())
234
+
235
+ # Handle effects that explicitly change signal properties
236
+ if effect.name == "rate":
237
+ new_signal = sox.SignalInfo(
238
+ rate=float(effect.to_args()[-1]),
239
+ channels=current_signal.channels,
240
+ precision=current_signal.precision,
241
+ )
242
+ chain.add_effect(e, current_signal, new_signal)
243
+ current_signal = new_signal
244
+ elif effect.name == "channels":
245
+ new_signal = sox.SignalInfo(
246
+ rate=current_signal.rate,
247
+ channels=int(effect.to_args()[0]),
248
+ precision=current_signal.precision,
249
+ )
250
+ chain.add_effect(e, current_signal, new_signal)
251
+ current_signal = new_signal
252
+ else:
253
+ # For other effects, pass same signal (allows libsox in-place updates)
254
+ chain.add_effect(e, current_signal, current_signal)
255
+
256
+ # After add_effect, current_signal may have been mutated
257
+ # Check if rate changed (pitch, speed, tempo, etc.)
258
+ # Always create fresh signal for next effect to avoid stale state
259
+ if e.out_signal.rate > 0 and e.out_signal.rate != original_rate:
260
+ current_signal = sox.SignalInfo(
261
+ rate=e.out_signal.rate,
262
+ channels=e.out_signal.channels,
263
+ precision=e.out_signal.precision,
264
+ )
265
+
266
+ # Add rate conversion if current rate differs from target
267
+ if current_signal.rate != target_rate:
268
+ new_signal = sox.SignalInfo(
269
+ rate=target_rate,
270
+ channels=current_signal.channels,
271
+ precision=current_signal.precision,
272
+ )
273
+ e = sox.Effect(sox.find_effect("rate"))
274
+ e.set_options(["-q", str(int(target_rate))]) # -q for quick to avoid FFT issues
275
+ chain.add_effect(e, current_signal, new_signal)
276
+ current_signal = new_signal
277
+
278
+ # Add channel conversion if needed
279
+ target_channels = channels or input_fmt.signal.channels
280
+ if current_signal.channels != target_channels:
281
+ new_signal = sox.SignalInfo(
282
+ rate=current_signal.rate,
283
+ channels=target_channels,
284
+ precision=current_signal.precision,
285
+ )
286
+ e = sox.Effect(sox.find_effect("channels"))
287
+ e.set_options([str(target_channels)])
288
+ chain.add_effect(e, current_signal, new_signal)
289
+ current_signal = new_signal
290
+
291
+ # Add output effect
292
+ e = sox.Effect(sox.find_effect("output"))
293
+ e.set_options([output_fmt])
294
+ chain.add_effect(e, current_signal, out_signal)
295
+
296
+ # Process
297
+ result = chain.flow_effects()
298
+
299
+ # Cleanup
300
+ input_fmt.close()
301
+ output_fmt.close()
302
+
303
+ if result != sox.SUCCESS:
304
+ raise RuntimeError(f"Effects processing failed with code {result}")
305
+
306
+
307
+ def stream(
308
+ path: Union[str, Path],
309
+ chunk_size: int = 8192,
310
+ ) -> Iterator[memoryview]:
311
+ """Stream audio samples from a file.
312
+
313
+ Yields chunks of samples as memoryview objects that can be used
314
+ with numpy, array.array, or any buffer protocol consumer.
315
+
316
+ Args:
317
+ path: Path to audio file.
318
+ chunk_size: Number of samples per chunk (default: 8192).
319
+
320
+ Yields:
321
+ memoryview of samples (int32 format).
322
+
323
+ Example:
324
+ >>> import numpy as np
325
+ >>> for chunk in cysox.stream('audio.wav'):
326
+ ... arr = np.frombuffer(chunk, dtype=np.int32)
327
+ ... process(arr)
328
+ """
329
+ _ensure_init()
330
+
331
+ path = str(path)
332
+ with sox.Format(path) as f:
333
+ remaining = f.signal.length
334
+ while remaining > 0:
335
+ to_read = min(chunk_size, remaining)
336
+ buf = f.read_buffer(to_read)
337
+ if len(buf) == 0:
338
+ break
339
+ remaining -= len(buf)
340
+ yield memoryview(buf)
341
+
342
+
343
+ def play(
344
+ path: Union[str, Path],
345
+ effects: Optional[List[Effect]] = None,
346
+ ) -> None:
347
+ """Play audio to the default audio device.
348
+
349
+ Uses libsox's audio output handlers (coreaudio on macOS,
350
+ alsa/pulseaudio on Linux).
351
+
352
+ Args:
353
+ path: Path to audio file.
354
+ effects: Optional list of effects to apply during playback.
355
+
356
+ Example:
357
+ >>> cysox.play('audio.wav')
358
+ >>> cysox.play('audio.wav', effects=[fx.Volume(db=-6)])
359
+
360
+ Note:
361
+ This function blocks until playback is complete.
362
+ """
363
+ _ensure_init()
364
+
365
+ import platform
366
+
367
+ path = str(path)
368
+
369
+ # Determine audio output type based on platform
370
+ system = platform.system()
371
+ if system == "Darwin":
372
+ output_type = "coreaudio"
373
+ elif system == "Linux":
374
+ # Try pulseaudio first, fall back to alsa
375
+ output_type = "pulseaudio"
376
+ else:
377
+ raise NotImplementedError(f"Playback not supported on {system}")
378
+
379
+ # Open input
380
+ input_fmt = sox.Format(path)
381
+
382
+ # Open audio output
383
+ try:
384
+ output_fmt = sox.Format(
385
+ "default",
386
+ signal=input_fmt.signal,
387
+ filetype=output_type,
388
+ mode='w'
389
+ )
390
+ except Exception:
391
+ # Try alsa as fallback on Linux
392
+ if system == "Linux":
393
+ output_type = "alsa"
394
+ output_fmt = sox.Format(
395
+ "default",
396
+ signal=input_fmt.signal,
397
+ filetype=output_type,
398
+ mode='w'
399
+ )
400
+ else:
401
+ raise
402
+
403
+ # Create effects chain
404
+ chain = sox.EffectsChain(input_fmt.encoding, output_fmt.encoding)
405
+ current_signal = input_fmt.signal
406
+
407
+ # Add input effect
408
+ e = sox.Effect(sox.find_effect("input"))
409
+ e.set_options([input_fmt])
410
+ chain.add_effect(e, current_signal, current_signal)
411
+
412
+ # Add user effects
413
+ if effects:
414
+ expanded = _expand_effects(effects)
415
+ for effect in expanded:
416
+ if isinstance(effect, PythonEffect):
417
+ raise NotImplementedError(
418
+ "PythonEffect not supported in play()"
419
+ )
420
+
421
+ handler = sox.find_effect(effect.name)
422
+ if handler is None:
423
+ raise ValueError(f"Unknown effect: {effect.name}")
424
+
425
+ e = sox.Effect(handler)
426
+ e.set_options(effect.to_args())
427
+ chain.add_effect(e, current_signal, current_signal)
428
+
429
+ # Add output effect
430
+ e = sox.Effect(sox.find_effect("output"))
431
+ e.set_options([output_fmt])
432
+ chain.add_effect(e, current_signal, current_signal)
433
+
434
+ # Play (blocks until complete)
435
+ result = chain.flow_effects()
436
+
437
+ # Cleanup
438
+ input_fmt.close()
439
+ output_fmt.close()
440
+
441
+ if result != sox.SUCCESS:
442
+ raise RuntimeError(f"Playback failed with code {result}")
443
+
444
+
445
+ def concat(
446
+ inputs: List[Union[str, Path]],
447
+ output_path: Union[str, Path],
448
+ *,
449
+ chunk_size: int = 8192,
450
+ ) -> None:
451
+ """Concatenate multiple audio files into one.
452
+
453
+ All input files must have the same sample rate and number of channels.
454
+ The output format is determined by the output file extension.
455
+
456
+ Args:
457
+ inputs: List of paths to input audio files (minimum 2).
458
+ output_path: Path for the concatenated output file.
459
+ chunk_size: Number of samples to read/write at a time (default: 8192).
460
+
461
+ Raises:
462
+ ValueError: If fewer than 2 input files provided.
463
+ ValueError: If input files have mismatched sample rates or channels.
464
+
465
+ Example:
466
+ >>> cysox.concat(['intro.wav', 'main.wav', 'outro.wav'], 'full.wav')
467
+ """
468
+ _ensure_init()
469
+
470
+ if len(inputs) < 2:
471
+ raise ValueError("concat() requires at least 2 input files")
472
+
473
+ inputs = [str(p) for p in inputs]
474
+ output_path = str(output_path)
475
+
476
+ output_fmt = None
477
+ reference_rate = None
478
+ reference_channels = None
479
+
480
+ try:
481
+ for i, input_path in enumerate(inputs):
482
+ input_fmt = sox.Format(input_path)
483
+
484
+ if i == 0:
485
+ # First file: capture reference signal and open output
486
+ reference_rate = input_fmt.signal.rate
487
+ reference_channels = input_fmt.signal.channels
488
+
489
+ out_signal = sox.SignalInfo(
490
+ rate=reference_rate,
491
+ channels=reference_channels,
492
+ precision=input_fmt.signal.precision,
493
+ )
494
+ out_encoding = sox.EncodingInfo(
495
+ encoding=int(input_fmt.encoding.encoding),
496
+ bits_per_sample=input_fmt.encoding.bits_per_sample,
497
+ )
498
+ output_fmt = sox.Format(
499
+ output_path, signal=out_signal, encoding=out_encoding, mode='w'
500
+ )
501
+ else:
502
+ # Subsequent files: verify compatibility
503
+ if input_fmt.signal.rate != reference_rate:
504
+ raise ValueError(
505
+ f"Sample rate mismatch: {input_path} has {input_fmt.signal.rate}Hz, "
506
+ f"expected {reference_rate}Hz"
507
+ )
508
+ if input_fmt.signal.channels != reference_channels:
509
+ raise ValueError(
510
+ f"Channel count mismatch: {input_path} has {input_fmt.signal.channels} channels, "
511
+ f"expected {reference_channels}"
512
+ )
513
+
514
+ # Copy all samples from this input to output
515
+ while True:
516
+ samples = input_fmt.read(chunk_size)
517
+ if len(samples) == 0:
518
+ break
519
+ output_fmt.write(samples)
520
+
521
+ input_fmt.close()
522
+
523
+ output_fmt.close()
524
+
525
+ except Exception:
526
+ if output_fmt is not None:
527
+ output_fmt.close()
528
+ raise
cysox/fx/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """Typed audio effect classes for cysox.
2
+
3
+ This module provides typed effect classes that can be used with
4
+ cysox.convert() and other high-level functions.
5
+
6
+ Example:
7
+ >>> import cysox
8
+ >>> from cysox import fx
9
+ >>>
10
+ >>> cysox.convert('input.wav', 'output.mp3', effects=[
11
+ ... fx.Volume(db=3),
12
+ ... fx.Bass(gain=5),
13
+ ... fx.Reverb(),
14
+ ... ])
15
+ """
16
+
17
+ # Base classes
18
+ from .base import (
19
+ Effect,
20
+ CompositeEffect,
21
+ PythonEffect,
22
+ CEffect,
23
+ )
24
+
25
+ # Volume and gain effects
26
+ from .volume import (
27
+ Volume,
28
+ Gain,
29
+ Normalize,
30
+ )
31
+
32
+ # Equalization effects
33
+ from .eq import (
34
+ Bass,
35
+ Treble,
36
+ Equalizer,
37
+ )
38
+
39
+ # Filter effects
40
+ from .filter import (
41
+ HighPass,
42
+ LowPass,
43
+ BandPass,
44
+ BandReject,
45
+ )
46
+
47
+ # Reverb and spatial effects
48
+ from .reverb import (
49
+ Reverb,
50
+ Echo,
51
+ Chorus,
52
+ Flanger,
53
+ )
54
+
55
+ # Time-based effects
56
+ from .time import (
57
+ Trim,
58
+ Pad,
59
+ Speed,
60
+ Tempo,
61
+ Pitch,
62
+ Reverse,
63
+ Fade,
64
+ Repeat,
65
+ )
66
+
67
+ # Conversion effects
68
+ from .convert import (
69
+ Rate,
70
+ Channels,
71
+ Remix,
72
+ Dither,
73
+ )
74
+
75
+ __all__ = [
76
+ # Base
77
+ "Effect",
78
+ "CompositeEffect",
79
+ "PythonEffect",
80
+ "CEffect",
81
+ # Volume
82
+ "Volume",
83
+ "Gain",
84
+ "Normalize",
85
+ # EQ
86
+ "Bass",
87
+ "Treble",
88
+ "Equalizer",
89
+ # Filter
90
+ "HighPass",
91
+ "LowPass",
92
+ "BandPass",
93
+ "BandReject",
94
+ # Reverb/Spatial
95
+ "Reverb",
96
+ "Echo",
97
+ "Chorus",
98
+ "Flanger",
99
+ # Time
100
+ "Trim",
101
+ "Pad",
102
+ "Speed",
103
+ "Tempo",
104
+ "Pitch",
105
+ "Reverse",
106
+ "Fade",
107
+ "Repeat",
108
+ # Convert
109
+ "Rate",
110
+ "Channels",
111
+ "Remix",
112
+ "Dither",
113
+ ]