Audio resilience: three-tier fallback for sunrise audio; add freesound download script
daily_sunrise_video.sh: priority chain is camera mic → library random MP3 → overlay-only → sped video promotion. If audio mixing fails a warning notification fires and the script retries with overlay-only so a video is always produced and uploaded. download-sunrise-sounds.py: downloads CC-licensed ambient sounds from freesound.org into 11 weather/season category folders (~275 files total at 25 per category), writing a manifest.json with attribution data. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
This commit is contained in:
+48
-13
@@ -140,27 +140,62 @@ DT="${DT}:x=w-tw-18:y=(h-th)/2"
|
|||||||
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
|
DT="${DT}:shadowcolor=black@0.55:shadowx=1:shadowy=1"
|
||||||
|
|
||||||
# ── Step 3: Overlay + optional audio ─────────────────────────────────────────
|
# ── Step 3: Overlay + optional audio ─────────────────────────────────────────
|
||||||
# With audio: DT must go into -filter_complex (can't mix -vf and -filter_complex)
|
# Priority: (1) camera mic recording, (2) library fallback, (3) no audio.
|
||||||
# Without audio: plain -vf is simpler and avoids any filter_complex overhead.
|
# If audio mixing fails, step 3 retries with overlay-only so the video is
|
||||||
# If overlay fails either way, the sped video is promoted so upload still fires.
|
# always made. If overlay itself fails, the sped video is promoted so upload
|
||||||
|
# still fires.
|
||||||
final_video="$output_dir/$current_date-daily-sunrise.mp4"
|
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)
|
||||||
step3_ok=true
|
|
||||||
|
|
||||||
if [ "${AUDIO_ENABLED:-false}" = "true" ] && [ -f "$audio_file" ]; then
|
# ── 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 ))
|
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_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)
|
audio_src="$cam_audio"
|
||||||
echo "Step 3/3: overlay + camera audio (offset ${audio_offset}s) → $final_video"
|
echo "Audio: camera recording offset=${audio_offset}s"
|
||||||
ffmpeg -loglevel warning \
|
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 [ -n "$audio_src" ]; then
|
||||||
|
echo "Step 3/3: overlay + audio → $final_video"
|
||||||
|
if ffmpeg -loglevel warning \
|
||||||
-i "$sped_video" \
|
-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]" \
|
-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]" \
|
-map "[vout]" -map "[aout]" \
|
||||||
-c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \
|
-c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \
|
||||||
-c:a aac -b:a 128k \
|
-c:a aac -b:a 128k \
|
||||||
-y "$final_video" || step3_ok=false
|
-y "$final_video"; then
|
||||||
else
|
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"
|
echo "Step 3/3: overlay (no audio) → $final_video"
|
||||||
ffmpeg -loglevel warning \
|
ffmpeg -loglevel warning \
|
||||||
-i "$sped_video" \
|
-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")
|
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})"
|
echo "Done: $final_video (${actual_dur}s, sunrise at ${SR_TIME})"
|
||||||
rm -f "$sped_video"
|
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" \
|
"$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \
|
||||||
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true
|
"$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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:
|
||||||
|
<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()
|
||||||
Reference in New Issue
Block a user