Skip to content

Commit ad0de65

Browse files
committed
add cache and watcher and librosa
1 parent 519efc8 commit ad0de65

File tree

7 files changed

+293
-78
lines changed

7 files changed

+293
-78
lines changed

pyproject.toml

Lines changed: 22 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,62 @@
11
[build-system]
2-
requires = ["setuptools>=61.0", "wheel"]
3-
build-backend = "setuptools.build_meta"
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
44

55
[project]
6-
name = "music-recognition"
6+
name = "music-recognition-tool"
77
version = "1.0.0"
8-
description = "Identify unknown music files using Shazam, write ID3 tags, rename and organize"
8+
description = "Identify music files using Shazam, write ID3 tags, rename and organize"
99
readme = "README.md"
10-
license = {text = "MIT"}
10+
license = "MIT"
1111
authors = [
12-
{name = "formeo", email = "formeo@example.com"}
13-
]
14-
keywords = [
15-
"music",
16-
"recognition",
17-
"shazam",
18-
"id3",
19-
"tags",
20-
"mp3",
21-
"audio",
22-
"fingerprinting",
23-
"organize",
24-
"rename",
12+
{ name = "Roman Gordienko", email = "grlformeo@gmail.com" }
2513
]
14+
keywords = ["music", "recognition", "shazam", "id3", "mp3", "tags"]
2615
classifiers = [
2716
"Development Status :: 4 - Beta",
2817
"Environment :: Console",
2918
"Intended Audience :: End Users/Desktop",
30-
"Intended Audience :: Developers",
3119
"License :: OSI Approved :: MIT License",
3220
"Operating System :: OS Independent",
3321
"Programming Language :: Python :: 3",
34-
"Programming Language :: Python :: 3.9",
3522
"Programming Language :: Python :: 3.10",
3623
"Programming Language :: Python :: 3.11",
3724
"Programming Language :: Python :: 3.12",
3825
"Topic :: Multimedia :: Sound/Audio",
3926
"Topic :: Multimedia :: Sound/Audio :: Analysis",
4027
]
41-
requires-python = ">=3.9"
28+
requires-python = ">=3.10"
4229
dependencies = [
4330
"shazamio>=0.6.0",
4431
"mutagen>=1.47.0",
4532
"pydub>=0.25.1",
46-
"aiohttp>=3.9.0",
47-
"aiofiles>=23.0.0",
4833
]
4934

5035
[project.optional-dependencies]
36+
analyze = ["librosa>=0.10.0", "numpy>=1.24.0"]
5137
dev = [
5238
"pytest>=7.0.0",
5339
"pytest-asyncio>=0.21.0",
54-
"pytest-cov>=4.0.0",
55-
"black>=23.0.0",
56-
"isort>=5.12.0",
40+
"ruff>=0.1.0",
5741
"mypy>=1.0.0",
58-
"flake8>=6.0.0",
5942
]
6043

6144
[project.scripts]
6245
music-recognize = "music_recognition.cli:main"
6346

6447
[project.urls]
6548
Homepage = "https://github.com/formeo/music_recognition"
66-
Documentation = "https://github.com/formeo/music_recognition#readme"
6749
Repository = "https://github.com/formeo/music_recognition"
6850
Issues = "https://github.com/formeo/music_recognition/issues"
69-
70-
[tool.setuptools.packages.find]
71-
where = ["src"]
72-
73-
[tool.pytest.ini_options]
74-
asyncio_mode = "auto"
75-
testpaths = ["tests"]
76-
python_files = ["test_*.py"]
77-
78-
[tool.black]
79-
line-length = 100
80-
target-version = ['py39', 'py310', 'py311', 'py312']
81-
82-
[tool.isort]
83-
profile = "black"
84-
line_length = 100
85-
src_paths = ["src"]
86-
87-
[tool.mypy]
88-
python_version = "3.9"
89-
warn_return_any = true
90-
warn_unused_configs = true
91-
ignore_missing_imports = true
51+
Changelog = "https://github.com/formeo/music_recognition/blob/main/CHANGELOG.md"
52+
53+
[tool.hatch.build.targets.wheel]
54+
packages = ["src/music_recognition"]
55+
56+
[tool.hatch.build.targets.sdist]
57+
include = [
58+
"/src",
59+
"/tests",
60+
"/README.md",
61+
"/LICENSE",
62+
]

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ mutagen>=1.47.0
44
pydub>=0.25.1
55
aiohttp>=3.9.0
66
aiofiles>=23.0.0
7+
librosa==0.11.0
8+
watchdog==6.0.0

