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()