Files

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);
});