refmatch 0.1.0__py3-none-any.whl
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.
- refmatch/__init__.py +3 -0
- refmatch/cli.py +239 -0
- refmatch/data/seed/metadata.json +1402 -0
- refmatch/data/seed/vectors.npy +0 -0
- refmatch/database.py +284 -0
- refmatch/display.py +119 -0
- refmatch/features.py +249 -0
- refmatch/matcher.py +140 -0
- refmatch-0.1.0.dist-info/METADATA +111 -0
- refmatch-0.1.0.dist-info/RECORD +13 -0
- refmatch-0.1.0.dist-info/WHEEL +4 -0
- refmatch-0.1.0.dist-info/entry_points.txt +2 -0
- refmatch-0.1.0.dist-info/licenses/LICENSE +21 -0
refmatch/__init__.py
ADDED
refmatch/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""RefMatch CLI — reference track suggestions for music producers."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from refmatch import __version__
|
|
10
|
+
from refmatch.database import TrackDatabase
|
|
11
|
+
from refmatch.display import display_analysis, display_library_stats, display_matches
|
|
12
|
+
from refmatch.features import DIMENSIONS, extract_features
|
|
13
|
+
from refmatch.matcher import find_matches
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Default paths
|
|
18
|
+
_DATA_DIR = Path.home() / ".refmatch"
|
|
19
|
+
_DB_PATH = _DATA_DIR / "library.db"
|
|
20
|
+
_SEED_DIR = Path(__file__).parent / "data" / "seed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_db() -> TrackDatabase:
|
|
24
|
+
"""Get or create the track database."""
|
|
25
|
+
return TrackDatabase(_DB_PATH)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ensure_seed_loaded(db: TrackDatabase) -> None:
|
|
29
|
+
"""Load seed database if not already loaded."""
|
|
30
|
+
if db.get_track_count(source="seed") > 0:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
seed_path = _SEED_DIR
|
|
34
|
+
if seed_path.exists() and (seed_path / "metadata.json").exists():
|
|
35
|
+
try:
|
|
36
|
+
count = db.import_seed(seed_path)
|
|
37
|
+
console.print(f"[dim]Loaded {count} seed tracks[/dim]")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
console.print(f"[yellow]Could not load seed database: {e}[/yellow]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group()
|
|
43
|
+
@click.version_option(version=__version__, prog_name="refmatch")
|
|
44
|
+
def main() -> None:
|
|
45
|
+
"""RefMatch — AI-powered reference track suggestions for music producers.
|
|
46
|
+
|
|
47
|
+
Analyze your track and find reference tracks that match its sonic characteristics.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
53
|
+
def analyze(file: str) -> None:
|
|
54
|
+
"""Analyze an audio file and display its features.
|
|
55
|
+
|
|
56
|
+
Extracts BPM, key, loudness, spectral balance, and more.
|
|
57
|
+
"""
|
|
58
|
+
path = Path(file)
|
|
59
|
+
console.print(f"[dim]Analyzing {path.name}...[/dim]")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
features = extract_features(str(path))
|
|
63
|
+
except ValueError as e:
|
|
64
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
console.print(f"[red]Could not process audio file: {e}[/red]")
|
|
68
|
+
console.print("[dim]Supported formats: WAV, MP3, FLAC, OGG, AIFF[/dim]")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
display_analysis(features, path.name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@main.command()
|
|
75
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
76
|
+
@click.option(
|
|
77
|
+
"-n", "--top", default=5, type=click.IntRange(min=1, max=50),
|
|
78
|
+
help="Number of matches to return", show_default=True,
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"-d",
|
|
82
|
+
"--dimension",
|
|
83
|
+
type=click.Choice(list(DIMENSIONS.keys())),
|
|
84
|
+
default=None,
|
|
85
|
+
help="Match on a specific sonic dimension",
|
|
86
|
+
)
|
|
87
|
+
@click.option("--library", is_flag=True, help="Match against your own library only")
|
|
88
|
+
def match(file: str, top: int, dimension: str | None, library: bool) -> None:
|
|
89
|
+
"""Find reference tracks that match your audio file.
|
|
90
|
+
|
|
91
|
+
Analyzes the input file and searches the database for the most similar tracks.
|
|
92
|
+
"""
|
|
93
|
+
path = Path(file)
|
|
94
|
+
console.print(f"[dim]Analyzing {path.name}...[/dim]")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
features = extract_features(str(path))
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
console.print(f"[red]Could not process audio file: {e}[/red]")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
db = _get_db()
|
|
106
|
+
_ensure_seed_loaded(db)
|
|
107
|
+
|
|
108
|
+
source = "user" if library else None
|
|
109
|
+
total = db.get_track_count(source)
|
|
110
|
+
|
|
111
|
+
if total == 0:
|
|
112
|
+
if library:
|
|
113
|
+
console.print(
|
|
114
|
+
"[yellow]Your library is empty. "
|
|
115
|
+
"Add tracks with: refmatch library add <file>[/yellow]"
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
console.print(
|
|
119
|
+
"[yellow]No tracks in database. "
|
|
120
|
+
"Add tracks or install the seed database.[/yellow]"
|
|
121
|
+
)
|
|
122
|
+
db.close()
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
console.print(f"[dim]Searching {total} tracks...[/dim]")
|
|
126
|
+
results = find_matches(features, db, top_k=top, dimension=dimension, source=source)
|
|
127
|
+
display_matches(results, path.name, dimension)
|
|
128
|
+
db.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@main.group()
|
|
132
|
+
def library() -> None:
|
|
133
|
+
"""Manage your reference track library."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@library.command("add")
|
|
137
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
138
|
+
@click.option("--artist", default="Unknown", help="Artist name")
|
|
139
|
+
@click.option("--genre", default="Unknown", help="Genre tag")
|
|
140
|
+
def library_add(path: str, artist: str, genre: str) -> None:
|
|
141
|
+
"""Add a track or directory of tracks to your library."""
|
|
142
|
+
p = Path(path)
|
|
143
|
+
db = _get_db()
|
|
144
|
+
|
|
145
|
+
audio_extensions = {".wav", ".mp3", ".flac", ".ogg", ".aiff", ".aif", ".m4a"}
|
|
146
|
+
|
|
147
|
+
if p.is_file():
|
|
148
|
+
files = [p]
|
|
149
|
+
elif p.is_dir():
|
|
150
|
+
files = [f for f in p.rglob("*") if f.suffix.lower() in audio_extensions]
|
|
151
|
+
if not files:
|
|
152
|
+
console.print(f"[yellow]No audio files found in {p}[/yellow]")
|
|
153
|
+
db.close()
|
|
154
|
+
return
|
|
155
|
+
console.print(f"[dim]Found {len(files)} audio files[/dim]")
|
|
156
|
+
else:
|
|
157
|
+
console.print(f"[red]Not a file or directory: {p}[/red]")
|
|
158
|
+
db.close()
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
added = 0
|
|
162
|
+
skipped = 0
|
|
163
|
+
|
|
164
|
+
for audio_file in files:
|
|
165
|
+
abs_path = str(audio_file.resolve())
|
|
166
|
+
if db.has_path(abs_path):
|
|
167
|
+
skipped += 1
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
features = extract_features(abs_path)
|
|
172
|
+
title = audio_file.stem.replace("_", " ").replace("-", " ").title()
|
|
173
|
+
db.add_track(
|
|
174
|
+
path=abs_path,
|
|
175
|
+
title=title,
|
|
176
|
+
artist=artist,
|
|
177
|
+
genre=genre,
|
|
178
|
+
bpm=features.bpm,
|
|
179
|
+
estimated_key=features.estimated_key,
|
|
180
|
+
lufs=features.lufs,
|
|
181
|
+
dynamic_range=features.dynamic_range,
|
|
182
|
+
duration_sec=features.duration_sec,
|
|
183
|
+
brightness=features.brightness,
|
|
184
|
+
vector=features.vector,
|
|
185
|
+
source="user",
|
|
186
|
+
)
|
|
187
|
+
added += 1
|
|
188
|
+
console.print(f" [green]+[/green] {audio_file.name}")
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
console.print(f" [yellow]![/yellow] {audio_file.name}: {e}")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
console.print(f" [red]x[/red] {audio_file.name}: {e}")
|
|
193
|
+
|
|
194
|
+
console.print(f"\n[bold]Added {added} tracks[/bold]", end="")
|
|
195
|
+
if skipped:
|
|
196
|
+
console.print(f" [dim]({skipped} already in library)[/dim]", end="")
|
|
197
|
+
console.print()
|
|
198
|
+
db.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@library.command("list")
|
|
202
|
+
def library_list() -> None:
|
|
203
|
+
"""Show library contents and stats."""
|
|
204
|
+
db = _get_db()
|
|
205
|
+
user_count = db.get_track_count(source="user")
|
|
206
|
+
seed_count = db.get_track_count(source="seed")
|
|
207
|
+
display_library_stats(user_count, seed_count)
|
|
208
|
+
|
|
209
|
+
if user_count > 0:
|
|
210
|
+
console.print("\n[bold]Your tracks:[/bold]")
|
|
211
|
+
tracks = db.get_all_tracks(source="user")
|
|
212
|
+
for t in tracks[:20]: # Show max 20
|
|
213
|
+
console.print(
|
|
214
|
+
f" {t.id:>4} {t.title:<30} {t.artist:<20} "
|
|
215
|
+
f"{t.bpm:.0f} BPM {t.estimated_key:<10} {t.lufs:.1f} LUFS"
|
|
216
|
+
)
|
|
217
|
+
if user_count > 20:
|
|
218
|
+
console.print(f" [dim]... and {user_count - 20} more[/dim]")
|
|
219
|
+
|
|
220
|
+
db.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@library.command("remove")
|
|
224
|
+
@click.argument("track_id", type=int)
|
|
225
|
+
def library_remove(track_id: int) -> None:
|
|
226
|
+
"""Remove a track from your library by ID (seed tracks are protected)."""
|
|
227
|
+
db = _get_db()
|
|
228
|
+
track = db.get_track_by_id(track_id)
|
|
229
|
+
if track is None:
|
|
230
|
+
console.print(f"[yellow]Track {track_id} not found[/yellow]")
|
|
231
|
+
elif track.source == "seed":
|
|
232
|
+
console.print(f"[red]Cannot remove seed track {track_id}[/red]")
|
|
233
|
+
elif db.remove_track(track_id):
|
|
234
|
+
console.print(f"[green]Removed track {track_id}[/green]")
|
|
235
|
+
db.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
main()
|