diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh
index 8de1ca2..4b45c70 100644
--- a/daily_sunrise_video.sh
+++ b/daily_sunrise_video.sh
@@ -140,27 +140,62 @@ DT="${DT}:x=w-tw-18:y=(h-th)/2"
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
# ── Step 3: Overlay + optional audio ─────────────────────────────────────────
-# With audio: DT must go into -filter_complex (can't mix -vf and -filter_complex)
-# Without audio: plain -vf is simpler and avoids any filter_complex overhead.
-# If overlay fails either way, the sped video is promoted so upload still fires.
+# Priority: (1) camera mic recording, (2) library fallback, (3) no audio.
+# If audio mixing fails, step 3 retries with overlay-only so the video is
+# always made. If overlay itself fails, the sped video is promoted so upload
+# still fires.
final_video="$output_dir/$current_date-daily-sunrise.mp4"
-audio_file="$image_dir/sunrise-audio.m4a"
+fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc)
+
+# ── Pick audio source ─────────────────────────────────────────────────────────
+audio_src=""
+audio_offset="0"
+
+cam_audio="$image_dir/sunrise-audio.m4a"
+if [ "${AUDIO_ENABLED:-false}" = "true" ]; then
+ if [ -f "$cam_audio" ]; then
+ # Camera recording: offset centres the clip on actual sunrise
+ buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 ))
+ audio_offset=$(echo "scale=1; $SUNRISE_PRE_MIN * 60 + $buffer_sec - $SUNRISE_TARGET_SECS / 2" | bc)
+ audio_src="$cam_audio"
+ echo "Audio: camera recording offset=${audio_offset}s"
+ else
+ # Library fallback: random file from sunrise-sounds/
+ library_dir="$SCRIPT_DIR/sunrise-sounds"
+ if [ -d "$library_dir" ]; then
+ random_file=$(find "$library_dir" -name "*.mp3" | shuf -n 1 2>/dev/null || true)
+ if [ -n "$random_file" ]; then
+ audio_src="$random_file"
+ echo "Audio: library fallback $(basename "$audio_src")"
+ fi
+ fi
+ fi
+fi
+
+# ── Attempt overlay + audio (falls back to overlay-only if audio fails) ───────
+audio_mixed=false
step3_ok=true
-if [ "${AUDIO_ENABLED:-false}" = "true" ] && [ -f "$audio_file" ]; then
- buffer_sec=$(( ${AUDIO_PRE_BUFFER_MIN:-2} * 60 ))
- audio_offset=$(echo "scale=1; $SUNRISE_PRE_MIN * 60 + $buffer_sec - $SUNRISE_TARGET_SECS / 2" | bc)
- fade_out=$(echo "scale=1; $SUNRISE_TARGET_SECS - 0.5" | bc)
- echo "Step 3/3: overlay + camera audio (offset ${audio_offset}s) → $final_video"
- ffmpeg -loglevel warning \
+if [ -n "$audio_src" ]; then
+ echo "Step 3/3: overlay + audio → $final_video"
+ if ffmpeg -loglevel warning \
-i "$sped_video" \
- -ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_file" \
+ -ss "$audio_offset" -t "$SUNRISE_TARGET_SECS" -i "$audio_src" \
-filter_complex "[0:v]${DT}[vout];[1:a]afade=t=in:st=0:d=0.5,afade=t=out:st=${fade_out}:d=0.5[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \
-c:a aac -b:a 128k \
- -y "$final_video" || step3_ok=false
-else
+ -y "$final_video"; then
+ audio_mixed=true
+ else
+ echo "Audio mix failed — retrying with overlay only"
+ "$SCRIPT_DIR/notify.sh" "WARNING: sunrise audio mix failed $current_date" \
+ "Audio could not be mixed — falling back to overlay-only" || true
+ [ "$audio_src" = "$cam_audio" ] && rm -f "$cam_audio"
+ fi
+fi
+
+if ! $audio_mixed; then
echo "Step 3/3: overlay (no audio) → $final_video"
ffmpeg -loglevel warning \
-i "$sped_video" \
@@ -173,7 +208,7 @@ if $step3_ok; then
actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_video")
echo "Done: $final_video (${actual_dur}s, sunrise at ${SR_TIME})"
rm -f "$sped_video"
- [ -f "$audio_file" ] && rm -f "$audio_file"
+ [ -f "$cam_audio" ] && rm -f "$cam_audio"
"$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true
else
diff --git a/download-sunrise-sounds.py b/download-sunrise-sounds.py
new file mode 100644
index 0000000..3822633
--- /dev/null
+++ b/download-sunrise-sounds.py
@@ -0,0 +1,293 @@
+#!/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()