#!/usr/bin/env python3 """ download-sunrise-sounds.py — download CC-licensed ambient sounds from Freesound for use as sunrise video fallback audio. Usage: python3 download-sunrise-sounds.py --api-key YOUR_KEY python3 download-sunrise-sounds.py --api-key YOUR_KEY --dir /path/to/sunrise-sounds python3 download-sunrise-sounds.py --api-key YOUR_KEY --per-category 30 Get a free API key at: https://freesound.org/apiv2/apply/ (Freesound account required — registration is free.) Files are saved as HQ 128 kbps MP3 previews, which is sufficient for background ambient audio. Full-quality downloads require OAuth2; if you want lossless originals, log into freesound.org and download the files listed in the manifest this script writes alongside the audio. Folder structure written: /clear-spring/ dawn chorus, birds, spring morning /clear-summer/ birds, insects, summer morning /clear-autumn/ sparse birds, leaves, autumn morning /clear-winter/ frost silence, sparse birds, winter morning /cloudy/ muffled dawn, overcast ambience /rain/ light rain, drizzle /heavy-rain/ downpour, storm rain /snow/ near-silence, snow ambience /foggy/ mist, fog ambience /thunder/ thunder + rain /windy/ wind, breeze through trees daily_sunrise_video.sh picks a random file from sunrise-sounds/ as a fallback when no camera mic recording is available for that day. A future update will match the folder to the day's actual weather via the OpenWeatherMap API. Requires: requests (pip3 install requests) """ import argparse import json import os import random import sys import time from pathlib import Path try: import requests except ImportError: sys.exit("Missing dependency: pip3 install requests") FREESOUND_API = "https://freesound.org/apiv2" # Each category maps to a list of search queries tried in order. # Queries are shuffled per-run so repeated runs fill in different sounds. CATEGORIES = { "clear-spring": [ "dawn chorus spring birds", "spring morning birds outdoor", "birds chirping spring sunrise", "dawn birds forest spring", "bird song spring morning", ], "clear-summer": [ "summer dawn birds outdoor", "morning birds insects summer", "dawn chorus summer", "bird song summer morning outdoor", "summer sunrise birds", ], "clear-autumn": [ "autumn morning birds outdoor", "fall dawn birds", "autumn bird song morning", "sparse birds autumn outdoor", "fall morning outdoor ambience", ], "clear-winter": [ "winter morning birds outdoor", "frost morning quiet outdoor", "winter dawn outdoor sparse", "quiet winter morning outdoor", "winter birds sparse outdoor", ], "cloudy": [ "overcast morning outdoor birds", "cloudy dawn outdoor ambience", "grey morning birds outdoor", "morning overcast outdoor", "cloudy outdoor morning", ], "rain": [ "light rain outdoor", "gentle rain leaves", "soft rain morning outdoor", "drizzle outdoor ambience", "rain birds outdoor", ], "heavy-rain": [ "heavy rain outdoor", "downpour rain", "rain storm outdoor", "heavy rainfall outdoor", "strong rain outdoor", ], "snow": [ "snow silence outdoor", "winter snow ambience outdoor", "quiet snow outdoor", "snowfall outdoor", "winter silence snow", ], "foggy": [ "fog morning outdoor", "mist ambience outdoor", "foggy morning birds", "misty dawn outdoor", "fog outdoor ambience", ], "thunder": [ "thunder rain outdoor", "distant thunder outdoor", "thunderstorm outdoor", "thunder rumble rain outdoor", "thunder lightning rain", ], "windy": [ "wind outdoor morning", "breeze through trees outdoor", "wind trees outdoor", "morning wind outdoor", "gentle wind outdoor", ], } # Licenses acceptable for personal use GOOD_LICENSES = {"Creative Commons 0", "Attribution", "Attribution NonCommercial"} def search(api_key: str, query: str, min_dur: int, page_size: int = 15) -> list: params = { "token": api_key, "query": query, "filter": ( f"duration:[{min_dur} TO 300] " 'license:("Creative Commons 0" OR "Attribution" OR "Attribution NonCommercial")' ), "fields": "id,name,previews,license,duration,username,tags", "page_size": page_size, "sort": "rating_desc", } r = requests.get(f"{FREESOUND_API}/search/text/", params=params, timeout=20) r.raise_for_status() return r.json().get("results", []) def download_preview(sound: dict, dest_dir: Path) -> Path | None: preview_url = sound["previews"].get("preview-hq-mp3") if not preview_url: return None safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in sound["name"]) filename = f"{sound['id']}-{safe}" if not filename.lower().endswith(".mp3"): filename += ".mp3" dest = dest_dir / filename if dest.exists(): return dest # already downloaded r = requests.get(preview_url, timeout=30, stream=True) r.raise_for_status() with open(dest, "wb") as f: for chunk in r.iter_content(8192): f.write(chunk) return dest def main() -> None: parser = argparse.ArgumentParser( description="Download CC ambient sounds from Freesound for sky-cam sunrise audio" ) parser.add_argument( "--api-key", required=True, help="Freesound API key — get one free at freesound.org/apiv2/apply/" ) parser.add_argument( "--dir", default="sunrise-sounds", help="Output directory (default: ./sunrise-sounds)" ) parser.add_argument( "--per-category", type=int, default=25, help="Target number of files per category (default: 25 → ~275 total)" ) parser.add_argument( "--min-duration", type=int, default=12, help="Minimum sound duration in seconds (default: 12)" ) parser.add_argument( "--categories", nargs="+", metavar="CAT", help="Only download these categories (default: all)" ) args = parser.parse_args() base_dir = Path(args.dir) base_dir.mkdir(parents=True, exist_ok=True) categories = args.categories or list(CATEGORIES.keys()) unknown = set(categories) - set(CATEGORIES.keys()) if unknown: sys.exit(f"Unknown categories: {unknown}\nValid: {list(CATEGORIES.keys())}") manifest_path = base_dir / "manifest.json" manifest: dict = {} if manifest_path.exists(): with open(manifest_path) as f: manifest = json.load(f) grand_total = 0 for category in categories: cat_dir = base_dir / category cat_dir.mkdir(exist_ok=True) existing = list(cat_dir.glob("*.mp3")) needed = args.per_category - len(existing) if needed <= 0: print(f"{category:20s} already has {len(existing)} files — skipping") continue print(f"\n{category} — need {needed} more (have {len(existing)})") queries = CATEGORIES[category][:] random.shuffle(queries) collected = 0 seen_ids = {p.name.split("-")[0] for p in existing if p.name[0].isdigit()} cat_manifest = manifest.setdefault(category, {}) for query in queries: if collected >= needed: break print(f" searching: '{query}'") try: results = search(args.api_key, query, args.min_duration) random.shuffle(results) for sound in results: if collected >= needed: break sid = str(sound["id"]) if sid in seen_ids: continue seen_ids.add(sid) try: dest = download_preview(sound, cat_dir) if dest: license_short = sound["license"].split("/")[-2] if "/" in sound["license"] else sound["license"] print( f" {dest.name} " f"({sound['duration']:.0f}s, {license_short}, " f"by {sound['username']})" ) cat_manifest[sid] = { "file": dest.name, "name": sound["name"], "duration": sound["duration"], "license": sound["license"], "username": sound["username"], } collected += 1 grand_total += 1 time.sleep(0.4) except Exception as e: print(f" skipped {sid}: {e}") except Exception as e: print(f" search error: {e}") time.sleep(1.0) total_now = len(existing) + collected print(f" {category}: {collected} downloaded → {total_now} total") with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) print(f"\n{grand_total} new files downloaded to {base_dir}/") print(f"Manifest written: {manifest_path}") print() print("Next steps:") print(f" 1. Set AUDIO_ENABLED=true in sky-cam.conf") print(f" 2. Verify sounds with: ls {base_dir}/*/ | head -40") print(f" 3. Re-run with --per-category 40 any time to add more variety") if __name__ == "__main__": main()