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.
- mixforge_audio-0.1.0/LICENSE +29 -0
- mixforge_audio-0.1.0/MANIFEST.in +2 -0
- mixforge_audio-0.1.0/PKG-INFO +153 -0
- mixforge_audio-0.1.0/README.md +126 -0
- mixforge_audio-0.1.0/pyproject.toml +44 -0
- mixforge_audio-0.1.0/setup.cfg +4 -0
- mixforge_audio-0.1.0/src/mixforge/__init__.py +5 -0
- mixforge_audio-0.1.0/src/mixforge/_version.py +1 -0
- mixforge_audio-0.1.0/src/mixforge/cli.py +107 -0
- mixforge_audio-0.1.0/src/mixforge/core.py +168 -0
- mixforge_audio-0.1.0/src/mixforge/data.py +201 -0
- mixforge_audio-0.1.0/src/mixforge/report.py +288 -0
- mixforge_audio-0.1.0/src/mixforge/verify.py +119 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/PKG-INFO +153 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/SOURCES.txt +19 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/dependency_links.txt +1 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/entry_points.txt +2 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/requires.txt +6 -0
- mixforge_audio-0.1.0/src/mixforge_audio.egg-info/top_level.txt +1 -0
- mixforge_audio-0.1.0/tests/conftest.py +59 -0
- mixforge_audio-0.1.0/tests/test_mixforge.py +573 -0
|
@@ -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,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,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)
|