478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
let isListening = false;
|
|
let currentTrack = null;
|
|
let currentSource = 'system'; // 'system', 'mic', 'device'
|
|
let currentStream = null;
|
|
let mediaRecorder = null;
|
|
let audioChunks = [];
|
|
let audioContext = null;
|
|
let analyser = null;
|
|
let animFrameId = null;
|
|
let isProcessingAudio = false;
|
|
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
const isLight = html.getAttribute('data-theme') === 'light';
|
|
const next = isLight ? 'dark' : 'light';
|
|
html.setAttribute('data-theme', next);
|
|
document.getElementById('theme-btn').textContent = isLight ? 'Dark' : 'Light';
|
|
if (currentTrack && currentTrack.key) {
|
|
window.updateWheel(currentTrack.key);
|
|
} else {
|
|
window.updateWheel(null);
|
|
}
|
|
}
|
|
|
|
// Audio Source Management
|
|
async function changeAudioSource(source) {
|
|
currentSource = source;
|
|
const deviceSelect = document.getElementById('audio-device-select');
|
|
|
|
if (source === 'device') {
|
|
deviceSelect.style.display = 'inline-block';
|
|
await populateAudioDevices();
|
|
} else {
|
|
deviceSelect.style.display = 'none';
|
|
}
|
|
|
|
// If already listening, restart stream with new source
|
|
if (isListening) {
|
|
stopAudioStream();
|
|
await initAudioStream();
|
|
}
|
|
}
|
|
|
|
async function populateAudioDevices() {
|
|
const deviceSelect = document.getElementById('audio-device-select');
|
|
deviceSelect.innerHTML = '<option value="">Gerät wählen...</option>';
|
|
|
|
try {
|
|
// Request permission once to get labels
|
|
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())).catch(() => {});
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
const audioInputs = devices.filter(d => d.kind === 'audioinput');
|
|
|
|
audioInputs.forEach((d, i) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = d.deviceId;
|
|
opt.textContent = d.label || `Audioeingang ${i + 1}`;
|
|
deviceSelect.appendChild(opt);
|
|
});
|
|
} catch (err) {
|
|
console.warn('[Audio] Could not enumerate devices:', err);
|
|
}
|
|
}
|
|
|
|
async function selectSpecificDevice(deviceId) {
|
|
if (!deviceId) return;
|
|
currentSource = 'device';
|
|
if (isListening) {
|
|
stopAudioStream();
|
|
await initAudioStream(deviceId);
|
|
}
|
|
}
|
|
|
|
// Stream Initialization
|
|
async function initAudioStream(specificDeviceId = null) {
|
|
try {
|
|
stopAudioStream();
|
|
|
|
if (currentSource === 'system') {
|
|
updateStatus('Wähle Tab/Bildschirm mit Audio...', 'waiting');
|
|
// System audio capture (YouTube, VLC, DJ software, browser tabs)
|
|
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: true,
|
|
audio: {
|
|
echoCancellation: false,
|
|
noiseSuppression: false,
|
|
autoGainControl: false,
|
|
systemAudio: 'include'
|
|
}
|
|
});
|
|
|
|
const audioTracks = displayStream.getAudioTracks();
|
|
if (audioTracks.length === 0) {
|
|
alert('Kein Audiotrack geteilt! Bitte setze im Teilen-Dialog das Häkchen bei "Audio teilen" (Systemaudio teilen).');
|
|
displayStream.getTracks().forEach(t => t.stop());
|
|
toggleListening(false);
|
|
return false;
|
|
}
|
|
|
|
// Stop video track to save CPU and battery
|
|
displayStream.getVideoTracks().forEach(t => t.stop());
|
|
currentStream = new MediaStream(audioTracks);
|
|
|
|
} else if (currentSource === 'device' && specificDeviceId) {
|
|
currentStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { deviceId: { exact: specificDeviceId } }
|
|
});
|
|
} else {
|
|
// Microphone
|
|
currentStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: false,
|
|
noiseSuppression: false
|
|
}
|
|
});
|
|
}
|
|
|
|
// When the user stops sharing via the browser bar
|
|
currentStream.getAudioTracks()[0].onended = () => {
|
|
console.log('[Audio] Stream ended by user.');
|
|
toggleListening(false);
|
|
};
|
|
|
|
// Setup Visualizer
|
|
setupVisualizer(currentStream);
|
|
return true;
|
|
|
|
} catch (err) {
|
|
console.error('[Audio] Stream init failed:', err);
|
|
if (err.name !== 'NotAllowedError') {
|
|
alert('Audio-Zugriff fehlgeschlagen: ' + err.message);
|
|
}
|
|
toggleListening(false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function stopAudioStream() {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
try { mediaRecorder.stop(); } catch (e) {}
|
|
}
|
|
if (currentStream) {
|
|
currentStream.getTracks().forEach(t => t.stop());
|
|
currentStream = null;
|
|
}
|
|
if (animFrameId) {
|
|
cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
const meter = document.getElementById('audio-meter-fill');
|
|
if (meter) meter.style.width = '0%';
|
|
}
|
|
|
|
// Live Visualizer (Level Meter)
|
|
function setupVisualizer(stream) {
|
|
try {
|
|
if (!audioContext || audioContext.state === 'closed') {
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
if (audioContext.state === 'suspended') {
|
|
audioContext.resume();
|
|
}
|
|
|
|
const source = audioContext.createMediaStreamSource(stream);
|
|
analyser = audioContext.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
source.connect(analyser);
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
const meter = document.getElementById('audio-meter-fill');
|
|
|
|
function updateMeter() {
|
|
if (!isListening) {
|
|
meter.style.width = '0%';
|
|
return;
|
|
}
|
|
|
|
analyser.getByteFrequencyData(dataArray);
|
|
let sum = 0;
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
sum += dataArray[i];
|
|
}
|
|
const avg = sum / dataArray.length;
|
|
const pct = Math.min(100, Math.round((avg / 128) * 100));
|
|
meter.style.width = `${pct}%`;
|
|
|
|
animFrameId = requestAnimationFrame(updateMeter);
|
|
}
|
|
|
|
updateMeter();
|
|
} catch (err) {
|
|
console.warn('[Visualizer] Error:', err);
|
|
}
|
|
}
|
|
|
|
// Listening Loop & Recognition
|
|
async function toggleListening(forceState = null) {
|
|
isListening = forceState !== null ? forceState : !isListening;
|
|
|
|
const statusEl = document.getElementById('listening-status');
|
|
const icon = statusEl.querySelector('i');
|
|
const text = document.getElementById('listening-text');
|
|
|
|
if (isListening) {
|
|
statusEl.classList.remove('paused');
|
|
statusEl.classList.add('active');
|
|
icon.className = 'fa-solid fa-microphone-lines';
|
|
|
|
const sourceLabel = currentSource === 'system' ? 'PC-Audio' : 'Mikrofon';
|
|
text.textContent = `Höre ${sourceLabel}...`;
|
|
|
|
const success = await initAudioStream();
|
|
if (success) {
|
|
startRecordingChunk();
|
|
}
|
|
} else {
|
|
statusEl.classList.remove('active');
|
|
statusEl.classList.add('paused');
|
|
icon.className = 'fa-solid fa-pause';
|
|
text.textContent = 'Pausiert';
|
|
stopAudioStream();
|
|
}
|
|
}
|
|
|
|
function startRecordingChunk() {
|
|
if (!isListening || !currentStream) return;
|
|
|
|
audioChunks = [];
|
|
try {
|
|
mediaRecorder = new MediaRecorder(currentStream, {
|
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : undefined
|
|
});
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data && e.data.size > 0) {
|
|
audioChunks.push(e.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
if (!isListening) return;
|
|
|
|
const blob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
audioChunks = [];
|
|
|
|
// Check if sound was loud enough before querying recognition
|
|
if (!isProcessingAudio && blob.size > 1000) {
|
|
await processAudio(blob);
|
|
}
|
|
|
|
if (isListening) {
|
|
setTimeout(startRecordingChunk, 1500); // 1.5s pause between checks
|
|
}
|
|
};
|
|
|
|
// Record for 7 seconds (optimal for ACRCloud / AudD)
|
|
mediaRecorder.start();
|
|
setTimeout(() => {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
mediaRecorder.stop();
|
|
}
|
|
}, 7000);
|
|
|
|
} catch (e) {
|
|
console.error('[Recorder] Error:', e);
|
|
}
|
|
}
|
|
|
|
async function processAudio(blob) {
|
|
isProcessingAudio = true;
|
|
updateStatus('Analysiere Track...', 'analyzing');
|
|
|
|
const formData = new FormData();
|
|
formData.append('audio', blob, 'sample.webm');
|
|
|
|
try {
|
|
const res = await fetch('/api/recognize', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const json = await res.json();
|
|
|
|
if (json.status === 'success' && json.data) {
|
|
const { title, artist, matchScore, key, bpm, cover_url, isCorrected, preview_url, duration_sec, energy, danceability, happiness } = json.data;
|
|
|
|
// Avoid refetching if same track is still playing
|
|
if (currentTrack && currentTrack.title.toLowerCase() === title.toLowerCase() && currentTrack.artist.toLowerCase() === artist.toLowerCase()) {
|
|
updateStatus(`Laufend: ${title}`, 'active');
|
|
isProcessingAudio = false;
|
|
return;
|
|
}
|
|
|
|
updateStatus(`Erkannt: ${title}`, 'active');
|
|
|
|
// Select track in UI and load details/suggestions
|
|
window.selectTrack({
|
|
title,
|
|
artist,
|
|
cover_url,
|
|
preview_url,
|
|
duration_sec,
|
|
energy,
|
|
danceability,
|
|
happiness,
|
|
camelot_key: key,
|
|
bpm: bpm,
|
|
is_corrected: isCorrected,
|
|
is_local: !!key
|
|
});
|
|
|
|
} else if (json.status === 'not_configured') {
|
|
updateStatus(json.message, 'warning');
|
|
} else {
|
|
const sourceLabel = currentSource === 'system' ? 'PC-Audio' : 'Mikrofon';
|
|
updateStatus(`Höre ${sourceLabel}...`, 'active');
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('[Audio] Recognition network error:', err);
|
|
updateStatus('Erkennungsfehler', 'warning');
|
|
} finally {
|
|
isProcessingAudio = false;
|
|
}
|
|
}
|
|
|
|
function updateStatus(text, type = 'normal') {
|
|
const statusEl = document.getElementById('listening-status');
|
|
const textEl = document.getElementById('listening-text');
|
|
if (!textEl) return;
|
|
|
|
textEl.textContent = text;
|
|
statusEl.className = 'status ' + type;
|
|
}
|
|
|
|
// Hero View Toggles
|
|
function toggleWheel() {
|
|
const hero = document.getElementById('hero');
|
|
if (hero.classList.contains('wheel-mode')) {
|
|
resetHero();
|
|
} else {
|
|
hero.classList.remove('cover-mode');
|
|
hero.classList.add('wheel-mode');
|
|
}
|
|
}
|
|
|
|
function toggleCover() {
|
|
const hero = document.getElementById('hero');
|
|
if (hero.classList.contains('cover-mode')) {
|
|
resetHero();
|
|
} else {
|
|
hero.classList.remove('wheel-mode');
|
|
hero.classList.add('cover-mode');
|
|
}
|
|
}
|
|
|
|
function resetHero() {
|
|
document.getElementById('hero').classList.remove('wheel-mode', 'cover-mode');
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// Global 30-Second Audio Preview Player (Tunebat Style)
|
|
// ----------------------------------------------------
|
|
let activeAudio = null;
|
|
let activePreviewUrl = null;
|
|
let activePlayBtn = null;
|
|
|
|
function stopAllPreviews() {
|
|
if (activeAudio) {
|
|
activeAudio.pause();
|
|
activeAudio.currentTime = 0;
|
|
activeAudio = null;
|
|
}
|
|
activePreviewUrl = null;
|
|
if (activePlayBtn) {
|
|
activePlayBtn.classList.remove('playing');
|
|
const icon = activePlayBtn.querySelector('i');
|
|
if (icon) icon.className = 'fa-solid fa-play';
|
|
activePlayBtn = null;
|
|
}
|
|
// Also reset hero preview button if needed
|
|
const heroBtn = document.getElementById('hero-preview-btn');
|
|
if (heroBtn) {
|
|
heroBtn.classList.remove('playing');
|
|
const heroIcon = heroBtn.querySelector('i');
|
|
if (heroIcon) heroIcon.className = 'fa-solid fa-play';
|
|
}
|
|
}
|
|
|
|
async function togglePreviewTrack(track, buttonEl) {
|
|
if (!track) return;
|
|
|
|
let previewUrl = track.preview_url;
|
|
|
|
// If clicking on already playing preview -> Pause & Stop
|
|
if (activeAudio && activePreviewUrl === previewUrl) {
|
|
stopAllPreviews();
|
|
return;
|
|
}
|
|
|
|
// If another track was playing, stop it first
|
|
stopAllPreviews();
|
|
|
|
// If track doesn't have preview_url yet, try to search Deezer dynamically
|
|
if (!previewUrl) {
|
|
if (buttonEl) {
|
|
const icon = buttonEl.querySelector('i');
|
|
if (icon) icon.className = 'fa-solid fa-spinner fa-spin';
|
|
}
|
|
try {
|
|
const sRes = await fetch(`/api/search?q=${encodeURIComponent(track.artist + ' ' + track.title)}`);
|
|
const sJson = await sRes.json();
|
|
if (sJson.status === 'success' && sJson.data && sJson.data.length > 0 && sJson.data[0].preview_url) {
|
|
previewUrl = sJson.data[0].preview_url;
|
|
track.preview_url = previewUrl;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Preview] Could not find preview URL:', e);
|
|
}
|
|
}
|
|
|
|
if (!previewUrl) {
|
|
alert(`Keine 30s-Vorschau für "${track.title}" verfügbar.`);
|
|
if (buttonEl) {
|
|
const icon = buttonEl.querySelector('i');
|
|
if (icon) icon.className = 'fa-solid fa-play';
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
activeAudio = new Audio(previewUrl);
|
|
activePreviewUrl = previewUrl;
|
|
activePlayBtn = buttonEl;
|
|
|
|
if (buttonEl) {
|
|
buttonEl.classList.add('playing');
|
|
const icon = buttonEl.querySelector('i');
|
|
if (icon) icon.className = 'fa-solid fa-pause';
|
|
}
|
|
|
|
activeAudio.onended = () => {
|
|
stopAllPreviews();
|
|
};
|
|
|
|
activeAudio.onerror = (e) => {
|
|
console.warn('[Preview] Audio playback error:', e);
|
|
stopAllPreviews();
|
|
};
|
|
|
|
await activeAudio.play();
|
|
} catch (err) {
|
|
console.warn('[Preview] Play failed (user interaction needed):', err);
|
|
stopAllPreviews();
|
|
}
|
|
}
|
|
|
|
function toggleHeroPreview() {
|
|
const heroBtn = document.getElementById('hero-preview-btn');
|
|
togglePreviewTrack(window.currentTrack, heroBtn);
|
|
}
|
|
|
|
// Expose globals
|
|
window.toggleTheme = toggleTheme;
|
|
window.changeAudioSource = changeAudioSource;
|
|
window.selectSpecificDevice = selectSpecificDevice;
|
|
window.toggleListening = toggleListening;
|
|
window.toggleWheel = toggleWheel;
|
|
window.toggleCover = toggleCover;
|
|
window.resetHero = resetHero;
|
|
window.stopAllPreviews = stopAllPreviews;
|
|
window.togglePreviewTrack = togglePreviewTrack;
|
|
window.toggleHeroPreview = toggleHeroPreview;
|
|
|
|
// Initial state
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.updateWheel(null);
|
|
}); |