296 lines
11 KiB
Python
296 lines
11 KiB
Python
# fingerprint_widget.py
|
|||
|
|
|
||
|
|
import os
|
||
|
|
import json
|
||
|
|
import tempfile
|
||
|
|
import subprocess
|
||
|
|
import threading
|
||
|
|
import requests
|
||
|
|
import numpy as np
|
||
|
|
import soundfile as sf
|
||
|
|
from PySide6.QtWidgets import (
|
||
|
|
QWidget, QVBoxLayout, QLabel,
|
||
|
|
QPushButton, QProgressBar, QFrame
|
||
|
|
)
|
||
|
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||
|
|
from PySide6.QtGui import QFont
|
||
|
|
|
||
|
|
|
||
|
|
class FingerprintWidget(QFrame):
|
||
|
|
result_ready = Signal(object)
|
||
|
|
error_ready = Signal(str)
|
||
|
|
|
||
|
|
def __init__(self, engine, api_key: str, parent=None):
|
||
|
|
super().__init__(parent)
|
||
|
|
self.engine = engine
|
||
|
|
self.acoustid_key = api_key.strip()
|
||
|
|
self.recording_seconds = 25
|
||
|
|
self.last_signal_check = False
|
||
|
|
|
||
|
|
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||
|
|
self.setStyleSheet("""
|
||
|
|
QFrame {
|
||
|
|
background-color: #0d0d1a;
|
||
|
|
border: 2px solid #4444aa;
|
||
|
|
border-radius: 6px;
|
||
|
|
}
|
||
|
|
""")
|
||
|
|
|
||
|
|
self._build_ui()
|
||
|
|
self.result_ready.connect(self._show_result)
|
||
|
|
self.error_ready.connect(self._show_error)
|
||
|
|
|
||
|
|
self.signal_timer = QTimer(self)
|
||
|
|
self.signal_timer.setInterval(500)
|
||
|
|
self.signal_timer.timeout.connect(self._check_signal)
|
||
|
|
self.signal_timer.start()
|
||
|
|
|
||
|
|
def _build_ui(self):
|
||
|
|
root = QVBoxLayout(self)
|
||
|
|
root.setContentsMargins(10, 8, 10, 8)
|
||
|
|
root.setSpacing(6)
|
||
|
|
|
||
|
|
header = QLabel("AUDIO FINGERPRINT")
|
||
|
|
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||
|
|
header.setStyleSheet("color: #6666cc; background: transparent; border: none;")
|
||
|
|
header.setAlignment(Qt.AlignCenter)
|
||
|
|
root.addWidget(header)
|
||
|
|
|
||
|
|
port_info = QLabel("JACK: RadioPanel:fingerprint_L / R")
|
||
|
|
port_info.setFont(QFont("Courier New", 8))
|
||
|
|
port_info.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||
|
|
port_info.setAlignment(Qt.AlignCenter)
|
||
|
|
root.addWidget(port_info)
|
||
|
|
|
||
|
|
self.signal_label = QLabel("● NO SIGNAL")
|
||
|
|
self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold))
|
||
|
|
self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;")
|
||
|
|
self.signal_label.setAlignment(Qt.AlignCenter)
|
||
|
|
root.addWidget(self.signal_label)
|
||
|
|
|
||
|
|
self.progress = QProgressBar()
|
||
|
|
self.progress.setRange(0, self.recording_seconds * 10)
|
||
|
|
self.progress.setValue(0)
|
||
|
|
self.progress.setTextVisible(False)
|
||
|
|
self.progress.setFixedHeight(8)
|
||
|
|
self.progress.setStyleSheet("""
|
||
|
|
QProgressBar {
|
||
|
|
border: 1px solid #4444aa;
|
||
|
|
border-radius: 3px;
|
||
|
|
background: #0a0a1a;
|
||
|
|
}
|
||
|
|
QProgressBar::chunk {
|
||
|
|
background-color: #6666cc;
|
||
|
|
}
|
||
|
|
""")
|
||
|
|
self.progress.hide()
|
||
|
|
root.addWidget(self.progress)
|
||
|
|
|
||
|
|
self.status_label = QLabel("READY")
|
||
|
|
self.status_label.setFont(QFont("Courier New", 10, QFont.Bold))
|
||
|
|
self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||
|
|
self.status_label.setAlignment(Qt.AlignCenter)
|
||
|
|
root.addWidget(self.status_label)
|
||
|
|
|
||
|
|
result_frame = QFrame()
|
||
|
|
result_frame.setStyleSheet("""
|
||
|
|
QFrame {
|
||
|
|
background-color: #0a0a1a;
|
||
|
|
border: 1px solid #333366;
|
||
|
|
border-radius: 3px;
|
||
|
|
}
|
||
|
|
""")
|
||
|
|
result_layout = QVBoxLayout(result_frame)
|
||
|
|
result_layout.setContentsMargins(6, 6, 6, 6)
|
||
|
|
|
||
|
|
self.result_artist = QLabel("Artist: -")
|
||
|
|
self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold))
|
||
|
|
self.result_artist.setStyleSheet("color: #555588;")
|
||
|
|
|
||
|
|
self.result_title = QLabel("Title: -")
|
||
|
|
self.result_title.setFont(QFont("Courier New", 11, QFont.Bold))
|
||
|
|
self.result_title.setStyleSheet("color: #555588;")
|
||
|
|
|
||
|
|
result_layout.addWidget(self.result_artist)
|
||
|
|
result_layout.addWidget(self.result_title)
|
||
|
|
root.addWidget(result_frame)
|
||
|
|
|
||
|
|
self.btn_fingerprint = QPushButton("FINGERPRINT")
|
||
|
|
self.btn_fingerprint.setFixedHeight(44)
|
||
|
|
self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold))
|
||
|
|
self.btn_fingerprint.setStyleSheet("""
|
||
|
|
QPushButton {
|
||
|
|
background-color: #4444aa;
|
||
|
|
color: white;
|
||
|
|
border: 1px solid #6666cc;
|
||
|
|
border-radius: 4px;
|
||
|
|
}
|
||
|
|
QPushButton:hover { background-color: #5555bb; }
|
||
|
|
QPushButton:pressed { background-color: #333399; }
|
||
|
|
QPushButton:disabled {
|
||
|
|
background-color: #222244;
|
||
|
|
color: #666688;
|
||
|
|
}
|
||
|
|
""")
|
||
|
|
self.btn_fingerprint.clicked.connect(self._start_fingerprint)
|
||
|
|
root.addWidget(self.btn_fingerprint)
|
||
|
|
|
||
|
|
root.addStretch()
|
||
|
|
|
||
|
|
def _check_signal(self):
|
||
|
|
if self.engine.recording:
|
||
|
|
return
|
||
|
|
levels = self.engine.fingerprint_levels
|
||
|
|
has_signal = (levels[0] > 0.01 or levels[1] > 0.01)
|
||
|
|
if has_signal != self.last_signal_check:
|
||
|
|
self.last_signal_check = has_signal
|
||
|
|
if has_signal:
|
||
|
|
self.signal_label.setText("● SIGNAL PRESENT")
|
||
|
|
self.signal_label.setStyleSheet("color: #00ff41; background: transparent;")
|
||
|
|
else:
|
||
|
|
self.signal_label.setText("● NO SIGNAL")
|
||
|
|
self.signal_label.setStyleSheet("color: #330000; background: transparent;")
|
||
|
|
|
||
|
|
def _start_fingerprint(self):
|
||
|
|
if not self.acoustid_key:
|
||
|
|
self.error_ready.emit("Set your AcoustID key in main.py first!")
|
||
|
|
return
|
||
|
|
if self.engine.recording:
|
||
|
|
return
|
||
|
|
|
||
|
|
self.btn_fingerprint.setEnabled(False)
|
||
|
|
self.progress.show()
|
||
|
|
self.progress.setValue(0)
|
||
|
|
self.status_label.setText("LISTENING...")
|
||
|
|
self.status_label.setStyleSheet("color: #ccaa00;")
|
||
|
|
self.signal_label.setText("● RECORDING...")
|
||
|
|
self.signal_label.setStyleSheet("color: #ccaa00;")
|
||
|
|
self.result_artist.setText("Artist: -")
|
||
|
|
self.result_title.setText("Title: -")
|
||
|
|
|
||
|
|
self.recording_timer = QTimer(self)
|
||
|
|
self.recording_timer.setInterval(100)
|
||
|
|
self.recording_timer.timeout.connect(self._update_progress)
|
||
|
|
self.recording_timer.start()
|
||
|
|
|
||
|
|
self.engine.start_recording(self.recording_seconds, self._on_recording_finished)
|
||
|
|
|
||
|
|
def _update_progress(self):
|
||
|
|
if not self.engine.recording:
|
||
|
|
self.recording_timer.stop()
|
||
|
|
self.progress.setValue(self.progress.maximum())
|
||
|
|
return
|
||
|
|
if self.engine.record_buffer is not None:
|
||
|
|
progress = self.engine.record_position / self.engine.record_target_frames
|
||
|
|
self.progress.setValue(int(progress * self.progress.maximum()))
|
||
|
|
|
||
|
|
def _on_recording_finished(self, audio_data: np.ndarray, sr: int):
|
||
|
|
self.recording_timer.stop()
|
||
|
|
self.status_label.setText("ANALYZING...")
|
||
|
|
self.status_label.setStyleSheet("color: #4488ff;")
|
||
|
|
self.signal_label.setText("● PROCESSING")
|
||
|
|
self.signal_label.setStyleSheet("color: #4488ff;")
|
||
|
|
threading.Thread(
|
||
|
|
target=self._fingerprint_thread,
|
||
|
|
args=(audio_data, sr),
|
||
|
|
daemon=True
|
||
|
|
).start()
|
||
|
|
|
||
|
|
def _fingerprint_thread(self, audio_data: np.ndarray, sr: int):
|
||
|
|
try:
|
||
|
|
result = self._identify_acoustid(audio_data, sr)
|
||
|
|
self.result_ready.emit(result)
|
||
|
|
except Exception as e:
|
||
|
|
err_str = str(e)
|
||
|
|
if "Invalid API key" in err_str:
|
||
|
|
err_str = "Invalid API Key"
|
||
|
|
elif "limit" in err_str.lower():
|
||
|
|
err_str = "Daily limit reached"
|
||
|
|
self.error_ready.emit(err_str)
|
||
|
|
|
||
|
|
def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict:
|
||
|
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||
|
|
temp_path = f.name
|
||
|
|
try:
|
||
|
|
sf.write(temp_path, audio_data.T, sr, subtype='PCM_16')
|
||
|
|
|
||
|
|
result = subprocess.run(
|
||
|
|
['fpcalc', '-json', temp_path],
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=15
|
||
|
|
)
|
||
|
|
|
||
|
|
if result.returncode != 0:
|
||
|
|
raise RuntimeError(f"fpcalc failed: {result.stderr}")
|
||
|
|
|
||
|
|
fp_data = json.loads(result.stdout)
|
||
|
|
fingerprint = fp_data['fingerprint']
|
||
|
|
duration = fp_data.get('duration', 0)
|
||
|
|
|
||
|
|
finally:
|
||
|
|
os.unlink(temp_path)
|
||
|
|
|
||
|
|
url = 'https://api.acoustid.org/v2/lookup'
|
||
|
|
params = {
|
||
|
|
'client': self.acoustid_key,
|
||
|
|
'fingerprint': fingerprint,
|
||
|
|
'duration': int(duration),
|
||
|
|
'meta': 'recordings'
|
||
|
|
}
|
||
|
|
|
||
|
|
response = requests.get(url, params=params, timeout=15)
|
||
|
|
data = response.json()
|
||
|
|
|
||
|
|
if data.get('status') != 'ok':
|
||
|
|
err = data.get('error', {}).get('message', 'Unknown')
|
||
|
|
raise RuntimeError(f"AcoustID: {err}")
|
||
|
|
|
||
|
|
results = data.get('results', [])
|
||
|
|
if not results:
|
||
|
|
return None
|
||
|
|
|
||
|
|
best = results[0]
|
||
|
|
recordings = best.get('recordings', [])
|
||
|
|
if not recordings:
|
||
|
|
return None
|
||
|
|
|
||
|
|
rec = recordings[0]
|
||
|
|
title = rec.get('title', 'Unknown')
|
||
|
|
artists = rec.get('artists', [])
|
||
|
|
artist = artists[0].get('name', 'Unknown') if artists else 'Unknown'
|
||
|
|
|
||
|
|
return {
|
||
|
|
'artist': artist,
|
||
|
|
'title': title,
|
||
|
|
'score': best.get('score', 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
def _show_result(self, result):
|
||
|
|
self.btn_fingerprint.setEnabled(True)
|
||
|
|
self.progress.hide()
|
||
|
|
self.signal_label.setText("● DONE")
|
||
|
|
self.signal_label.setStyleSheet("color: #00aa33;")
|
||
|
|
|
||
|
|
if result is None:
|
||
|
|
self.status_label.setText("NO MATCH")
|
||
|
|
self.status_label.setStyleSheet("color: #aa4444;")
|
||
|
|
else:
|
||
|
|
self.status_label.setText(f"MATCH {result['score']:.0%}")
|
||
|
|
self.status_label.setStyleSheet("color: #00ff41;")
|
||
|
|
self.result_artist.setText(f"Artist: {result['artist']}")
|
||
|
|
self.result_title.setText(f"Title: {result['title']}")
|
||
|
|
self.result_artist.setStyleSheet("color: #cccccc;")
|
||
|
|
self.result_title.setStyleSheet("color: #00ff41;")
|
||
|
|
|
||
|
|
def _show_error(self, error: str):
|
||
|
|
self.btn_fingerprint.setEnabled(True)
|
||
|
|
self.progress.hide()
|
||
|
|
self.signal_label.setText("● ERROR")
|
||
|
|
self.signal_label.setStyleSheet("color: #aa4444;")
|
||
|
|
self.status_label.setText("ERROR")
|
||
|
|
self.status_label.setStyleSheet("color: #ff4444;")
|
||
|
|
self.result_artist.setText("Error:")
|
||
|
|
self.result_title.setText(error[:60])
|
||
|
|
self.result_artist.setStyleSheet("color: #aa4444;")
|
||
|
|
self.result_title.setStyleSheet("color: #aa4444;")
|