iNaturalist has an API, too. they’re in the middle of transitioning from v1 to v2.
even if you don’t know how to program, the AIs out there nowadays are pretty good at coding if you can feed them adequately detailed instructions. if using v2, you can use a single request to get up to 200 random bird observations with sounds. (you can filter for other parameters, if you like.) here are the instructions that i fed my browser’s built-in AI (based on GPT-5):
create a simple web page that gets data from iNaturalist via GET https://api.inaturalist.org/v2/observations?per_page=200&order_by=random&sounds=true&taxon_id=3&fields=id,uuid,taxon.id,taxon.name,taxon.rank,user.login,description,observed_on,place_guess,quality_grade,photos.all,sounds.all. the response from the API request should include a results array that includes up to 200 (per_page=200) observations. while the data is being retrieved, display an indicator “fetching observations with sounds…”, and when it’s done, display a button for the user to press that will play the sounds. when the user clicks the button, for the observation record found, please display the observation ID (and link to the observation page via https://www.inatuarlist.org/observations/{id}), the taxon name (rank) from taxon.id and taxon.rank, observer from user.login, observation date from observed_on, the location from place_guess, and notes from description. if any of those fields are null or blank, don’t display them. when displaying description and location, please explicitly display text only to prevent unintentional html or scripts. if the photos array contains any records, get the first photos record and display an image based on the url from that first record, and also show the attribution beneath the image. on the same page, please display an audio player for each sound record found in the sounds array, and display the file name from file_url and an attribution from attribution. (note that the file_url have an extraneous bit like “?123456” at the end, and that (the question mark and everything after) should be removed. autoplay the first sound file, and when it’s done playing, stop and autoplay the next sound file for the observation. if there are multiple audio players and the user clicks to play another audio player while another one is playing, stop the one that is playing before starting to play the audio that the user selected. when the last sound file for the observation is done playing, display the next observation and its sound files. keep going until the last record in the results (observations) array has been reached. also, on the page, include back and next buttons to allow the user to go forward and backward between observations. also nclude buttons to go to the first and to the last observations. make sure that on the first observation, the back and go to first buttons are disabled. similarly, on the last observation, the next and go to last buttons are disabled. between the back and next buttons, please include a small indicator like 1 of 200 that shows which observation is being displayed. for the number before “of”, make that editable so that the user can go to a specific observation. finally, please make the page respond appropriately the browser’s dark mode setting.
and here’s the code it returned. as far as i can tell, works exactly as i expect it to. (just copy and paste it into a text file, save it with an .html extension, and then open the resulting file in your browser.)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>iNaturalist Sound & Photo Player</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #0b0b0b;
--muted: #555;
--border: #ddd;
--btn-bg: #f5f5f5;
--btn-fg: #111;
--link: #0b57d0;
--chip-bg: #f2f2f2;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--fg: #e6e6e6;
--muted: #b8b8b8;
--border: #2a2a2a;
--btn-bg: #1f1f1f;
--btn-fg: #e6e6e6;
--link: #80b3ff;
--chip-bg: #1a1a1a;
}
}
html, body {
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
line-height: 1.45;
}
body { margin: 20px; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { margin: 0 0 12px; font-size: 1.4rem; }
h2 { margin: 16px 0 10px; font-size: 1.1rem; }
#status { margin: 10px 0; }
.controls {
margin-top: 16px;
display: none; /* shown after fetch */
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
button, input[type="number"] {
background: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 6px;
font-size: 14px;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
input[type="number"] { width: 72px; }
.sep { color: var(--muted); }
.meta { margin: 8px 0 12px; }
.meta div { margin: 2px 0; }
.label { font-weight: 600; color: var(--muted); }
.chip {
display: inline-block;
background: var(--chip-bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
font-size: 0.85rem;
margin-left: 6px;
color: var(--muted);
}
.photo { margin: 10px 0 12px; }
.photo img {
max-width: 380px;
width: 100%;
height: auto;
display: block;
border-radius: 6px;
border: 1px solid var(--border);
}
.photo .attr { font-size: 0.9rem; color: var(--muted); margin-top: 4px; }
.sound-item { margin: 10px 0; }
.sound-item .info { font-size: 0.95rem; color: var(--muted); margin-top: 2px; }
.empty { color: var(--muted); }
</style>
</head>
<body>
<h1>iNaturalist observations with sounds</h1>
<div id="status">fetching observations with sounds...</div>
<div id="observation" style="display:none;"></div>
<div class="controls" id="nav">
<button id="firstBtn">⏮ First</button>
<button id="backBtn">◀ Back</button>
<input type="number" id="obsIndex" min="1" value="1" />
<span class="sep">of</span> <span id="totalCount">0</span>
<button id="nextBtn">Next ▶</button>
<button id="lastBtn">Last ⏭</button>
</div>
<script>
const API_URL = "https://api.inaturalist.org/v2/observations?per_page=200&order_by=random&sounds=true&taxon_id=3&fields=id,uuid,taxon.id,taxon.name,taxon.rank,user.login,description,observed_on,place_guess,quality_grade,photos.all,sounds.all";
let observations = [];
let currentIndex = 0;
let audioElements = [];
let startedByUser = false;
// Utils
function stripQuery(url = "") {
const pos = url.indexOf("?");
return pos >= 0 ? url.slice(0, pos) : url;
}
function fileNameFromUrl(url = "") {
const clean = stripQuery(url);
const parts = clean.split("/");
return parts[parts.length - 1] || clean;
}
function escapeHTML(str = "") {
return String(str).replace(/[&<>"']/g, c => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
function stopAllAudio() {
audioElements.forEach(a => {
a.pause();
try { a.currentTime = 0; } catch (_) {}
});
}
function updateControls() {
const atFirst = currentIndex === 0;
const atLast = currentIndex === observations.length - 1;
document.getElementById("firstBtn").disabled = atFirst;
document.getElementById("backBtn").disabled = atFirst;
document.getElementById("nextBtn").disabled = atLast;
document.getElementById("lastBtn").disabled = atLast;
}
// Fetch data and prepare UI
async function fetchData() {
try {
const res = await fetch(API_URL);
const data = await res.json();
observations = Array.isArray(data.results) ? data.results : [];
document.getElementById("totalCount").textContent = observations.length;
document.getElementById("obsIndex").max = observations.length;
const status = document.getElementById("status");
status.innerHTML = '<button id="startBtn">▶ Start playback</button>';
document.getElementById("startBtn").onclick = () => {
startedByUser = true; // establishes a user gesture for autoplay
status.style.display = "none";
document.getElementById("nav").style.display = "flex";
document.getElementById("observation").style.display = "block";
showObservation(0, { autoPlayFirst: true });
};
// If no results, inform user and hide nav
if (observations.length === 0) {
status.textContent = "No observations with sounds found.";
document.getElementById("nav").style.display = "none";
}
} catch (e) {
console.error(e);
document.getElementById("status").textContent = "Error fetching data.";
document.getElementById("nav").style.display = "none";
}
}
// Render a single observation by index
function showObservation(index, opts = {}) {
if (index < 0 || index >= observations.length) return;
stopAllAudio();
audioElements = [];
currentIndex = index;
const obs = observations[index] || {};
const container = document.getElementById("observation");
// Title with ID link (only if id exists)
const idFragment = (typeof obs.id === "number" || typeof obs.id === "string")
? `Observation <a href="https://www.inaturalist.org/observations/${obs.id}" target="_blank" rel="noopener">${obs.id}</a>`
: "Observation";
let html = `<h2>${idFragment}</h2><div class="meta">`;
// Only render non-empty fields
const taxonName = obs.taxon?.name;
const taxonRank = obs.taxon?.rank;
if (taxonName) {
html += `<div><span class="label">Taxon:</span> ${escapeHTML(taxonName)}${taxonRank ? " (" + escapeHTML(taxonRank) + ")" : ""}</div>`;
}
if (obs.user?.login) {
html += `<div><span class="label">Observer:</span> ${escapeHTML(obs.user.login)}</div>`;
}
if (obs.observed_on) {
html += `<div><span class="label">Observed on:</span> ${escapeHTML(obs.observed_on)}</div>`;
}
if (obs.place_guess) {
html += `<div><span class="label">Location:</span> ${escapeHTML(obs.place_guess)}</div>`;
}
if (obs.description) {
html += `<div><span class="label">Notes:</span> ${escapeHTML(obs.description)}</div>`;
}
html += `</div>`;
// First photo with attribution (if available)
if (Array.isArray(obs.photos) && obs.photos.length > 0) {
const p = obs.photos[0] || {};
if (p.url) {
const photoUrl = p.url; // iNat photo URLs are safe to embed directly
const photoAttr = p.attribution || "";
html += `
<div class="photo">
<img src="${photoUrl}" alt="Observation photo" />
${photoAttr ? `<div class="attr">${photoAttr}</div>` : ""}
</div>
`;
}
}
// Sounds container
html += `<div id="sounds"></div>`;
container.innerHTML = html;
// Build sounds list
const soundsDiv = document.getElementById("sounds");
const sounds = Array.isArray(obs.sounds) ? obs.sounds : [];
if (sounds.length === 0) {
soundsDiv.innerHTML = `<p class="empty">No sounds available.</p>`;
} else {
sounds.forEach((s) => {
const raw = s.file_url || "";
const cleanSrc = stripQuery(raw);
const pretty = fileNameFromUrl(raw);
const attribution = s.attribution || "Attribution unavailable";
const wrap = document.createElement("div");
wrap.className = "sound-item";
const audio = document.createElement("audio");
audio.src = cleanSrc;
audio.controls = true;
audio.preload = "none";
// Ensure only one audio plays at a time
audio.addEventListener("play", () => {
audioElements.forEach(a => {
if (a !== audio && !a.paused) {
a.pause();
try { a.currentTime = 0; } catch (_) {}
}
});
});
const info = document.createElement("div");
info.className = "info";
info.textContent = `${pretty} — ${attribution}`;
wrap.appendChild(audio);
wrap.appendChild(info);
soundsDiv.appendChild(wrap);
audioElements.push(audio);
});
setupAutoplay();
}
// Update nav state and indicator
document.getElementById("obsIndex").value = currentIndex + 1;
updateControls();
// Autoplay first sound when requested (after user click)
if (opts.autoPlayFirst && audioElements.length > 0) {
audioElements[0].play().catch(() => {});
}
}
// Chain autoplay across sounds and observations
function setupAutoplay() {
if (audioElements.length === 0) return;
audioElements.forEach((audio, idx) => {
audio.onended = () => {
if (idx + 1 < audioElements.length) {
audioElements[idx + 1].play().catch(() => {});
} else {
// Move to next observation
if (currentIndex + 1 < observations.length) {
showObservation(currentIndex + 1, { autoPlayFirst: true });
}
// If we're at the last observation, we simply stop
}
};
});
}
// Navigation handlers
document.getElementById("firstBtn").onclick = () => showObservation(0, { autoPlayFirst: startedByUser });
document.getElementById("backBtn").onclick = () => showObservation(currentIndex - 1, { autoPlayFirst: startedByUser });
document.getElementById("nextBtn").onclick = () => showObservation(currentIndex + 1, { autoPlayFirst: startedByUser });
document.getElementById("lastBtn").onclick = () => showObservation(observations.length - 1, { autoPlayFirst: startedByUser });
document.getElementById("obsIndex").addEventListener("change", (e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isFinite(val)) return;
const idx = val - 1;
if (idx >= 0 && idx < observations.length) {
showObservation(idx, { autoPlayFirst: startedByUser });
} else {
// Snap back to current index if out of range
e.target.value = currentIndex + 1;
}
});
// Start
fetchData();
</script>
</body>
</html>
the result looks something like this: