musci 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.
- musci-0.1.0/LICENSE +21 -0
- musci-0.1.0/MANIFEST.in +9 -0
- musci-0.1.0/PKG-INFO +119 -0
- musci-0.1.0/README.md +85 -0
- musci-0.1.0/pyproject.toml +53 -0
- musci-0.1.0/setup.cfg +4 -0
- musci-0.1.0/src/musci/__init__.py +3 -0
- musci-0.1.0/src/musci/analyzer.py +196 -0
- musci-0.1.0/src/musci/cli.py +509 -0
- musci-0.1.0/src/musci/downloader.py +243 -0
- musci-0.1.0/src/musci/models.py +122 -0
- musci-0.1.0/src/musci/sources/__init__.py +44 -0
- musci-0.1.0/src/musci/sources/apple_music.py +376 -0
- musci-0.1.0/src/musci/sources/spotify.py +405 -0
- musci-0.1.0/src/musci/sources/ytdlp_source.py +242 -0
- musci-0.1.0/src/musci/tagger.py +123 -0
- musci-0.1.0/src/musci/ui.py +349 -0
- musci-0.1.0/src/musci.egg-info/PKG-INFO +119 -0
- musci-0.1.0/src/musci.egg-info/SOURCES.txt +24 -0
- musci-0.1.0/src/musci.egg-info/dependency_links.txt +1 -0
- musci-0.1.0/src/musci.egg-info/entry_points.txt +2 -0
- musci-0.1.0/src/musci.egg-info/requires.txt +15 -0
- musci-0.1.0/src/musci.egg-info/top_level.txt +1 -0
- musci-0.1.0/tests/test_analyzer_tagger.py +171 -0
- musci-0.1.0/tests/test_cli.py +141 -0
- musci-0.1.0/tests/test_sources.py +269 -0
musci-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sami Akhnoukh
|
|
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.
|
musci-0.1.0/MANIFEST.in
ADDED
musci-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: musci
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bulk Spotify, Apple Music & YouTube playlist 320kbps MP3 downloader and DJ BPM/Camelot key analyzer
|
|
5
|
+
Author: Sami Akhnoukh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sakhnoukh/musci
|
|
8
|
+
Project-URL: Repository, https://github.com/sakhnoukh/musci
|
|
9
|
+
Keywords: dj,music,mp3,bpm,camelot,spotify,apple-music,playlist
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
16
|
+
Requires-Python: <3.15,>=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: yt-dlp<2027.0.0,>=2025.1.1
|
|
20
|
+
Requires-Dist: yt-dlp-ejs<1.0.0,>=0.3.0
|
|
21
|
+
Requires-Dist: librosa<0.12.0,>=0.10.2
|
|
22
|
+
Requires-Dist: numpy<3.0.0,>=1.26.0
|
|
23
|
+
Requires-Dist: soundfile<0.14.0,>=0.12.1
|
|
24
|
+
Requires-Dist: mutagen<1.49.0,>=1.47.0
|
|
25
|
+
Requires-Dist: httpx<0.29.0,>=0.27.0
|
|
26
|
+
Requires-Dist: typer<0.22.0,>=0.12.0
|
|
27
|
+
Requires-Dist: rich<15.0.0,>=13.7.0
|
|
28
|
+
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest<10.0.0,>=8.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: build<2.0.0,>=1.2.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine<7.0.0,>=5.0.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# musci
|
|
36
|
+
|
|
37
|
+
I wanted to start fucking around with mixing music, and couldn't be asked to download mp3s myself from youtube, so i thought i'd do something about it.
|
|
38
|
+
Could extend this to be like an npm package, but i still don't know if Spotify and Apple apis will let me do that.
|
|
39
|
+
|
|
40
|
+
Bulk playlist MP3 downloader and DJ mixing prep CLI. Supports **Spotify playlists/albums/tracks**, **Apple Music playlists/albums/songs**, **YouTube playlists/videos**, SoundCloud/Bandcamp links, and text tracklists.
|
|
41
|
+
|
|
42
|
+
Downloads audio concurrently as **320kbps CBR MP3s** (constant bitrate to prevent beatgrid drift in Rekordbox, Serato, Traktor, and CDJs) with **BPM + Camelot Wheel key detection** and **ID3v2.3** tagging.
|
|
43
|
+
|
|
44
|
+
## Install (No Code / `.venv` Needed)
|
|
45
|
+
|
|
46
|
+
Install `ffmpeg` + `pipx` once on macOS (Homebrew), then install `musci` globally:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
brew install ffmpeg pipx
|
|
50
|
+
pipx ensurepath
|
|
51
|
+
pipx install git+https://github.com/sakhnoukh/musci.git
|
|
52
|
+
```
|
|
53
|
+
*(Once published to PyPI, this becomes `pipx install musci`.)*
|
|
54
|
+
|
|
55
|
+
## Launch the Interactive Terminal UI
|
|
56
|
+
|
|
57
|
+
Just run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
musci
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`musci` boots with an animated **3D neon logo + spinning DJ turntable/EQ sprite**, system readiness indicators, a command cheat sheet, and an interactive `musci ▸` prompt where you can simply **paste any playlist URL** and hit Enter (or run commands directly from your shell below).
|
|
64
|
+
|
|
65
|
+
### Direct CLI Commands
|
|
66
|
+
|
|
67
|
+
#### 1. Download a Spotify, Apple Music, or YouTube playlist (with BPM & Camelot key detection)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
musci download "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
|
71
|
+
musci download "https://music.apple.com/us/playlist/..."
|
|
72
|
+
musci download "https://www.youtube.com/playlist?list=PL..."
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. Fast download-only mode (skip BPM & key analysis)
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
musci download "https://open.spotify.com/playlist/..." --download-only
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Prefix filenames with `[BPM - Camelot]` for quick folder browsing
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
musci download "https://open.spotify.com/playlist/..." --prefix-filename
|
|
85
|
+
# Produces e.g.: [128 - 8A] Artist - Track Title.mp3
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 4. Batch download from a text file of playlist URLs or `Artist - Title` lines
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
musci download --from-file playlists.txt --workers 6 -o ./downloads
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 5. Analyze an existing folder of MP3s for BPM & Camelot key
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
musci analyze ./downloads/my-playlist --prefix-filename
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Large Spotify Playlists (>100 tracks)
|
|
101
|
+
|
|
102
|
+
Out of the box, `musci` reads public Spotify playlists with **zero configuration** (up to ~100 tracks per playlist via Spotify's public embed metadata).
|
|
103
|
+
|
|
104
|
+
For playlists with **100+ tracks** (using your Spotify Premium account):
|
|
105
|
+
|
|
106
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click **Create app**:
|
|
107
|
+
- **Redirect URI**: `http://127.0.0.1:8888/callback`
|
|
108
|
+
- Check **Web API** and click **Save**.
|
|
109
|
+
2. Run the guided setup wizard (prompts for your Client ID & Secret, stores them securely in `~/.config/musci/.env`, and opens browser login automatically):
|
|
110
|
+
```bash
|
|
111
|
+
musci spotify-setup
|
|
112
|
+
```
|
|
113
|
+
4. **If the 100+ track playlist was created by someone else**: Under Spotify's 2026 Developer Mode rules, the API only returns full 100+ item lists for playlists you own or collaborate on. In Spotify Desktop, open the playlist, click `...` -> **Add to other playlist** -> **New playlist** (takes 2 seconds), and pass your new playlist's link to `musci download`.
|
|
114
|
+
|
|
115
|
+
## Notes for DJ Mixing
|
|
116
|
+
|
|
117
|
+
- **320kbps CBR MP3**: All downloads are encoded using `libmp3lame` at 320kbps constant bitrate with ID3v2.3 tags (`TBPM`, `TKEY`, `TIT2`, `TPE1`, `TALB`, cover art) for reliable beatgrids across Rekordbox, Serato, VirtualDJ, Traktor, and Mixxx.
|
|
118
|
+
- **Source Audio Quality**: YouTube native audio streams top out around 128–160kbps Opus/AAC. While 320kbps CBR MP3 packaging works well for bedroom mixing, set prep, and casual gigs, large club sound systems benefit from lossless/DJ-pool masters.
|
|
119
|
+
- **Incremental Sync**: Each output folder stores `.musci-manifest.json` so re-running `musci download` on an updated playlist skips tracks you already downloaded.
|
musci-0.1.0/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# musci
|
|
2
|
+
|
|
3
|
+
I wanted to start fucking around with mixing music, and couldn't be asked to download mp3s myself from youtube, so i thought i'd do something about it.
|
|
4
|
+
Could extend this to be like an npm package, but i still don't know if Spotify and Apple apis will let me do that.
|
|
5
|
+
|
|
6
|
+
Bulk playlist MP3 downloader and DJ mixing prep CLI. Supports **Spotify playlists/albums/tracks**, **Apple Music playlists/albums/songs**, **YouTube playlists/videos**, SoundCloud/Bandcamp links, and text tracklists.
|
|
7
|
+
|
|
8
|
+
Downloads audio concurrently as **320kbps CBR MP3s** (constant bitrate to prevent beatgrid drift in Rekordbox, Serato, Traktor, and CDJs) with **BPM + Camelot Wheel key detection** and **ID3v2.3** tagging.
|
|
9
|
+
|
|
10
|
+
## Install (No Code / `.venv` Needed)
|
|
11
|
+
|
|
12
|
+
Install `ffmpeg` + `pipx` once on macOS (Homebrew), then install `musci` globally:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
brew install ffmpeg pipx
|
|
16
|
+
pipx ensurepath
|
|
17
|
+
pipx install git+https://github.com/sakhnoukh/musci.git
|
|
18
|
+
```
|
|
19
|
+
*(Once published to PyPI, this becomes `pipx install musci`.)*
|
|
20
|
+
|
|
21
|
+
## Launch the Interactive Terminal UI
|
|
22
|
+
|
|
23
|
+
Just run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
musci
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`musci` boots with an animated **3D neon logo + spinning DJ turntable/EQ sprite**, system readiness indicators, a command cheat sheet, and an interactive `musci ▸` prompt where you can simply **paste any playlist URL** and hit Enter (or run commands directly from your shell below).
|
|
30
|
+
|
|
31
|
+
### Direct CLI Commands
|
|
32
|
+
|
|
33
|
+
#### 1. Download a Spotify, Apple Music, or YouTube playlist (with BPM & Camelot key detection)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
musci download "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
|
37
|
+
musci download "https://music.apple.com/us/playlist/..."
|
|
38
|
+
musci download "https://www.youtube.com/playlist?list=PL..."
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Fast download-only mode (skip BPM & key analysis)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
musci download "https://open.spotify.com/playlist/..." --download-only
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. Prefix filenames with `[BPM - Camelot]` for quick folder browsing
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
musci download "https://open.spotify.com/playlist/..." --prefix-filename
|
|
51
|
+
# Produces e.g.: [128 - 8A] Artist - Track Title.mp3
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 4. Batch download from a text file of playlist URLs or `Artist - Title` lines
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
musci download --from-file playlists.txt --workers 6 -o ./downloads
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 5. Analyze an existing folder of MP3s for BPM & Camelot key
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
musci analyze ./downloads/my-playlist --prefix-filename
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Large Spotify Playlists (>100 tracks)
|
|
67
|
+
|
|
68
|
+
Out of the box, `musci` reads public Spotify playlists with **zero configuration** (up to ~100 tracks per playlist via Spotify's public embed metadata).
|
|
69
|
+
|
|
70
|
+
For playlists with **100+ tracks** (using your Spotify Premium account):
|
|
71
|
+
|
|
72
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click **Create app**:
|
|
73
|
+
- **Redirect URI**: `http://127.0.0.1:8888/callback`
|
|
74
|
+
- Check **Web API** and click **Save**.
|
|
75
|
+
2. Run the guided setup wizard (prompts for your Client ID & Secret, stores them securely in `~/.config/musci/.env`, and opens browser login automatically):
|
|
76
|
+
```bash
|
|
77
|
+
musci spotify-setup
|
|
78
|
+
```
|
|
79
|
+
4. **If the 100+ track playlist was created by someone else**: Under Spotify's 2026 Developer Mode rules, the API only returns full 100+ item lists for playlists you own or collaborate on. In Spotify Desktop, open the playlist, click `...` -> **Add to other playlist** -> **New playlist** (takes 2 seconds), and pass your new playlist's link to `musci download`.
|
|
80
|
+
|
|
81
|
+
## Notes for DJ Mixing
|
|
82
|
+
|
|
83
|
+
- **320kbps CBR MP3**: All downloads are encoded using `libmp3lame` at 320kbps constant bitrate with ID3v2.3 tags (`TBPM`, `TKEY`, `TIT2`, `TPE1`, `TALB`, cover art) for reliable beatgrids across Rekordbox, Serato, VirtualDJ, Traktor, and Mixxx.
|
|
84
|
+
- **Source Audio Quality**: YouTube native audio streams top out around 128–160kbps Opus/AAC. While 320kbps CBR MP3 packaging works well for bedroom mixing, set prep, and casual gigs, large club sound systems benefit from lossless/DJ-pool masters.
|
|
85
|
+
- **Incremental Sync**: Each output folder stores `.musci-manifest.json` so re-running `musci download` on an updated playlist skips tracks you already downloaded.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0,<85.0", "wheel>=0.40,<1.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "musci"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bulk Spotify, Apple Music & YouTube playlist 320kbps MP3 downloader and DJ BPM/Camelot key analyzer"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{name = "Sami Akhnoukh"}]
|
|
12
|
+
requires-python = ">=3.11,<3.15"
|
|
13
|
+
keywords = ["dj", "music", "mp3", "bpm", "camelot", "spotify", "apple-music", "playlist"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"yt-dlp>=2025.1.1,<2027.0.0",
|
|
24
|
+
"yt-dlp-ejs>=0.3.0,<1.0.0",
|
|
25
|
+
"librosa>=0.10.2,<0.12.0",
|
|
26
|
+
"numpy>=1.26.0,<3.0.0",
|
|
27
|
+
"soundfile>=0.12.1,<0.14.0",
|
|
28
|
+
"mutagen>=1.47.0,<1.49.0",
|
|
29
|
+
"httpx>=0.27.0,<0.29.0",
|
|
30
|
+
"typer>=0.12.0,<0.22.0",
|
|
31
|
+
"rich>=13.7.0,<15.0.0",
|
|
32
|
+
"python-dotenv>=1.0.1,<2.0.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0.0,<10.0.0",
|
|
38
|
+
"build>=1.2.0,<2.0.0",
|
|
39
|
+
"twine>=5.0.0,<7.0.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/sakhnoukh/musci"
|
|
44
|
+
Repository = "https://github.com/sakhnoukh/musci"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
musci = "musci.cli:app"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
musci-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Audio BPM and Camelot Wheel key detection using librosa."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import librosa
|
|
10
|
+
from mutagen import File as MutagenFile
|
|
11
|
+
import numpy as np
|
|
12
|
+
import soundfile as sf
|
|
13
|
+
|
|
14
|
+
from musci.models import CAMELOT_MAP, AnalysisResult
|
|
15
|
+
|
|
16
|
+
SAMPLE_RATE = 22050
|
|
17
|
+
MAX_ANALYSIS_WINDOW_SEC = 90.0
|
|
18
|
+
|
|
19
|
+
# Krumhansl-Kessler pitch-class key profiles (C, C#, D, ..., B)
|
|
20
|
+
KRUMHANSL_MAJOR = np.array(
|
|
21
|
+
[6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88],
|
|
22
|
+
dtype=np.float64,
|
|
23
|
+
)
|
|
24
|
+
KRUMHANSL_MINOR = np.array(
|
|
25
|
+
[6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17],
|
|
26
|
+
dtype=np.float64,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fold_bpm_into_range(bpm: float, min_bpm: float = 70.0, max_bpm: float = 180.0) -> float:
|
|
31
|
+
"""Fold half-time or double-time tempo estimates into [min_bpm, max_bpm]."""
|
|
32
|
+
if bpm <= 0 or min_bpm <= 0 or max_bpm <= min_bpm:
|
|
33
|
+
return max(0.0, bpm)
|
|
34
|
+
adjusted = float(bpm)
|
|
35
|
+
while adjusted < min_bpm:
|
|
36
|
+
adjusted *= 2.0
|
|
37
|
+
while adjusted > max_bpm:
|
|
38
|
+
adjusted /= 2.0
|
|
39
|
+
return round(adjusted, 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_bpm_from_samples(
|
|
43
|
+
y: np.ndarray,
|
|
44
|
+
sr: int = SAMPLE_RATE,
|
|
45
|
+
min_bpm: float = 70.0,
|
|
46
|
+
max_bpm: float = 180.0,
|
|
47
|
+
) -> float:
|
|
48
|
+
"""Estimate BPM from a mono waveform with sub-BPM beat-interval refinement."""
|
|
49
|
+
hop_length = 256
|
|
50
|
+
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
|
|
51
|
+
start_bpm = max(min_bpm, min(max_bpm, 124.0))
|
|
52
|
+
tempo, beat_frames = librosa.beat.beat_track(
|
|
53
|
+
onset_envelope=onset_env,
|
|
54
|
+
sr=sr,
|
|
55
|
+
hop_length=hop_length,
|
|
56
|
+
start_bpm=start_bpm,
|
|
57
|
+
)
|
|
58
|
+
raw_bpm = float(np.asarray(tempo).flat[0])
|
|
59
|
+
|
|
60
|
+
# Refine integer-frame lag quantization using tracked inter-beat intervals
|
|
61
|
+
if len(beat_frames) >= 5:
|
|
62
|
+
beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=hop_length)
|
|
63
|
+
ibis = np.diff(beat_times)
|
|
64
|
+
median_ibi = float(np.median(ibis))
|
|
65
|
+
if median_ibi > 0:
|
|
66
|
+
inliers = ibis[np.abs(ibis - median_ibi) <= 0.12 * median_ibi]
|
|
67
|
+
if len(inliers) >= 3:
|
|
68
|
+
raw_bpm = 60.0 / float(np.mean(inliers))
|
|
69
|
+
|
|
70
|
+
if raw_bpm <= 0.0:
|
|
71
|
+
fallback = librosa.feature.rhythm.tempo(
|
|
72
|
+
onset_envelope=onset_env,
|
|
73
|
+
sr=sr,
|
|
74
|
+
hop_length=hop_length,
|
|
75
|
+
start_bpm=start_bpm,
|
|
76
|
+
)
|
|
77
|
+
raw_bpm = float(np.asarray(fallback).flat[0])
|
|
78
|
+
return fold_bpm_into_range(raw_bpm, min_bpm=min_bpm, max_bpm=max_bpm)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pearson_corr(a: np.ndarray, b: np.ndarray) -> float:
|
|
82
|
+
a_centered = a - np.mean(a)
|
|
83
|
+
b_centered = b - np.mean(b)
|
|
84
|
+
denom = float(np.linalg.norm(a_centered) * np.linalg.norm(b_centered))
|
|
85
|
+
if denom < 1e-12:
|
|
86
|
+
return 0.0
|
|
87
|
+
return float(np.dot(a_centered, b_centered) / denom)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_key_from_samples(y: np.ndarray, sr: int = SAMPLE_RATE) -> tuple[str, str, float]:
|
|
91
|
+
"""Detect (camelot_key, musical_key, confidence) using spectrogram-domain HPSS chromagram."""
|
|
92
|
+
stft_mag = np.abs(librosa.stft(y=y, n_fft=4096, hop_length=2048))
|
|
93
|
+
harm_mag, _ = librosa.decompose.hpss(stft_mag, kernel_size=15, margin=2.0)
|
|
94
|
+
chroma = librosa.feature.chroma_stft(S=harm_mag**2, sr=sr, n_fft=4096)
|
|
95
|
+
chroma_vec = np.mean(chroma, axis=1)
|
|
96
|
+
|
|
97
|
+
if float(np.max(chroma_vec)) < 1e-8:
|
|
98
|
+
camelot, musical = CAMELOT_MAP[(9, "minor")]
|
|
99
|
+
return camelot, musical, 0.0
|
|
100
|
+
|
|
101
|
+
best_corr = -2.0
|
|
102
|
+
best_key_pair = (9, "minor")
|
|
103
|
+
|
|
104
|
+
for pitch_class in range(12):
|
|
105
|
+
maj_corr = _pearson_corr(chroma_vec, np.roll(KRUMHANSL_MAJOR, pitch_class))
|
|
106
|
+
if maj_corr > best_corr:
|
|
107
|
+
best_corr = maj_corr
|
|
108
|
+
best_key_pair = (pitch_class, "major")
|
|
109
|
+
|
|
110
|
+
min_corr = _pearson_corr(chroma_vec, np.roll(KRUMHANSL_MINOR, pitch_class))
|
|
111
|
+
if min_corr > best_corr:
|
|
112
|
+
best_corr = min_corr
|
|
113
|
+
best_key_pair = (pitch_class, "minor")
|
|
114
|
+
|
|
115
|
+
camelot_key, musical_key = CAMELOT_MAP[best_key_pair]
|
|
116
|
+
confidence = round(max(0.0, min(1.0, best_corr)), 3)
|
|
117
|
+
return camelot_key, musical_key, confidence
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _probe_duration_sec(audio_path: Path) -> float:
|
|
121
|
+
meta = MutagenFile(str(audio_path))
|
|
122
|
+
if meta is not None and getattr(meta, "info", None) is not None and getattr(meta.info, "length", 0):
|
|
123
|
+
return float(meta.info.length)
|
|
124
|
+
try:
|
|
125
|
+
return float(sf.info(str(audio_path)).duration)
|
|
126
|
+
except Exception:
|
|
127
|
+
return float(librosa.get_duration(path=str(audio_path)))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _choose_analysis_window(audio_path: Path, max_window_sec: float = MAX_ANALYSIS_WINDOW_SEC) -> tuple[float, float | None]:
|
|
131
|
+
total_sec = _probe_duration_sec(audio_path)
|
|
132
|
+
if total_sec <= max_window_sec:
|
|
133
|
+
return 0.0, None
|
|
134
|
+
# Sample from the middle of the track to avoid beatless DJ intros/outros
|
|
135
|
+
offset = max(0.0, (total_sec - max_window_sec) / 2.0)
|
|
136
|
+
return round(offset, 2), max_window_sec
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_audio_window(audio_path: Path, offset: float, duration: float | None) -> tuple[np.ndarray, int]:
|
|
140
|
+
ffmpeg_bin = shutil.which("ffmpeg")
|
|
141
|
+
if ffmpeg_bin:
|
|
142
|
+
cmd = [ffmpeg_bin, "-v", "error"]
|
|
143
|
+
if offset > 0:
|
|
144
|
+
cmd.extend(["-ss", str(offset)])
|
|
145
|
+
if duration is not None:
|
|
146
|
+
cmd.extend(["-t", str(duration)])
|
|
147
|
+
cmd.extend(
|
|
148
|
+
[
|
|
149
|
+
"-i",
|
|
150
|
+
str(audio_path),
|
|
151
|
+
"-vn",
|
|
152
|
+
"-ac",
|
|
153
|
+
"1",
|
|
154
|
+
"-ar",
|
|
155
|
+
str(SAMPLE_RATE),
|
|
156
|
+
"-f",
|
|
157
|
+
"f32le",
|
|
158
|
+
"pipe:1",
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
proc = subprocess.run(cmd, capture_output=True, check=False)
|
|
162
|
+
if proc.returncode == 0 and proc.stdout:
|
|
163
|
+
samples = np.frombuffer(proc.stdout, dtype=np.float32).copy()
|
|
164
|
+
return samples, SAMPLE_RATE
|
|
165
|
+
|
|
166
|
+
y, sr = librosa.load(
|
|
167
|
+
str(audio_path),
|
|
168
|
+
sr=SAMPLE_RATE,
|
|
169
|
+
mono=True,
|
|
170
|
+
offset=offset,
|
|
171
|
+
duration=duration,
|
|
172
|
+
)
|
|
173
|
+
return y, int(sr)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def analyze_audio_file(
|
|
177
|
+
audio_path: Path,
|
|
178
|
+
min_bpm: float = 70.0,
|
|
179
|
+
max_bpm: float = 180.0,
|
|
180
|
+
max_window_sec: float = MAX_ANALYSIS_WINDOW_SEC,
|
|
181
|
+
) -> AnalysisResult:
|
|
182
|
+
"""Load representative window of an audio file and compute BPM & Camelot/musical key."""
|
|
183
|
+
offset, duration = _choose_analysis_window(audio_path, max_window_sec=max_window_sec)
|
|
184
|
+
y, sr = _load_audio_window(audio_path, offset=offset, duration=duration)
|
|
185
|
+
if y.size < SAMPLE_RATE or float(np.max(np.abs(y))) < 1e-6:
|
|
186
|
+
raise ValueError(f"Audio file '{audio_path.name}' is too short or silent to analyze.")
|
|
187
|
+
|
|
188
|
+
bpm = detect_bpm_from_samples(y, sr=sr, min_bpm=min_bpm, max_bpm=max_bpm)
|
|
189
|
+
camelot_key, musical_key, confidence = detect_key_from_samples(y, sr=sr)
|
|
190
|
+
return AnalysisResult(
|
|
191
|
+
bpm=bpm,
|
|
192
|
+
bpm_rounded=int(round(bpm)),
|
|
193
|
+
camelot_key=camelot_key,
|
|
194
|
+
musical_key=musical_key,
|
|
195
|
+
key_confidence=confidence,
|
|
196
|
+
)
|