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 ADDED
@@ -0,0 +1,3 @@
1
+ """RefMatch — AI-powered reference track suggestions for music producers."""
2
+
3
+ __version__ = "0.1.0"
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()