src/music_recognition/__init__.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
"""
2-
Music Recognition - Identify, tag, rename and organize your music collection.
1+
# src/music_recognition/__init__.py
2+
"""Music Recognition - Identify, tag, rename and organize music files."""
33

4-
Usage:
5-
from music_recognition import MusicRecognizer
6-
7-
recognizer = MusicRecognizer()
8-
stats = await recognizer.process_directory("/music", rename=True)
9-
"""
4+
__version__ = "1.1.0"
5+
__author__ = "formeo"
106

117
from .core import (
128
MusicRecognizer,
@@ -15,17 +11,14 @@
1511
ProcessingStats,
1612
recognize_and_tag,
1713
setup_logging,
18-
__version__,
19-
__author__,
2014
)
2115

2216
__all__ = [
23-
'MusicRecognizer',
24-
'TrackInfo',
25-
'ProcessingResult',
26-
'ProcessingStats',
27-
'recognize_and_tag',
28-
'setup_logging',
29-
'__version__',
30-
'__author__',
31-
]
17+
"MusicRecognizer",
18+
"TrackInfo",
19+
"ProcessingResult",
20+
"ProcessingStats",
21+
"recognize_and_tag",
22+
"setup_logging",
23+
"__version__",
24+
]

src/music_recognition/analyzer.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
3+
import numpy as np
4+
5+
6+
@dataclass
7+
class AudioAnalysis:
8+
"""Audio analysis results."""
9+
bpm: float = 0.0
10+
key: str = ""
11+
energy: float = 0.0
12+
duration: float = 0.0
13+
loudness_db: float = 0.0
14+
15+
def to_comment(self) -> str:
16+
"""Format as ID3 comment."""
17+
return f"BPM: {self.bpm:.0f} | Key: {self.key} | Energy: {self.energy:.2f}"
18+
19+
20+
class AudioAnalyzer:
21+
"""Analyze audio characteristics using librosa."""
22+
23+
KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
24+
MODE_NAMES = ['minor', 'major']
25+
26+
def __init__(self):
27+
try:
28+
import librosa
29+
self.librosa = librosa
30+
except ImportError:
31+
raise ImportError("librosa is required: pip install librosa")
32+
33+
def analyze(self, file_path: str) -> AudioAnalysis:
34+
"""Perform full audio analysis."""
35+
y, sr = self.librosa.load(file_path, sr=22050, duration=60) # First 60 sec
36+
37+
return AudioAnalysis(
38+
bpm=self._detect_bpm(y, sr),
39+
key=self._detect_key(y, sr),
40+
energy=self._calculate_energy(y),
41+
duration=self.librosa.get_duration(y=y, sr=sr),
42+
loudness_db=self._calculate_loudness(y),
43+
)
44+
45+
def _detect_bpm(self, y, sr) -> float:
46+
"""Detect tempo in BPM."""
47+
tempo, _ = self.librosa.beat.beat_track(y=y, sr=sr)
48+
return float(tempo[0]) if hasattr(tempo, '__iter__') else float(tempo)
49+
50+
def _detect_key(self, y, sr) -> str:
51+
"""Detect musical key."""
52+
# Compute chromagram
53+
chroma = self.librosa.feature.chroma_cqt(y=y, sr=sr)
54+
chroma_mean = np.mean(chroma, axis=1)
55+
56+
# Find dominant pitch class
57+
key_idx = int(np.argmax(chroma_mean))
58+
59+
# Simple major/minor detection based on intervals
60+
# (more sophisticated methods exist)
61+
return f"{self.KEY_NAMES[key_idx]}"
62+
63+
def _calculate_energy(self, y) -> float:
64+
"""Calculate average RMS energy."""
65+
rms = self.librosa.feature.rms(y=y)
66+
return float(np.mean(rms))
67+
68+
def _calculate_loudness(self, y) -> float:
69+
"""Calculate loudness in dB."""
70+
rms = np.sqrt(np.mean(y ** 2))
71+
return float(20 * np.log10(rms + 1e-10))

