Files
sky-cam/download-sunrise-sounds.py
T
Claude ae40b898f6 Fix executable bits and add camera name to 4-seasons notifications
- chmod +x all *.sh and *.py in install.sh so permissions survive git checkouts
- Fix daily_sunrise_video.sh and Python scripts missing executable bit
- Add [CAM_NAME] prefix to all 4-seasons.sh notify.sh calls so multi-camera
  setups show which camera each daily clip came from
- Include camera name in output filename: YYYY-MM-DD_<cam>_MvtN-DayXofY-final.mp4

https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
2026-04-22 12:56:37 +00:00

294 lines
9.8 KiB
Python
Executable File

#!/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:
<dir>/clear-spring/ dawn chorus, birds, spring morning
<dir>/clear-summer/ birds, insects, summer morning
<dir>/clear-autumn/ sparse birds, leaves, autumn morning
<dir>/clear-winter/ frost silence, sparse birds, winter morning
<dir>/cloudy/ muffled dawn, overcast ambience
<dir>/rain/ light rain, drizzle
<dir>/heavy-rain/ downpour, storm rain
<dir>/snow/ near-silence, snow ambience
<dir>/foggy/ mist, fog ambience
<dir>/thunder/ thunder + rain
<dir>/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()