| import React, { useState, useCallback } from 'react' |
| import { motion, AnimatePresence } from 'framer-motion' |
| import Header from './components/Header' |
| import AudioUploader from './components/AudioUploader' |
| import Spectrogram from './components/Spectrogram' |
| import BirdResult from './components/BirdResult' |
| import RangeMap from './components/RangeMap' |
| import { enrichResult } from './data/mockData' |
| import { useSpectrogram } from './hooks/useSpectrogram' |
| import styles from './App.module.css' |
|
|
| const API_URL = 'https://mg643-chirp.hf.space' |
|
|
| export default function App() { |
| const [audioSelection, setAudioSelection] = useState(null) |
| const [isLoading, setIsLoading] = useState(false) |
| const [result, setResult] = useState(null) |
| const [error, setError] = useState(null) |
|
|
| const { spectrogramUrl, generateSpectrogram, isGenerating } = useSpectrogram() |
|
|
| const handleAudioSelected = useCallback(async (selection) => { |
| setAudioSelection(selection) |
| setResult(null) |
| setError(null) |
|
|
| if (selection.file) { |
| generateSpectrogram(selection.file) |
| } else if (selection.sampleId) { |
| try { |
| const res = await fetch(`/samples/${selection.sampleId}.ogg`) |
| const blob = await res.blob() |
| generateSpectrogram(new File([blob], `${selection.sampleId}.ogg`, { type: 'audio/ogg' })) |
| } catch (err) { |
| console.warn('Spectrogram generation failed for sample:', err) |
| } |
| } |
| }, [generateSpectrogram]) |
|
|
| const handleIdentify = useCallback(async () => { |
| if (!audioSelection) return |
| setIsLoading(true) |
| setError(null) |
|
|
| try { |
| let audioFile = audioSelection.file |
| if (!audioFile && audioSelection.sampleId) { |
| const res = await fetch(`/samples/${audioSelection.sampleId}.ogg`) |
| const blob = await res.blob() |
| audioFile = new File([blob], `${audioSelection.sampleId}.ogg`, { type: 'audio/ogg' }) |
| } |
|
|
| const formData = new FormData() |
| formData.append('file', audioFile) |
|
|
| const res = await fetch(`${API_URL}/predict?top_k=3`, { |
| method: 'POST', |
| body: formData, |
| }) |
| if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`) |
|
|
| const data = await res.json() |
| setResult(enrichResult(data)) |
| } catch (err) { |
| console.error('Identify failed:', err) |
| setError('Could not reach the identification service. Please try again.') |
| } finally { |
| setIsLoading(false) |
| } |
| }, [audioSelection]) |
|
|
| const hasAudio = audioSelection && (audioSelection.file || audioSelection.sampleId) |
|
|
| return ( |
| <div className={styles.page}> |
| <div className={styles.blob1} /> |
| <div className={styles.blob2} /> |
| |
| <div className={styles.container}> |
| <Header /> |
| |
| <main className={styles.main}> |
| {/* ── Left panel ── */} |
| <motion.div |
| className={styles.leftPanel} |
| initial={{ opacity: 0, x: -20 }} |
| animate={{ opacity: 1, x: 0 }} |
| transition={{ duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] }} |
| > |
| <div className={styles.panelCard}> |
| <p className={styles.panelTitle}>Listen & Identify</p> |
| <p className={styles.panelSub}> |
| Upload a bird call recording or try our sample. |
| Chirp identifies the species in seconds. |
| </p> |
| |
| <div className={styles.divider} /> |
| |
| <AudioUploader |
| onAudioSelected={handleAudioSelected} |
| isLoading={isLoading} |
| /> |
| |
| {hasAudio && !isLoading && !result && ( |
| <motion.button |
| className={styles.identifyBtn} |
| onClick={handleIdentify} |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| whileHover={{ scale: 1.02 }} |
| whileTap={{ scale: 0.98 }} |
| > |
| Identify Bird |
| </motion.button> |
| )} |
| |
| {isLoading && ( |
| <div className={styles.loadingState}> |
| <div className={styles.spinner} /> |
| <p>Analysing audio…</p> |
| </div> |
| )} |
| |
| {error && ( |
| <p className={styles.errorMsg}>{error}</p> |
| )} |
| </div> |
| |
| {/* Spectrogram */} |
| <AnimatePresence> |
| {(spectrogramUrl || isGenerating) && ( |
| <motion.div |
| className={styles.panelCard} |
| initial={{ opacity: 0, y: 10 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0 }} |
| > |
| <Spectrogram url={spectrogramUrl} isGenerating={isGenerating} /> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </motion.div> |
| |
| {/* ── Right panel ── */} |
| <AnimatePresence mode="wait"> |
| {!result ? ( |
| <motion.div |
| key="placeholder" |
| className={`${styles.rightPanel} ${styles.placeholder}`} |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| > |
| <div className={styles.placeholderInner}> |
| <div className={styles.birdIllustration}> |
| <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| <ellipse cx="52" cy="42" rx="22" ry="14" fill="var(--mauve-200)" /> |
| <circle cx="74" cy="34" r="10" fill="var(--mauve-200)" /> |
| <ellipse cx="30" cy="46" rx="18" ry="8" fill="var(--mauve-100)" transform="rotate(-15 30 46)" /> |
| <ellipse cx="68" cy="56" rx="14" ry="5" fill="var(--mauve-100)" transform="rotate(10 68 56)" /> |
| <circle cx="78" cy="31" r="2.5" fill="var(--ink)" /> |
| <circle cx="79" cy="30" r="0.8" fill="white" /> |
| <path d="M83 34 L92 36 L83 37 Z" fill="var(--gold)" /> |
| <path d="M10 65 Q60 60 110 68" stroke="var(--beige-300)" strokeWidth="2.5" strokeLinecap="round" fill="none" /> |
| </svg> |
| </div> |
| <p className={styles.placeholderText}> |
| Upload or select an audio clip<br />to identify the species |
| </p> |
| </div> |
| </motion.div> |
| ) : ( |
| <motion.div |
| key="results" |
| className={styles.rightPanel} |
| initial={{ opacity: 0, x: 20 }} |
| animate={{ opacity: 1, x: 0 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }} |
| > |
| <BirdResult result={result} /> |
| <RangeMap |
| range={result.range} |
| commonName={result.commonName} |
| color={result.color} |
| /> |
| <button |
| className={styles.resetBtn} |
| onClick={() => { setResult(null); setAudioSelection(null) }} |
| > |
| ← Identify another bird |
| </button> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </main> |
| |
| <footer className={styles.footer}> |
| <p>chirp! · built with BirdCLEF 2023 · EfficientNet-B0</p> |
| </footer> |
| </div> |
| </div> |
| ) |
| } |
|
|