mixforge-audio 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.
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boobalan Arjunan
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.
22
+
23
+ ---
24
+ This project depends on third-party libraries (numpy, scipy, soundfile,
25
+ librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
26
+ optionally openai-whisper, torch/torchaudio). Each carries its own license
27
+ (mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
28
+ license before redistributing, especially if your GTV program has its own
29
+ licensing rules.
@@ -0,0 +1,2 @@
1
+ include LICENSE README.md
2
+ recursive-include tests *.py
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: mixforge-audio
3
+ Version: 0.1.0
4
+ Summary: Build test mixes with a known answer key: mix separate stems at known levels, add a known amount of bleed, and keep the exact originals plus a recipe. For testing music source separation.
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Mix-Forge
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Mix-Forge/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Mix-Forge
11
+ Keywords: audio,music,source-separation,stems,ground-truth,synthetic-data,bleed,evaluation,machine-learning
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy
22
+ Requires-Dist: scipy
23
+ Requires-Dist: soundfile
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # mixforge
29
+
30
+ Make test mixes where you know the right answer.
31
+
32
+ You give mixforge separate stems (vocals, drums, bass, other). It mixes them at known levels, can add a **known amount of bleed** (for example "a little drums leaking into the vocal"), and keeps the exact originals plus a `recipe.json`. You can then test whether a separation tool, or a bleed detector, gives the right answer, because you know what went in.
33
+
34
+ > **Status: alpha (0.1.0).** Tested on synthetic audio with known answers, mutation checks, and verified on real music stems from MUSDB18-HQ (see [Real-data check](#real-data-check) below).
35
+
36
+ ## Install
37
+
38
+ ```
39
+ pip install --pre mixforge
40
+ ```
41
+
42
+ (`--pre` is needed while it is an alpha release.)
43
+
44
+ ## Quick start
45
+
46
+ Folder layout, one folder per song (this is the MUSDB18-HQ layout; a `mixture.wav` next to the stems is ignored):
47
+
48
+ ```
49
+ stems/
50
+ Song A/ vocals.wav drums.wav bass.wav other.wav
51
+ Song B/ ...
52
+ ```
53
+
54
+ ```
55
+ mixforge make stems --out mixes --count 3 --seconds 10 \
56
+ --gain-range -6 6 \
57
+ --bleed "drums>vocals:-26@4000" --bleed "bass>other:-30" \
58
+ --seed 1
59
+ mixforge verify mixes
60
+ ```
61
+
62
+ Each mix gets its own folder:
63
+
64
+ ```
65
+ mixes/0000_Song_A_000/
66
+ mixture.wav the mix
67
+ stems/*.wav the answer key: the clean stems exactly as they sit in the mix
68
+ leaky/*.wav the same stems with the bleed added (only if you asked for bleed)
69
+ recipe.json everything that was done, so the result can be repeated and checked
70
+ mixes/index.csv one line per mix
71
+ ```
72
+
73
+ ## What the options mean
74
+
75
+ | Option | Meaning |
76
+ |---|---|
77
+ | `--gain-range LO HI` | random level change per stem, in dB, drawn from the seed |
78
+ | `--gain STEM=DB` | fixed level for one stem |
79
+ | `--bleed SRC>DST:DB[@HZ]` | leak `SRC` into `DST`. `DB` is the leak level relative to `SRC` as it sits in the mix: `-20` = a tenth of the amplitude, `-6` = about half. `@HZ` low-passes the leak (real bleed is often duller) |
80
+ | `--seconds S` | cut the same random window of `S` seconds out of every stem |
81
+ | `--count N` | `N` different mixes per song |
82
+ | `--seed N` | same seed, same files (byte for byte) |
83
+ | `--subtype` | `FLOAT` (default, never clips), `PCM_16` or `PCM_24` |
84
+ | `--max-songs N` | only the first `N` songs, for a quick trial |
85
+ | `--html FILE` | write an interactive HTML listening audit report with multi-track players |
86
+
87
+ Rules mixforge follows:
88
+
89
+ - **The mixture is the exact sum of the stems in `stems/`.** If the sum would clip, everything is turned down together so the sum still holds. `recipe.json` records the change.
90
+ - **Stems that do not match are matched up and noted.** Different sample rates are resampled to the most common one, mono stems are copied to stereo, and different lengths are trimmed to the shortest. Each fix is written into `recipe.json` under `notes`.
91
+ - **Your files are never modified**, and existing output is never overwritten.
92
+ - **`mixforge verify`** re-checks any output folder: mixture = sum of stems, and each leaky file really equals the clean stem plus the bleed the recipe describes. It exits 0 if all is well, 1 if something fails, 2 for usage errors.
93
+
94
+ ## Python
95
+
96
+ ```python
97
+ import mixforge
98
+ from mixforge import Bleed, make_mix
99
+
100
+ r = make_mix(stems, sr, gain_range=(-6, 6), seed=1,
101
+ bleed=[Bleed("drums", "vocals", -26.0, lowpass_hz=4000)])
102
+ r.mixture, r.stems, r.leaky, r.recipe
103
+ ```
104
+
105
+ ## Real-data check
106
+
107
+ **Dataset:** MUSDB18-HQ (`train` split, 20 songs, 40 mixes, 10 s windows)
108
+ **Date:** November 5, 2026
109
+ **Recipe:** gain range -6..+6 dB, bleed `drums>vocals:-26@4000`, `bass>other:-30`, `vocals>other:-20`, 2 mixes per song.
110
+
111
+ ### Pipeline, FLOAT
112
+ - **Mixes verified:** 40 of 40 (0 failures)
113
+ - **Largest $|\text{mixture} - \sum \text{stems}|$:** 6.52e-08
114
+ - **Bleed without low-pass:** 0.0000 dB gap between asked and measured level
115
+ - **Bleed with low-pass:** measured level minus asked level between -1.21 dB and 0.0 dB
116
+ - **Headroom scaling:** 15 mixes scaled automatically to prevent clipping
117
+ - **Time:** 8.5 s
118
+
119
+ ### Pipeline, PCM_16
120
+ - **Mixes verified:** 40 of 40 (0 failures)
121
+ - **Largest $|\text{mixture} - \sum \text{stems}|$:** 9.16e-05
122
+ - **Bleed without low-pass:** 0.0000 dB gap between asked and measured level
123
+ - **Bleed with low-pass:** measured level minus asked level between -1.21 dB and 0.0 dB
124
+ - **Headroom scaling:** 15 mixes scaled automatically to prevent clipping
125
+ - **Time:** 9.9 s
126
+
127
+ ### Information check vs dataset's own mixture.wav (30 s window, no gain changes)
128
+ - **Songs compared:** 20
129
+ - **Residual (lower is closer):** best -73.3 dB, median -69.3 dB, worst -62.8 dB (all well below the -60 dB auditory perception threshold)
130
+
131
+ ## Limits (please read)
132
+
133
+ - **This is test material, not music.** The mixes are gain-scaled sums of stems. They are not mastered and do not claim to sound like finished records.
134
+ - **The bleed is simple.** It is a quieter, optionally duller copy of one stem added to another. Real microphone bleed also has delay, room sound and frequency changes that this does not copy. A detector that works here may still struggle on real recordings.
135
+ - **Stems must already be time-aligned** (as in MUSDB18-HQ). mixforge does not align them.
136
+ - **No cross-song remixing yet** (vocals from one song over drums from another).
137
+ - Stems are read with `soundfile` (WAV, FLAC, OGG, AIFF, and MP3 where your libsndfile supports it). Not supported: `.stem.mp4` files (use MUSDB18-HQ's WAV version).
138
+
139
+ ## Related work
140
+
141
+ - [MUSDB18](https://sigsep.github.io/datasets/musdb.html) and `museval` provide real stems and standard separation scores. mixforge does not replace them. It makes controlled, repeatable test mixes you can use next to them.
142
+ - Mixing tools such as Scaper and audiomentations exist for other uses (sound-event data, augmentation). mixforge's focus is a recorded answer key and controlled bleed for separation testing.
143
+
144
+ ## Development
145
+
146
+ ```
147
+ pip install -e ".[dev]"
148
+ python -m pytest -q
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,126 @@
1
+ # mixforge
2
+
3
+ Make test mixes where you know the right answer.
4
+
5
+ You give mixforge separate stems (vocals, drums, bass, other). It mixes them at known levels, can add a **known amount of bleed** (for example "a little drums leaking into the vocal"), and keeps the exact originals plus a `recipe.json`. You can then test whether a separation tool, or a bleed detector, gives the right answer, because you know what went in.
6
+
7
+ > **Status: alpha (0.1.0).** Tested on synthetic audio with known answers, mutation checks, and verified on real music stems from MUSDB18-HQ (see [Real-data check](#real-data-check) below).
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install --pre mixforge
13
+ ```
14
+
15
+ (`--pre` is needed while it is an alpha release.)
16
+
17
+ ## Quick start
18
+
19
+ Folder layout, one folder per song (this is the MUSDB18-HQ layout; a `mixture.wav` next to the stems is ignored):
20
+
21
+ ```
22
+ stems/
23
+ Song A/ vocals.wav drums.wav bass.wav other.wav
24
+ Song B/ ...
25
+ ```
26
+
27
+ ```
28
+ mixforge make stems --out mixes --count 3 --seconds 10 \
29
+ --gain-range -6 6 \
30
+ --bleed "drums>vocals:-26@4000" --bleed "bass>other:-30" \
31
+ --seed 1
32
+ mixforge verify mixes
33
+ ```
34
+
35
+ Each mix gets its own folder:
36
+
37
+ ```
38
+ mixes/0000_Song_A_000/
39
+ mixture.wav the mix
40
+ stems/*.wav the answer key: the clean stems exactly as they sit in the mix
41
+ leaky/*.wav the same stems with the bleed added (only if you asked for bleed)
42
+ recipe.json everything that was done, so the result can be repeated and checked
43
+ mixes/index.csv one line per mix
44
+ ```
45
+
46
+ ## What the options mean
47
+
48
+ | Option | Meaning |
49
+ |---|---|
50
+ | `--gain-range LO HI` | random level change per stem, in dB, drawn from the seed |
51
+ | `--gain STEM=DB` | fixed level for one stem |
52
+ | `--bleed SRC>DST:DB[@HZ]` | leak `SRC` into `DST`. `DB` is the leak level relative to `SRC` as it sits in the mix: `-20` = a tenth of the amplitude, `-6` = about half. `@HZ` low-passes the leak (real bleed is often duller) |
53
+ | `--seconds S` | cut the same random window of `S` seconds out of every stem |
54
+ | `--count N` | `N` different mixes per song |
55
+ | `--seed N` | same seed, same files (byte for byte) |
56
+ | `--subtype` | `FLOAT` (default, never clips), `PCM_16` or `PCM_24` |
57
+ | `--max-songs N` | only the first `N` songs, for a quick trial |
58
+ | `--html FILE` | write an interactive HTML listening audit report with multi-track players |
59
+
60
+ Rules mixforge follows:
61
+
62
+ - **The mixture is the exact sum of the stems in `stems/`.** If the sum would clip, everything is turned down together so the sum still holds. `recipe.json` records the change.
63
+ - **Stems that do not match are matched up and noted.** Different sample rates are resampled to the most common one, mono stems are copied to stereo, and different lengths are trimmed to the shortest. Each fix is written into `recipe.json` under `notes`.
64
+ - **Your files are never modified**, and existing output is never overwritten.
65
+ - **`mixforge verify`** re-checks any output folder: mixture = sum of stems, and each leaky file really equals the clean stem plus the bleed the recipe describes. It exits 0 if all is well, 1 if something fails, 2 for usage errors.
66
+
67
+ ## Python
68
+
69
+ ```python
70
+ import mixforge
71
+ from mixforge import Bleed, make_mix
72
+
73
+ r = make_mix(stems, sr, gain_range=(-6, 6), seed=1,
74
+ bleed=[Bleed("drums", "vocals", -26.0, lowpass_hz=4000)])
75
+ r.mixture, r.stems, r.leaky, r.recipe
76
+ ```
77
+
78
+ ## Real-data check
79
+
80
+ **Dataset:** MUSDB18-HQ (`train` split, 20 songs, 40 mixes, 10 s windows)
81
+ **Date:** November 5, 2026
82
+ **Recipe:** gain range -6..+6 dB, bleed `drums>vocals:-26@4000`, `bass>other:-30`, `vocals>other:-20`, 2 mixes per song.
83
+
84
+ ### Pipeline, FLOAT
85
+ - **Mixes verified:** 40 of 40 (0 failures)
86
+ - **Largest $|\text{mixture} - \sum \text{stems}|$:** 6.52e-08
87
+ - **Bleed without low-pass:** 0.0000 dB gap between asked and measured level
88
+ - **Bleed with low-pass:** measured level minus asked level between -1.21 dB and 0.0 dB
89
+ - **Headroom scaling:** 15 mixes scaled automatically to prevent clipping
90
+ - **Time:** 8.5 s
91
+
92
+ ### Pipeline, PCM_16
93
+ - **Mixes verified:** 40 of 40 (0 failures)
94
+ - **Largest $|\text{mixture} - \sum \text{stems}|$:** 9.16e-05
95
+ - **Bleed without low-pass:** 0.0000 dB gap between asked and measured level
96
+ - **Bleed with low-pass:** measured level minus asked level between -1.21 dB and 0.0 dB
97
+ - **Headroom scaling:** 15 mixes scaled automatically to prevent clipping
98
+ - **Time:** 9.9 s
99
+
100
+ ### Information check vs dataset's own mixture.wav (30 s window, no gain changes)
101
+ - **Songs compared:** 20
102
+ - **Residual (lower is closer):** best -73.3 dB, median -69.3 dB, worst -62.8 dB (all well below the -60 dB auditory perception threshold)
103
+
104
+ ## Limits (please read)
105
+
106
+ - **This is test material, not music.** The mixes are gain-scaled sums of stems. They are not mastered and do not claim to sound like finished records.
107
+ - **The bleed is simple.** It is a quieter, optionally duller copy of one stem added to another. Real microphone bleed also has delay, room sound and frequency changes that this does not copy. A detector that works here may still struggle on real recordings.
108
+ - **Stems must already be time-aligned** (as in MUSDB18-HQ). mixforge does not align them.
109
+ - **No cross-song remixing yet** (vocals from one song over drums from another).
110
+ - Stems are read with `soundfile` (WAV, FLAC, OGG, AIFF, and MP3 where your libsndfile supports it). Not supported: `.stem.mp4` files (use MUSDB18-HQ's WAV version).
111
+
112
+ ## Related work
113
+
114
+ - [MUSDB18](https://sigsep.github.io/datasets/musdb.html) and `museval` provide real stems and standard separation scores. mixforge does not replace them. It makes controlled, repeatable test mixes you can use next to them.
115
+ - Mixing tools such as Scaper and audiomentations exist for other uses (sound-event data, augmentation). mixforge's focus is a recorded answer key and controlled bleed for separation testing.
116
+
117
+ ## Development
118
+
119
+ ```
120
+ pip install -e ".[dev]"
121
+ python -m pytest -q
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mixforge-audio"
7
+ version = "0.1.0"
8
+ description = "Build test mixes with a known answer key: mix separate stems at known levels, add a known amount of bleed, and keep the exact originals plus a recipe. For testing music source separation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
17
+ ]
18
+ keywords = ["audio", "music", "source-separation", "stems", "ground-truth", "synthetic-data", "bleed", "evaluation", "machine-learning"]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Science/Research",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ ]
27
+ dependencies = ["numpy", "scipy", "soundfile"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/BoobalanArjunan/Mix-Forge"
34
+ Issues = "https://github.com/BoobalanArjunan/Mix-Forge/issues"
35
+ Source = "https://github.com/BoobalanArjunan/Mix-Forge"
36
+
37
+ [project.scripts]
38
+ mixforge = "mixforge.cli:main"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """mixforge: build test mixes with a known answer key (for testing music source separation)."""
2
+ from ._version import __version__ # noqa: F401
3
+ from .core import Bleed, MixforgeError, MixResult, leak_signal, make_mix, parse_bleed # noqa: F401
4
+ from .data import DatasetResult, make_dataset, write_mix # noqa: F401
5
+ from .verify import VerifyResult, verify_folder # noqa: F401
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ._version import __version__
9
+ from .core import MixforgeError, parse_bleed
10
+ from .data import DEFAULT_EXCLUDE, SUBTYPES, make_dataset
11
+ from .report import to_html
12
+ from .verify import verify_folder
13
+
14
+
15
+ def _gains(items):
16
+ out = {}
17
+ for it in items or []:
18
+ name, sep, val = it.partition("=")
19
+ try:
20
+ if not sep or not name:
21
+ raise ValueError
22
+ out[name] = float(val)
23
+ except ValueError:
24
+ raise MixforgeError(f"bad --gain {it!r}: use STEM=DB, e.g. vocals=-3") from None
25
+ return out
26
+
27
+
28
+ def main(argv=None) -> int:
29
+ ap = argparse.ArgumentParser(prog="mixforge", description="Build test mixes with a known answer key.")
30
+ ap.add_argument("--version", action="version", version=f"mixforge {__version__}")
31
+ sub = ap.add_subparsers(dest="cmd")
32
+
33
+ m = sub.add_parser("make", help="mix stems into test songs, with an answer key and a recipe")
34
+ m.add_argument("source", help="a folder of stem files (one song) or a folder of song folders")
35
+ m.add_argument("--out", required=True, help="new folder to write the mixes into")
36
+ m.add_argument("--count", type=int, default=1, help="mixes per song (default 1)")
37
+ m.add_argument("--seconds", type=float, help="cut a random window of this length (same window for every stem)")
38
+ m.add_argument("--gain-range", type=float, nargs=2, metavar=("LO", "HI"), default=(0.0, 0.0),
39
+ help="random level change per stem in dB, e.g. -6 6 (default: none)")
40
+ m.add_argument("--gain", action="append", metavar="STEM=DB", help="fixed level for one stem; can repeat")
41
+ m.add_argument("--bleed", action="append", metavar="SRC>DST:DB[@HZ]",
42
+ help="leak SRC into DST at DB (negative = quieter), optional low-pass; can repeat")
43
+ m.add_argument("--max-songs", type=int, help="only use the first N songs (alphabetical), for a quick trial")
44
+ m.add_argument("--seed", type=int, default=0, help="same seed gives the same mixes (default 0)")
45
+ m.add_argument("--subtype", choices=SUBTYPES, default="FLOAT", help="audio format (default FLOAT, no clipping)")
46
+ m.add_argument("--sr", type=int, help="sample rate to use (default: the most common one among the stems)")
47
+ m.add_argument("--exclude", action="append", default=None,
48
+ help=f"file name to ignore (default: {', '.join(DEFAULT_EXCLUDE)}); can repeat")
49
+ m.add_argument("--peak", type=float, default=0.98, help="loudest allowed peak before turning everything down (default 0.98)")
50
+ m.add_argument("--json", action="store_true", help="print JSON instead of text")
51
+ m.add_argument("--html", metavar="FILE", help="write an interactive HTML listening audit report")
52
+
53
+ v = sub.add_parser("verify", help="re-check mixes: mixture = sum of stems, bleed = recipe")
54
+ v.add_argument("folder", help="a folder written by 'mixforge make' (or one mix)")
55
+ v.add_argument("--json", action="store_true", help="print JSON instead of text")
56
+ v.add_argument("--html", metavar="FILE", help="write an interactive HTML listening audit report")
57
+
58
+ a = ap.parse_args(argv)
59
+ if a.cmd not in ("make", "verify"):
60
+ ap.print_help()
61
+ return 2
62
+ try:
63
+ if a.cmd == "make":
64
+ res = make_dataset(
65
+ Path(a.source), Path(a.out), count=a.count, seconds=a.seconds, gain_range=tuple(a.gain_range),
66
+ gains_db=_gains(a.gain), bleed=[parse_bleed(b) for b in (a.bleed or [])], seed=a.seed,
67
+ subtype=a.subtype, sr=a.sr, exclude=tuple(a.exclude) if a.exclude else DEFAULT_EXCLUDE,
68
+ peak_limit=a.peak, max_songs=a.max_songs)
69
+ if a.html:
70
+ v_res = verify_folder(Path(a.out))
71
+ html_content = to_html(v_res, out_path=Path(a.html), base_dir=Path(a.out))
72
+ Path(a.html).parent.mkdir(parents=True, exist_ok=True)
73
+ Path(a.html).write_text(html_content, encoding="utf-8")
74
+ if a.json:
75
+ print(json.dumps({"out": str(res.out), "songs": res.songs, "mixes": res.mixes, "notes": res.notes}, indent=2))
76
+ else:
77
+ print(f"wrote {len(res.mixes)} mix(es) from {res.songs} song(s) to {res.out}")
78
+ for n in res.notes:
79
+ print(f"note: {n}")
80
+ if a.html:
81
+ print(f"wrote HTML report to {a.html}")
82
+ print(f"check them with: mixforge verify {res.out}")
83
+ return 0
84
+ res = verify_folder(Path(a.folder))
85
+ except (MixforgeError, OSError) as e:
86
+ print(f"error: {e}", file=sys.stderr)
87
+ return 2
88
+ if a.html:
89
+ html_content = to_html(res, out_path=Path(a.html), base_dir=Path(a.folder))
90
+ Path(a.html).parent.mkdir(parents=True, exist_ok=True)
91
+ Path(a.html).write_text(html_content, encoding="utf-8")
92
+ if a.json:
93
+ print(json.dumps({"ok": res.ok, "checks": [{k: v for k, v in vars(c).items() if k != "path"} for c in res.checks]}, indent=2))
94
+ else:
95
+ for c in res.checks:
96
+ print(f"{'PASS' if c.ok else 'FAIL'} {c.mix}")
97
+ for p in c.problems:
98
+ print(f" {p}")
99
+ bad = sum(not c.ok for c in res.checks)
100
+ print(f"{len(res.checks) - bad} of {len(res.checks)} mixes OK")
101
+ if a.html:
102
+ print(f"wrote HTML report to {a.html}")
103
+ return 0 if res.ok else 1
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1,168 @@
1
+ """The mixing itself: gains, clipping guard, cropping, and bleed. No file access here."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ import numpy as np
9
+ from scipy import signal
10
+
11
+ from ._version import __version__
12
+
13
+
14
+ class MixforgeError(ValueError):
15
+ """A problem the user can fix (bad input or option). The CLI shows it without a traceback."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Bleed:
20
+ """Leak part of `source` into `target`.
21
+
22
+ `db` is the leak level relative to the source stem as it sits in the mix:
23
+ -20 means the leak is 10 times quieter than the source (amplitude), -6 about half.
24
+ `lowpass_hz` optionally removes the highs from the leak (real bleed is often duller).
25
+ """
26
+ source: str
27
+ target: str
28
+ db: float
29
+ lowpass_hz: float | None = None
30
+
31
+
32
+ _BLEED_RE = re.compile(
33
+ r"\s*([^>:@]+?)\s*>\s*([^>:@]+?)\s*:\s*([-+]?\d+(?:\.\d+)?)\s*(?:@\s*(\d+(?:\.\d+)?))?\s*")
34
+
35
+
36
+ def parse_bleed(text: str) -> Bleed:
37
+ """Parse 'drums>vocals:-26' or 'drums>vocals:-26@4000' (optional low-pass in Hz)."""
38
+ m = _BLEED_RE.fullmatch(text)
39
+ if not m:
40
+ raise MixforgeError(f"bad bleed {text!r}: use SOURCE>TARGET:DB, e.g. drums>vocals:-26 "
41
+ "(optionally @LOWPASS_HZ, e.g. drums>vocals:-26@4000)")
42
+ src, dst, db, lp = m.groups()
43
+ return Bleed(src, dst, float(db), float(lp) if lp else None)
44
+
45
+
46
+ def leak_signal(source: np.ndarray, db: float, lowpass_hz: float | None, sr: int) -> np.ndarray:
47
+ """The audio that leaks: the source, scaled by `db`, optionally low-passed. Shape (samples, channels)."""
48
+ leak = np.asarray(source, dtype=np.float64) * (10.0 ** (db / 20.0))
49
+ if lowpass_hz is not None:
50
+ if not (0 < lowpass_hz < sr / 2):
51
+ raise MixforgeError(f"bleed low-pass {lowpass_hz} Hz must be between 0 and half the sample rate ({sr / 2:g})")
52
+ sos = signal.butter(4, lowpass_hz, btype="low", fs=sr, output="sos")
53
+ leak = signal.sosfilt(sos, leak, axis=0)
54
+ return leak
55
+
56
+
57
+ def _rms(x: np.ndarray) -> float:
58
+ return float(np.sqrt(np.mean(np.square(x)))) if x.size else 0.0
59
+
60
+
61
+ def _db(x: float) -> float | None:
62
+ return round(20.0 * math.log10(x), 4) if x > 1e-12 else None
63
+
64
+
65
+ @dataclass
66
+ class MixResult:
67
+ mixture: np.ndarray # (samples, channels)
68
+ stems: dict[str, np.ndarray] # the answer key: clean stems exactly as they sit in the mixture
69
+ leaky: dict[str, np.ndarray] | None # same stems with the requested bleed added (None if no bleed)
70
+ recipe: dict
71
+ sr: int
72
+
73
+
74
+ def make_mix(stems: dict[str, np.ndarray], sr: int, *, gains_db: dict[str, float] | None = None,
75
+ gain_range: tuple[float, float] = (0.0, 0.0), bleed=(), seconds: float | None = None,
76
+ seed=0, peak_limit: float = 0.98, song: str = "", notes=()) -> MixResult:
77
+ """Mix `stems` (name -> array of shape (samples,) or (samples, channels), all the same shape).
78
+
79
+ The mixture is the exact sum of the returned stems. If that sum would be louder than `peak_limit`,
80
+ every stem and the mixture are scaled down together, so the sum still holds.
81
+ """
82
+ if len(stems) < 2:
83
+ raise MixforgeError("need at least 2 stems to make a mix")
84
+ arrs = {}
85
+ for name, a in stems.items():
86
+ a = np.asarray(a) # converted to float64 only after cropping (saves memory on long songs)
87
+ arrs[name] = a[:, None] if a.ndim == 1 else a
88
+ shapes = {a.shape for a in arrs.values()}
89
+ if len(shapes) != 1:
90
+ raise MixforgeError(f"all stems must have the same length and channels, got {sorted(shapes)}")
91
+ names = sorted(arrs)
92
+ n, ch = next(iter(shapes))
93
+ lo, hi = gain_range
94
+ if lo > hi:
95
+ raise MixforgeError(f"gain range {lo}..{hi} is backwards")
96
+ gains_db = dict(gains_db or {})
97
+ for k in gains_db:
98
+ if k not in arrs:
99
+ raise MixforgeError(f"gain given for unknown stem {k!r} (stems: {', '.join(names)})")
100
+ bleed = list(bleed)
101
+ seen = set()
102
+ for b in bleed:
103
+ for end in (b.source, b.target):
104
+ if end not in arrs:
105
+ raise MixforgeError(f"bleed uses unknown stem {end!r} (stems: {', '.join(names)})")
106
+ if b.source == b.target:
107
+ raise MixforgeError(f"bleed from {b.source!r} into itself makes no sense")
108
+ if (b.source, b.target) in seen:
109
+ raise MixforgeError(f"bleed {b.source}>{b.target} given twice")
110
+ seen.add((b.source, b.target))
111
+ if b.lowpass_hz is not None and not (0 < b.lowpass_hz < sr / 2):
112
+ raise MixforgeError(f"bleed low-pass {b.lowpass_hz} Hz must be between 0 and half the sample rate ({sr / 2:g})")
113
+
114
+ rng = np.random.default_rng(seed)
115
+ notes = list(notes)
116
+
117
+ # crop one aligned window out of every stem
118
+ offset, n_use = 0, n
119
+ if seconds is not None:
120
+ want = int(round(seconds * sr))
121
+ if want <= 0:
122
+ raise MixforgeError("--seconds must be positive")
123
+ if n > want:
124
+ offset = int(rng.integers(0, n - want + 1))
125
+ n_use = want
126
+ else:
127
+ notes.append(f"song is {n / sr:.2f} s, shorter than the {seconds:g} s asked for; used all of it")
128
+
129
+ # gains (drawn in sorted-name order so a seed always gives the same result)
130
+ gain_used = {}
131
+ for name in names:
132
+ g = rng.uniform(lo, hi)
133
+ gain_used[name] = float(gains_db[name]) if name in gains_db else round(float(g), 4)
134
+ gained = {name: arrs[name][offset:offset + n_use].astype(np.float64) * (10.0 ** (gain_used[name] / 20.0))
135
+ for name in names}
136
+
137
+ mixture = sum(gained[name] for name in names)
138
+ peak = float(np.abs(mixture).max()) if mixture.size else 0.0
139
+ scale = min(1.0, peak_limit / peak) if peak > 0 else 1.0
140
+ if scale < 1.0:
141
+ gained = {k: v * scale for k, v in gained.items()}
142
+ mixture = sum(gained[name] for name in names)
143
+ notes.append(f"mix would clip; everything scaled by {20 * math.log10(scale):.2f} dB")
144
+
145
+ leaky, bleed_rec = None, []
146
+ if bleed:
147
+ leaky = {name: gained[name].copy() for name in names}
148
+ for b in bleed:
149
+ leak = leak_signal(gained[b.source], b.db, b.lowpass_hz, sr)
150
+ leaky[b.target] = leaky[b.target] + leak
151
+ bleed_rec.append({
152
+ "source": b.source, "target": b.target, "requested_db": b.db, "lowpass_hz": b.lowpass_hz,
153
+ "measured_db_vs_source": _db(_rms(leak) / _rms(gained[b.source])) if _rms(gained[b.source]) > 0 else None,
154
+ "leak_vs_target_db": _db(_rms(leak) / _rms(gained[b.target])) if _rms(gained[b.target]) > 0 else None,
155
+ })
156
+
157
+ recipe = {
158
+ "mixforge": __version__, "song": song, "seed": list(seed) if isinstance(seed, (list, tuple)) else seed,
159
+ "sample_rate": sr, "channels": ch, "samples": int(n_use), "seconds": round(n_use / sr, 6),
160
+ "offset_samples": offset, "source_samples": int(n),
161
+ "peak_limit": peak_limit, "peak_scale": scale,
162
+ "stems": {name: {"gain_db": gain_used[name],
163
+ "rms_dbfs": _db(_rms(gained[name])), "silent": _rms(gained[name]) == 0.0}
164
+ for name in names},
165
+ "bleed": bleed_rec,
166
+ "notes": notes,
167
+ }
168
+ return MixResult(mixture, gained, leaky, recipe, sr)