const imageInput = document.getElementById('imageInput'); const audioInput = document.getElementById('audioInput'); const mixedInput = document.getElementById('mixedInput'); const imageLabel = document.getElementById('imageLabel'); const audioLabel = document.getElementById('audioLabel'); const preview = document.getElementById('preview'); const result = document.getElementById('result'); const resultText = document.getElementById('resultText'); const nextBtn = document.getElementById('nextBtn'); const status = document.getElementById('status'); const recordBtn = document.getElementById('recordBtn'); const stopBtn = document.getElementById('stopBtn'); const dropzone = document.getElementById('dropzone'); const browseBtn = document.getElementById('browseBtn'); let modelsLoaded = false; let mediaRecorder = null; let recordedChunks = []; let activeStream = null; const MAX_IMAGE_LOAD_RETRIES = 12; const audioSupported = Boolean( navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder ); function setStatus(type, message) { status.className = `status ${type}`; status.textContent = message; } function stopActiveStream() { if (!activeStream) { return; } activeStream.getTracks().forEach((track) => track.stop()); activeStream = null; } function resetRecorderButtons() { recordBtn.disabled = !audioSupported; stopBtn.disabled = true; } function renderResultCard(title, metaParts) { const meta = metaParts .filter(Boolean) .map((part) => `${part}`) .join(''); return `
${title}
${meta}
`; } function handleSelectedFile(file) { if (!file) { return; } if (file.type.startsWith('image/')) { classifyImage(file); return; } if (file.type.startsWith('audio/')) { classifyAudio(file); return; } setStatus('error', 'Unsupported file type. Upload an image or audio file.'); } // Check models status on page load function checkStatus() { fetch('/status') .then((response) => response.json()) .then((data) => { modelsLoaded = data.loaded; imageLabel.style.opacity = '1'; audioLabel.style.opacity = '1'; if (modelsLoaded) { setStatus('success', 'Image model loaded. Audio uses BirdNET on demand.'); } else if (data.error) { setStatus('error', `Model loading failed: ${data.error}`); } else if (data.loading) { setStatus('loading', 'Loading image model. Audio is ready.'); setTimeout(checkStatus, 2000); } else { setStatus('success', 'Ready. Audio works now. Image model loads on first photo upload.'); } }) .catch(() => { setStatus('error', 'Unable to reach the server.'); }); } checkStatus(); resetRecorderButtons(); if (!audioSupported) { recordBtn.textContent = 'Recording Unavailable'; } browseBtn.addEventListener('click', () => { mixedInput.click(); }); dropzone.addEventListener('click', () => { mixedInput.click(); }); dropzone.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); mixedInput.click(); } }); ['dragenter', 'dragover'].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add('dragover'); }); }); ['dragleave', 'drop'].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.remove('dragover'); }); }); dropzone.addEventListener('drop', (event) => { const [file] = event.dataTransfer.files; handleSelectedFile(file); }); mixedInput.addEventListener('change', () => { handleSelectedFile(mixedInput.files[0]); mixedInput.value = ''; }); imageInput.addEventListener('change', () => { if (imageInput.files[0]) { classifyImage(imageInput.files[0]); } }); audioInput.addEventListener('change', () => { if (audioInput.files[0]) { classifyAudio(audioInput.files[0]); } }); function classifyImage(file, retryCount = 0) { setStatus('loading', 'Processing image...'); result.classList.add('hidden'); const reader = new FileReader(); reader.onload = (e) => { preview.innerHTML = `Preview`; preview.classList.add('active'); }; reader.readAsDataURL(file); const formData = new FormData(); formData.append('file', file); fetch('/classify-image', { method: 'POST', body: formData }) .then(async (response) => ({ status: response.status, data: await response.json() })) .then((response) => { if (response.data.error && response.data.error.includes('still loading')) { if (retryCount >= MAX_IMAGE_LOAD_RETRIES) { setStatus( 'error', 'Image model did not finish loading. On Render this usually means the instance is too small or ran out of memory.' ); return; } setStatus('loading', 'Starting image model. The first photo may take a minute...'); setTimeout(checkStatus, 2000); setTimeout(() => classifyImage(file, retryCount + 1), 5000); } else if (response.data.error) { setStatus('error', response.data.error); } else { modelsLoaded = true; setStatus('success', 'Image classification complete.'); resultText.innerHTML = renderResultCard( response.data.species, ['Image scan complete', 'Top visual match'] ); result.classList.remove('hidden'); } }) .catch((error) => { setStatus('error', 'Error processing image.'); console.error(error); }); } function classifyAudio(file) { setStatus('loading', 'Processing audio...'); result.classList.add('hidden'); const previewUrl = URL.createObjectURL(file); preview.innerHTML = `

Audio ready: ${file.name}

`; preview.classList.add('active'); const formData = new FormData(); formData.append('file', file); fetch('/classify-audio', { method: 'POST', body: formData }) .then((response) => response.json()) .then((data) => { if (data.error) { setStatus('error', data.error); } else if (data.rejected || !data.candidates?.length) { setStatus('error', data.message || 'No confident bird calls detected.'); } else { setStatus('success', 'Audio classification complete.'); resultText.innerHTML = data.candidates .map((candidate, index) => { const confidence = Math.round(candidate.confidence * 100); const timeRange = `${candidate.start_time.toFixed(1)}s - ${candidate.end_time.toFixed(1)}s`; return renderResultCard( `${index + 1}. ${candidate.species}`, [`Confidence ${confidence}%`, `Window ${timeRange}`] ); }) .join(''); result.classList.remove('hidden'); } }) .catch((error) => { setStatus('error', 'Error processing audio.'); console.error(error); }); } recordBtn.addEventListener('click', async () => { if (!audioSupported) { setStatus('error', 'This browser does not support microphone recording.'); return; } try { activeStream = await navigator.mediaDevices.getUserMedia({ audio: true }); recordedChunks = []; mediaRecorder = new MediaRecorder(activeStream); mediaRecorder.addEventListener('dataavailable', (event) => { if (event.data.size > 0) { recordedChunks.push(event.data); } }); mediaRecorder.addEventListener('stop', () => { const mimeType = mediaRecorder.mimeType || 'audio/webm'; const extension = mimeType.includes('ogg') ? 'ogg' : 'webm'; const audioBlob = new Blob(recordedChunks, { type: mimeType }); const audioFile = new File([audioBlob], `recording.${extension}`, { type: mimeType }); resetRecorderButtons(); stopActiveStream(); classifyAudio(audioFile); }); mediaRecorder.start(); recordBtn.disabled = true; stopBtn.disabled = false; setStatus('loading', 'Recording from microphone...'); preview.innerHTML = '

Microphone is recording. Click "Stop Recording" when ready.

'; preview.classList.add('active'); } catch (error) { stopActiveStream(); resetRecorderButtons(); setStatus('error', 'Microphone access was denied or unavailable.'); console.error(error); } }); stopBtn.addEventListener('click', () => { if (!mediaRecorder || mediaRecorder.state === 'inactive') { return; } setStatus('loading', 'Finishing recording...'); stopBtn.disabled = true; mediaRecorder.stop(); }); nextBtn.addEventListener('click', () => { stopActiveStream(); preview.innerHTML = ''; preview.classList.remove('active'); result.classList.add('hidden'); imageInput.value = ''; audioInput.value = ''; resetRecorderButtons(); setStatus('success', 'Ready for the next prediction.'); });