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.
- {mixing-0.0.76 → mixing-0.0.78}/.claude/CLAUDE.md +9 -6
- {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-audio/SKILL.md +1 -1
- {mixing-0.0.76 → mixing-0.0.78}/PKG-INFO +1 -1
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_widget.py +24 -16
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/leveling.py +4 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/voice.py +1 -32
- {mixing-0.0.76 → mixing-0.0.78}/mixing/chapters.py +4 -7
- mixing-0.0.78/mixing/decode.py +99 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/srt.py +4 -7
- {mixing-0.0.76 → mixing-0.0.78}/mixing/loudness.py +201 -62
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_base_install.py +162 -23
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_loudness.py +152 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_voice.py +6 -20
- {mixing-0.0.76 → mixing-0.0.78}/mixing/util.py +65 -15
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/genai.py +4 -3
- {mixing-0.0.76 → mixing-0.0.78}/pyproject.toml +1 -1
- {mixing-0.0.76 → mixing-0.0.78}/.claude/agents/mixing-dev.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.claude/agents/mixing-editor.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing/SKILL.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-dubbing/SKILL.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-transcript/SKILL.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.claude/skills/mixing-video/SKILL.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.github/workflows/ci.yml +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/.gitignore +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/LICENSE +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/README.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/conftest.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/README_audio_widget.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/audio_widget_EXAMPLE_NOTEBOOK.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_bgremove_efficient.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_bgremove_kodokan.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_cartoon_C.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_facexform_animegan.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_facexform_sticker.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_redact_blur.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/discussion_media/video_editing/pair_stylize_B1.gif +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/examples/audio_offset_example.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/misc/examples/demo_audio_widget.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/__init__.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/_cache.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/_elevenlabs.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/__init__.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/_pcm.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_ops.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/audio_util.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/beats.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/samples.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/segmentation.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/tagging.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/talk_edges.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/audio/turns.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/__init__.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/dub.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/dubbing/tts.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/egress.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/errors.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/filtergraph.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/srt.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/data/talk_edges_labels.json +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_adaptive_window.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_aligned_spans.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_features.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_ingest.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_alignment_margin.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_ambient_bed.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_alignment.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_ops.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_audio_widget.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_beats.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_chapters.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_collision_detection.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_cutaways.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_dubbing.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_egress.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_filtergraph.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_gif_and_crop_box.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_audio_class.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_audio_ops_more.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_chapters_more.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_no_print.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_srt_and_persist.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_thumbnail.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_class.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_ops_more.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_guard_video_util.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_ken_burns.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_leveling.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_overlay_mix.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_scribe_cache.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_segmentation.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_segmentation_audioset.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_shots.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_srt_vtt.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_support_statistic.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_talk_edges.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_transcript.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_tts_api_key.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_tts_cache_status.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_util.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_video_audio_integration.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/tests/test_video_transitions.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/__init__.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/fillers.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/formats.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/media.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/persist.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/pipeline.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/transcript/scribe.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/README.md +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/__init__.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/_helpers.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/cutaways.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/gif.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/shot_boundaries.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/thumbnail.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_concat.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_ops.py +0 -0
- {mixing-0.0.76 → mixing-0.0.78}/mixing/video/video_subtitles.py +0 -0
- {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
|
|
136
|
-
|
|
137
|
-
extra)` and `require_package`'s install hint both read it, so
|
|
138
|
-
declared there and in `pyproject.toml` (a test
|
|
139
|
-
|
|
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)` (
|
|
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):
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|