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 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.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ prune downloads
4
+ prune .venv
5
+ prune {{pkgetc}}
6
+ global-exclude .env
7
+ global-exclude .env.*
8
+ global-exclude *.mp3
9
+ global-exclude *.py[cod]
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """musci: Bulk playlist MP3 downloader and DJ BPM/key analyzer."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ )