mixing 0.0.76__tar.gz → 0.0.78__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.
Files changed (119) hide show
  1. {mixing-0.0.76 → mixing-0.0.78}/.claude/CLAUDE.md +9 -6
  2. {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-audio/SKILL.md +1 -1
  3. {mixing-0.0.76 → mixing-0.0.78}/PKG-INFO +1 -1
  4. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_widget.py +24 -16
  5. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/leveling.py +4 -0
  6. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/voice.py +1 -32
  7. {mixing-0.0.76 → mixing-0.0.78}/mixing/chapters.py +4 -7
  8. mixing-0.0.78/mixing/decode.py +99 -0
  9. {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/srt.py +4 -7
  10. {mixing-0.0.76 → mixing-0.0.78}/mixing/loudness.py +201 -62
  11. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_base_install.py +162 -23
  12. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_loudness.py +152 -0
  13. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_voice.py +6 -20
  14. {mixing-0.0.76 → mixing-0.0.78}/mixing/util.py +65 -15
  15. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/genai.py +4 -3
  16. {mixing-0.0.76 → mixing-0.0.78}/pyproject.toml +1 -1
  17. {mixing-0.0.76 → mixing-0.0.78}/.claude/agents/mixing-dev.md +0 -0
  18. {mixing-0.0.76 → mixing-0.0.78}/.claude/agents/mixing-editor.md +0 -0
  19. {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing/SKILL.md +0 -0
  20. {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-dubbing/SKILL.md +0 -0
  21. {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-transcript/SKILL.md +0 -0
  22. {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-video/SKILL.md +0 -0
  23. {mixing-0.0.76 → mixing-0.0.78}/.github/workflows/ci.yml +0 -0
  24. {mixing-0.0.76 → mixing-0.0.78}/.gitignore +0 -0
  25. {mixing-0.0.76 → mixing-0.0.78}/LICENSE +0 -0
  26. {mixing-0.0.76 → mixing-0.0.78}/README.md +0 -0
  27. {mixing-0.0.76 → mixing-0.0.78}/conftest.py +0 -0
  28. {mixing-0.0.76 → mixing-0.0.78}/misc/README_audio_widget.md +0 -0
  29. {mixing-0.0.76 → mixing-0.0.78}/misc/audio_widget_EXAMPLE_NOTEBOOK.md +0 -0
  30. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_bgremove_efficient.gif +0 -0
  31. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_bgremove_kodokan.gif +0 -0
  32. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_cartoon_C.gif +0 -0
  33. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_facexform_animegan.gif +0 -0
  34. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_facexform_sticker.gif +0 -0
  35. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_redact_blur.gif +0 -0
  36. {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_stylize_B1.gif +0 -0
  37. {mixing-0.0.76 → mixing-0.0.78}/misc/examples/audio_offset_example.py +0 -0
  38. {mixing-0.0.76 → mixing-0.0.78}/misc/examples/demo_audio_widget.py +0 -0
  39. {mixing-0.0.76 → mixing-0.0.78}/mixing/__init__.py +0 -0
  40. {mixing-0.0.76 → mixing-0.0.78}/mixing/_cache.py +0 -0
  41. {mixing-0.0.76 → mixing-0.0.78}/mixing/_elevenlabs.py +0 -0
  42. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/__init__.py +0 -0
  43. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/_pcm.py +0 -0
  44. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_ops.py +0 -0
  45. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_util.py +0 -0
  46. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/beats.py +0 -0
  47. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/samples.py +0 -0
  48. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/segmentation.py +0 -0
  49. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/tagging.py +0 -0
  50. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/talk_edges.py +0 -0
  51. {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/turns.py +0 -0
  52. {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/__init__.py +0 -0
  53. {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/dub.py +0 -0
  54. {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/tts.py +0 -0
  55. {mixing-0.0.76 → mixing-0.0.78}/mixing/egress.py +0 -0
  56. {mixing-0.0.76 → mixing-0.0.78}/mixing/errors.py +0 -0
  57. {mixing-0.0.76 → mixing-0.0.78}/mixing/filtergraph.py +0 -0
  58. {mixing-0.0.76 → mixing-0.0.78}/mixing/srt.py +0 -0
  59. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/data/talk_edges_labels.json +0 -0
  60. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_adaptive_window.py +0 -0
  61. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_aligned_spans.py +0 -0
  62. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_features.py +0 -0
  63. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_ingest.py +0 -0
  64. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_margin.py +0 -0
  65. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_ambient_bed.py +0 -0
  66. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_alignment.py +0 -0
  67. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_ops.py +0 -0
  68. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_widget.py +0 -0
  69. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_beats.py +0 -0
  70. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_chapters.py +0 -0
  71. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_collision_detection.py +0 -0
  72. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_cutaways.py +0 -0
  73. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_dubbing.py +0 -0
  74. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_egress.py +0 -0
  75. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_filtergraph.py +0 -0
  76. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_gif_and_crop_box.py +0 -0
  77. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_audio_class.py +0 -0
  78. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_audio_ops_more.py +0 -0
  79. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_chapters_more.py +0 -0
  80. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_no_print.py +0 -0
  81. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_srt_and_persist.py +0 -0
  82. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_thumbnail.py +0 -0
  83. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_class.py +0 -0
  84. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_ops_more.py +0 -0
  85. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_util.py +0 -0
  86. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_ken_burns.py +0 -0
  87. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_leveling.py +0 -0
  88. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_overlay_mix.py +0 -0
  89. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_scribe_cache.py +0 -0
  90. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_segmentation.py +0 -0
  91. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_segmentation_audioset.py +0 -0
  92. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_shots.py +0 -0
  93. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_srt_vtt.py +0 -0
  94. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_support_statistic.py +0 -0
  95. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_talk_edges.py +0 -0
  96. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_transcript.py +0 -0
  97. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_tts_api_key.py +0 -0
  98. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_tts_cache_status.py +0 -0
  99. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_util.py +0 -0
  100. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_video_audio_integration.py +0 -0
  101. {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_video_transitions.py +0 -0
  102. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/__init__.py +0 -0
  103. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/fillers.py +0 -0
  104. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/formats.py +0 -0
  105. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/media.py +0 -0
  106. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/persist.py +0 -0
  107. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/pipeline.py +0 -0
  108. {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/scribe.py +0 -0
  109. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/README.md +0 -0
  110. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/__init__.py +0 -0
  111. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/_helpers.py +0 -0
  112. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/cutaways.py +0 -0
  113. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/gif.py +0 -0
  114. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/shot_boundaries.py +0 -0
  115. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/thumbnail.py +0 -0
  116. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_concat.py +0 -0
  117. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_ops.py +0 -0
  118. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_subtitles.py +0 -0
  119. {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_util.py +0 -0
@@ -33,7 +33,8 @@ Focused subpackages, each with a clear dependency footprint:
33
33
 
34
34
  | Module | Purpose | Heavy deps |
35
35
  |---|---|---|
36
- | `mixing.loudness` | array-only loudness: `integrated_lufs(meter=)` (numpy / ffmpeg_ebur128 / pyloudnorm / callable), `level_group`, `limit_peaks` (sample or true peak), `true_peak_db`; `mixing.audio.leveling` builds its `Audio` layer on it | `numpy`, `scipy` (extra `loudness`; no media stack) |
36
+ | `mixing.loudness` | array-only loudness: `integrated_lufs(meter=)` (numpy / ffmpeg_ebur128 / pyloudnorm / callable), `stream_loudness`/`file_loudness` (I + LRA in bounded memory), `level_group`, `limit_peaks` (sample or true peak), `true_peak_db`; `mixing.audio.leveling` builds its `Audio` layer on it | `numpy`, `scipy` (extra `loudness`; no media stack) |
37
+ | `mixing.decode` | `decode_float_chunks`: a file's audio as float chunks at its own rate/channels, through ffmpeg (feeds `mixing.loudness.file_loudness`, bounded memory) | `numpy` + an ffmpeg |
37
38
  | `mixing.audio` | `Audio`/`AudioSamples`, fades, crop, concat, overlay, `loop_audio`, `duck_audio`, `find_audio_offset`, segmentation | `pydub`, `numpy`, `scipy` |
38
39
  | `mixing.audio.beats` | `beat_grid`/`BeatGrid` — beat/downbeat/onset analysis | `librosa` (extra `beats`, lazy-imported) |
39
40
  | `mixing.audio.tagging` | `WindowProbabilities` + `ast_audioset_classifier`: the classifier seam behind `find_segments(strategy="audioset")` (songs vs jingles vs talk on radio shows) | `torch`, `transformers` (extra `ml`, lazy-imported) |
@@ -132,11 +133,13 @@ The base install carries only the pure modules (`srt`, `chapters`, `errors`,
132
133
  pydub, numpy, pillow, burns, looks); importing either without it raises an
133
134
  `ImportError` naming `pip install 'mixing[media]'` (`util.require_media_extra`,
134
135
  called at the top of both package `__init__`s). Tests install it via `testing`.
135
- `mixing.util.EXTRAS` is the one table of the extras mixing checks for (media,
136
- ml, beats, loudness → the import names each supplies): `require_extra(feature,
137
- extra)` and `require_package`'s install hint both read it, so such an extra is
138
- declared there and in `pyproject.toml` (a test checks every import name's
139
- distribution is in that extra).
136
+ `mixing.util.EXTRAS` is the one table of mixing's extras (every extra but
137
+ `dev`/`testing` → the import names it supplies): `require_extra(feature,
138
+ extra)` and `require_package`'s install hint both read it, so an extra is
139
+ declared there and in `pyproject.toml` (a test holds the two equal, both
140
+ directions). A module in several extras hints the first; a caller in a narrower
141
+ extra names it: `require_package("scipy.signal", extra=LOUDNESS_EXTRA)`. Never
142
+ hand-write a `pip install` hint for an optional dependency.
140
143
 
141
144
  `pip install mixing[<extra>]`: `media` (see above), `audio` (ffmpeg-python/soundfile, on top of `media`),
142
145
  `loudness` (numpy + scipy, for `mixing.loudness`), `widget` (Jupyter audio widget), `gen` (Veo/`google-genai`), `llm` (`aix`, for
@@ -519,7 +519,7 @@ shift_pitch(
519
519
  ) # +2 semitones, same duration and loudness
520
520
  ```
521
521
 
522
- - `voice_profile`: `f0_hz` is the median f0 over voiced frames of `f0_tracker` (default `pyin_tracker()`, needs `mixing[beats]`; `f0=False` skips it). Configure pyin through the tracker, `f0_tracker=pyin_tracker(fmin=50, fmax=400, min_voiced_prob=0.2)` (the same arguments on `voice_profile` are deprecated). Default range 65-600 Hz; a voice above `fmax` reads as a subharmonic. Spans shorter than the tracker's `min_samples` (pyin: one 128 ms frame) give `f0_hz=None, voiced_fraction=None`. Any `(mono samples, rate) -> (f0 per frame, voiced flags)` callable is a tracker; it applies its own voicing threshold, e.g. over `denote`:
522
+ - `voice_profile`: `f0_hz` is the median f0 over voiced frames of `f0_tracker` (default `pyin_tracker()`, needs `mixing[beats]`; `f0=False` skips it). Configure pyin through the tracker, `f0_tracker=pyin_tracker(fmin=50, fmax=400, min_voiced_prob=0.2)` (`voice_profile` itself no longer takes them). Default range 65-600 Hz; a voice above `fmax` reads as a subharmonic. Spans shorter than the tracker's `min_samples` (pyin: one 128 ms frame) give `f0_hz=None, voiced_fraction=None`. Any `(mono samples, rate) -> (f0 per frame, voiced flags)` callable is a tracker; it applies its own voicing threshold, e.g. over `denote`:
523
523
 
524
524
  ```python
525
525
  def denote_tracker(*, threshold=0.5, **kwargs):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mixing
3
- Version: 0.0.76
3
+ Version: 0.0.78
4
4
  Summary: Tools for video and audio editing
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/mixing
6
6
  Author: Thor Whalen
@@ -6,11 +6,31 @@ import logging
6
6
  import numpy as np
7
7
  from pathlib import Path
8
8
  from typing import Union, Tuple, Optional
9
- from IPython.display import display, HTML
9
+
10
+ from ..util import WIDGET_EXTRA, install_hint
11
+
12
+ try:
13
+ from IPython.display import display, HTML
14
+ except ImportError as e:
15
+ raise ImportError(
16
+ f"The audio widget needs Jupyter: {install_hint(WIDGET_EXTRA)}", name=e.name
17
+ ) from e
10
18
 
11
19
  logger = logging.getLogger(__name__)
12
20
 
13
21
 
22
+ def _soundfile():
23
+ """``soundfile``, or an ``ImportError`` naming the ``widget`` extra."""
24
+ try:
25
+ import soundfile
26
+ except ImportError as e:
27
+ raise ImportError(
28
+ f"soundfile is required for the audio widget: {install_hint(WIDGET_EXTRA)}",
29
+ name="soundfile",
30
+ ) from e
31
+ return soundfile
32
+
33
+
14
34
  # Type aliases
15
35
  WaveformArray = (
16
36
  np.ndarray
@@ -49,13 +69,7 @@ def ensure_wfsr(audio_input: AudioInput) -> WfSr:
49
69
  >>> wf, sr = ensure_wfsr(audio_bytes) # doctest: +SKIP
50
70
  >>> wf, sr = ensure_wfsr((wf, sr)) # doctest: +SKIP
51
71
  """
52
- try:
53
- import soundfile as sf
54
- except ImportError:
55
- raise ImportError(
56
- "soundfile is required for audio processing. "
57
- "Install it with: pip install soundfile"
58
- )
72
+ sf = _soundfile()
59
73
 
60
74
  # Already in (wf, sr) format
61
75
  if isinstance(audio_input, tuple) and len(audio_input) == 2:
@@ -99,10 +113,7 @@ def wf_to_base64_wav(wf: WaveformArray, sr: SampleRate) -> str:
99
113
  Returns:
100
114
  Base64-encoded data URL string suitable for HTML audio element
101
115
  """
102
- try:
103
- import soundfile as sf
104
- except ImportError:
105
- raise ImportError("soundfile required for audio encoding")
116
+ sf = _soundfile()
106
117
 
107
118
  # Ensure wf is in proper format
108
119
  if wf.dtype != np.float32 and wf.dtype != np.float64:
@@ -653,10 +664,7 @@ __temp_sel_end = ${{sel.end}}
653
664
  filepath: Output file path
654
665
  format: Audio format (auto-detected from extension if None)
655
666
  """
656
- try:
657
- import soundfile as sf
658
- except ImportError:
659
- raise ImportError("soundfile required for saving audio")
667
+ sf = _soundfile()
660
668
 
661
669
  filepath = Path(filepath)
662
670
 
@@ -8,6 +8,10 @@ every speaker turn of a recording to the same loudness, which whole-track peak
8
8
  normalisation (:meth:`Audio.normalize`) cannot do. ``loudness_range`` (EBU
9
9
  Tech 3342 LRA) and ``true_peak_db`` (4x oversampled) sit beside it.
10
10
 
11
+ This module is the :class:`Audio` layer. The array-level pieces (the BS.1770
12
+ constants, ``numpy_lufs``, ``level_group``, ``limit_peaks``) live in
13
+ :mod:`mixing.loudness` and are not re-exported here: import them from there.
14
+
11
15
  ``level_turns`` is :func:`mixing.audio.turns.process_turns` with a chain of
12
16
  stages per turn, all run on the turn padded by ``pad_s`` (so filters settle):
13
17
  the speaker's EQ -> pre-gain to a common level -> compressor -> gain to
@@ -268,20 +268,6 @@ def _voiced_f0(
268
268
  return (float(np.median(values)) if len(values) else None), len(values) / n_frames
269
269
 
270
270
 
271
- def _deprecated_pyin_args(**kwargs) -> dict:
272
- """The pyin arguments ``voice_profile`` was given (``None`` = not given),
273
- with a ``DeprecationWarning`` when there are any."""
274
- given = {k: v for k, v in kwargs.items() if v is not None}
275
- if given:
276
- warnings.warn(
277
- f"voice_profile({', '.join(given)}=...) is deprecated and will be "
278
- "removed (reeleehq/mixing#99); pass f0_tracker=pyin_tracker(...) instead",
279
- DeprecationWarning,
280
- stacklevel=3,
281
- )
282
- return given
283
-
284
-
285
271
  def voice_profile(
286
272
  audio: Union[str, Path, Audio],
287
273
  spans: Optional[Spans] = None,
@@ -291,10 +277,6 @@ def voice_profile(
291
277
  f0_sample_rate: int = DEFAULT_F0_SAMPLE_RATE,
292
278
  low_band: Span = DEFAULT_LOW_BAND_HZ,
293
279
  welch_nperseg: int = DEFAULT_WELCH_NPERSEG,
294
- fmin: Optional[float] = None,
295
- fmax: Optional[float] = None,
296
- f0_frame_length: Optional[int] = None,
297
- min_voiced_prob: Optional[float] = None,
298
280
  ) -> dict:
299
281
  """Pitch, loudness and timbre of each labelled part of ``audio``.
300
282
 
@@ -318,8 +300,6 @@ def voice_profile(
318
300
  f0_sample_rate: the rate spans are resampled to before tracking.
319
301
  low_band: ``(lo_hz, hi_hz)`` of the band reported by ``low_band_db``.
320
302
  welch_nperseg: Welch segment length for centroid and low-band ratio.
321
- fmin, fmax, f0_frame_length, min_voiced_prob: deprecated; pass them to
322
- :func:`pyin_tracker` as ``f0_tracker``.
323
303
 
324
304
  Returns:
325
305
  ``{label: {...}}`` of plain JSON-able dicts with keys
@@ -343,19 +323,8 @@ def voice_profile(
343
323
  fs = segment.frame_rate
344
324
  data = segment_samples(segment)
345
325
  grouped = _spans_by_label(spans, len(data) / fs)
346
- pyin_args = _deprecated_pyin_args(
347
- fmin=fmin,
348
- fmax=fmax,
349
- frame_length=f0_frame_length,
350
- min_voiced_prob=min_voiced_prob,
351
- )
352
326
  if f0_tracker is None:
353
- f0_tracker = pyin_tracker(**pyin_args)
354
- elif f0 and pyin_args:
355
- raise ValueError(
356
- "fmin, fmax, f0_frame_length and min_voiced_prob configure the default "
357
- "pyin tracker only; pass them to pyin_tracker() as f0_tracker"
358
- )
327
+ f0_tracker = pyin_tracker()
359
328
 
360
329
  def profile(ranges) -> dict:
361
330
  frames = [
@@ -21,6 +21,7 @@ from dataclasses import dataclass
21
21
  from typing import Callable, Sequence
22
22
 
23
23
  from mixing.srt import parse_srt as _parse_srt
24
+ from mixing.util import require_llm_chat
24
25
 
25
26
  #: A segmenter maps (segments, target_count) -> [{"start": float, "title": str}].
26
27
  #: ``segments`` is a list of ``{"start", "end", "text"}`` sentence-ish units.
@@ -254,13 +255,9 @@ def default_segment_fn(
254
255
  boundaries as a JSON array of ``{"start", "title"}``. Pass your own
255
256
  ``segment_fn`` to :func:`detect_chapters` to avoid the ``aix`` dependency.
256
257
  """
257
- try:
258
- from aix import chat
259
- except ImportError as e: # pragma: no cover - environment dependent
260
- raise ImportError(
261
- "default_segment_fn needs the 'aix' package. Install it, or pass a "
262
- "custom segment_fn to detect_chapters()."
263
- ) from e
258
+ chat = require_llm_chat(
259
+ "default_segment_fn", alternative="segment_fn to detect_chapters()"
260
+ )
264
261
 
265
262
  lines = "\n".join(f"[{_fmt(s['start'])}] {s['text']}" for s in segments)
266
263
  prompt = (
@@ -0,0 +1,99 @@
1
+ """Decode a media file's audio as a stream of float chunks, through ffmpeg.
2
+
3
+ :func:`decode_float_chunks` runs ffmpeg once and hands back the sample rate
4
+ and an iterator of ``(k, channels)`` float32 arrays in [-1, 1], at the file's
5
+ own rate and channel count (no resample, no downmix). Only a chunk is held
6
+ at a time, so a consumer such as :func:`mixing.loudness.stream_loudness`
7
+ works on an hour-long track in bounded memory. Needs numpy and an ffmpeg
8
+ (:func:`mixing.util.ffmpeg_exe`), not the media stack.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ import subprocess
15
+ import tempfile
16
+ from typing import Iterator, Optional
17
+
18
+ from mixing.util import LOUDNESS_EXTRA, ffmpeg_exe, install_hint
19
+
20
+ try:
21
+ import numpy as np
22
+ except ImportError as e:
23
+ raise ImportError(
24
+ f"mixing.decode needs numpy: {install_hint(LOUDNESS_EXTRA)}", name=e.name
25
+ ) from e
26
+
27
+ #: Seconds of audio per chunk by default.
28
+ DECODE_CHUNK_S = 5.0
29
+ #: ffmpeg writes the stream as 32-bit float WAV (its header gives rate and
30
+ #: channels).
31
+ _WAV_CODEC = "pcm_f32le"
32
+ _SAMPLE_BYTES = 4
33
+
34
+
35
+ def _wav_format(stream) -> tuple[int, int]:
36
+ """``(channels, rate)`` from a streamed WAV header, read up to its ``data``
37
+ chunk (ffmpeg writes unknown sizes when piping, so they are not used)."""
38
+ header = stream.read(12)
39
+ if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
40
+ raise ValueError("not a WAV stream")
41
+ channels = rate = None
42
+ while True:
43
+ chunk = stream.read(8)
44
+ if len(chunk) < 8:
45
+ raise ValueError("WAV stream ended before its data chunk")
46
+ name, size = chunk[:4], struct.unpack("<I", chunk[4:])[0]
47
+ if name == b"data":
48
+ if channels is None:
49
+ raise ValueError("WAV stream has no fmt chunk")
50
+ return channels, rate
51
+ body = stream.read(size + size % 2)
52
+ if name == b"fmt ":
53
+ channels, rate = struct.unpack("<HI", body[2:8])
54
+
55
+
56
+ def _decode(path, chunk_s: float, ffmpeg: Optional[str]):
57
+ """First ``(channels, rate)``, then the float chunks."""
58
+ cmd = [
59
+ ffmpeg or ffmpeg_exe(), "-nostdin", "-v", "error", "-i", str(path), "-vn",
60
+ "-map", "0:a:0", "-c:a", _WAV_CODEC, "-f", "wav", "-",
61
+ ] # fmt: skip
62
+ # stderr to a file: a pipe nobody reads fills up and blocks ffmpeg
63
+ with tempfile.TemporaryFile() as errors:
64
+ with subprocess.Popen(
65
+ cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=errors
66
+ ) as proc:
67
+ try:
68
+ channels, rate = _wav_format(proc.stdout)
69
+ except ValueError:
70
+ channels = None
71
+ if channels is not None:
72
+ yield channels, rate
73
+ frame = _SAMPLE_BYTES * channels
74
+ step = max(1, int(round(chunk_s * rate))) * frame
75
+ while block := proc.stdout.read(step):
76
+ usable = len(block) - len(block) % frame
77
+ yield np.frombuffer(block[:usable], "<f4").reshape(-1, channels)
78
+ if proc.returncode != 0 or channels is None:
79
+ errors.seek(0)
80
+ message = errors.read().decode(errors="replace")
81
+ raise RuntimeError(
82
+ f"ffmpeg could not decode the audio of {path!s}: {message}"
83
+ )
84
+
85
+
86
+ def decode_float_chunks(
87
+ path, *, chunk_s: float = DECODE_CHUNK_S, ffmpeg: Optional[str] = None
88
+ ) -> tuple[int, Iterator[np.ndarray]]:
89
+ """``(rate, chunks)``: the first audio stream of ``path`` as float32 chunks.
90
+
91
+ Each chunk is ``(k, channels)`` with ``k`` up to ``chunk_s * rate``. ffmpeg
92
+ (``ffmpeg``, default :func:`mixing.util.ffmpeg_exe`) starts at once; it is
93
+ stopped when the iterator is exhausted or closed. Raises ``RuntimeError``
94
+ (with ffmpeg's message) when the file has no decodable audio, here or,
95
+ for a stream that breaks partway, from the iterator.
96
+ """
97
+ chunks = _decode(path, chunk_s, ffmpeg)
98
+ _, rate = next(chunks)
99
+ return rate, chunks
@@ -16,6 +16,7 @@ from typing import Callable, Sequence
16
16
 
17
17
  # Canonical SRT primitives (re-exported for backward compatibility).
18
18
  from mixing.srt import Cue, dump_srt, parse_srt, srt_time_to_seconds
19
+ from mixing.util import require_llm_chat
19
20
 
20
21
  __all__ = [
21
22
  "Cue",
@@ -84,13 +85,9 @@ def default_translate_fn(
84
85
  one-to-one mapping with the input cues is preserved. Falls back to a clear
85
86
  error if ``aix`` is not importable — pass your own ``translate_fn`` then.
86
87
  """
87
- try:
88
- from aix import chat # provider-agnostic LLM interface
89
- except ImportError as e: # pragma: no cover - depends on environment
90
- raise ImportError(
91
- "default_translate_fn needs the 'aix' package for LLM translation. "
92
- "Install aix, or pass a custom translate_fn to translate_srt()."
93
- ) from e
88
+ chat = require_llm_chat(
89
+ "default_translate_fn", alternative="translate_fn to translate_srt()"
90
+ )
94
91
 
95
92
  import json as _json
96
93