src/music_recognition/cache.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# В начало файла
2+
import hashlib
3+
import sqlite3
4+
from contextlib import contextmanager
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
from music_recognition import TrackInfo
9+
10+
11+
class RecognitionCache:
12+
"""Cache for recognition results to avoid duplicate API calls."""
13+
14+
def __init__(self, db_path: str = "~/.music_recognition/cache.db"):
15+
self.db_path = Path(db_path).expanduser()
16+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
17+
self._init_db()
18+
19+
def _init_db(self):
20+
with self._connect() as conn:
21+
conn.execute("""
22+
CREATE TABLE IF NOT EXISTS cache (
23+
audio_hash TEXT PRIMARY KEY,
24+
track_info TEXT NOT NULL,
25+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
26+
)
27+
""")
28+
conn.execute("""
29+
CREATE INDEX IF NOT EXISTS idx_created_at ON cache(created_at)
30+
""")
31+
32+
@contextmanager
33+
def _connect(self):
34+
conn = sqlite3.connect(self.db_path)
35+
try:
36+
yield conn
37+
conn.commit()
38+
finally:
39+
conn.close()
40+
41+
def get(self, file_path: str) -> Optional[TrackInfo]:
42+
"""Get cached result by file hash."""
43+
audio_hash = self._hash_audio(file_path)
44+
with self._connect() as conn:
45+
row = conn.execute(
46+
"SELECT track_info FROM cache WHERE audio_hash = ?",
47+
(audio_hash,)
48+
).fetchone()
49+
50+
if row:
51+
import json
52+
data = json.loads(row[0])
53+
return TrackInfo(**data)
54+
return None
55+
56+
def set(self, file_path: str, info: TrackInfo):
57+
"""Cache recognition result."""
58+
import json
59+
audio_hash = self._hash_audio(file_path)
60+
with self._connect() as conn:
61+
conn.execute(
62+
"INSERT OR REPLACE INTO cache (audio_hash, track_info) VALUES (?, ?)",
63+
(audio_hash, json.dumps(info.to_dict()))
64+
)
65+
66+
def clear(self, older_than_days: int = 30):
67+
"""Remove old cache entries."""
68+
with self._connect() as conn:
69+
conn.execute(
70+
"DELETE FROM cache WHERE created_at < datetime('now', ?)",
71+
(f'-{older_than_days} days',)
72+
)
73+
74+
@staticmethod
75+
def _hash_audio(file_path: str, chunk_size: int = 1024 * 1024) -> str:
76+
"""Hash first ~1MB of audio file (enough for fingerprint)."""
77+
hasher = hashlib.sha256()
78+
with open(file_path, 'rb') as f:
79+
hasher.update(f.read(chunk_size))
80+
return hasher.hexdigest()

src/music_recognition/core.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,12 @@
2525
from pydub import AudioSegment
2626
from shazamio import Shazam
2727

28-
__version__ = "1.0.0"
28+
__version__ = "1.1.0"
2929
__author__ = "formeo"
3030

31-
logger = logging.getLogger(__name__)
32-
31+
from music_recognition.cache import RecognitionCache
3332

34-
# ============================================================
35-
# Data Classes
36-
# ============================================================
33+
logger = logging.getLogger(__name__)
3734

3835
@dataclass
3936
class TrackInfo:
@@ -149,6 +146,8 @@ def __init__(
149146
max_concurrent: int = 5,
150147
delay_between_requests: float = 0.5,
151148
retry_attempts: int = 2,
149+
use_cache: bool = True,
150+
cache_path: Optional[str] = None,
152151
):
153152
"""
154153
Initialize the recognizer.
@@ -162,10 +161,26 @@ def __init__(
162161
self.semaphore = asyncio.Semaphore(max_concurrent)
163162
self.delay = delay_between_requests
164163
self.retry_attempts = retry_attempts
164+
self.cache = RecognitionCache(cache_path) if use_cache else None
165165

166166
# ==================== Recognition ====================
167-
168-
async def recognize_file(self, file_path: str) -> TrackInfo:
167+
168+
async def recognize_file(self, file_path: str, skip_cache: bool = False) -> TrackInfo:
169+
"""Recognize with cache support."""
170+
if self.cache and not skip_cache:
171+
cached = self.cache.get(file_path)
172+
if cached:
173+
logger.debug(f"Cache hit: {file_path}")
174+
return cached
175+
176+
info = await self._recognize_shazam(file_path)
177+
178+
if self.cache and info.is_recognized:
179+
self.cache.set(file_path, info)
180+
181+
return info
182+
183+
async def _recognize_shazam(self, file_path: str) -> TrackInfo:
169184
"""
170185
Recognize a single audio file using Shazam.
171186

0 commit comments

Comments
 (0)