Files
KeySelector/public/app.js
T

368 lines
10 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 } = 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,
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');
}
// Expose globals
window.toggleTheme = toggleTheme;
window.changeAudioSource = changeAudioSource;
window.selectSpecificDevice = selectSpecificDevice;
window.toggleListening = toggleListening;
window.toggleWheel = toggleWheel;
window.toggleCover = toggleCover;
window.resetHero = resetHero;
// Initial state
document.addEventListener('DOMContentLoaded', () => {
window.updateWheel(null);
});