// Sound effects from Openverse (https://api.openverse.org): free, no key. // Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall // back to the bundled library when it is unavailable. import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/"; // The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives. export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]); const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]); const MAX_SFX_MS = 15000; const IDEAL_SFX_MS = 4000; const BY_PENALTY = 2; const SEARCH_TIMEOUT_MS = 10000; // Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded. const oneLine = (text) => String(text ?? "") .replace(/[\p{Cc}`[\]<>\\]+/gu, " ") .trim() .slice(0, 300); // Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed. const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]); function httpsUrl(url, hosts) { try { const parsed = new URL(url); return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname)) ? oneLine(parsed.href) : ""; } catch { return ""; } } /** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */ export class OpenverseUnavailableError extends Error { constructor(message) { super(message); this.name = "OpenverseUnavailableError"; } } function usable(result) { return ( OPENVERSE_SFX_LICENSES.has(result?.license) && PLAYABLE_FILETYPES.has(result.filetype) && typeof result.url === "string" && result.url.startsWith("https://") && Number.isFinite(result.duration) && result.duration > 0 && result.duration <= MAX_SFX_MS ); } // Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY. function penalty(result, position) { const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000; return position + longBy + (result.license === "by" ? BY_PENALTY : 0); } /** Best usable sound in Openverse's relevance-ordered results, or null. */ export function pickOpenverseSfx(results) { let best = null; results.forEach((result, position) => { if (!usable(result)) return; const score = penalty(result, position); if (!best || score < best.score) best = { result, score }; }); if (!best) return null; const r = best.result; return { id: r.id, url: r.url, ext: `.${r.filetype}`, title: oneLine(r.title), creator: oneLine(r.creator), license: r.license, licenseUrl: httpsUrl(r.license_url), sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS), credit: oneLine(r.attribution), durationS: Math.round(r.duration) / 1000, }; } /** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */ export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) { const params = new URLSearchParams({ q: query, page_size: "20", license: [...OPENVERSE_SFX_LICENSES].join(","), }); let response; try { response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), }); } catch (error) { throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`); } if (response.status === 429) throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)"); if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`); let body; try { body = await response.json(); } catch { throw new OpenverseUnavailableError("Openverse returned non-JSON"); } return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []); } /** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */ export function writeSfxCredit(creditsPath, file, sound) { if (sound.license !== "by") return; // The sound may come from a saved record, not this run's pick: clean it again here. const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS); const source = sourceUrl ? ` Source: ${sourceUrl}` : ""; const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`; let prior = ""; try { prior = readFileSync(creditsPath, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } const kept = prior .split("\n") .filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`)); const header = "# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n"; mkdirSync(dirname(creditsPath), { recursive: true }); writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`); }