From 481019642d84849945d00b420c1f8e7aecbbcfca Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 18:00:00 +0100 Subject: [PATCH 01/23] fix(newsletter-reels): gallery plays only the m1/m2 stock tracks, never the generated score --- .claude/skills/newsletter-reels/scripts/gallery.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/gallery.html b/.claude/skills/newsletter-reels/scripts/gallery.html index 66690ced..66701af6 100644 --- a/.claude/skills/newsletter-reels/scripts/gallery.html +++ b/.claude/skills/newsletter-reels/scripts/gallery.html @@ -15,7 +15,7 @@ .box{display:flex;flex-direction:column;align-items:center;gap:10px}iframe{border:0;width:65.6vh;max-width:92vw;aspect-ratio:var(--w,4)/var(--h,5);background:#000;border-radius:8px} .bar{display:flex;gap:8px;align-items:center}.bar>button{padding:8px 14px;border-radius:8px;border:1px solid #555;background:#222;color:#eee;cursor:pointer}#cap{color:#ccc;min-width:220px;text-align:center} -

Newsletter reels

Pick one reel per topic and the row collapses to it, with its audio options (score / m1 / m2, saved). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

+

Newsletter reels

Pick one reel per topic and the row collapses to it, with its two stock-track options (m1 / m2, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

@@ -25,11 +25,11 @@

Newsletter reels

Pick one reel per topic and $('title').textContent=`Issue ${ISSUE} reels`; document.title=`${ISSUE} reels`; async function refresh(){ const r=await (await fetch('/list')).json(); have=new Set(r.html); music=new Set(r.music||[]); formats=r.formats||{}; S=r.selections||{}; S.audio=S.audio||{}; try{ (await (await fetch('music-variants.txt')).text()).trim().split('\n').forEach(l=>{const [s,g,f]=l.split(' ');TR[s+'-'+g]=f.replace(/-\d+\.mp3$/,'');}); }catch(e){} draw(); } -const aud=t=>S.audio[t]||'score'; +const aud=t=>S.audio[t]||'m1'; function dims(slug){ const f=formats[slug]||formats[slug.replace(/-m\d+$/,'')]||'1080x1350', m=f.match(/^(\d+)x(\d+)$/); return m?[Number(m[1]),Number(m[2]),f]:[4,5,f]; } function ratio(slug){ const [w,h]=dims(slug); return `--w:${w};--h:${h}`; } function fitFrame(fr,w,h){ fr.style.width=Math.min(innerWidth*.92,innerHeight*.82*w/h)+'px'; fr.style.setProperty('--w',w); fr.style.setProperty('--h',h); fr.dataset.w=w; fr.dataset.h=h; } -function audButtons(t){ const pk=S[t]; return [['score','score']].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) +function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) .map(([k,l])=>``).join(''); } function card(i,collapsed){ const ok=have.has(i.slug), t=i.topic, isSel=S[t]===i.slug; return `

`+(ok?`
▶ Play with audio
`:`
rendering...
`)+ @@ -41,11 +41,11 @@

Newsletter reels

Pick one reel per topic and async function save(){ await fetch('/select',{method:'POST',body:JSON.stringify(S)}); } async function pick(slug){ const t=item(slug).topic; if(S[t]===slug){ delete S[t]; } else { S[t]=slug; open_[t]=false; } await save(); draw(); } async function setAud(t,k){ S.audio[t]=k; await save(); draw(); if(cur&&item(cur).topic===t&&S[t]===cur) play(cur); } -function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'score', fr=$('fr'); +function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'m1', fr=$('fr'); fr.onload=()=>{ try{ fr.contentWindow.addEventListener('keydown',e=>{ if(e.key==='Escape') close_(); }); const b=fr.contentDocument.getElementById('start'); b&&b.click(); fr.contentWindow.focus(); }catch(e){} }; - const playSlug=slug+(a==='score'?'':'-'+a), [w,h,format]=dims(playSlug); fitFrame(fr,w,h); + const playSlug=slug+'-'+a, [w,h,format]=dims(playSlug); fitFrame(fr,w,h); fr.src=`out/${playSlug}.html?v=${V()}`; $('modal').classList.add('on'); - $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${a!=='score'?' | '+a:''}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } + $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } function step(d){ const list=ITEMS.filter(i=>have.has(i.slug)&&(!S[i.topic]||open_[i.topic]||S[i.topic]===i.slug)).map(i=>i.slug); let k=list.indexOf(cur); play(list[(k+d+list.length)%list.length]); } function close_(){ $('fr').src='about:blank'; $('modal').classList.remove('on'); cur=null; refresh(); } $('close').onclick=close_; $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); From ea639ff64115087d5810f60b5a8d5e0a73751801 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 18:00:00 +0100 Subject: [PATCH 02/23] fix(newsletter-reels): export defaults to the m1 stock track and refuses the generated score --- .claude/skills/newsletter-reels/scripts/export.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/export.py b/.claude/skills/newsletter-reels/scripts/export.py index 726e635b..64e9c715 100755 --- a/.claude/skills/newsletter-reels/scripts/export.py +++ b/.claude/skills/newsletter-reels/scripts/export.py @@ -63,8 +63,11 @@ def main() -> None: export_out.mkdir(parents=True, exist_ok=True) desktop.mkdir(parents=True, exist_ok=True) for topic, slug in picks: - choice = audio.get(topic, "score") - export_slug = slug if choice == "score" else f"{slug}-{choice}" + # The owner's rule: always one of the stock tracks, never the generated score. + choice = audio.get(topic, "m1") + if choice == "score": + raise SystemExit(f"{slug}: the generated score is never exported; choose m1 or m2") + export_slug = f"{slug}-{choice}" post_dirs = sorted(issue_posts.glob(f"{topic}-*")) if issue_posts.exists() else [] destination = export_out / f"{export_slug}.mp4" if not args.output_dir and topic == "0" and issue_posts.exists(): From a11dda5e5b233deb61f114d8541e734601b70e65 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 18:00:00 +0100 Subject: [PATCH 03/23] feat(newsletter-reels): build stock-track variants for every reel as it lands, and state the stock-only music rule in the skill and worker brief --- .claude/skills/newsletter-reels/SKILL.md | 12 ++++++---- .../skills/newsletter-reels/briefs/worker.md | 4 ++-- .../newsletter-reels/scripts/stock_music.sh | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) create mode 100755 .claude/skills/newsletter-reels/scripts/stock_music.sh diff --git a/.claude/skills/newsletter-reels/SKILL.md b/.claude/skills/newsletter-reels/SKILL.md index cf8c4115..ec45a90b 100644 --- a/.claude/skills/newsletter-reels/SKILL.md +++ b/.claude/skills/newsletter-reels/SKILL.md @@ -1,12 +1,14 @@ --- name: newsletter-reels -description: Make short motion-graphics reels for a newsletter issue - one 1920x1080 reel for the issue itself and one 1080x1350 reel per article post, 20-30 s with music - five directions each built by parallel Opus workers, reviewed and picked in a local gallery, revised in rounds, scored with a stock track or the composed score, exported once. Use after newsletter-issue step 8, or when asked for reels, shorts or videos of the issue's posts. +description: Make short motion-graphics reels for a newsletter issue - one 1920x1080 reel for the issue itself and one 1080x1350 reel per article post, 20-30 s with music - five directions each built by parallel Opus workers, reviewed and picked in a local gallery, revised in rounds, scored with one of the owner's stock tracks (never the generated score), exported once. Use after newsletter-issue step 8, or when asked for reels, shorts or videos of the issue's posts. --- # Newsletter reels Six topics: `0-issue`, the newsletter's own reel (1920x1080, LinkedIn's landscape feed video), and the five article posts (1080x1350). Five reels per topic, each a different direction, built in parallel by headless Opus workers on the vendored motion engine in `engine/`. The owner reviews live HTML pages in a gallery, picks one per article, and every change after that is a round on the same worker. The mp4s are exported once, at the end. +**Music rule, no exceptions:** every reel the owner hears or receives plays one of the owner's stock tracks from the main checkout's `.music/pixabay/`. The worker's composed score is only a beat grid that the cuts and sound effects lock to: it is never played in the gallery, never offered as a choice, and never exported. `gallery.html` plays only the m1/m2 stock variants, `export.py` refuses `score`, and `stock_music.sh` builds both variants for every reel as soon as it lands (step 6). + In the commands below `$SK` is this skill's directory and `$R` is `tmp/reels//`. Scripts live in `$SK/scripts/`. ## 1. Prepare the inputs @@ -28,11 +30,13 @@ Explain the launch to the owner (how many workers, what each builds) and wait fo About a minute after the launch, check that the processes are alive and the logs exist before you report anything as running. +Write `$R/music-variants.txt` (step 6) before or right after the launch, then run `bash $SK/scripts/stock_music.sh ` in the background. It builds the m1 and m2 stock variants for each reel the moment its page lands, and exits when the launcher does, so no reel ever reaches the gallery with only its score. + `launch.sh` and `resume.sh` were only exercised with a stand-in command when the skill was written (the 409 workers ran from the prototype scripts). If either misbehaves on a real run, fix the script, note what broke in `references/learnings.md`, and carry on. ## 4. Gallery and picks -1. Run `python3 $SK/scripts/gallery.py ` in the background and open `http://127.0.0.1:8767/gallery.html` in the owner's browser. Always open review URLs in the browser for the owner; never only print them. +1. Check nothing else holds port 8767 first (`lsof -nP -iTCP:8767 -sTCP:LISTEN`): a previous issue's gallery server left running serves the old issue silently. Run `python3 $SK/scripts/gallery.py ` in the background and open `http://127.0.0.1:8767/gallery.html` in the owner's browser. Always open review URLs in the browser for the owner; never only print them. 2. **Gate:** the owner plays the reels and picks one per topic. Picks land in `$R/selections.json`. ## 5. Revision rounds @@ -46,9 +50,9 @@ Before you resume, explain to the owner which reels you will resume and with wha ## 6. Music 1. Stock tracks live in the main checkout's `.music/pixabay/` (gitignored). The owner downloads them; you never download music yourself. Before the run, tell the owner which moods the library is thin on, and what fits best: 1-3 minute instrumental tracks at about 118-130 bpm that hit full energy early, across a few moods (dark cinematic, upbeat house, minimal techno, playful), from the Pixabay Music licence. New tracks get analysed with `python3 $SK/scripts/analyse_tracks.py` (it skips the ones already in `analysis.json`). -2. Pick two tracks per picked reel by tempo and mood (the reel's `work//beatmap.md` names its sound), and write them to `$R/music-variants.txt`. +2. Pick two tracks for every reel, all 30, by tempo and mood (the topic's mood and the reel's `work//beatmap.md`), spreading the library so the set does not repeat one sound, and write them to `$R/music-variants.txt` as ` ` lines. Avoid the previous issue's picks for the issue reel. 3. `python3 $SK/scripts/swap_music.py ` builds `-`: the track from its first full-energy beat, cut to exactly the film's length and faded out over the last 2.5 s, the sound effects kept. A track longer than the film makes the player hold the last frame while the music plays on. `resume.sh --music` rebuilds these after every round. -4. **Gate:** the owner chooses score, m1 or m2 per reel in the gallery. +4. **Gate:** the owner chooses m1 or m2 per reel in the gallery (m1 plays by default). There is no score option anywhere: `stock_music.sh` has already built both variants for every reel, and `export.py` defaults to m1 and refuses `score`. ## 7. Export and hand over diff --git a/.claude/skills/newsletter-reels/briefs/worker.md b/.claude/skills/newsletter-reels/briefs/worker.md index 626e84c7..174bd1af 100644 --- a/.claude/skills/newsletter-reels/briefs/worker.md +++ b/.claude/skills/newsletter-reels/briefs/worker.md @@ -1,6 +1,6 @@ To make a motion graphics video that shows what an incredible motion designer you are, like it's your showreel for a global expo keynote. Go all out. -You are ONE of {{COUNT}} parallel workers. Each is building one short social reel (LinkedIn/X feed, 1080x1350 portrait, 30 fps, 20-30 s, with composed music and sound effects) for one article in issue {{N}} of the ML Engineering newsletter from The Institute for Ethical AI Alignment & Safety (ethical.institute). The owner will see five reels per article, each in a radically different direction, and pick one. Earlier batches were judged "pretty bad" when they were generic, timid or too fast to read. A safe, template-looking result is the failure case. Yours must be polished and must show off a real capability. +You are ONE of {{COUNT}} parallel workers. Each is building one short social reel (LinkedIn/X feed, 1080x1350 portrait, 30 fps, 20-30 s, with sound effects; the music is one of the owner's stock tracks, swapped in after you build) for one article in issue {{N}} of the ML Engineering newsletter from The Institute for Ethical AI Alignment & Safety (ethical.institute). The owner will see five reels per article, each in a radically different direction, and pick one. Earlier batches were judged "pretty bad" when they were generic, timid or too fast to read. A safe, template-looking result is the failure case. Yours must be polished and must show off a real capability. ## Your assignment - Slug: {{SLUG}} @@ -15,7 +15,7 @@ You are ONE of {{COUNT}} parallel workers. Each is building one short social ree export REEL_HOME={{REELS}}/work export REEL_OUT={{REELS}}/out and `python3 reel.py init {{SLUG}} --format 1080x1350`, run from {{ENGINE}}. There is no user sign-off: write beatmap.md and proceed. -3. Compose your own score.py for this direction (genre, tempo and mood that suit it; not the example score). +3. Compose a score.py for this direction at 118-130 bpm, but only as the beat grid your cuts and sound effects lock to. The owner never hears it: every reel ships with one of the owner's stock tracks, cut from its first full-energy beat to your film's length, so land your hits on a steady 4/4 grid and keep the sound effects carrying the accents. 4. At least 3 review rounds on the check sheets (look at them with Read), with the SOCIAL.md checklist and the prompts.md feedback lens. Every headline held >= 1.5-2 s, key numbers 2-3 s. Captions on for sound-off viewing. No text clipping, overlaps or tiny type at phone size. 5. `build` only. Do NOT export: the owner reviews the HTML page, and the mp4 is exported once at the end. diff --git a/.claude/skills/newsletter-reels/scripts/stock_music.sh b/.claude/skills/newsletter-reels/scripts/stock_music.sh new file mode 100755 index 00000000..1a9a3f9f --- /dev/null +++ b/.claude/skills/newsletter-reels/scripts/stock_music.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Build the m1/m2 stock-track variants for every reel as soon as its page lands, +# so no reel ever reaches the gallery with only its generated score. +# Usage: stock_music.sh (reads $R/music-variants.txt: " ") +set -u +issue=$1 +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(git rev-parse --show-toplevel) +main=$(cd "$(git rev-parse --git-common-dir)/.." && pwd) +reels=${REEL_ROOT:-$root/tmp/reels}/$issue +music=$main/.music/pixabay +while true; do + while read -r slug tag track; do + [ -n "$slug" ] && [ -f "$reels/out/$slug.html" ] || continue + [ -f "$reels/out/$slug-$tag.html" ] && continue + if python3 "$script_dir/swap_music.py" "$issue" "$slug" "$tag" "$music/$track" < /dev/null > "$reels/logs/swap-$slug-$tag.log" 2>&1; then + echo "music ok $slug $tag" + else + echo "music FAIL $slug $tag (see logs/swap-$slug-$tag.log)" + fi + done < "$reels/music-variants.txt" + pgrep -f "launch.sh --controller $issue" > /dev/null || { echo "launcher done; $(ls "$reels"/out/*-m[12].html 2>/dev/null | wc -l | tr -d ' ') stock variants built"; exit 0; } + sleep 30 +done From 7ee0b71412eb4780cd1adcd0942d2bf2e79aacf6 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 18:00:00 +0100 Subject: [PATCH 04/23] docs(newsletter-reels): issue 410 learnings - stock music only, stale gallery port, issue reel reach --- .claude/skills/newsletter-reels/references/learnings.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/skills/newsletter-reels/references/learnings.md b/.claude/skills/newsletter-reels/references/learnings.md index 036fba37..5d014c1c 100644 --- a/.claude/skills/newsletter-reels/references/learnings.md +++ b/.claude/skills/newsletter-reels/references/learnings.md @@ -2,6 +2,14 @@ Newest first. Fold a rule into the step or brief where it applies once it has held; keep here why it exists. +## Issue 410 (2026-10-25) + +- **Never play the generated score.** The gallery defaulted to each worker's composed score and the m1/m2 stock variants were only built for picks, so the owner reviewed every reel with generated music and the first issue-reel export defaulted to `score`. The owner's rule is absolute: stock tracks only. The gallery now has no score option, export refuses it, and `stock_music.sh` builds both variants for all 30 reels as they land. +- **A stale gallery server serves the wrong issue.** Last week's gallery server was still bound to 8767, the new one died with "Address already in use", and the browser showed 409's reels. Check the port before starting. +- **The issue reel is the one that earns reach.** From Buffer's metrics: the 409 issue reel got ~56k LinkedIn impressions, about 25x the earlier issue posts, while the story reels sat around the image-post median. Only the photo-finish race held viewers (92% of impressions became views against 27-38% for the rest). Briefs now carry both points; the issue reel keeps the 409 skeleton. +- **Pull the issue reel forward.** The owner wanted to post the issue reel as soon as it existed: export it on its own (`export.py --slug `) once picked, without waiting for the story reels. +- **Workers read their brief at launch,** so a brief edited while the launcher is running reaches every worker not yet started. That is how the motion-reference techniques reached 20 of 30 reels mid-run. + ## Issue 409 (2026-10-18), first run - **Two formats.** The newsletter's own reel posts as LinkedIn's landscape feed video: 1920x1080. The five story reels stay 1080x1350 (the owner confirmed both after comparing against a real LinkedIn post). From 3c06c766ba10331b0ba0ef67a03ebaba7cefa92f Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 19:12:07 +0100 Subject: [PATCH 05/23] feat(newsletter-reels): slice the licensed Sonniss library into role-tagged one-shots --- .../newsletter-reels/scripts/sfx_library.py | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100755 .claude/skills/newsletter-reels/scripts/sfx_library.py diff --git a/.claude/skills/newsletter-reels/scripts/sfx_library.py b/.claude/skills/newsletter-reels/scripts/sfx_library.py new file mode 100755 index 00000000..f60ca851 --- /dev/null +++ b/.claude/skills/newsletter-reels/scripts/sfx_library.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Turn a folder of licensed WAV files into a small, role-based one-shot library.""" + +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + +from _common import main_root + + +ROLES = ("ui", "type", "paper", "whoosh", "riser", "impact", "boom", "glitch", "sheen", "alarm", "drone") +LONG_ROLES = {"riser", "boom", "drone"} + + +def role_for(path: Path) -> str | None: + text = " ".join(path.parts[-2:]).lower() + name = path.name.lower() + if any(word in text for word in ("paper", "newspaper", "page turn", "page movement")): + return "paper" + if "typewriter" in text: + return "type" + if "alarm" in text: + return "alarm" + if any(word in text for word in ("boom", "bass drop", "downer", "braam")): + return "boom" + if any(word in text for word in ("powerup", "power up", "blast off", "riser")): + return "riser" + if any(word in text for word in ("glitch", " arc", "arc_", "radio", "eq sweeping", "buzz")): + return "glitch" + if any(word in text for word in ("drone", "ambience", "roomtone", "sinister texture", "screechy tones")): + return "drone" + if any(word in text for word in ("magic", "sparkle", "shimmer", "twinkle", "kalimba", "xylophone", "bird flute")): + return "sheen" + if any(word in name for word in ("whoosh", "woosh", "rush", "pass by")): + return "whoosh" + if any(word in name for word in ("impact", "impt", " hit", "hit ", "thunk", "clatter", "shaker snap")): + return "impact" + if any(word in text for word in (" ui ", "ui_", "uimisc", "interface", "button", "click", "mechanism", "coin flip")): + return "ui" + return None + + +def probe_duration(path: Path) -> float: + out = subprocess.check_output( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)], + text=True, + ) + return float(out.strip()) + + +def takes(path: Path, duration: float) -> list[tuple[float, float]]: + run = subprocess.run( + ["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-af", "silencedetect=noise=-46dB:d=0.22", "-f", "null", "-"], + capture_output=True, + text=True, + check=True, + ) + spans, start = [], 0.0 + for line in run.stderr.splitlines(): + found = re.search(r"silence_(start|end):\s*([0-9.]+)", line) + if not found: + continue + when = float(found.group(2)) + if found.group(1) == "start": + if when - start >= 0.035: + spans.append((start, when)) + else: + start = when + if duration - start >= 0.035: + spans.append((start, duration)) + return spans or [(0.0, duration)] + + +def safe_name(path: Path, take: int) -> str: + stem = re.sub(r"[^a-z0-9]+", "-", path.stem.lower()).strip("-")[:64] + digest = hashlib.sha1(str(path).encode()).hexdigest()[:8] + return f"{stem}-{digest}-{take:02d}.wav" + + +def render(source: Path, destination: Path, start: float, end: float, role: str) -> float: + limit = 6.0 if role in LONG_ROLES else 3.0 + duration = min(limit, end - start) + fade = min(0.012, duration / 4) + filters = ( + "aresample=48000," + "loudnorm=I=-20:LRA=7:TP=-1.5," + f"afade=t=in:st=0:d={fade:.4f}," + f"afade=t=out:st={max(0, duration - fade):.4f}:d={fade:.4f}" + ) + subprocess.run( + [ + "ffmpeg", "-v", "error", "-y", "-ss", f"{start:.5f}", "-t", f"{duration:.5f}", "-i", str(source), + "-af", filters, "-ar", "48000", "-ac", "2", "-c:a", "pcm_s24le", str(destination), + ], + check=True, + stdin=subprocess.DEVNULL, + ) + return duration + + +def main() -> None: + default_source = main_root() / ".music" / "sfx" / "sonniss-gdc2026" + default_output = main_root() / ".music" / "sfx" / "oneshots" + parser = argparse.ArgumentParser() + parser.add_argument("source", nargs="?", type=Path, default=default_source) + parser.add_argument("output", nargs="?", type=Path, default=default_output) + args = parser.parse_args() + source, output = args.source.resolve(), args.output.resolve() + if not source.is_dir(): + raise SystemExit(f"missing sound library: {source}") + output.mkdir(parents=True, exist_ok=True) + index = {"sample_rate": 48000, "roles": {role: [] for role in ROLES}, "dropped": []} + for wav in sorted(source.rglob("*.wav")): + role = role_for(wav.relative_to(source)) + if not role: + index["dropped"].append(str(wav.relative_to(source))) + continue + role_dir = output / role + role_dir.mkdir(exist_ok=True) + duration = probe_duration(wav) + for number, (start, end) in enumerate(takes(wav, duration), 1): + name = safe_name(wav.relative_to(source), number) + relative = Path(role) / name + rendered = render(wav, output / relative, start, end, role) + index["roles"][role].append({ + "file": relative.as_posix(), + "source": str(wav.relative_to(source)), + "take": number, + "duration": round(rendered, 4), + }) + (output / "index.json").write_text(json.dumps(index, indent=2) + "\n") + for role in ROLES: + print(f"{role}: {len(index['roles'][role])}") + print(f"dropped: {len(index['dropped'])}") + for path in index["dropped"]: + print(f" {path}") + missing = [role for role in ("ui", "whoosh", "impact") if len(index["roles"][role]) < 3] + if missing: + raise SystemExit("too few samples for: " + ", ".join(missing)) + + +if __name__ == "__main__": + main() From 0dd89cacdd951bc94f0ee652fc57ac6a4c29c1f3 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 19:12:08 +0100 Subject: [PATCH 06/23] feat(newsletter-reels): recorded-sample mode in sfx.py behind SFX_LIB, synth path byte-identical when unset --- .claude/skills/newsletter-reels/engine/sfx.py | 170 +++++++++++++----- 1 file changed, 127 insertions(+), 43 deletions(-) diff --git a/.claude/skills/newsletter-reels/engine/sfx.py b/.claude/skills/newsletter-reels/engine/sfx.py index c8315bdf..66142b8a 100644 --- a/.claude/skills/newsletter-reels/engine/sfx.py +++ b/.claude/skills/newsletter-reels/engine/sfx.py @@ -22,7 +22,9 @@ pass -2 dBFS. The stem is the same length as the music. Unknown cue kinds are skipped with a warning. """ +import hashlib import json +import os import sys import time from pathlib import Path @@ -38,6 +40,7 @@ PLAYER_GAIN = 0.8 # engine.js MIX: music 1, sfx 1, master .8 -> each stem reaches the output at 0.8 BUS_HP = {"ui": 250, "move": 180, "tone": 200, "hit": 110} BUS_GAIN = {"ui": 1.6, "move": 1.25, "tone": 1.25, "hit": 0.8, "low": 0.7} # booms own the peaks, not the loudness +SAMPLE_GAIN = 10 ** (-4 / 20) EASE = { "linear": lambda x: x, "outCubic": lambda x: 1 - (1 - x) ** 3, @@ -219,6 +222,24 @@ def build(work): bus = {k: np.zeros((2, N), np.float32) for k in ("ui", "move", "hit", "low", "tone")} send = np.zeros((2, N), np.float32) + sample_root = Path(os.environ["SFX_LIB"]).expanduser().resolve() if os.environ.get("SFX_LIB") else None + samples = {} + sample_rng = None + sample_last = {} + sample_used = {} + if sample_root: + try: + sample_index = json.loads((sample_root / "index.json").read_text()) + samples = { + role: sorted(sample_root / item["file"] for item in items if (sample_root / item["file"]).is_file()) + for role, items in sample_index.get("roles", {}).items() + } + seed_text = work.name + "\n" + json.dumps(cues, sort_keys=True, separators=(",", ":")) + sample_rng = np.random.default_rng(int(hashlib.sha256(seed_text.encode()).hexdigest()[:16], 16)) + except (OSError, ValueError, TypeError, KeyError) as exc: + print(f"sfx: warning: could not load sample library {sample_root}: {exc}; using synthesis") + sample_root = None + def put(b, sig, t0, gain=1.0, pan=0.0, rev=0.0): i = int(round(t0 * SR)) sig = np.atleast_2d(sig) @@ -237,6 +258,27 @@ def put(b, sig, t0, gain=1.0, pan=0.0, rev=0.0): def xpan(e, amt=0.55): return ((e.get("x", FW / 2) / FW) * 2 - 1) * amt + def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=None): + choices = samples.get(role, []) + if not choices: + return False + previous = sample_last.get((kind, role)) + available = [path for path in choices if path != previous] or choices + path = available[int(sample_rng.integers(len(available)))] + sample_last[(kind, role)] = path + relative = path.relative_to(sample_root).as_posix() + if relative not in sample_used.setdefault(kind, []): + sample_used[kind].append(relative) + sig = M.read_audio(path).mean(axis=0) + if max_d and len(sig) > int(max_d * SR): + sig = sig[: int(max_d * SR)].copy() + fade = min(len(sig), int(0.006 * SR)) + sig[-fade:] *= np.linspace(1, 0, fade) + put(b, sig, t0, gain * SAMPLE_GAIN, pan, rev) + if also: + put(also[0], sig, t0, also[1] * SAMPLE_GAIN, also[2], also[3]) + return True + popf = { "pill": deg(4), "input": deg(2), @@ -255,17 +297,26 @@ def xpan(e, amt=0.55): counts[k] = counts.get(k, 0) + 1 if k == "type": txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - put( - "ui", - P.click(0.005, 3000, 8000, seed=int(s * 1000) + i), - s + i * rate, - 0.09 * (0.6 + 0.4 * rng.random()), - xpan(e) + (i / max(n, 1) - 0.5) * 0.2, - ) - put("ui", P.tick(deg(9 + n % 3), 0.04), s + n * rate, 0.025, xpan(e)) + if samples.get("type"): + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + put_sample( + k, "type", "ui", s + i * rate, 0.09 * (0.6 + 0.4 * sample_rng.random()), + xpan(e) + (i / max(n, 1) - 0.5) * 0.2, max_d=min(0.09, rate * 1.8), + ) + else: + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + put( + "ui", + P.click(0.005, 3000, 8000, seed=int(s * 1000) + i), + s + i * rate, + 0.09 * (0.6 + 0.4 * rng.random()), + xpan(e) + (i / max(n, 1) - 0.5) * 0.2, + ) + put("ui", P.tick(deg(9 + n % 3), 0.04), s + n * rate, 0.025, xpan(e)) elif k == "slam": n, st, size = ( int(e.get("n", len(str(e.get("s", "x"))))), @@ -273,6 +324,10 @@ def xpan(e, amt=0.55): float(e.get("size", 90)), ) big = size >= 90 + if samples.get("whoosh") and samples.get("impact"): + put_sample(k, "whoosh", "move", s, 0.10 if big else 0.05, xpan(e), rev=0.15) + put_sample(k, "impact", "hit", s + 0.06, 0.35 if size >= 150 else 0.18, xpan(e), rev=0.12) + continue put( "move", P.swish(n * st + 0.35, 1500, 6000 if big else 4500, 0.8, 0.25, seed=int(s * 997)), @@ -292,53 +347,64 @@ def xpan(e, amt=0.55): if size >= 150: put("hit", P.thock(150 if size > 200 else 190), s + 0.06, 0.35, xpan(e)) elif k == "para": - put("move", P.swish(0.35, 2500, 5000, 0.9, 0.3, seed=int(s * 71)), s, 0.025, xpan(e)) + if not put_sample(k, "whoosh", "move", s, 0.025, xpan(e)): + put("move", P.swish(0.35, 2500, 5000, 0.9, 0.3, seed=int(s * 71)), s, 0.025, xpan(e)) elif k == "pop": - put("ui", P.pop(popf.get(e.get("s"), deg(5))), s, 0.16, xpan(e, 0.3), rev=0.12) + if not put_sample(k, "ui", "ui", s, 0.16, xpan(e, 0.3), rev=0.12): + put("ui", P.pop(popf.get(e.get("s"), deg(5))), s, 0.16, xpan(e, 0.3), rev=0.12) elif k == "ring": if e.get("r", 100) < 40: continue - put( - "tone", - P.bell(deg(10 + int(e.get("r", 100) > 300) * 2), 0.9, 0.6, 2.0), - s, - 0.035, - xpan(e, 0.4), - rev=0.35, - ) + if not put_sample(k, "sheen", "tone", s, 0.035, xpan(e, 0.4), rev=0.35): + put( + "tone", + P.bell(deg(10 + int(e.get("r", 100) > 300) * 2), 0.9, 0.6, 2.0), + s, + 0.035, + xpan(e, 0.4), + rev=0.35, + ) elif k == "heart": put("hit", P.heart(), s, 0.5) elif k == "drop": # outBounce contacts for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: put("hit", P.thock(180), s + x * d, 0.45 * a) elif k == "whoosh": - put( - "move", - P.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), - s - (0.02 if e.get("dir") != "in" else 0), - 0.22, - rev=0.12, - ) + if not put_sample(k, "whoosh", "move", s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.12): + put( + "move", + P.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), + s - (0.02 if e.get("dir") != "in" else 0), + 0.22, + rev=0.12, + ) elif k == "riser": - put("move", P.riser(max(d, 0.3)), s, 0.18, rev=0.2) + if not put_sample(k, "riser", "move", s, 0.18, rev=0.2, max_d=max(d, 0.3)): + put("move", P.riser(max(d, 0.3)), s, 0.18, rev=0.2) elif k == "shock": put("move", P.swish(0.55, 400, 2500, 1.2, 0.08, seed=int(s * 17)), s, 0.10) put("hit", P.thock(130, 0.2), s, 0.25) elif k == "impact": st = float(e.get("s", 1.0)) - if st >= 0.95: + if put_sample(k, "impact", "hit", s, 0.55 * max(0.3, st), rev=0.18): + pass + elif st >= 0.95: put("hit", P.impact(1), s, 0.55, rev=0.18) elif st >= 0.5: put("hit", P.impact(0.6), s, 0.5 * st, rev=0.12) else: put("hit", P.thock(180, 0.12), s, 0.5 * st + 0.1) elif k == "boom": + if samples.get("boom"): + put_sample(k, "boom", "hit", s, 0.75, rev=0.3, also=("low", 0.9, 0.0, 0.0)) + continue st, sub = P.boom(min(1.2, float(e.get("s", 1.2)) / 1.2)) put("hit", st, s, 0.75, rev=0.3) put("low", sub, s, 0.9) put("move", P.swish(0.9, 9000, 3000, 1.0, 0.02, seed=int(s)), s, 0.08, rev=0.3) elif k == "flip": - put("ui", P.flip(), s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08) + if not put_sample(k, "glitch", "ui", s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08): + put("ui", P.flip(), s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08) elif k == "sweep": n = int(d * SR) t = np.arange(n) / SR @@ -408,8 +474,9 @@ def xpan(e, amt=0.55): ) put("move", P.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.15) elif k == "strike": - put("move", P.swish(d + 0.12, 2000, 9000, 0.6, 0.8, seed=81), s, 0.22, rev=0.1) - put("ui", P.click(0.006, 1500, 9000, 82), s + d, 0.35) + if not put_sample(k, "whoosh", "move", s, 0.22, rev=0.1, max_d=d + 0.12): + put("move", P.swish(d + 0.12, 2000, 9000, 0.6, 0.8, seed=81), s, 0.22, rev=0.1) + put("ui", P.click(0.006, 1500, 9000, 82), s + d, 0.35) elif k == "shimmer": for i in range(22): put( @@ -421,6 +488,8 @@ def xpan(e, amt=0.55): rev=0.5, ) elif k == "bells": + if put_sample(k, "sheen", "tone", s, 0.10, rev=0.6): + continue final = bool(e.get("final")) for i, m in enumerate([0, 4, 7, 9, 12] if final else [0, 2, 4, 5, 7, 9]): put( @@ -449,6 +518,8 @@ def xpan(e, amt=0.55): put("ui", P.click(0.0025, 2500, 9000, 200 + j), s + j / 30, 0.03, xpan(e, 0.4)) put("ui", P.thock(900, 0.04), s + d, 0.06, xpan(e, 0.4)) elif k == "burst": + if put_sample(k, "glitch", "ui", s, 0.22, rev=0.12): + continue for i in range(90): put( "ui", @@ -461,6 +532,8 @@ def xpan(e, amt=0.55): put("move", P.swish(0.7, 7000, 1500, 0.9, 0.05, seed=301), s, 0.1, 0.3, rev=0.15) elif k == "swirl": g = float(e.get("s", 1.0)) + if put_sample(k, "sheen", "tone", s, 0.07 * g, rev=0.35, max_d=d): + continue p0, p1 = (e.get("from", FW / 2) / FW) * 2 - 1, (e.get("to", FW / 2) / FW) * 2 - 1 nn = int(60 + 90 * d) for i in range(nn): @@ -487,13 +560,17 @@ def xpan(e, amt=0.55): ) elif k == "sheen": g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 - put("move", P.swish(d, 3500, 11000, 0.6, 0.7, seed=321), s, 0.07 * g, -0.4, rev=0.2) - for j, m in enumerate((12, 14)): - put("tone", P.bell(deg(m), 1.4, 0.8, 2.0), s + d * 0.6 + j * 0.06, 0.035 * g, 0.4, rev=0.5) + if not put_sample(k, "sheen", "tone", s, 0.07 * g, -0.4, rev=0.35, max_d=d): + put("move", P.swish(d, 3500, 11000, 0.6, 0.7, seed=321), s, 0.07 * g, -0.4, rev=0.2) + for j, m in enumerate((12, 14)): + put("tone", P.bell(deg(m), 1.4, 0.8, 2.0), s + d * 0.6 + j * 0.06, 0.035 * g, 0.4, rev=0.5) elif k == "lock": - put("ui", P.click(0.006, 2000, 10000, 331), s, 0.3) - put("move", P.swish(0.5, 9000, 2500, 0.9, 0.02, seed=332), s, 0.08, rev=0.3) + if not put_sample(k, "ui", "ui", s, 0.3, rev=0.2): + put("ui", P.click(0.006, 2000, 10000, 331), s, 0.3) + put("move", P.swish(0.5, 9000, 2500, 0.9, 0.02, seed=332), s, 0.08, rev=0.3) elif k == "halo": + if put_sample(k, "drone", "tone", s, 0.025, rev=0.4, max_d=d): + continue n = int(d * SR) t = np.arange(n) / SR env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) @@ -507,8 +584,9 @@ def xpan(e, amt=0.55): ) put("tone", np.stack([pad * env, np.roll(pad, 300) * env]), s, 0.025, rev=0.4) elif k == "blip": - put("tone", P.bell(deg(10), 1.6, 0.8, 2.0), s, 0.12, rev=0.6) - put("ui", P.tick(deg(10), 0.06), s, 0.08) + if not put_sample(k, "ui", "ui", s, 0.12, rev=0.4): + put("tone", P.bell(deg(10), 1.6, 0.8, 2.0), s, 0.12, rev=0.6) + put("ui", P.tick(deg(10), 0.06), s, 0.08) elif ( k == "count" ): # pitched ticks climbing the scale with the value, thinned to >= 30 ms, a bell on the final value @@ -529,18 +607,24 @@ def xpan(e, amt=0.55): continue last = tk frac = min(1, float(v) / to) if to else 0.5 - put("ui", P.tick(deg(int(frac * 9)), 0.03), tk, 0.07 + 0.05 * frac, xpan(e, 0.35)) - if ticks and ("to" in e or "d" in e): + if not put_sample(k, "ui", "ui", tk, 0.07 + 0.05 * frac, xpan(e, 0.35), max_d=0.05): + put("ui", P.tick(deg(int(frac * 9)), 0.03), tk, 0.07 + 0.05 * frac, xpan(e, 0.35)) + if not samples.get("ui") and ticks and ("to" in e or "d" in e): for m, a in [(10, 0.12), (14, 0.07)]: put("tone", P.bell(deg(m), 1.8, 1.4), ticks[-1][0], a, 0, rev=0.4) elif k == "land": j = lands.index(e) f = deg(5 + int(j / max(len(lands), 1) * 10)) - put("ui", P.pop(f * (1 + 0.02 * rng.standard_normal()), 0.03), s, 0.028, xpan(e, 0.6)) + if not put_sample(k, "ui", "ui", s, 0.028, xpan(e, 0.6), max_d=0.06): + put("ui", P.pop(f * (1 + 0.02 * rng.standard_normal()), 0.03), s, 0.028, xpan(e, 0.6)) else: unknown[k] = unknown.get(k, 0) + 1 for k, n in unknown.items(): print(f"sfx: warning: skipped {n} '{k}' cue(s): unknown kind") + if sample_used and os.environ.get("SFX_REPORT_SAMPLES"): + print("sfx samples:") + for kind, paths in sorted(sample_used.items()): + print(f" {kind} -> {', '.join(paths)}") # ---- mix: carve, duck the sub under the music's kick, reverb, level stem = {b: M.fftfilt(bus[b].astype(np.float64), M.hpg(fc, 2)) for b, fc in BUS_HP.items()} From 755f0dedaccb6741b6ee93a0aa2b54a5ce807d94 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 19:12:08 +0100 Subject: [PATCH 07/23] feat(newsletter-reels): build m3 variants (m1 track plus recorded sfx) for A/B review --- .../newsletter-reels/scripts/recorded_sfx.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100755 .claude/skills/newsletter-reels/scripts/recorded_sfx.py diff --git a/.claude/skills/newsletter-reels/scripts/recorded_sfx.py b/.claude/skills/newsletter-reels/scripts/recorded_sfx.py new file mode 100755 index 00000000..abf91771 --- /dev/null +++ b/.claude/skills/newsletter-reels/scripts/recorded_sfx.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Build m3 variants: m1 stock music with recorded sound effects.""" + +import os +import subprocess +import sys +from pathlib import Path + +from _common import engine_dir, main_root, music_dir, reels_dir + + +def main() -> None: + if len(sys.argv) < 3: + raise SystemExit("usage: recorded_sfx.py ...") + issue, *slugs = sys.argv[1:] + root = reels_dir(issue) + variants = {} + for line in (root / "music-variants.txt").read_text().splitlines(): + fields = line.split(maxsplit=2) + if len(fields) == 3 and fields[1] == "m1": + variants[fields[0]] = fields[2] + sfx_lib = Path(os.environ.get("SFX_LIB", main_root() / ".music" / "sfx" / "oneshots")).resolve() + if not (sfx_lib / "index.json").is_file(): + raise SystemExit(f"missing recorded SFX library: {sfx_lib}") + scripts = Path(__file__).resolve().parent + env = dict( + os.environ, + REEL_HOME=str(root / "work"), + REEL_OUT=str(root / "out"), + SFX_LIB=str(sfx_lib), + ) + engine_venv = Path.home() / ".config" / "slt-agentic-marketplace" / "motion-reel" / "venv" / "bin" + if (engine_venv / "python3").exists(): + env["PATH"] = str(engine_venv) + os.pathsep + env.get("PATH", "") + reported_samples = False + for slug in slugs: + if not (root / "out" / f"{slug}.html").is_file(): + print(f"{slug}: skipped (missing out/{slug}.html)") + continue + track = variants.get(slug) + if not track: + print(f"{slug}: skipped (no m1 line in music-variants.txt)") + continue + subprocess.run( + [sys.executable, str(scripts / "swap_music.py"), issue, slug, "m3", str(music_dir() / track)], + env=env, + check=True, + stdin=subprocess.DEVNULL, + ) + new_slug = f"{slug}-m3" + build_env = dict(env) + if not reported_samples: + build_env["SFX_REPORT_SAMPLES"] = "1" + subprocess.run( + ["python3", "reel.py", "sfx", new_slug], cwd=engine_dir(), env=build_env, check=True, stdin=subprocess.DEVNULL + ) + reported_samples = True + subprocess.run( + ["python3", "reel.py", "build", new_slug], cwd=engine_dir(), env=env, check=True, stdin=subprocess.DEVNULL + ) + print(f"{new_slug}: {track} with recorded sfx -> out/{new_slug}.html") + + +if __name__ == "__main__": + main() From 848b486ef68f5eb6fab94f1d03d4ebee1285b070 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 19:12:08 +0100 Subject: [PATCH 08/23] feat(newsletter-reels): gallery offers m3 and shows the audio buttons for every reel it plays --- .claude/skills/newsletter-reels/scripts/gallery.html | 9 +++++---- .claude/skills/newsletter-reels/scripts/gallery.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/gallery.html b/.claude/skills/newsletter-reels/scripts/gallery.html index 66701af6..b3785934 100644 --- a/.claude/skills/newsletter-reels/scripts/gallery.html +++ b/.claude/skills/newsletter-reels/scripts/gallery.html @@ -15,7 +15,7 @@ .box{display:flex;flex-direction:column;align-items:center;gap:10px}iframe{border:0;width:65.6vh;max-width:92vw;aspect-ratio:var(--w,4)/var(--h,5);background:#000;border-radius:8px} .bar{display:flex;gap:8px;align-items:center}.bar>button{padding:8px 14px;border-radius:8px;border:1px solid #555;background:#222;color:#eee;cursor:pointer}#cap{color:#ccc;min-width:220px;text-align:center} -

Newsletter reels

Pick one reel per topic and the row collapses to it, with its two stock-track options (m1 / m2, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

+

Newsletter reels

Pick one reel per topic and the row collapses to it, with its stock-track and recorded-SFX options (m1 / m2 / m3, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

@@ -29,7 +29,7 @@

Newsletter reels

Pick one reel per topic and function dims(slug){ const f=formats[slug]||formats[slug.replace(/-m\d+$/,'')]||'1080x1350', m=f.match(/^(\d+)x(\d+)$/); return m?[Number(m[1]),Number(m[2]),f]:[4,5,f]; } function ratio(slug){ const [w,h]=dims(slug); return `--w:${w};--h:${h}`; } function fitFrame(fr,w,h){ fr.style.width=Math.min(innerWidth*.92,innerHeight*.82*w/h)+'px'; fr.style.setProperty('--w',w); fr.style.setProperty('--h',h); fr.dataset.w=w; fr.dataset.h=h; } -function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) +function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')]),music.has(pk+'-m3')?[['m3','m1 + recorded sfx']]:[]) .map(([k,l])=>``).join(''); } function card(i,collapsed){ const ok=have.has(i.slug), t=i.topic, isSel=S[t]===i.slug; return `

`+(ok?`
▶ Play with audio
`:`
rendering...
`)+ @@ -41,11 +41,12 @@

Newsletter reels

Pick one reel per topic and async function save(){ await fetch('/select',{method:'POST',body:JSON.stringify(S)}); } async function pick(slug){ const t=item(slug).topic; if(S[t]===slug){ delete S[t]; } else { S[t]=slug; open_[t]=false; } await save(); draw(); } async function setAud(t,k){ S.audio[t]=k; await save(); draw(); if(cur&&item(cur).topic===t&&S[t]===cur) play(cur); } -function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'m1', fr=$('fr'); +function playButtons(slug,a){ const t=item(slug).topic; return ['m1','m2','m3'].filter(m=>music.has(slug+'-'+m)).map(m=>``).join(''); } +function play(slug,a0){ cur=slug; const t=item(slug).topic, a=a0||(S[t]===slug?aud(t):'m1'), fr=$('fr'); fr.onload=()=>{ try{ fr.contentWindow.addEventListener('keydown',e=>{ if(e.key==='Escape') close_(); }); const b=fr.contentDocument.getElementById('start'); b&&b.click(); fr.contentWindow.focus(); }catch(e){} }; const playSlug=slug+'-'+a, [w,h,format]=dims(playSlug); fitFrame(fr,w,h); fr.src=`out/${playSlug}.html?v=${V()}`; $('modal').classList.add('on'); - $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } + $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=playButtons(slug,a); } function step(d){ const list=ITEMS.filter(i=>have.has(i.slug)&&(!S[i.topic]||open_[i.topic]||S[i.topic]===i.slug)).map(i=>i.slug); let k=list.indexOf(cur); play(list[(k+d+list.length)%list.length]); } function close_(){ $('fr').src='about:blank'; $('modal').classList.remove('on'); cur=null; refresh(); } $('close').onclick=close_; $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); diff --git a/.claude/skills/newsletter-reels/scripts/gallery.py b/.claude/skills/newsletter-reels/scripts/gallery.py index e1348163..2ee9c1b1 100755 --- a/.claude/skills/newsletter-reels/scripts/gallery.py +++ b/.claude/skills/newsletter-reels/scripts/gallery.py @@ -78,7 +78,7 @@ def do_GET(self) -> None: shutil.copy2(source, destination) out = self.reels / "out" base = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]\.html$") - music = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]-m\d+\.html$") + music = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]-m[123]\.html$") names = [entry.name for entry in out.iterdir()] if out.exists() else [] formats = {} for directory in work.iterdir() if work.exists() else []: From 16b748596ed35cdfb9bac6b6f402dc7e2bffcc9a Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 19:12:18 +0100 Subject: [PATCH 09/23] fix(newsletter-reels): briefs.py accepts any direction letter, not only a-e --- .claude/skills/newsletter-reels/scripts/briefs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/briefs.py b/.claude/skills/newsletter-reels/scripts/briefs.py index 4fefb50a..436a6fc9 100644 --- a/.claude/skills/newsletter-reels/scripts/briefs.py +++ b/.claude/skills/newsletter-reels/scripts/briefs.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Render one worker brief per direction in tmp/reels//directions.md into tmp/reels//briefs/-.md. +"""Render one worker brief per direction in tmp/reels//directions.md into tmp/reels//briefs/-.md. -usage: briefs.py [--only ...] [--force] +usage: briefs.py [--only ...] [--force] Topic 0 (the issue reel) gets briefs/issue-reel.md appended and the 1920x1080 format; the stories stay 1080x1350. """ @@ -14,7 +14,7 @@ SKILL = Path(__file__).resolve().parent.parent TOPIC = re.compile(r"^## (\d)-(\S+)\s*(?:\((.*)\))?\s*$") -ITEM = re.compile(r"^- (?:\[PICKED\] )?\*\*(\d)([a-e]) (.+?)\*\*\s*(.*)$") +ITEM = re.compile(r"^- (?:\[PICKED\] )?\*\*(\d)([a-z]) (.+?)\*\*\s*(.*)$") def parse(text: str): From da01e7963b7703c8a6cf4416c9c5fe20ce6d7cdd Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:08 +0100 Subject: [PATCH 10/23] wip(newsletter-reels): three synthesised sfx palettes behind SFX_PALETTE and the sfx_lab comparison renderer --- .claude/skills/newsletter-reels/engine/sfx.py | 12 + .../newsletter-reels/engine/sfx_palettes.py | 802 ++++++++++++++++++ .../newsletter-reels/scripts/sfx_lab.py | 206 +++++ 3 files changed, 1020 insertions(+) create mode 100644 .claude/skills/newsletter-reels/engine/sfx_palettes.py create mode 100644 .claude/skills/newsletter-reels/scripts/sfx_lab.py diff --git a/.claude/skills/newsletter-reels/engine/sfx.py b/.claude/skills/newsletter-reels/engine/sfx.py index 66142b8a..4acfe628 100644 --- a/.claude/skills/newsletter-reels/engine/sfx.py +++ b/.claude/skills/newsletter-reels/engine/sfx.py @@ -20,6 +20,9 @@ ~110 Hz, and it is ducked by the music's kick. The stem sits TARGET_UNDER dB under the music (integrated), <= -2 dBTP, and a sum limiter pulls the stem down only where music + stem, as the player mixes them, would pass -2 dBFS. The stem is the same length as the music. Unknown cue kinds are skipped with a warning. + +SFX_PALETTE=b|c|d swaps the voices for another sound world (sfx_palettes.py) on the same buses, times and levels; +unset or a, it is the palette above. """ import hashlib @@ -217,6 +220,13 @@ def build(work): N = music.shape[1] tonic, mode = M.parse_key(info.get("key", "A minor")) P = Palette(tonic, mode, M._seed(info.get("key", "")) + len(events), float(info.get("root_hz", 55.0))) + V = None + if os.environ.get("SFX_PALETTE", "a") != "a": + from sfx_palettes import PALETTES + + V = PALETTES[os.environ["SFX_PALETTE"]]( + tonic, mode, M._seed(info.get("key", "")) + len(events), float(info.get("root_hz", 55.0)) + ) rng = P.rng deg = P.deg bus = {k: np.zeros((2, N), np.float32) for k in ("ui", "move", "hit", "low", "tone")} @@ -295,6 +305,8 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N k, s = e.get("k"), float(e.get("t", 0)) d = float(e.get("d", 0.6)) counts[k] = counts.get(k, 0) + 1 + if V and V.voice(k, e, s, d, put, xpan, lands, FW): + continue if k == "type": txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) if samples.get("type"): diff --git a/.claude/skills/newsletter-reels/engine/sfx_palettes.py b/.claude/skills/newsletter-reels/engine/sfx_palettes.py new file mode 100644 index 00000000..fddd30f0 --- /dev/null +++ b/.claude/skills/newsletter-reels/engine/sfx_palettes.py @@ -0,0 +1,802 @@ +"""Alternative SFX palettes for sfx.py, picked with SFX_PALETTE=b|c|d (a, the default, is sfx.py's own voices). + +Each palette voices every cue kind sfx.build() knows, on the same bus, at the same times and with the same panning +as palette a, so build() mixes and levels it exactly the same way. Pitched sounds use deg(), the key's pentatonic. + + b organic / analog marimba, kalimba and wood-block modal plucks, Karplus-Strong, felt clicks, breathy + tape-wobbled air, warm saturated drums. No glassy shimmer. + c minimal / clinical short dry resonant clicks, tiny noise ticks, soft sine thumps, air breaths. No bells. + d electronic / textural resonant filter sweeps, bit-crushed square blips, granular stutters, reversed swells, + vinyl grit, a deep 808 drop. Sparkles become crushed arpeggios and glitch flutters. +""" + +import numpy as np + +import music as M +from sfx import EASE, Palette, adsr, mf, tt + +SR = M.SR + + +def modal(f, ratios, amps, decays, d, att=0.0006): + """A struck object: decaying sine partials at f * ratio.""" + t = tt(d) + y = sum(a * np.sin(2 * np.pi * f * r * t) * np.exp(-t / dc) for r, a, dc in zip(ratios, amps, decays) if f * r < 16000) + return y * np.minimum(1, t / att) + + +def mix(*xs): + """Sum mono signals of different lengths.""" + y = np.zeros(max(len(x) for x in xs)) + for x in xs: + y[: len(x)] += x + return y + + +def sat(x, k=2.0): + return np.tanh(k * x) / np.tanh(k) + + +def crush(x, bits=6, hold=4): + """Bit depth and sample-rate reduction.""" + q = 2 ** (bits - 1) + y = np.round(np.repeat(x[::hold], hold)[: len(x)] * q) / q + return y + + +def env_hann(n, p=1.0): + return np.sin(np.pi * np.arange(n) / max(n, 1)) ** p + + +class Voiced(Palette): + """A palette that voices whole cues: voice() returns True when it handled the cue.""" + + def voice(self, k, e, s, d, put, xpan, lands, FW): + fn = getattr(self, "c_" + str(k), None) + if fn is None: + return False + self.e, self.put, self.xpan, self.lands, self.FW = e, put, xpan, lands, FW + fn(e, s, d) + return True + + def popf(self, name): + deg = self.deg + return { + "pill": deg(4), "input": deg(2), "point": deg(7), "dot": deg(0), "snap": deg(5), + "core": deg(2, self.low), "disc": deg(0, self.low), "square": deg(4, self.low), + }.get(name, deg(5)) + + def count_ticks(self, e, s, d): + """The same tick list palette a plays: thinned to >= 30 ms, the last one always kept.""" + to = float(e.get("to", 0) or 0) + ticks = e.get("ticks") + if ticks is None and "v" in e: + ticks = [[s, e["v"]]] + if ticks is None: + ease, ticks, last = EASE.get(e.get("ease"), EASE["outCubic"]), [], 0 + for x in np.arange(0, d + 1e-9, 0.001): + v = int(np.floor(to * ease(min(1, x / d)) + 1e-9)) + if v > last: + ticks.append([s + x, v]) + last = v + out, last = [], -1.0 + for j, (tk, v) in enumerate(ticks): + if tk - last < 0.03 and j < len(ticks) - 1: + continue + last = tk + out.append((tk, min(1, float(v) / to) if to else 0.5)) + return out, bool(ticks) and ("to" in e or "d" in e) + + def pan_from_to(self, e): + return (e.get("from", self.FW / 2) / self.FW) * 2 - 1, (e.get("to", self.FW / 2) / self.FW) * 2 - 1 + + def land_index(self, e): + return self.lands.index(e), max(len(self.lands), 1) + + +# ---------------------------------------------------------------------------------------------------- b: organic + + +class Organic(Voiced): + def felt(self, d=0.012, lo=300, hi=2600, seed=None): + n = int(d * SR) + x = M.fftfilt(self.noise(n + 512, seed), M.bpg(lo, hi))[256 : 256 + n] + return x * adsr(n, 0.0015, d / 3) + + def wood(self, f=900, d=0.09, seed=None): + y = modal(f, (1, 2.57, 4.21), (1, 0.35, 0.12), (d / 3, d / 8, d / 14), d) + k = self.felt(0.006, 600, 4000, seed) + y[: len(k)] += 0.5 * k + return y + + def marimba(self, f, d=0.9): + y = modal(f, (1, 3.93, 9.2), (1, 0.22, 0.05), (d / 3.2, d / 14, d / 40), d, 0.001) + k = self.felt(0.01, 200, 1800) + y[: len(k)] += 0.25 * k + return sat(y, 1.2) + + def kalimba(self, f, d=1.2): + y = modal(f, (1, 5.95, 2.0), (1, 0.16, 0.04), (d / 3.5, d / 25, d / 8), d, 0.0008) + k = self.felt(0.004, 1500, 6000) + y[: len(k)] += 0.15 * k + return y + + def ks(self, f, d=0.6, damp=0.996, seed=None): + """Karplus-Strong, computed a period at a time.""" + n, p = int(d * SR), max(2, int(SR / f)) + y = np.zeros(n + p + 1) + y[: p + 1] = M.fftfilt(self.noise(p + 1, seed), M.lpg(5000)) + for a in range(p + 1, n + p + 1, p): + b = min(a + p, n + p + 1) + y[a:b] = damp * 0.5 * (y[a - p : b - p] + y[a - p - 1 : b - p - 1]) + y = y[p + 1 :] + return y / (np.abs(y).max() + 1e-9) * adsr(n, 0.001, d / 2.5) + + def drum(self, f, d=0.5): + f = self.snap(f) + t = tt(d) + y = modal(f, (1, 1.59, 2.14, 2.3), (1, 0.4, 0.25, 0.15), (d / 4, d / 9, d / 12, d / 15), d, 0.0015) + y *= 1 + 0.25 * np.exp(-t / 0.02) + k = self.felt(0.02, 150, 1500) + y[: len(k)] += 0.5 * k + return sat(y, 2.2) + + def breath(self, d, lo=400, hi=2000, peak=0.4, bw=1.6, seed=None): + n = int(d * SR) + y = M.stft_band(self.noise(n, seed), np.geomspace(lo, hi, 48), np.full(48, bw)) + t = np.arange(n) / SR + y = y * (1 + 0.18 * np.sin(2 * np.pi * 5.3 * t) + 0.08 * np.sin(2 * np.pi * 0.9 * t)) # tape wobble + u = np.arange(n) / n + y = y * np.where(u < peak, np.sin(np.pi / 2 * u / peak) ** 2, np.exp(-(u - peak) / (1 - peak) * 4)) + return sat(M.fftfilt(y, M.lpg(6500)) / (np.abs(y).max() + 1e-9), 1.6) * 0.35 # level with palette a's swish + + def whoosh(self, d, s=0.6, dir="side", seed=None): + d = max(d, 0.25) + n = int((d + 0.15) * SR) + f0, f1, pk = { + "in": (300, 2400, 0.85), "out": (2400, 300, 0.25), "up": (400, 1900, 0.55), + "down": (1900, 400, 0.5), "side": (600, 1400, 0.5), + }.get(dir, (600, 1400, 0.5)) + y = self.breath(d + 0.15, f0, f1, pk * d / (d + 0.15), 1.7, seed) + h = int(0.006 * SR) + L, R = y, np.r_[np.zeros(h), y[:-h]] + sw = np.linspace(-0.5, 0.5, n) if dir in ("side", "in") else np.linspace(0.3, -0.3, n) + return np.stack([L * np.sqrt((1 - sw) / 2), R * np.sqrt((1 + sw) / 2)]) * 5.0 * s + + def c_type(self, e, s, d): + txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + self.put("ui", self.felt(0.01, 500 + 300 * (i % 3), 3500, int(s * 1000) + i), s + i * rate, + 0.14 * (0.6 + 0.4 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.2) + self.put("ui", self.wood(self.deg(5 + n % 3), 0.06), s + n * rate, 0.04, self.xpan(e)) + + def c_slam(self, e, s, d): + n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) + big = size >= 90 + self.put("move", self.breath(n * st + 0.35, 500, 2200 if big else 1600, 0.25, seed=int(s * 997)), s, + 0.09 if big else 0.05, self.xpan(e), rev=0.12) + for i in range(0, n, 2 if n > 14 else 1): + self.put("ui", self.wood(self.deg(i % 5) * 2, 0.04, int(s * 131) + i), s + i * st + 0.05, + 0.04 if big else 0.025, self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) + if size >= 150: + self.put("hit", self.drum(150 if size > 200 else 190, 0.35), s + 0.06, 0.4, self.xpan(e)) + + def c_para(self, e, s, d): + self.put("move", self.breath(0.35, 600, 1600, 0.3, seed=int(s * 71)), s, 0.08, self.xpan(e)) + + def c_pop(self, e, s, d): + self.put("ui", self.marimba(self.popf(e.get("s")) / 2, 0.35), s, 0.16, self.xpan(e, 0.3), rev=0.08) + + def c_ring(self, e, s, d): + if e.get("r", 100) < 40: + return + self.put("tone", self.kalimba(self.deg(5 + int(e.get("r", 100) > 300) * 2), 1.0), s, 0.05, + self.xpan(e, 0.4), rev=0.2) + + def c_heart(self, e, s, d): + self.put("hit", self.drum(110, 0.3), s, 0.5) + + def c_drop(self, e, s, d): + for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: + self.put("hit", self.wood(self.snap(260), 0.12), s + x * d, 0.45 * a) + + def c_whoosh(self, e, s, d): + self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), + s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.1) + + def c_riser(self, e, s, d): + d = max(d, 0.3) + self.put("move", self.breath(d, 300, 2600, 0.97, 1.4, seed=5), s, 0.12, rev=0.15) + t, gap, j = 0.0, 0.14, 0 + while t < d: # a marimba roll on root and fifth, accelerating and swelling + self.put("tone", self.marimba(self.deg(j % 2 * 3, self.low), 0.3), s + t, 0.1 * (0.25 + 0.75 * t / d), + (j % 2 - 0.5) * 0.4, rev=0.15) + t += gap + gap = max(0.035, gap * 0.9) + j += 1 + + def c_shock(self, e, s, d): + self.put("move", self.breath(0.55, 250, 1200, 0.08, seed=int(s * 17)), s, 0.1) + self.put("hit", self.drum(130, 0.3), s, 0.3) + + def c_impact(self, e, s, d): + st = float(e.get("s", 1.0)) + if st >= 0.95: + self.put("hit", self.drum(160, 0.6), s, 0.6, rev=0.12) + self.put("move", self.breath(0.3, 300, 1200, 0.05, seed=int(s * 7)), s, 0.06) + elif st >= 0.5: + self.put("hit", self.drum(190, 0.4), s, 0.5 * st, rev=0.1) + else: + self.put("hit", self.wood(self.snap(320), 0.12), s, 0.5 * st + 0.1) + + def c_boom(self, e, s, d): + big = min(1.2, float(e.get("s", 1.2)) / 1.2) + _, sub = Palette.boom(self, big) + self.put("hit", self.drum(95, 1.4) + 0.4 * np.r_[self.felt(0.05, 100, 900), np.zeros(int(1.35 * SR))][: int(1.4 * SR)], + s, 0.85, rev=0.25) + n = int(1.6 * SR) + brush = M.fftfilt(self.noise((2, n), 22), M.bpg(500, 3500)) * adsr(n, 0.01, 0.4) + self.put("move", brush, s, 0.06 * big, rev=0.2) + self.put("low", sat(sub, 1.5) * big, s, 0.9) + + def c_flip(self, e, s, d): + y = np.r_[self.wood(1400, 0.03, 31), np.zeros(int(0.008 * SR)), 0.6 * self.wood(1100, 0.03, 32)] + n = int(0.12 * SR) + y = mix(y, M.fftfilt(self.noise(n, 33), M.bpg(900, 4000)) * adsr(n, 0.002, 0.03) * 0.4) + self.put("ui", y, s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.06) + + def c_sweep(self, e, s, d): + self.put("move", self.breath(d, 700, 1500, 0.5, seed=41), s, 0.15, -0.3) + for i in range(5): + self.put("tone", self.ks(self.deg(i * 2), 0.6, seed=40 + i), s + d * i / 5, 0.07, 0.3, rev=0.2) + + def c_snap(self, e, s, d): + self.put("ui", np.r_[self.wood(1600, 0.04, 51), np.zeros(200), self.wood(1250, 0.04, 52)], s, 0.3) + for i in range(8): + self.put("ui", self.felt(0.006, 800, 3500, 60 + i), s + 0.02 + i * 0.015, 0.06 * (1 - i / 8), + (i % 2 - 0.5) * 0.8) + + def c_sparkle(self, e, s, d): + for i in range(int(e.get("n", 6))): + self.put("tone", self.kalimba(self.deg(3 + (i * 3) % 7), 0.6), s + i * float(e.get("st", 0.08)), 0.04, + np.sin(i * 2.3) * 0.7, rev=0.2) + + def c_draw(self, e, s, d): + n = int(d * SR) + g = np.abs(self.noise(n, 44)) + scratch = M.fftfilt(self.noise(n, 43) * (0.4 + M.fftfilt(g, M.lpg(30))), M.bpg(1200, 4000)) + self.put("move", scratch * env_hann(n, 1.5), s, 0.05, -0.2) + self.put("tone", self.marimba(self.deg(5 if e.get("s") else 3), 0.5), s + d, 0.05, rev=0.15) + + def c_scatter(self, e, s, d): + nn = int(e.get("n", 70)) + for i in range(0, nn, 2): + self.put("ui", self.wood(900 + 1400 * self.rng.random(), 0.03, 70 + i), s + d * (i / nn) ** 1.8, + 0.06 * (1 - i / nn) + 0.01, np.sin(i * 1.7) * 0.9) + self.put("move", self.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.15) + + def c_strike(self, e, s, d): + self.put("move", self.breath(d + 0.12, 600, 2600, 0.8, seed=81), s, 0.4, rev=0.08) + self.put("ui", self.wood(1300, 0.05, 82), s + d, 0.3) + + def c_shimmer(self, e, s, d): + for i in range(12): + self.put("tone", self.kalimba(self.deg(3 + (i * 2) % 9), 0.9), s + d * (i / 12) ** 1.3, + 0.08 * (0.5 + i / 12), np.sin(i * 2.1) * 0.8, rev=0.3) + + def c_bells(self, e, s, d): + final = bool(e.get("final")) + for i, m in enumerate([0, 4, 7, 9, 12] if final else [0, 2, 4, 5, 7, 9]): + self.put("tone", self.marimba(self.deg(m), 1.1), s + i * (0.1 if final else 0.075), 0.13 * (1 - i * 0.08), + ((i % 3) - 1) * 0.5, rev=0.25) + self.put("tone", self.marimba(mf(self.low), 1.6), s, 0.12, 0, rev=0.25) + + def c_line(self, e, s, d): + self.put("move", self.breath(d, 600, 2000, 0.9, seed=91), s, 0.25) + self.put("ui", self.wood(self.deg(5), 0.05), s + d, 0.14) + + def c_stream(self, e, s, d): + for i in range(90): + self.put("ui", self.felt(0.006, 700, 3000, 100 + i), s + i * 0.045 + self.rng.random() * 0.02, + 0.035 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.7) + + def c_flap(self, e, s, d): + for j in range(int(d * 30)): + self.put("ui", self.wood(1800 + 300 * (j % 3), 0.02, 200 + j), s + j / 30, 0.04, self.xpan(e, 0.4)) + self.put("ui", self.wood(700, 0.06), s + d, 0.08, self.xpan(e, 0.4)) + + def c_burst(self, e, s, d): + for i in range(0, 90, 2): + self.put("ui", self.wood(700 + 1800 * self.rng.random(), 0.03, 300 + i), s + 0.35 * (i / 90) ** 2, + 0.08 * (1 - i / 90) + 0.01, np.sin(i * 2.7) * 0.8) + self.put("hit", self.drum(240, 0.25), s, 0.35, 0.3) + self.put("move", self.breath(0.7, 1600, 400, 0.05, seed=301), s, 0.1, 0.3, rev=0.1) + + def c_swirl(self, e, s, d): + g = float(e.get("s", 1.0)) + p0, p1 = self.pan_from_to(e) + nn = int(10 + 10 * d) + for i in range(nn): + u = i / nn + self.put("tone", self.kalimba(self.deg(int(u * 7)), 0.5), s + d * u, 0.05 * np.sin(np.pi * (u + 0.05)) * g, + p0 + (p1 - p0) * u, rev=0.2) + self.put("move", self.breath(d, 500, 1500, 0.5, seed=311), s, 0.06 * g, (p0 + p1) / 2, rev=0.15) + + def c_sheen(self, e, s, d): + g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 + self.put("move", self.breath(d, 500, 2200, 0.7, seed=321), s, 0.16 * g, -0.4, rev=0.15) + self.put("tone", self.marimba(self.deg(7), 0.8), s + d * 0.6, 0.12 * g, 0.4, rev=0.2) + + def c_lock(self, e, s, d): + self.put("ui", self.wood(self.snap(800), 0.08, 331), s, 0.35) + self.put("hit", self.drum(220, 0.15), s, 0.15) + + def c_halo(self, e, s, d): + n = int(d * SR) + t = np.arange(n) / SR + env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) + pad = sum(sat(np.sin(2 * np.pi * mf(m) * t + j), 1.8) * (1 + 0.25 * np.sin(2 * np.pi * (0.3 + j * 0.11) * t)) + for j, m in enumerate((self.low - 12, self.low - 5, self.low))) / 3 + air = M.fftfilt(self.noise(n, 401), M.bpg(300, 1500)) * 0.15 + self.put("tone", np.stack([(pad + air) * env, (np.roll(pad, 400) + air) * env]), s, 0.03, rev=0.3) + + def c_blip(self, e, s, d): + self.put("tone", self.marimba(self.deg(5), 0.6), s, 0.25, rev=0.2) + self.put("ui", self.felt(0.01, 600, 3000), s, 0.1) + + def c_count(self, e, s, d): + ticks, final = self.count_ticks(e, s, d) + for tk, frac in ticks: + self.put("ui", self.wood(self.deg(int(frac * 9)), 0.05), tk, 0.07 + 0.05 * frac, self.xpan(e, 0.35)) + if final: + for m, a in [(5, 0.12), (9, 0.07)]: + self.put("tone", self.marimba(self.deg(m), 1.0), ticks[-1][0], a, 0, rev=0.2) + + def c_land(self, e, s, d): + j, n = self.land_index(e) + self.put("ui", self.wood(self.deg(int(j / n * 10)), 0.05), s, 0.05, self.xpan(e, 0.6)) + + +# ---------------------------------------------------------------------------------------------------- c: minimal + + +class Minimal(Voiced): + def rclick(self, f, d=0.004): + """A filtered click: a resonance that dies in a few ms.""" + t = tt(d * 6) + return np.sin(2 * np.pi * f * t) * np.exp(-t / d) * np.minimum(1, t / 0.0002) + + def nt(self, d=0.0018, lo=4000, hi=11000, seed=None): + return self.click(d, lo, hi, seed) + + def thump(self, f, d=0.06): + t = tt(d * 5) + return np.sin(2 * np.pi * np.cumsum(f * (1 + 0.3 * np.exp(-t / 0.008))) / SR) * adsr(len(t), 0.002, d) + + def air(self, d, lo=3000, hi=11000, p=2.0, seed=None): + n = int(d * SR) + return M.fftfilt(self.noise(n, seed), M.bpg(lo, hi)) * env_hann(n, p) * 0.35 + + def whoosh(self, d, s=0.6, dir="side", seed=None): + d = max(d, 0.2) * 0.7 + n = int(d * SR) + f0, f1 = {"in": (1500, 6000), "out": (6000, 1500), "up": (2000, 5000), "down": (5000, 2000)}.get(dir, (2500, 4500)) + y = M.stft_band(self.noise(n, seed), np.geomspace(f0, f1, 32), np.full(32, 0.8)) * env_hann(n, 2.5) + return np.stack([y, y]) * 0.95 * s + + def c_type(self, e, s, d): + txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + self.put("ui", self.nt(0.0015, 5000, 11000, int(s * 1000) + i), s + i * rate, + 0.07 * (0.7 + 0.3 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.1) + self.put("ui", self.rclick(2400, 0.003), s + n * rate, 0.05, self.xpan(e)) + + def c_slam(self, e, s, d): + n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) + for i in range(0, n, 2 if n > 14 else 1): + self.put("ui", self.rclick(1800 + 60 * (i % 4), 0.002), s + i * st + 0.05, 0.08 if size >= 90 else 0.05, + self.xpan(e) + (i / max(n, 1) - 0.5) * 0.2) + if size >= 150: + self.put("hit", self.thump(self.snap(170), 0.05), s + 0.06, 0.4, self.xpan(e)) + + def c_para(self, e, s, d): + self.put("move", self.air(0.15, 4000, 10000, seed=int(s * 71)), s, 0.02, self.xpan(e)) + + def c_pop(self, e, s, d): + self.put("ui", self.thump(self.popf(e.get("s")) / 2, 0.025), s, 0.16, self.xpan(e, 0.3)) + + def c_ring(self, e, s, d): + if e.get("r", 100) < 40: + return + self.put("ui", self.rclick(self.deg(10), 0.004), s, 0.12, self.xpan(e, 0.4)) + + def c_heart(self, e, s, d): + self.put("hit", self.thump(self.snap(120), 0.05), s, 0.45) + + def c_drop(self, e, s, d): + for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: + self.put("hit", self.thump(self.snap(200), 0.03), s + x * d, 0.45 * a) + + def c_whoosh(self, e, s, d): + self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), + s - (0.02 if e.get("dir") != "in" else 0), 0.2) + + def c_riser(self, e, s, d): + d = max(d, 0.3) + n = int(d * SR) + u = np.arange(n) / n + self.put("move", M.stft_band(self.noise(n, 5), np.geomspace(2000, 9000, 32), np.full(32, 0.7)) * u**3, s, 0.12) + t, gap = 0.0, 0.18 + while t < d: # ticks closing in on the hit + self.put("ui", self.rclick(2200, 0.0025), s + t, 0.05 * (0.3 + 0.7 * t / d)) + t += gap + gap = max(0.03, gap * 0.85) + + def c_shock(self, e, s, d): + self.put("move", self.air(0.3, 2000, 8000, seed=int(s * 17)), s, 0.06) + self.put("hit", self.thump(self.snap(130), 0.06), s, 0.3) + + def c_impact(self, e, s, d): + st = float(e.get("s", 1.0)) + self.put("hit", self.thump(self.snap(150 if st >= 0.95 else 190), 0.07 if st >= 0.95 else 0.045), s, + 0.55 * max(0.35, st)) + self.put("ui", self.rclick(1600, 0.003), s, 0.08 * st) + + def c_boom(self, e, s, d): + big = min(1.2, float(e.get("s", 1.2)) / 1.2) + _, sub = Palette.boom(self, big) + self.put("hit", self.thump(self.snap(110), 0.12), s, 0.8) + self.put("low", sub * big, s, 0.9) + self.put("move", self.air(0.4, 3000, 9000, 4, seed=int(s)), s, 0.03) + + def c_flip(self, e, s, d): + y = np.r_[self.rclick(2600, 0.0025), np.zeros(int(0.01 * SR)), 0.6 * self.rclick(2000, 0.0025)] + self.put("ui", y, s, 0.2 if e.get("to") != "ink" else 0.12) + + def c_sweep(self, e, s, d): + self.put("move", self.air(d, 2500, 7000, 1.5, seed=41), s, 0.05, -0.3) + + def c_snap(self, e, s, d): + self.put("ui", np.r_[self.rclick(3000, 0.002), np.zeros(240), self.rclick(2200, 0.002)], s, 0.3) + + def c_sparkle(self, e, s, d): + self.put("move", self.air(0.12, 5000, 12000, seed=int(s * 7)), s, 0.04, 0.2) + self.put("ui", self.rclick(self.deg(10), 0.003), s + 0.05, 0.03, -0.2) + + def c_draw(self, e, s, d): + self.put("move", self.air(d, 3500, 8000, 1.2, seed=43), s, 0.08, -0.2) + self.put("ui", self.rclick(2400, 0.003), s + d, 0.05) + + def c_scatter(self, e, s, d): + nn = int(e.get("n", 70)) + for i in range(0, nn, 3): + self.put("ui", self.nt(0.0015, 5000, 11000, 70 + i), s + d * (i / nn) ** 1.8, 0.15 * (1 - i / nn) + 0.03, + np.sin(i * 1.7) * 0.6) + + def c_strike(self, e, s, d): + self.put("move", self.air(d + 0.1, 3000, 9000, 1, seed=81), s, 0.12) + self.put("ui", self.rclick(2000, 0.003), s + d, 0.25) + + def c_shimmer(self, e, s, d): + self.put("move", self.air(d, 5000, 12000, 1.5, seed=int(s * 9)), s, 0.05) + for i in range(3): + self.put("ui", self.rclick(self.deg(10 + i * 2), 0.003), s + d * (0.3 + i * 0.25), 0.02, (i - 1) * 0.5) + + def c_bells(self, e, s, d): + final = bool(e.get("final")) + for i, m in enumerate([0, 4, 7] if final else [0, 2, 4]): + self.put("ui", self.thump(self.deg(m + 5) / 2, 0.04), s + i * 0.09, 0.2, (i - 1) * 0.3) + + def c_line(self, e, s, d): + self.put("move", self.air(d, 3000, 8000, 1, seed=91), s, 0.06) + self.put("ui", self.rclick(2400, 0.003), s + d, 0.06) + + def c_stream(self, e, s, d): + for i in range(0, 90, 2): + self.put("ui", self.nt(0.0012, 6000, 12000, 100 + i), s + i * 0.045 + self.rng.random() * 0.02, + 0.025 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.5) + + def c_flap(self, e, s, d): + for j in range(int(d * 30)): + self.put("ui", self.rclick(2800, 0.0015), s + j / 30, 0.03, self.xpan(e, 0.4)) + self.put("ui", self.thump(self.snap(400), 0.02), s + d, 0.08, self.xpan(e, 0.4)) + + def c_burst(self, e, s, d): + for i in range(0, 90, 3): + self.put("ui", self.nt(0.0015, 4000, 11000, 300 + i), s + 0.25 * (i / 90) ** 2, 0.06 * (1 - i / 90) + 0.01, + np.sin(i * 2.7) * 0.7) + self.put("hit", self.thump(self.snap(220), 0.04), s, 0.3) + + def c_swirl(self, e, s, d): + g = float(e.get("s", 1.0)) + p0, p1 = self.pan_from_to(e) + n = int(d * SR) + y = self.air(d, 3500, 9000, 1.2, seed=311) + sw = np.linspace(p0, p1, n) + self.put("move", np.stack([y * np.sqrt((1 - sw) / 2), y * np.sqrt((1 + sw) / 2)]) * 1.414, s, 0.05 * g) + + def c_sheen(self, e, s, d): + g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 + self.put("move", self.air(min(d, 0.35), 4500, 12000, 2, seed=321), s + d * 0.3, 0.15 * g, -0.2) + + def c_lock(self, e, s, d): + self.put("ui", self.rclick(1800, 0.004), s, 0.3) + self.put("hit", self.thump(self.snap(200), 0.03), s, 0.2) + + def c_halo(self, e, s, d): + n = int(d * SR) + t = np.arange(n) / SR + env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) * (1 + 0.3 * np.sin(2 * np.pi * 0.25 * t)) + y = M.fftfilt(self.noise((2, n), 401), M.bpg(250, 1200)) + self.put("tone", y * env, s, 0.02) + + def c_blip(self, e, s, d): + self.put("ui", self.rclick(self.deg(5), 0.006), s, 0.35) + + def c_count(self, e, s, d): + ticks, final = self.count_ticks(e, s, d) + for tk, frac in ticks: + self.put("ui", self.rclick(self.deg(int(frac * 9)), 0.002), tk, 0.06 + 0.04 * frac, self.xpan(e, 0.35)) + if final: + self.put("ui", self.thump(self.deg(5) / 2, 0.05), ticks[-1][0], 0.12) + + def c_land(self, e, s, d): + j, n = self.land_index(e) + self.put("ui", self.thump(self.deg(int(j / n * 10)) / 2, 0.015), s, 0.05, self.xpan(e, 0.6)) + + +# ---------------------------------------------------------------------------------------------------- d: electronic + + +class Electronic(Voiced): + def sq(self, f, d=0.08, bits=4, hold=6, drop=0.0): + t = tt(d) + ph = 2 * np.pi * np.cumsum(f * (1 + drop * np.exp(-t / 0.01))) / SR + return crush(np.sign(np.sin(ph)) * 1.8 * adsr(len(t), 0.0005, d / 4), bits, hold) + + def reso(self, d, f0, f1, bw=0.22, src="noise", seed=None, base=55.0): + """A resonant band sweep over noise or a saw.""" + n = int(d * SR) + x = self.noise(n, seed) if src == "noise" else M._polyblep_saw(base, n) + 0.3 * self.noise(n, seed) + y = M.stft_band(x, np.geomspace(f0, f1, 64), np.full(64, bw)) + return y / (np.sqrt(np.mean(y**2)) + 1e-9) * 0.3 # 10 dB over palette a's swish: a narrow band reads quieter + + def stutter(self, x, slice_d=0.02, reps=5, decay=0.75): + k = int(slice_d * SR) + g = x[:k] * np.r_[np.ones(k - 40), np.linspace(1, 0, 40)] + return np.concatenate([g * decay**i for i in range(reps)]) + + def grit(self, n, seed=None, rate=40): + r = np.random.default_rng(seed) + y = r.standard_normal(n) * 0.04 + idx = r.integers(0, n, max(1, int(rate * n / SR))) + y[idx] += r.choice([-1, 1], len(idx)) * r.uniform(0.3, 1, len(idx)) + return M.fftfilt(y, M.bpg(800, 9000)) + + def k808(self, f, d=0.5, drive=2.5): + t = tt(d) + y = np.sin(2 * np.pi * np.cumsum(f * (1 + 1.5 * np.exp(-t / 0.03))) / SR) * adsr(len(t), 0.001, d / 3) + return sat(y, drive) + + def whoosh(self, d, s=0.6, dir="side", seed=None): + d = max(d, 0.25) + n = int((d + 0.15) * SR) + f0, f1, pk = { + "in": (500, 5000, 0.85), "out": (5000, 500, 0.2), "up": (700, 4000, 0.55), + "down": (4000, 600, 0.5), "side": (900, 3000, 0.5), + }.get(dir, (900, 3000, 0.5)) + y = self.reso(d + 0.15, f0, f1, 0.3, seed=seed) + u = np.arange(n) / SR / d + y = y * np.where(u < pk, (u / pk) ** 2, np.exp(-(u - pk) * 6)) + self.grit(n, seed) * 0.2 + y = crush(y, 8, 2) + sw = np.linspace(-0.6, 0.6, n) if dir in ("side", "in") else np.linspace(0.4, -0.4, n) + return np.stack([y * np.sqrt((1 - sw) / 2), y * np.sqrt((1 + sw) / 2)]) * 2.5 * s + + def c_type(self, e, s, d): + txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + self.put("ui", self.sq(self.deg(10 + (i * 3) % 5), 0.012, 3, 8), s + i * rate, + 0.05 * (0.6 + 0.4 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) + self.put("ui", self.sq(self.deg(9 + n % 3), 0.05, 4, 4), s + n * rate, 0.03, self.xpan(e)) + + def c_slam(self, e, s, d): + n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) + big = size >= 90 + dd = n * st + 0.35 + y = self.reso(dd, 600, 5000 if big else 3500, 0.25, seed=int(s * 997)) * env_hann(int(dd * SR), 1) + self.put("move", y, s, 0.035 if big else 0.018, self.xpan(e), rev=0.1) + for i in range(0, n, 2 if n > 14 else 1): + self.put("ui", self.sq(self.deg(5 + i % 5), 0.015, 3, 10), s + i * st + 0.05, 0.04 if big else 0.025, + self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) + if size >= 150: + self.put("hit", self.k808(self.snap(150 if size > 200 else 190), 0.25), s + 0.06, 0.35, self.xpan(e)) + + def c_para(self, e, s, d): + n = int(0.3 * SR) + self.put("move", self.reso(0.3, 1500, 3500, 0.3, seed=int(s * 71)) * env_hann(n, 2), s, 0.02, self.xpan(e)) + + def c_pop(self, e, s, d): + self.put("ui", self.sq(self.popf(e.get("s")) / 2, 0.07, 5, 3, drop=1.0), s, 0.12, self.xpan(e, 0.3), rev=0.1) + + def c_ring(self, e, s, d): + if e.get("r", 100) < 40: + return + self.put("tone", self.stutter(self.sq(self.deg(5 + int(e.get("r", 100) > 300) * 2), 0.04, 4, 6), 0.03, 4, 0.6), + s, 0.08, self.xpan(e, 0.4), rev=0.2) + + def c_heart(self, e, s, d): + self.put("hit", self.k808(self.snap(120), 0.3), s, 0.5) + + def c_drop(self, e, s, d): + for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: + self.put("hit", crush(self.k808(self.snap(180), 0.12), 6, 3), s + x * d, 0.45 * a) + + def c_whoosh(self, e, s, d): + self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), + s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.12) + + def c_riser(self, e, s, d): + d = max(d, 0.3) + n = int(d * SR) + u = np.arange(n) / n + hit = self.reso(1.2, 300, 6000, 0.25, src="saw", seed=5, base=mf(self.low - 24))[::-1] # a reversed swell + y = self.reso(d, 300, 7000, 0.2, src="saw", seed=6, base=mf(self.low - 24)) * u**2.5 + y[-min(n, len(hit)) :] += hit[-min(n, len(hit)) :] * 0.5 + self.put("move", crush(y, 7, 2), s, 0.3, rev=0.15) + + def c_shock(self, e, s, d): + y = self.reso(0.4, 3000, 400, 0.3, seed=int(s * 17)) * adsr(int(0.4 * SR), 0.002, 0.1) + self.put("move", y, s, 0.035) + self.put("hit", self.k808(self.snap(130), 0.3), s, 0.3) + + def c_impact(self, e, s, d): + st = float(e.get("s", 1.0)) + n = int(0.3 * SR) + burst = crush(M.fftfilt(self.noise(n, 11), M.bpg(600, 6000)) * adsr(n, 0.0005, 0.03), 4, 6) + if st >= 0.95: + self.put("hit", mix(self.k808(self.snap(160), 0.5), 0.5 * burst), s, 0.55, rev=0.15) + self.put("move", self.grit(int(0.6 * SR), 12, 80) * adsr(int(0.6 * SR), 0.002, 0.2), s, 0.2) + elif st >= 0.5: + self.put("hit", mix(self.k808(self.snap(190), 0.3), 0.4 * burst), s, 0.5 * st, rev=0.1) + else: + self.put("hit", crush(self.k808(self.snap(220), 0.1), 5, 4), s, 0.5 * st + 0.1) + + def c_boom(self, e, s, d): + big = min(1.2, float(e.get("s", 1.2)) / 1.2) + f0 = min((self.root * 2.0**o for o in range(-3, 2)), key=lambda f: abs(np.log(f / 42))) + t = tt(2.6) + st = np.maximum(0, t - 0.045) + drop = sat(np.sin(2 * np.pi * np.cumsum(f0 * (1 + 2.0 * np.exp(-st / 0.12))) / SR) * (t > 0.045) + * np.minimum(1, st / 0.01) * np.exp(-st / 0.7), 2.2) + self.put("low", drop * big, s, 1.0) + n = int(0.25 * SR) + punch = crush(M.fftfilt(self.noise(n, 21), M.bpg(300, 5000)) * adsr(n, 0.0005, 0.04), 4, 4) + self.put("hit", punch + 0.6 * self.k808(self.snap(220), 0.25)[:n], s, 0.7, rev=0.2) + tail = self.reso(1.2, 4000, 200, 0.25, seed=int(s)) * adsr(int(1.2 * SR), 0.005, 0.35) # a tape-stop sweep + self.put("move", tail + self.grit(len(tail), 23, 60) * 0.1, s, 0.03 * big, rev=0.2) + + def c_flip(self, e, s, d): + y = self.stutter(crush(self.click(0.012, 2000, 10000, 31), 4, 3), 0.009, 3, 0.65) + self.put("ui", y, s, 0.6 if e.get("to") != "ink" else 0.35, rev=0.06) + + def c_sweep(self, e, s, d): + n = int(d * SR) + self.put("move", self.reso(d, 300, 4000, 0.18, src="saw", seed=41, base=mf(self.low - 12)) * env_hann(n, 1.5), + s, 0.15, -0.3) + + def c_snap(self, e, s, d): + self.put("ui", self.stutter(crush(self.click(0.02, 2000, 12000, 51), 4, 2), 0.012, 4, 0.7), s, 0.3) + + def c_sparkle(self, e, s, d): + for i in range(int(e.get("n", 6))): + self.put("tone", self.sq(self.deg(5 + (i * 3) % 7), 0.05, 4, 5), s + i * float(e.get("st", 0.08)) * 0.6, + 0.04, np.sin(i * 2.3) * 0.7, rev=0.2) + + def c_draw(self, e, s, d): + n = int(d * SR) + y = self.reso(d, 400, 2500 if e.get("s") else 1500, 0.2, src="saw", seed=43, base=mf(self.low)) * env_hann(n, 1) + self.put("tone", crush(y, 6, 3), s, 0.08, -0.2, rev=0.15) + + def c_scatter(self, e, s, d): + nn = int(e.get("n", 70)) + for i in range(0, nn, 3): + g = crush(self.click(0.01, 1500, 9000, 70 + i), int(3 + i % 4), 4) + self.put("ui", self.stutter(g, 0.006, 2, 0.5), s + d * (i / nn) ** 1.8, 0.07 * (1 - i / nn) + 0.01, + np.sin(i * 1.7) * 0.9) + self.put("move", self.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.12) + + def c_strike(self, e, s, d): + n = int((d + 0.12) * SR) + self.put("move", self.reso(d + 0.12, 800, 6000, 0.25, seed=81) * env_hann(n, 0.8), s, 0.18, rev=0.1) + self.put("ui", self.sq(self.deg(10), 0.03, 4, 4), s + d, 0.2) + + def c_shimmer(self, e, s, d): + for i in range(16): + self.put("tone", self.sq(self.deg(5 + (i * 2) % 9), 0.06, 3, 6), s + d * (i / 16) ** 1.3, + 0.03 * (0.5 + i / 16), np.sin(i * 2.1) * 0.8, rev=0.25) + + def c_bells(self, e, s, d): + final = bool(e.get("final")) + notes = [0, 4, 7, 9, 12, 7, 12] if final else [0, 2, 4, 5, 7, 9] + for i, m in enumerate(notes): + self.put("tone", self.sq(self.deg(m), 0.18, 5, 4), s + i * 0.065, 0.09 * (1 - i * 0.06), + ((i % 3) - 1) * 0.5, rev=0.3) + n = int(1.5 * SR) + self.put("tone", sat(np.sin(2 * np.pi * mf(self.low) * tt(1.5)), 1.5) * adsr(n, 0.005, 0.4), s, 0.06, rev=0.3) + self.put("move", self.grit(n, 501, 30) * adsr(n, 0.01, 0.6), s, 0.15) + + def c_line(self, e, s, d): + n = int(d * SR) + self.put("move", self.reso(d, 700, 4000, 0.25, seed=91) * env_hann(n, 1), s, 0.08) + self.put("ui", self.sq(self.deg(10), 0.03, 4, 4), s + d, 0.05) + + def c_stream(self, e, s, d): + for i in range(90): + self.put("ui", self.sq(self.deg(8 + int(self.rng.integers(0, 7))), 0.012, 3, 6), + s + i * 0.045 + self.rng.random() * 0.02, 0.02 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.7) + + def c_flap(self, e, s, d): + for j in range(int(d * 30)): + self.put("ui", crush(self.click(0.003, 2500, 9000, 200 + j), 4, 3), s + j / 30, 0.1, self.xpan(e, 0.4)) + self.put("ui", self.sq(self.deg(5), 0.04, 4, 4), s + d, 0.06, self.xpan(e, 0.4)) + + def c_burst(self, e, s, d): + base = crush(self.click(0.05, 1000, 9000, 300), 3, 6) + for i in range(10): # a glitch flutter: the same crushed grain, retriggered faster and faster + self.put("ui", base[: int(0.03 * SR)], s + 0.3 * (i / 10) ** 1.6, 0.12 * (1 - i / 12), np.sin(i * 2.7) * 0.8) + self.put("hit", self.k808(self.snap(240), 0.2), s, 0.3, 0.3) + + def c_swirl(self, e, s, d): + g = float(e.get("s", 1.0)) + p0, p1 = self.pan_from_to(e) + nn = int(20 + 20 * d) + src = self.reso(0.4, 1500, 5000, 0.2, src="saw", seed=311, base=mf(self.low)) + for i in range(nn): # granular: short grains of a resonant saw, panned from -> to + u = i / nn + a = int(self.rng.integers(0, len(src) - int(0.03 * SR))) + gr = crush(src[a : a + int(0.03 * SR)] * env_hann(int(0.03 * SR)), 5, 3) + self.put("tone", gr, s + d * u, 0.12 * np.sin(np.pi * (u + 0.02)) * g, p0 + (p1 - p0) * u, rev=0.2) + + def c_sheen(self, e, s, d): + g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 + n = int(d * SR) + swell = (self.reso(d, 6000, 1500, 0.3, seed=321) * adsr(n, 0.002, d / 4))[::-1] # reversed, peaks at the end + self.put("move", swell, s, 0.15 * g, -0.4, rev=0.2) + self.put("tone", self.stutter(self.sq(self.deg(7), 0.04, 4, 4), 0.025, 3, 0.6), s + d, 0.04 * g, 0.4, rev=0.2) + + def c_lock(self, e, s, d): + self.put("ui", crush(self.click(0.008, 1500, 9000, 331), 4, 2), s, 0.3) + self.put("hit", self.k808(self.snap(200), 0.12), s, 0.25) + + def c_halo(self, e, s, d): + n = int(d * SR) + t = np.arange(n) / SR + env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) + pad = sum(M._polyblep_saw(mf(m) * (1 + det), n) for m in (self.low - 12, self.low - 5) for det in (-0.004, 0.004)) + pad = M.stft_band(pad, 400 + 300 * np.sin(np.linspace(0, 2 * np.pi, 16)) ** 2, np.full(16, 0.6)) / 3 + y = crush(pad, 8, 2) + self.grit(n, 402, 20) * 0.5 + self.put("tone", np.stack([y * env, np.roll(y, 300) * env]), s, 0.1, rev=0.3) + + def c_blip(self, e, s, d): + self.put("ui", self.sq(self.deg(10) / 2, 0.08, 4, 4, drop=0.6), s, 0.12, rev=0.3) + + def c_count(self, e, s, d): + ticks, final = self.count_ticks(e, s, d) + for tk, frac in ticks: + self.put("ui", self.sq(self.deg(int(frac * 9)), 0.02, 3, 6), tk, 0.06 + 0.04 * frac, self.xpan(e, 0.35)) + if final: + for j, m in enumerate((10, 14)): + self.put("tone", self.sq(self.deg(m) / 2, 0.15, 5, 3), ticks[-1][0] + j * 0.06, 0.08, 0, rev=0.3) + + def c_land(self, e, s, d): + j, n = self.land_index(e) + self.put("ui", self.sq(self.deg(5 + int(j / n * 10)), 0.02, 4, 4, drop=0.5), s, 0.04, self.xpan(e, 0.6)) + + +PALETTES = {"b": Organic, "c": Minimal, "d": Electronic} diff --git a/.claude/skills/newsletter-reels/scripts/sfx_lab.py b/.claude/skills/newsletter-reels/scripts/sfx_lab.py new file mode 100644 index 00000000..430808b7 --- /dev/null +++ b/.claude/skills/newsletter-reels/scripts/sfx_lab.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Render one clip per (palette, cue kind) through the real sfx.build(), plus recorded-library candidates. + + sfx_lab.py [out-dir] (default out: tmp/sfx-lab) + +Each clip is the cue alone, placed after the reel's own cue sheet on a silent music track, so build() levels the stem +the way it levels a real reel and the clip keeps its loudness relative to the rest of the palette. Writes +/audio//.mp3, /audio/lib//.mp3 and /manifest.json. +""" + +import json +import os +import sys +import tempfile +from multiprocessing import Pool +from pathlib import Path + +import numpy as np + +ENGINE = Path(__file__).resolve().parent.parent / "engine" +sys.path.insert(0, str(ENGINE)) +import music as M # noqa: E402 + +LIB = Path(__file__).resolve().parents[4] / ".music" / "sfx" / "oneshots" +if not LIB.is_dir(): # a worktree: the library lives in the main checkout + LIB = Path("/Users/asaucedo/Programming/ethical/ethical/.music/sfx/oneshots") +T0 = 32.0 # the cell starts here, well after the reel's own cues and their reverb tails +PALETTES = {"a": "current (a)", "b": "organic (b)", "c": "minimal (c)", "d": "electronic (d)"} + +# cue kind -> (default event fields, one-line hint of when the reels use it) +CUES = { + "type": ({"s": "fetch ok", "n": 8, "rate": 0.016, "x": 960}, "text typing on, one tick per character"), + "slam": ({"s": "SHIP IT", "n": 7, "st": 0.026, "size": 160}, "a big headline slams in"), + "para": ({"s": "a paragraph fades in", "size": 20}, "body copy appears"), + "pop": ({"s": "pill"}, "a pill, dot or chip pops in"), + "ring": ({"r": 330}, "a ring or circle draws out"), + "heart": ({}, "a heartbeat pulse"), + "drop": ({"d": 0.8}, "an object drops and bounces"), + "whoosh": ({"dir": "in", "d": 0.6, "s": 0.6}, "a scene or panel moves in"), + "riser": ({"d": 1.5}, "tension into the next hit"), + "shock": ({}, "a shockwave from a hit"), + "impact": ({"s": 1.0}, "a scene-change hit"), + "boom": ({"s": 1.2}, "the drop: the biggest hit"), + "flip": ({"to": "paper"}, "a colour-field flip"), + "sweep": ({"d": 1.0}, "a light sweep across"), + "snap": ({}, "a hard snap into place"), + "sparkle": ({"n": 6, "st": 0.08}, "a little sparkle accent"), + "draw": ({"d": 0.8, "s": 1}, "a pen line draws"), + "scatter": ({"d": 0.7, "n": 70}, "particles scatter"), + "strike": ({"d": 0.4}, "a strike-through line"), + "shimmer": ({"d": 1.2}, "a long shimmering wash"), + "bells": ({"final": True}, "the final lock-up chord"), + "line": ({"d": 0.6}, "a rule line extends"), + "stream": ({}, "a data stream ticks by"), + "flap": ({"d": 0.6}, "a split-flap display settles"), + "burst": ({}, "a burst of fragments"), + "swirl": ({"d": 1.2, "from": 400, "to": 1500, "s": 1.0}, "grains swirl across"), + "sheen": ({"d": 0.8, "s": 1.0}, "a glint across a logo or card"), + "lock": ({}, "a logo locks into place"), + "halo": ({"d": 3.0}, "a held end, so it is never silent"), + "blip": ({}, "a small accent blip"), + "count": ({"to": 42, "d": 1.2, "ease": "outCubic", "x": 960}, "a number counts up"), + "land": ({}, "items land one after another"), +} + +# cue kind -> recorded one-shots that fit it, best first (judged by role, name and length) +LIB_PICKS = { + "type": ["type/*-03.wav", "glitch/sbvr-mode-select-wheel-006-*-01.wav", "glitch/sbvr-mode-select-wheel-006-*-02.wav", + "type/comtype-typewriter-space-key-*", "impact/mechltch-*"], + "slam": ["whoosh/woosh-sweep-slide-*", "impact/impact-cut-sweep-*", "impact/mechltch-*", + "impact/transition-frantic-shaker-snap-*", "whoosh/metlmisc-metal-slow-whoosh-*"], + "para": ["whoosh/woosh-sweep-slide-*", "paper/encyclopedia-glossy-page-turn-*", "paper/*-990659d9-01.wav", + "whoosh/winddsgn-wind-rush-*"], + "pop": ["ui/interface-pop-high-short-*", "ui/interface-percussion-snap-*", "ui/game-entry-happy-short-*", + "glitch/sbvr-power-button-*"], + "ring": ["sheen/uimisc-kalimba-3-up-*", "ui/interface-sci-fi-ping-down-*", "ui/interface-accept-glassy-snap-*", + "sheen/button-arp-twinkle-*"], + "heart": ["impact/mechltch-*", "ui/interface-deny-low-fat-dark-*", "ui/deny-muted-*"], + "drop": ["impact/mechltch-*", "ui/deny-muted-*", "impact/metltonl-item-spring-*"], + "whoosh": ["whoosh/winddsgn-wind-rush-*", "whoosh/woosh-sweep-slide-*", "whoosh/metlmisc-metal-slow-whoosh-*", + "whoosh/woosh-debris-*", "whoosh/glasmvmt-*"], + "riser": ["riser/aerojet-*", "riser/elecarc-*"], + "shock": ["impact/impact-cut-sweep-*", "impact/watrmisc-*", "whoosh/firewhsh-*"], + "impact": ["impact/impact-cut-sweep-*", "impact/watrmisc-*", "impact/impact-hit-rapid-chord-*", "impact/watrimpt-*", + "impact/mechltch-*"], + "boom": ["boom/*-80091dc9-01.wav", "boom/*-5499869a-01.wav", "boom/effectivetrailer-booms-vol2-011-*", + "boom/dsgnbass-jump-start-drop-*", "boom/transition-braam-slow-dark-*"], + "flip": ["glitch/sbvr-power-button-*", "ui/interface-percussion-snap-*", "glitch/sbvr-mode-select-wheel-006-*-03.wav", + "paper/encyclopedia-glossy-page-turn-*"], + "sweep": ["glitch/sbvr-am-eq-sweeping-*", "whoosh/woosh-sweep-slide-*", "whoosh/winddsgn-wind-rush-*"], + "snap": ["ui/interface-percussion-snap-*", "ui/interface-accept-glassy-snap-*", + "impact/transition-frantic-shaker-snap-*", "impact/mechltch-*"], + "sparkle": ["sheen/button-arp-twinkle-*", "ui/interface-plucks-happy-*", "sheen/uimisc-kalimba-3-up-*", + "ui/ting-coins-*", "sheen/uimisc-xylophone-ringtone-*"], + "draw": ["ui/mechmisc-tool-tape-measure-*", "paper/a4-printing-paper-rattle-*", "whoosh/woosh-sweep-slide-*"], + "scatter": ["whoosh/cofetti-whoosh-pluck-spill-*", "whoosh/woosh-debris-*", "whoosh/glasmvmt-*", + "impact/metltonl-item-spring-*"], + "strike": ["whoosh/woosh-sweep-slide-*", "whoosh/metlmisc-metal-slow-whoosh-*", "impact/impact-cut-sweep-*"], + "shimmer": ["sheen/magic-energy-flow-astonishment-*", "sheen/magic-energy-flow-astral-travel-*", + "sheen/gamemisc-magic-creation-*", "sheen/button-arp-twinkle-*", "sheen/toonmisc-bird-flutes-*"], + "bells": ["sheen/uimisc-xylophone-ringtone-*", "ui/interface-plucks-happy-*", "sheen/uimisc-kalimba-3-up-*", + "ui/interface-arp-reveal-down-long-*", "sheen/button-arp-twinkle-*"], + "line": ["whoosh/woosh-sweep-slide-*", "ui/readout-thin-long-*", "glitch/sbvr-am-eq-sweeping-*"], + "stream": ["ui/readout-thin-long-*", "glitch/uiglitch-user-interface-glitch-high-*", "ui/machmech-*", + "glitch/elecbuzz-*"], + "flap": ["ui/machmech-*", "paper/*-990659d9-02.wav", "type/*-01.wav", "paper/a4-printing-paper-rattle-*"], + "burst": ["glitch/uiglitch-designed-glitch-*", "whoosh/cofetti-whoosh-pluck-spill-*", "glitch/elecarc-*", + "glitch/psycho-glitched-*", "impact/transition-frantic-shaker-snap-*"], + "swirl": ["sheen/magic-energy-flow-astral-travel-*", "whoosh/glasmvmt-*", "sheen/gamemisc-magic-creation-*", + "glitch/sbvr-am-eq-sweeping-*"], + "sheen": ["sheen/magic-energy-flow-astonishment-*", "sheen/button-arp-twinkle-*", "sheen/uimisc-kalimba-3-up-*", + "whoosh/winddsgn-wind-rush-*", "ui/interface-accept-glassy-snap-*"], + "lock": ["impact/mechltch-*", "ui/interface-accept-glassy-snap-*", "glitch/sbvr-power-button-*", + "ui/interface-percussion-snap-*", "type/comtype-typewriter-space-key-*"], + "halo": ["drone/roomtone-space-ship-*", "drone/magic-drone-tension-*", "drone/dark-industrial-ambience-*"], + "blip": ["ui/interface-pop-high-short-*", "glitch/sbvr-mode-select-wheel-006-*-01.wav", "ui/game-entry-happy-short-*", + "ui/interface-sci-fi-ping-down-*"], + "count": ["ui/machmech-*", "glitch/sbvr-mode-select-wheel-006-*-02.wav", "ui/interface-pop-high-short-*", + "type/*-03.wav"], + "land": ["ui/interface-pop-high-short-*", "glitch/sbvr-mode-select-wheel-006-*-03.wav", "impact/mechltch-*", + "ui/interface-percussion-snap-*"], +} + + +def cell_events(cue): + fields, _ = CUES[cue] + if cue == "land": # one land is a single pop; five show the rising run + return [{"k": "land", "t": T0 + i * 0.12, "x": 600 + 180 * i} for i in range(5)] + return [{"k": cue, "t": T0, **fields}] + + +def trim(y, start, tail=0.2, cap=6.0): + seg = y[:, int((start - 0.02) * M.SR) : int((start + cap) * M.SR)] + loud = np.nonzero(np.abs(seg).max(0) > 10 ** (-58 / 20))[0] + end = min(seg.shape[1], (loud[-1] if len(loud) else 0) + int(tail * M.SR)) + seg = seg[:, :end].copy() + f = min(end, int(0.05 * M.SR)) + seg[:, end - f :] *= np.linspace(1, 0, f) + return seg + + +def render_palette(job): + palette, work, out = job + os.environ["SFX_PALETTE"] = palette + import sfx # after SFX_PALETTE is set; build() reads it per call + + base = json.loads((Path(work) / "cues.json").read_text()) + info = json.loads((Path(work) / "music.json").read_text()) + peaks = {} + with tempfile.TemporaryDirectory(dir=out) as tmp: + w = Path(tmp) + (w / "music.json").write_text(json.dumps({**info, "dur": T0 + 7})) + M.write_mp3(w / "music.mp3", np.zeros((2, int((T0 + 7) * M.SR)))) + for cue in CUES: + events = (base["events"] if isinstance(base, dict) else base) + cell_events(cue) + (w / "cues.json").write_text(json.dumps({**(base if isinstance(base, dict) else {}), "events": events})) + sfx.build(w) + clip = trim(M.read_audio(w / "sfx.mp3"), T0) + dst = Path(out) / "audio" / palette / f"{cue}.mp3" + dst.parent.mkdir(parents=True, exist_ok=True) + M.write_mp3(dst, clip) + peaks[cue] = float(np.abs(clip).max()) + return palette, peaks + + +def render_lib(out, peaks_a): + picks = {} + for cue, pats in LIB_PICKS.items(): + files = [] + for pat in pats: + for f in sorted(LIB.glob(pat)): + if f not in files: + files.append(f) + break + picks[cue] = [] + for i, f in enumerate(files[:5]): + y = M.read_audio(f)[:, : int(4 * M.SR)] + f_out = min(y.shape[1], int(0.05 * M.SR)) + y[:, y.shape[1] - f_out :] *= np.linspace(1, 0, f_out) + y *= peaks_a.get(cue, 0.5) / (np.abs(y).max() + 1e-9) # peak-matched to palette a's clip + dst = Path(out) / "audio" / "lib" / cue / f"{i + 1}.mp3" + dst.parent.mkdir(parents=True, exist_ok=True) + M.write_mp3(dst, y) + picks[cue].append({"file": f"audio/lib/{cue}/{i + 1}.mp3", "source": str(f), "name": f.name}) + return picks + + +def main(): + work = Path(sys.argv[1]).resolve() + out = Path(sys.argv[2] if len(sys.argv) > 2 else "tmp/sfx-lab").resolve() + out.mkdir(parents=True, exist_ok=True) + with Pool(len(PALETTES)) as pool: + peaks = dict(pool.map(render_palette, [(p, str(work), str(out)) for p in PALETTES])) + lib = render_lib(out, peaks["a"]) + manifest = { + "key": json.loads((work / "music.json").read_text()).get("key"), + "rows": [{"id": p, "label": label} for p, label in PALETTES.items()] + [{"id": "lib", "label": "recorded library"}], + "cues": [{"id": c, "hint": h} for c, (_, h) in CUES.items()], + "lib": lib, + } + (out / "manifest.json").write_text(json.dumps(manifest, indent=1)) + print(f"sfx-lab: {len(PALETTES)} x {len(CUES)} cells + {sum(len(v) for v in lib.values())} library clips -> {out}") + + +if __name__ == "__main__": + main() From b54cbb0c780228bab5dafa017d1afd4145d5c901 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:10 +0100 Subject: [PATCH 11/23] Revert "wip(newsletter-reels): three synthesised sfx palettes behind SFX_PALETTE and the sfx_lab comparison renderer" This reverts commit da01e7963b7703c8a6cf4416c9c5fe20ce6d7cdd. --- .claude/skills/newsletter-reels/engine/sfx.py | 12 - .../newsletter-reels/engine/sfx_palettes.py | 802 ------------------ .../newsletter-reels/scripts/sfx_lab.py | 206 ----- 3 files changed, 1020 deletions(-) delete mode 100644 .claude/skills/newsletter-reels/engine/sfx_palettes.py delete mode 100644 .claude/skills/newsletter-reels/scripts/sfx_lab.py diff --git a/.claude/skills/newsletter-reels/engine/sfx.py b/.claude/skills/newsletter-reels/engine/sfx.py index 4acfe628..66142b8a 100644 --- a/.claude/skills/newsletter-reels/engine/sfx.py +++ b/.claude/skills/newsletter-reels/engine/sfx.py @@ -20,9 +20,6 @@ ~110 Hz, and it is ducked by the music's kick. The stem sits TARGET_UNDER dB under the music (integrated), <= -2 dBTP, and a sum limiter pulls the stem down only where music + stem, as the player mixes them, would pass -2 dBFS. The stem is the same length as the music. Unknown cue kinds are skipped with a warning. - -SFX_PALETTE=b|c|d swaps the voices for another sound world (sfx_palettes.py) on the same buses, times and levels; -unset or a, it is the palette above. """ import hashlib @@ -220,13 +217,6 @@ def build(work): N = music.shape[1] tonic, mode = M.parse_key(info.get("key", "A minor")) P = Palette(tonic, mode, M._seed(info.get("key", "")) + len(events), float(info.get("root_hz", 55.0))) - V = None - if os.environ.get("SFX_PALETTE", "a") != "a": - from sfx_palettes import PALETTES - - V = PALETTES[os.environ["SFX_PALETTE"]]( - tonic, mode, M._seed(info.get("key", "")) + len(events), float(info.get("root_hz", 55.0)) - ) rng = P.rng deg = P.deg bus = {k: np.zeros((2, N), np.float32) for k in ("ui", "move", "hit", "low", "tone")} @@ -305,8 +295,6 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N k, s = e.get("k"), float(e.get("t", 0)) d = float(e.get("d", 0.6)) counts[k] = counts.get(k, 0) + 1 - if V and V.voice(k, e, s, d, put, xpan, lands, FW): - continue if k == "type": txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) if samples.get("type"): diff --git a/.claude/skills/newsletter-reels/engine/sfx_palettes.py b/.claude/skills/newsletter-reels/engine/sfx_palettes.py deleted file mode 100644 index fddd30f0..00000000 --- a/.claude/skills/newsletter-reels/engine/sfx_palettes.py +++ /dev/null @@ -1,802 +0,0 @@ -"""Alternative SFX palettes for sfx.py, picked with SFX_PALETTE=b|c|d (a, the default, is sfx.py's own voices). - -Each palette voices every cue kind sfx.build() knows, on the same bus, at the same times and with the same panning -as palette a, so build() mixes and levels it exactly the same way. Pitched sounds use deg(), the key's pentatonic. - - b organic / analog marimba, kalimba and wood-block modal plucks, Karplus-Strong, felt clicks, breathy - tape-wobbled air, warm saturated drums. No glassy shimmer. - c minimal / clinical short dry resonant clicks, tiny noise ticks, soft sine thumps, air breaths. No bells. - d electronic / textural resonant filter sweeps, bit-crushed square blips, granular stutters, reversed swells, - vinyl grit, a deep 808 drop. Sparkles become crushed arpeggios and glitch flutters. -""" - -import numpy as np - -import music as M -from sfx import EASE, Palette, adsr, mf, tt - -SR = M.SR - - -def modal(f, ratios, amps, decays, d, att=0.0006): - """A struck object: decaying sine partials at f * ratio.""" - t = tt(d) - y = sum(a * np.sin(2 * np.pi * f * r * t) * np.exp(-t / dc) for r, a, dc in zip(ratios, amps, decays) if f * r < 16000) - return y * np.minimum(1, t / att) - - -def mix(*xs): - """Sum mono signals of different lengths.""" - y = np.zeros(max(len(x) for x in xs)) - for x in xs: - y[: len(x)] += x - return y - - -def sat(x, k=2.0): - return np.tanh(k * x) / np.tanh(k) - - -def crush(x, bits=6, hold=4): - """Bit depth and sample-rate reduction.""" - q = 2 ** (bits - 1) - y = np.round(np.repeat(x[::hold], hold)[: len(x)] * q) / q - return y - - -def env_hann(n, p=1.0): - return np.sin(np.pi * np.arange(n) / max(n, 1)) ** p - - -class Voiced(Palette): - """A palette that voices whole cues: voice() returns True when it handled the cue.""" - - def voice(self, k, e, s, d, put, xpan, lands, FW): - fn = getattr(self, "c_" + str(k), None) - if fn is None: - return False - self.e, self.put, self.xpan, self.lands, self.FW = e, put, xpan, lands, FW - fn(e, s, d) - return True - - def popf(self, name): - deg = self.deg - return { - "pill": deg(4), "input": deg(2), "point": deg(7), "dot": deg(0), "snap": deg(5), - "core": deg(2, self.low), "disc": deg(0, self.low), "square": deg(4, self.low), - }.get(name, deg(5)) - - def count_ticks(self, e, s, d): - """The same tick list palette a plays: thinned to >= 30 ms, the last one always kept.""" - to = float(e.get("to", 0) or 0) - ticks = e.get("ticks") - if ticks is None and "v" in e: - ticks = [[s, e["v"]]] - if ticks is None: - ease, ticks, last = EASE.get(e.get("ease"), EASE["outCubic"]), [], 0 - for x in np.arange(0, d + 1e-9, 0.001): - v = int(np.floor(to * ease(min(1, x / d)) + 1e-9)) - if v > last: - ticks.append([s + x, v]) - last = v - out, last = [], -1.0 - for j, (tk, v) in enumerate(ticks): - if tk - last < 0.03 and j < len(ticks) - 1: - continue - last = tk - out.append((tk, min(1, float(v) / to) if to else 0.5)) - return out, bool(ticks) and ("to" in e or "d" in e) - - def pan_from_to(self, e): - return (e.get("from", self.FW / 2) / self.FW) * 2 - 1, (e.get("to", self.FW / 2) / self.FW) * 2 - 1 - - def land_index(self, e): - return self.lands.index(e), max(len(self.lands), 1) - - -# ---------------------------------------------------------------------------------------------------- b: organic - - -class Organic(Voiced): - def felt(self, d=0.012, lo=300, hi=2600, seed=None): - n = int(d * SR) - x = M.fftfilt(self.noise(n + 512, seed), M.bpg(lo, hi))[256 : 256 + n] - return x * adsr(n, 0.0015, d / 3) - - def wood(self, f=900, d=0.09, seed=None): - y = modal(f, (1, 2.57, 4.21), (1, 0.35, 0.12), (d / 3, d / 8, d / 14), d) - k = self.felt(0.006, 600, 4000, seed) - y[: len(k)] += 0.5 * k - return y - - def marimba(self, f, d=0.9): - y = modal(f, (1, 3.93, 9.2), (1, 0.22, 0.05), (d / 3.2, d / 14, d / 40), d, 0.001) - k = self.felt(0.01, 200, 1800) - y[: len(k)] += 0.25 * k - return sat(y, 1.2) - - def kalimba(self, f, d=1.2): - y = modal(f, (1, 5.95, 2.0), (1, 0.16, 0.04), (d / 3.5, d / 25, d / 8), d, 0.0008) - k = self.felt(0.004, 1500, 6000) - y[: len(k)] += 0.15 * k - return y - - def ks(self, f, d=0.6, damp=0.996, seed=None): - """Karplus-Strong, computed a period at a time.""" - n, p = int(d * SR), max(2, int(SR / f)) - y = np.zeros(n + p + 1) - y[: p + 1] = M.fftfilt(self.noise(p + 1, seed), M.lpg(5000)) - for a in range(p + 1, n + p + 1, p): - b = min(a + p, n + p + 1) - y[a:b] = damp * 0.5 * (y[a - p : b - p] + y[a - p - 1 : b - p - 1]) - y = y[p + 1 :] - return y / (np.abs(y).max() + 1e-9) * adsr(n, 0.001, d / 2.5) - - def drum(self, f, d=0.5): - f = self.snap(f) - t = tt(d) - y = modal(f, (1, 1.59, 2.14, 2.3), (1, 0.4, 0.25, 0.15), (d / 4, d / 9, d / 12, d / 15), d, 0.0015) - y *= 1 + 0.25 * np.exp(-t / 0.02) - k = self.felt(0.02, 150, 1500) - y[: len(k)] += 0.5 * k - return sat(y, 2.2) - - def breath(self, d, lo=400, hi=2000, peak=0.4, bw=1.6, seed=None): - n = int(d * SR) - y = M.stft_band(self.noise(n, seed), np.geomspace(lo, hi, 48), np.full(48, bw)) - t = np.arange(n) / SR - y = y * (1 + 0.18 * np.sin(2 * np.pi * 5.3 * t) + 0.08 * np.sin(2 * np.pi * 0.9 * t)) # tape wobble - u = np.arange(n) / n - y = y * np.where(u < peak, np.sin(np.pi / 2 * u / peak) ** 2, np.exp(-(u - peak) / (1 - peak) * 4)) - return sat(M.fftfilt(y, M.lpg(6500)) / (np.abs(y).max() + 1e-9), 1.6) * 0.35 # level with palette a's swish - - def whoosh(self, d, s=0.6, dir="side", seed=None): - d = max(d, 0.25) - n = int((d + 0.15) * SR) - f0, f1, pk = { - "in": (300, 2400, 0.85), "out": (2400, 300, 0.25), "up": (400, 1900, 0.55), - "down": (1900, 400, 0.5), "side": (600, 1400, 0.5), - }.get(dir, (600, 1400, 0.5)) - y = self.breath(d + 0.15, f0, f1, pk * d / (d + 0.15), 1.7, seed) - h = int(0.006 * SR) - L, R = y, np.r_[np.zeros(h), y[:-h]] - sw = np.linspace(-0.5, 0.5, n) if dir in ("side", "in") else np.linspace(0.3, -0.3, n) - return np.stack([L * np.sqrt((1 - sw) / 2), R * np.sqrt((1 + sw) / 2)]) * 5.0 * s - - def c_type(self, e, s, d): - txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - self.put("ui", self.felt(0.01, 500 + 300 * (i % 3), 3500, int(s * 1000) + i), s + i * rate, - 0.14 * (0.6 + 0.4 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.2) - self.put("ui", self.wood(self.deg(5 + n % 3), 0.06), s + n * rate, 0.04, self.xpan(e)) - - def c_slam(self, e, s, d): - n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) - big = size >= 90 - self.put("move", self.breath(n * st + 0.35, 500, 2200 if big else 1600, 0.25, seed=int(s * 997)), s, - 0.09 if big else 0.05, self.xpan(e), rev=0.12) - for i in range(0, n, 2 if n > 14 else 1): - self.put("ui", self.wood(self.deg(i % 5) * 2, 0.04, int(s * 131) + i), s + i * st + 0.05, - 0.04 if big else 0.025, self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) - if size >= 150: - self.put("hit", self.drum(150 if size > 200 else 190, 0.35), s + 0.06, 0.4, self.xpan(e)) - - def c_para(self, e, s, d): - self.put("move", self.breath(0.35, 600, 1600, 0.3, seed=int(s * 71)), s, 0.08, self.xpan(e)) - - def c_pop(self, e, s, d): - self.put("ui", self.marimba(self.popf(e.get("s")) / 2, 0.35), s, 0.16, self.xpan(e, 0.3), rev=0.08) - - def c_ring(self, e, s, d): - if e.get("r", 100) < 40: - return - self.put("tone", self.kalimba(self.deg(5 + int(e.get("r", 100) > 300) * 2), 1.0), s, 0.05, - self.xpan(e, 0.4), rev=0.2) - - def c_heart(self, e, s, d): - self.put("hit", self.drum(110, 0.3), s, 0.5) - - def c_drop(self, e, s, d): - for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: - self.put("hit", self.wood(self.snap(260), 0.12), s + x * d, 0.45 * a) - - def c_whoosh(self, e, s, d): - self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), - s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.1) - - def c_riser(self, e, s, d): - d = max(d, 0.3) - self.put("move", self.breath(d, 300, 2600, 0.97, 1.4, seed=5), s, 0.12, rev=0.15) - t, gap, j = 0.0, 0.14, 0 - while t < d: # a marimba roll on root and fifth, accelerating and swelling - self.put("tone", self.marimba(self.deg(j % 2 * 3, self.low), 0.3), s + t, 0.1 * (0.25 + 0.75 * t / d), - (j % 2 - 0.5) * 0.4, rev=0.15) - t += gap - gap = max(0.035, gap * 0.9) - j += 1 - - def c_shock(self, e, s, d): - self.put("move", self.breath(0.55, 250, 1200, 0.08, seed=int(s * 17)), s, 0.1) - self.put("hit", self.drum(130, 0.3), s, 0.3) - - def c_impact(self, e, s, d): - st = float(e.get("s", 1.0)) - if st >= 0.95: - self.put("hit", self.drum(160, 0.6), s, 0.6, rev=0.12) - self.put("move", self.breath(0.3, 300, 1200, 0.05, seed=int(s * 7)), s, 0.06) - elif st >= 0.5: - self.put("hit", self.drum(190, 0.4), s, 0.5 * st, rev=0.1) - else: - self.put("hit", self.wood(self.snap(320), 0.12), s, 0.5 * st + 0.1) - - def c_boom(self, e, s, d): - big = min(1.2, float(e.get("s", 1.2)) / 1.2) - _, sub = Palette.boom(self, big) - self.put("hit", self.drum(95, 1.4) + 0.4 * np.r_[self.felt(0.05, 100, 900), np.zeros(int(1.35 * SR))][: int(1.4 * SR)], - s, 0.85, rev=0.25) - n = int(1.6 * SR) - brush = M.fftfilt(self.noise((2, n), 22), M.bpg(500, 3500)) * adsr(n, 0.01, 0.4) - self.put("move", brush, s, 0.06 * big, rev=0.2) - self.put("low", sat(sub, 1.5) * big, s, 0.9) - - def c_flip(self, e, s, d): - y = np.r_[self.wood(1400, 0.03, 31), np.zeros(int(0.008 * SR)), 0.6 * self.wood(1100, 0.03, 32)] - n = int(0.12 * SR) - y = mix(y, M.fftfilt(self.noise(n, 33), M.bpg(900, 4000)) * adsr(n, 0.002, 0.03) * 0.4) - self.put("ui", y, s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.06) - - def c_sweep(self, e, s, d): - self.put("move", self.breath(d, 700, 1500, 0.5, seed=41), s, 0.15, -0.3) - for i in range(5): - self.put("tone", self.ks(self.deg(i * 2), 0.6, seed=40 + i), s + d * i / 5, 0.07, 0.3, rev=0.2) - - def c_snap(self, e, s, d): - self.put("ui", np.r_[self.wood(1600, 0.04, 51), np.zeros(200), self.wood(1250, 0.04, 52)], s, 0.3) - for i in range(8): - self.put("ui", self.felt(0.006, 800, 3500, 60 + i), s + 0.02 + i * 0.015, 0.06 * (1 - i / 8), - (i % 2 - 0.5) * 0.8) - - def c_sparkle(self, e, s, d): - for i in range(int(e.get("n", 6))): - self.put("tone", self.kalimba(self.deg(3 + (i * 3) % 7), 0.6), s + i * float(e.get("st", 0.08)), 0.04, - np.sin(i * 2.3) * 0.7, rev=0.2) - - def c_draw(self, e, s, d): - n = int(d * SR) - g = np.abs(self.noise(n, 44)) - scratch = M.fftfilt(self.noise(n, 43) * (0.4 + M.fftfilt(g, M.lpg(30))), M.bpg(1200, 4000)) - self.put("move", scratch * env_hann(n, 1.5), s, 0.05, -0.2) - self.put("tone", self.marimba(self.deg(5 if e.get("s") else 3), 0.5), s + d, 0.05, rev=0.15) - - def c_scatter(self, e, s, d): - nn = int(e.get("n", 70)) - for i in range(0, nn, 2): - self.put("ui", self.wood(900 + 1400 * self.rng.random(), 0.03, 70 + i), s + d * (i / nn) ** 1.8, - 0.06 * (1 - i / nn) + 0.01, np.sin(i * 1.7) * 0.9) - self.put("move", self.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.15) - - def c_strike(self, e, s, d): - self.put("move", self.breath(d + 0.12, 600, 2600, 0.8, seed=81), s, 0.4, rev=0.08) - self.put("ui", self.wood(1300, 0.05, 82), s + d, 0.3) - - def c_shimmer(self, e, s, d): - for i in range(12): - self.put("tone", self.kalimba(self.deg(3 + (i * 2) % 9), 0.9), s + d * (i / 12) ** 1.3, - 0.08 * (0.5 + i / 12), np.sin(i * 2.1) * 0.8, rev=0.3) - - def c_bells(self, e, s, d): - final = bool(e.get("final")) - for i, m in enumerate([0, 4, 7, 9, 12] if final else [0, 2, 4, 5, 7, 9]): - self.put("tone", self.marimba(self.deg(m), 1.1), s + i * (0.1 if final else 0.075), 0.13 * (1 - i * 0.08), - ((i % 3) - 1) * 0.5, rev=0.25) - self.put("tone", self.marimba(mf(self.low), 1.6), s, 0.12, 0, rev=0.25) - - def c_line(self, e, s, d): - self.put("move", self.breath(d, 600, 2000, 0.9, seed=91), s, 0.25) - self.put("ui", self.wood(self.deg(5), 0.05), s + d, 0.14) - - def c_stream(self, e, s, d): - for i in range(90): - self.put("ui", self.felt(0.006, 700, 3000, 100 + i), s + i * 0.045 + self.rng.random() * 0.02, - 0.035 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.7) - - def c_flap(self, e, s, d): - for j in range(int(d * 30)): - self.put("ui", self.wood(1800 + 300 * (j % 3), 0.02, 200 + j), s + j / 30, 0.04, self.xpan(e, 0.4)) - self.put("ui", self.wood(700, 0.06), s + d, 0.08, self.xpan(e, 0.4)) - - def c_burst(self, e, s, d): - for i in range(0, 90, 2): - self.put("ui", self.wood(700 + 1800 * self.rng.random(), 0.03, 300 + i), s + 0.35 * (i / 90) ** 2, - 0.08 * (1 - i / 90) + 0.01, np.sin(i * 2.7) * 0.8) - self.put("hit", self.drum(240, 0.25), s, 0.35, 0.3) - self.put("move", self.breath(0.7, 1600, 400, 0.05, seed=301), s, 0.1, 0.3, rev=0.1) - - def c_swirl(self, e, s, d): - g = float(e.get("s", 1.0)) - p0, p1 = self.pan_from_to(e) - nn = int(10 + 10 * d) - for i in range(nn): - u = i / nn - self.put("tone", self.kalimba(self.deg(int(u * 7)), 0.5), s + d * u, 0.05 * np.sin(np.pi * (u + 0.05)) * g, - p0 + (p1 - p0) * u, rev=0.2) - self.put("move", self.breath(d, 500, 1500, 0.5, seed=311), s, 0.06 * g, (p0 + p1) / 2, rev=0.15) - - def c_sheen(self, e, s, d): - g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 - self.put("move", self.breath(d, 500, 2200, 0.7, seed=321), s, 0.16 * g, -0.4, rev=0.15) - self.put("tone", self.marimba(self.deg(7), 0.8), s + d * 0.6, 0.12 * g, 0.4, rev=0.2) - - def c_lock(self, e, s, d): - self.put("ui", self.wood(self.snap(800), 0.08, 331), s, 0.35) - self.put("hit", self.drum(220, 0.15), s, 0.15) - - def c_halo(self, e, s, d): - n = int(d * SR) - t = np.arange(n) / SR - env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) - pad = sum(sat(np.sin(2 * np.pi * mf(m) * t + j), 1.8) * (1 + 0.25 * np.sin(2 * np.pi * (0.3 + j * 0.11) * t)) - for j, m in enumerate((self.low - 12, self.low - 5, self.low))) / 3 - air = M.fftfilt(self.noise(n, 401), M.bpg(300, 1500)) * 0.15 - self.put("tone", np.stack([(pad + air) * env, (np.roll(pad, 400) + air) * env]), s, 0.03, rev=0.3) - - def c_blip(self, e, s, d): - self.put("tone", self.marimba(self.deg(5), 0.6), s, 0.25, rev=0.2) - self.put("ui", self.felt(0.01, 600, 3000), s, 0.1) - - def c_count(self, e, s, d): - ticks, final = self.count_ticks(e, s, d) - for tk, frac in ticks: - self.put("ui", self.wood(self.deg(int(frac * 9)), 0.05), tk, 0.07 + 0.05 * frac, self.xpan(e, 0.35)) - if final: - for m, a in [(5, 0.12), (9, 0.07)]: - self.put("tone", self.marimba(self.deg(m), 1.0), ticks[-1][0], a, 0, rev=0.2) - - def c_land(self, e, s, d): - j, n = self.land_index(e) - self.put("ui", self.wood(self.deg(int(j / n * 10)), 0.05), s, 0.05, self.xpan(e, 0.6)) - - -# ---------------------------------------------------------------------------------------------------- c: minimal - - -class Minimal(Voiced): - def rclick(self, f, d=0.004): - """A filtered click: a resonance that dies in a few ms.""" - t = tt(d * 6) - return np.sin(2 * np.pi * f * t) * np.exp(-t / d) * np.minimum(1, t / 0.0002) - - def nt(self, d=0.0018, lo=4000, hi=11000, seed=None): - return self.click(d, lo, hi, seed) - - def thump(self, f, d=0.06): - t = tt(d * 5) - return np.sin(2 * np.pi * np.cumsum(f * (1 + 0.3 * np.exp(-t / 0.008))) / SR) * adsr(len(t), 0.002, d) - - def air(self, d, lo=3000, hi=11000, p=2.0, seed=None): - n = int(d * SR) - return M.fftfilt(self.noise(n, seed), M.bpg(lo, hi)) * env_hann(n, p) * 0.35 - - def whoosh(self, d, s=0.6, dir="side", seed=None): - d = max(d, 0.2) * 0.7 - n = int(d * SR) - f0, f1 = {"in": (1500, 6000), "out": (6000, 1500), "up": (2000, 5000), "down": (5000, 2000)}.get(dir, (2500, 4500)) - y = M.stft_band(self.noise(n, seed), np.geomspace(f0, f1, 32), np.full(32, 0.8)) * env_hann(n, 2.5) - return np.stack([y, y]) * 0.95 * s - - def c_type(self, e, s, d): - txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - self.put("ui", self.nt(0.0015, 5000, 11000, int(s * 1000) + i), s + i * rate, - 0.07 * (0.7 + 0.3 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.1) - self.put("ui", self.rclick(2400, 0.003), s + n * rate, 0.05, self.xpan(e)) - - def c_slam(self, e, s, d): - n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) - for i in range(0, n, 2 if n > 14 else 1): - self.put("ui", self.rclick(1800 + 60 * (i % 4), 0.002), s + i * st + 0.05, 0.08 if size >= 90 else 0.05, - self.xpan(e) + (i / max(n, 1) - 0.5) * 0.2) - if size >= 150: - self.put("hit", self.thump(self.snap(170), 0.05), s + 0.06, 0.4, self.xpan(e)) - - def c_para(self, e, s, d): - self.put("move", self.air(0.15, 4000, 10000, seed=int(s * 71)), s, 0.02, self.xpan(e)) - - def c_pop(self, e, s, d): - self.put("ui", self.thump(self.popf(e.get("s")) / 2, 0.025), s, 0.16, self.xpan(e, 0.3)) - - def c_ring(self, e, s, d): - if e.get("r", 100) < 40: - return - self.put("ui", self.rclick(self.deg(10), 0.004), s, 0.12, self.xpan(e, 0.4)) - - def c_heart(self, e, s, d): - self.put("hit", self.thump(self.snap(120), 0.05), s, 0.45) - - def c_drop(self, e, s, d): - for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: - self.put("hit", self.thump(self.snap(200), 0.03), s + x * d, 0.45 * a) - - def c_whoosh(self, e, s, d): - self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), - s - (0.02 if e.get("dir") != "in" else 0), 0.2) - - def c_riser(self, e, s, d): - d = max(d, 0.3) - n = int(d * SR) - u = np.arange(n) / n - self.put("move", M.stft_band(self.noise(n, 5), np.geomspace(2000, 9000, 32), np.full(32, 0.7)) * u**3, s, 0.12) - t, gap = 0.0, 0.18 - while t < d: # ticks closing in on the hit - self.put("ui", self.rclick(2200, 0.0025), s + t, 0.05 * (0.3 + 0.7 * t / d)) - t += gap - gap = max(0.03, gap * 0.85) - - def c_shock(self, e, s, d): - self.put("move", self.air(0.3, 2000, 8000, seed=int(s * 17)), s, 0.06) - self.put("hit", self.thump(self.snap(130), 0.06), s, 0.3) - - def c_impact(self, e, s, d): - st = float(e.get("s", 1.0)) - self.put("hit", self.thump(self.snap(150 if st >= 0.95 else 190), 0.07 if st >= 0.95 else 0.045), s, - 0.55 * max(0.35, st)) - self.put("ui", self.rclick(1600, 0.003), s, 0.08 * st) - - def c_boom(self, e, s, d): - big = min(1.2, float(e.get("s", 1.2)) / 1.2) - _, sub = Palette.boom(self, big) - self.put("hit", self.thump(self.snap(110), 0.12), s, 0.8) - self.put("low", sub * big, s, 0.9) - self.put("move", self.air(0.4, 3000, 9000, 4, seed=int(s)), s, 0.03) - - def c_flip(self, e, s, d): - y = np.r_[self.rclick(2600, 0.0025), np.zeros(int(0.01 * SR)), 0.6 * self.rclick(2000, 0.0025)] - self.put("ui", y, s, 0.2 if e.get("to") != "ink" else 0.12) - - def c_sweep(self, e, s, d): - self.put("move", self.air(d, 2500, 7000, 1.5, seed=41), s, 0.05, -0.3) - - def c_snap(self, e, s, d): - self.put("ui", np.r_[self.rclick(3000, 0.002), np.zeros(240), self.rclick(2200, 0.002)], s, 0.3) - - def c_sparkle(self, e, s, d): - self.put("move", self.air(0.12, 5000, 12000, seed=int(s * 7)), s, 0.04, 0.2) - self.put("ui", self.rclick(self.deg(10), 0.003), s + 0.05, 0.03, -0.2) - - def c_draw(self, e, s, d): - self.put("move", self.air(d, 3500, 8000, 1.2, seed=43), s, 0.08, -0.2) - self.put("ui", self.rclick(2400, 0.003), s + d, 0.05) - - def c_scatter(self, e, s, d): - nn = int(e.get("n", 70)) - for i in range(0, nn, 3): - self.put("ui", self.nt(0.0015, 5000, 11000, 70 + i), s + d * (i / nn) ** 1.8, 0.15 * (1 - i / nn) + 0.03, - np.sin(i * 1.7) * 0.6) - - def c_strike(self, e, s, d): - self.put("move", self.air(d + 0.1, 3000, 9000, 1, seed=81), s, 0.12) - self.put("ui", self.rclick(2000, 0.003), s + d, 0.25) - - def c_shimmer(self, e, s, d): - self.put("move", self.air(d, 5000, 12000, 1.5, seed=int(s * 9)), s, 0.05) - for i in range(3): - self.put("ui", self.rclick(self.deg(10 + i * 2), 0.003), s + d * (0.3 + i * 0.25), 0.02, (i - 1) * 0.5) - - def c_bells(self, e, s, d): - final = bool(e.get("final")) - for i, m in enumerate([0, 4, 7] if final else [0, 2, 4]): - self.put("ui", self.thump(self.deg(m + 5) / 2, 0.04), s + i * 0.09, 0.2, (i - 1) * 0.3) - - def c_line(self, e, s, d): - self.put("move", self.air(d, 3000, 8000, 1, seed=91), s, 0.06) - self.put("ui", self.rclick(2400, 0.003), s + d, 0.06) - - def c_stream(self, e, s, d): - for i in range(0, 90, 2): - self.put("ui", self.nt(0.0012, 6000, 12000, 100 + i), s + i * 0.045 + self.rng.random() * 0.02, - 0.025 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.5) - - def c_flap(self, e, s, d): - for j in range(int(d * 30)): - self.put("ui", self.rclick(2800, 0.0015), s + j / 30, 0.03, self.xpan(e, 0.4)) - self.put("ui", self.thump(self.snap(400), 0.02), s + d, 0.08, self.xpan(e, 0.4)) - - def c_burst(self, e, s, d): - for i in range(0, 90, 3): - self.put("ui", self.nt(0.0015, 4000, 11000, 300 + i), s + 0.25 * (i / 90) ** 2, 0.06 * (1 - i / 90) + 0.01, - np.sin(i * 2.7) * 0.7) - self.put("hit", self.thump(self.snap(220), 0.04), s, 0.3) - - def c_swirl(self, e, s, d): - g = float(e.get("s", 1.0)) - p0, p1 = self.pan_from_to(e) - n = int(d * SR) - y = self.air(d, 3500, 9000, 1.2, seed=311) - sw = np.linspace(p0, p1, n) - self.put("move", np.stack([y * np.sqrt((1 - sw) / 2), y * np.sqrt((1 + sw) / 2)]) * 1.414, s, 0.05 * g) - - def c_sheen(self, e, s, d): - g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 - self.put("move", self.air(min(d, 0.35), 4500, 12000, 2, seed=321), s + d * 0.3, 0.15 * g, -0.2) - - def c_lock(self, e, s, d): - self.put("ui", self.rclick(1800, 0.004), s, 0.3) - self.put("hit", self.thump(self.snap(200), 0.03), s, 0.2) - - def c_halo(self, e, s, d): - n = int(d * SR) - t = np.arange(n) / SR - env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) * (1 + 0.3 * np.sin(2 * np.pi * 0.25 * t)) - y = M.fftfilt(self.noise((2, n), 401), M.bpg(250, 1200)) - self.put("tone", y * env, s, 0.02) - - def c_blip(self, e, s, d): - self.put("ui", self.rclick(self.deg(5), 0.006), s, 0.35) - - def c_count(self, e, s, d): - ticks, final = self.count_ticks(e, s, d) - for tk, frac in ticks: - self.put("ui", self.rclick(self.deg(int(frac * 9)), 0.002), tk, 0.06 + 0.04 * frac, self.xpan(e, 0.35)) - if final: - self.put("ui", self.thump(self.deg(5) / 2, 0.05), ticks[-1][0], 0.12) - - def c_land(self, e, s, d): - j, n = self.land_index(e) - self.put("ui", self.thump(self.deg(int(j / n * 10)) / 2, 0.015), s, 0.05, self.xpan(e, 0.6)) - - -# ---------------------------------------------------------------------------------------------------- d: electronic - - -class Electronic(Voiced): - def sq(self, f, d=0.08, bits=4, hold=6, drop=0.0): - t = tt(d) - ph = 2 * np.pi * np.cumsum(f * (1 + drop * np.exp(-t / 0.01))) / SR - return crush(np.sign(np.sin(ph)) * 1.8 * adsr(len(t), 0.0005, d / 4), bits, hold) - - def reso(self, d, f0, f1, bw=0.22, src="noise", seed=None, base=55.0): - """A resonant band sweep over noise or a saw.""" - n = int(d * SR) - x = self.noise(n, seed) if src == "noise" else M._polyblep_saw(base, n) + 0.3 * self.noise(n, seed) - y = M.stft_band(x, np.geomspace(f0, f1, 64), np.full(64, bw)) - return y / (np.sqrt(np.mean(y**2)) + 1e-9) * 0.3 # 10 dB over palette a's swish: a narrow band reads quieter - - def stutter(self, x, slice_d=0.02, reps=5, decay=0.75): - k = int(slice_d * SR) - g = x[:k] * np.r_[np.ones(k - 40), np.linspace(1, 0, 40)] - return np.concatenate([g * decay**i for i in range(reps)]) - - def grit(self, n, seed=None, rate=40): - r = np.random.default_rng(seed) - y = r.standard_normal(n) * 0.04 - idx = r.integers(0, n, max(1, int(rate * n / SR))) - y[idx] += r.choice([-1, 1], len(idx)) * r.uniform(0.3, 1, len(idx)) - return M.fftfilt(y, M.bpg(800, 9000)) - - def k808(self, f, d=0.5, drive=2.5): - t = tt(d) - y = np.sin(2 * np.pi * np.cumsum(f * (1 + 1.5 * np.exp(-t / 0.03))) / SR) * adsr(len(t), 0.001, d / 3) - return sat(y, drive) - - def whoosh(self, d, s=0.6, dir="side", seed=None): - d = max(d, 0.25) - n = int((d + 0.15) * SR) - f0, f1, pk = { - "in": (500, 5000, 0.85), "out": (5000, 500, 0.2), "up": (700, 4000, 0.55), - "down": (4000, 600, 0.5), "side": (900, 3000, 0.5), - }.get(dir, (900, 3000, 0.5)) - y = self.reso(d + 0.15, f0, f1, 0.3, seed=seed) - u = np.arange(n) / SR / d - y = y * np.where(u < pk, (u / pk) ** 2, np.exp(-(u - pk) * 6)) + self.grit(n, seed) * 0.2 - y = crush(y, 8, 2) - sw = np.linspace(-0.6, 0.6, n) if dir in ("side", "in") else np.linspace(0.4, -0.4, n) - return np.stack([y * np.sqrt((1 - sw) / 2), y * np.sqrt((1 + sw) / 2)]) * 2.5 * s - - def c_type(self, e, s, d): - txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - self.put("ui", self.sq(self.deg(10 + (i * 3) % 5), 0.012, 3, 8), s + i * rate, - 0.05 * (0.6 + 0.4 * self.rng.random()), self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) - self.put("ui", self.sq(self.deg(9 + n % 3), 0.05, 4, 4), s + n * rate, 0.03, self.xpan(e)) - - def c_slam(self, e, s, d): - n, st, size = int(e.get("n", len(str(e.get("s", "x"))))), float(e.get("st", 0.026)), float(e.get("size", 90)) - big = size >= 90 - dd = n * st + 0.35 - y = self.reso(dd, 600, 5000 if big else 3500, 0.25, seed=int(s * 997)) * env_hann(int(dd * SR), 1) - self.put("move", y, s, 0.035 if big else 0.018, self.xpan(e), rev=0.1) - for i in range(0, n, 2 if n > 14 else 1): - self.put("ui", self.sq(self.deg(5 + i % 5), 0.015, 3, 10), s + i * st + 0.05, 0.04 if big else 0.025, - self.xpan(e) + (i / max(n, 1) - 0.5) * 0.3) - if size >= 150: - self.put("hit", self.k808(self.snap(150 if size > 200 else 190), 0.25), s + 0.06, 0.35, self.xpan(e)) - - def c_para(self, e, s, d): - n = int(0.3 * SR) - self.put("move", self.reso(0.3, 1500, 3500, 0.3, seed=int(s * 71)) * env_hann(n, 2), s, 0.02, self.xpan(e)) - - def c_pop(self, e, s, d): - self.put("ui", self.sq(self.popf(e.get("s")) / 2, 0.07, 5, 3, drop=1.0), s, 0.12, self.xpan(e, 0.3), rev=0.1) - - def c_ring(self, e, s, d): - if e.get("r", 100) < 40: - return - self.put("tone", self.stutter(self.sq(self.deg(5 + int(e.get("r", 100) > 300) * 2), 0.04, 4, 6), 0.03, 4, 0.6), - s, 0.08, self.xpan(e, 0.4), rev=0.2) - - def c_heart(self, e, s, d): - self.put("hit", self.k808(self.snap(120), 0.3), s, 0.5) - - def c_drop(self, e, s, d): - for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: - self.put("hit", crush(self.k808(self.snap(180), 0.12), 6, 3), s + x * d, 0.45 * a) - - def c_whoosh(self, e, s, d): - self.put("move", self.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), - s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.12) - - def c_riser(self, e, s, d): - d = max(d, 0.3) - n = int(d * SR) - u = np.arange(n) / n - hit = self.reso(1.2, 300, 6000, 0.25, src="saw", seed=5, base=mf(self.low - 24))[::-1] # a reversed swell - y = self.reso(d, 300, 7000, 0.2, src="saw", seed=6, base=mf(self.low - 24)) * u**2.5 - y[-min(n, len(hit)) :] += hit[-min(n, len(hit)) :] * 0.5 - self.put("move", crush(y, 7, 2), s, 0.3, rev=0.15) - - def c_shock(self, e, s, d): - y = self.reso(0.4, 3000, 400, 0.3, seed=int(s * 17)) * adsr(int(0.4 * SR), 0.002, 0.1) - self.put("move", y, s, 0.035) - self.put("hit", self.k808(self.snap(130), 0.3), s, 0.3) - - def c_impact(self, e, s, d): - st = float(e.get("s", 1.0)) - n = int(0.3 * SR) - burst = crush(M.fftfilt(self.noise(n, 11), M.bpg(600, 6000)) * adsr(n, 0.0005, 0.03), 4, 6) - if st >= 0.95: - self.put("hit", mix(self.k808(self.snap(160), 0.5), 0.5 * burst), s, 0.55, rev=0.15) - self.put("move", self.grit(int(0.6 * SR), 12, 80) * adsr(int(0.6 * SR), 0.002, 0.2), s, 0.2) - elif st >= 0.5: - self.put("hit", mix(self.k808(self.snap(190), 0.3), 0.4 * burst), s, 0.5 * st, rev=0.1) - else: - self.put("hit", crush(self.k808(self.snap(220), 0.1), 5, 4), s, 0.5 * st + 0.1) - - def c_boom(self, e, s, d): - big = min(1.2, float(e.get("s", 1.2)) / 1.2) - f0 = min((self.root * 2.0**o for o in range(-3, 2)), key=lambda f: abs(np.log(f / 42))) - t = tt(2.6) - st = np.maximum(0, t - 0.045) - drop = sat(np.sin(2 * np.pi * np.cumsum(f0 * (1 + 2.0 * np.exp(-st / 0.12))) / SR) * (t > 0.045) - * np.minimum(1, st / 0.01) * np.exp(-st / 0.7), 2.2) - self.put("low", drop * big, s, 1.0) - n = int(0.25 * SR) - punch = crush(M.fftfilt(self.noise(n, 21), M.bpg(300, 5000)) * adsr(n, 0.0005, 0.04), 4, 4) - self.put("hit", punch + 0.6 * self.k808(self.snap(220), 0.25)[:n], s, 0.7, rev=0.2) - tail = self.reso(1.2, 4000, 200, 0.25, seed=int(s)) * adsr(int(1.2 * SR), 0.005, 0.35) # a tape-stop sweep - self.put("move", tail + self.grit(len(tail), 23, 60) * 0.1, s, 0.03 * big, rev=0.2) - - def c_flip(self, e, s, d): - y = self.stutter(crush(self.click(0.012, 2000, 10000, 31), 4, 3), 0.009, 3, 0.65) - self.put("ui", y, s, 0.6 if e.get("to") != "ink" else 0.35, rev=0.06) - - def c_sweep(self, e, s, d): - n = int(d * SR) - self.put("move", self.reso(d, 300, 4000, 0.18, src="saw", seed=41, base=mf(self.low - 12)) * env_hann(n, 1.5), - s, 0.15, -0.3) - - def c_snap(self, e, s, d): - self.put("ui", self.stutter(crush(self.click(0.02, 2000, 12000, 51), 4, 2), 0.012, 4, 0.7), s, 0.3) - - def c_sparkle(self, e, s, d): - for i in range(int(e.get("n", 6))): - self.put("tone", self.sq(self.deg(5 + (i * 3) % 7), 0.05, 4, 5), s + i * float(e.get("st", 0.08)) * 0.6, - 0.04, np.sin(i * 2.3) * 0.7, rev=0.2) - - def c_draw(self, e, s, d): - n = int(d * SR) - y = self.reso(d, 400, 2500 if e.get("s") else 1500, 0.2, src="saw", seed=43, base=mf(self.low)) * env_hann(n, 1) - self.put("tone", crush(y, 6, 3), s, 0.08, -0.2, rev=0.15) - - def c_scatter(self, e, s, d): - nn = int(e.get("n", 70)) - for i in range(0, nn, 3): - g = crush(self.click(0.01, 1500, 9000, 70 + i), int(3 + i % 4), 4) - self.put("ui", self.stutter(g, 0.006, 2, 0.5), s + d * (i / nn) ** 1.8, 0.07 * (1 - i / nn) + 0.01, - np.sin(i * 1.7) * 0.9) - self.put("move", self.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.12) - - def c_strike(self, e, s, d): - n = int((d + 0.12) * SR) - self.put("move", self.reso(d + 0.12, 800, 6000, 0.25, seed=81) * env_hann(n, 0.8), s, 0.18, rev=0.1) - self.put("ui", self.sq(self.deg(10), 0.03, 4, 4), s + d, 0.2) - - def c_shimmer(self, e, s, d): - for i in range(16): - self.put("tone", self.sq(self.deg(5 + (i * 2) % 9), 0.06, 3, 6), s + d * (i / 16) ** 1.3, - 0.03 * (0.5 + i / 16), np.sin(i * 2.1) * 0.8, rev=0.25) - - def c_bells(self, e, s, d): - final = bool(e.get("final")) - notes = [0, 4, 7, 9, 12, 7, 12] if final else [0, 2, 4, 5, 7, 9] - for i, m in enumerate(notes): - self.put("tone", self.sq(self.deg(m), 0.18, 5, 4), s + i * 0.065, 0.09 * (1 - i * 0.06), - ((i % 3) - 1) * 0.5, rev=0.3) - n = int(1.5 * SR) - self.put("tone", sat(np.sin(2 * np.pi * mf(self.low) * tt(1.5)), 1.5) * adsr(n, 0.005, 0.4), s, 0.06, rev=0.3) - self.put("move", self.grit(n, 501, 30) * adsr(n, 0.01, 0.6), s, 0.15) - - def c_line(self, e, s, d): - n = int(d * SR) - self.put("move", self.reso(d, 700, 4000, 0.25, seed=91) * env_hann(n, 1), s, 0.08) - self.put("ui", self.sq(self.deg(10), 0.03, 4, 4), s + d, 0.05) - - def c_stream(self, e, s, d): - for i in range(90): - self.put("ui", self.sq(self.deg(8 + int(self.rng.integers(0, 7))), 0.012, 3, 6), - s + i * 0.045 + self.rng.random() * 0.02, 0.02 * min(1, i / 10) * (1 - i / 90), np.sin(i * 2.9) * 0.7) - - def c_flap(self, e, s, d): - for j in range(int(d * 30)): - self.put("ui", crush(self.click(0.003, 2500, 9000, 200 + j), 4, 3), s + j / 30, 0.1, self.xpan(e, 0.4)) - self.put("ui", self.sq(self.deg(5), 0.04, 4, 4), s + d, 0.06, self.xpan(e, 0.4)) - - def c_burst(self, e, s, d): - base = crush(self.click(0.05, 1000, 9000, 300), 3, 6) - for i in range(10): # a glitch flutter: the same crushed grain, retriggered faster and faster - self.put("ui", base[: int(0.03 * SR)], s + 0.3 * (i / 10) ** 1.6, 0.12 * (1 - i / 12), np.sin(i * 2.7) * 0.8) - self.put("hit", self.k808(self.snap(240), 0.2), s, 0.3, 0.3) - - def c_swirl(self, e, s, d): - g = float(e.get("s", 1.0)) - p0, p1 = self.pan_from_to(e) - nn = int(20 + 20 * d) - src = self.reso(0.4, 1500, 5000, 0.2, src="saw", seed=311, base=mf(self.low)) - for i in range(nn): # granular: short grains of a resonant saw, panned from -> to - u = i / nn - a = int(self.rng.integers(0, len(src) - int(0.03 * SR))) - gr = crush(src[a : a + int(0.03 * SR)] * env_hann(int(0.03 * SR)), 5, 3) - self.put("tone", gr, s + d * u, 0.12 * np.sin(np.pi * (u + 0.02)) * g, p0 + (p1 - p0) * u, rev=0.2) - - def c_sheen(self, e, s, d): - g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 - n = int(d * SR) - swell = (self.reso(d, 6000, 1500, 0.3, seed=321) * adsr(n, 0.002, d / 4))[::-1] # reversed, peaks at the end - self.put("move", swell, s, 0.15 * g, -0.4, rev=0.2) - self.put("tone", self.stutter(self.sq(self.deg(7), 0.04, 4, 4), 0.025, 3, 0.6), s + d, 0.04 * g, 0.4, rev=0.2) - - def c_lock(self, e, s, d): - self.put("ui", crush(self.click(0.008, 1500, 9000, 331), 4, 2), s, 0.3) - self.put("hit", self.k808(self.snap(200), 0.12), s, 0.25) - - def c_halo(self, e, s, d): - n = int(d * SR) - t = np.arange(n) / SR - env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) - pad = sum(M._polyblep_saw(mf(m) * (1 + det), n) for m in (self.low - 12, self.low - 5) for det in (-0.004, 0.004)) - pad = M.stft_band(pad, 400 + 300 * np.sin(np.linspace(0, 2 * np.pi, 16)) ** 2, np.full(16, 0.6)) / 3 - y = crush(pad, 8, 2) + self.grit(n, 402, 20) * 0.5 - self.put("tone", np.stack([y * env, np.roll(y, 300) * env]), s, 0.1, rev=0.3) - - def c_blip(self, e, s, d): - self.put("ui", self.sq(self.deg(10) / 2, 0.08, 4, 4, drop=0.6), s, 0.12, rev=0.3) - - def c_count(self, e, s, d): - ticks, final = self.count_ticks(e, s, d) - for tk, frac in ticks: - self.put("ui", self.sq(self.deg(int(frac * 9)), 0.02, 3, 6), tk, 0.06 + 0.04 * frac, self.xpan(e, 0.35)) - if final: - for j, m in enumerate((10, 14)): - self.put("tone", self.sq(self.deg(m) / 2, 0.15, 5, 3), ticks[-1][0] + j * 0.06, 0.08, 0, rev=0.3) - - def c_land(self, e, s, d): - j, n = self.land_index(e) - self.put("ui", self.sq(self.deg(5 + int(j / n * 10)), 0.02, 4, 4, drop=0.5), s, 0.04, self.xpan(e, 0.6)) - - -PALETTES = {"b": Organic, "c": Minimal, "d": Electronic} diff --git a/.claude/skills/newsletter-reels/scripts/sfx_lab.py b/.claude/skills/newsletter-reels/scripts/sfx_lab.py deleted file mode 100644 index 430808b7..00000000 --- a/.claude/skills/newsletter-reels/scripts/sfx_lab.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""Render one clip per (palette, cue kind) through the real sfx.build(), plus recorded-library candidates. - - sfx_lab.py [out-dir] (default out: tmp/sfx-lab) - -Each clip is the cue alone, placed after the reel's own cue sheet on a silent music track, so build() levels the stem -the way it levels a real reel and the clip keeps its loudness relative to the rest of the palette. Writes -/audio//.mp3, /audio/lib//.mp3 and /manifest.json. -""" - -import json -import os -import sys -import tempfile -from multiprocessing import Pool -from pathlib import Path - -import numpy as np - -ENGINE = Path(__file__).resolve().parent.parent / "engine" -sys.path.insert(0, str(ENGINE)) -import music as M # noqa: E402 - -LIB = Path(__file__).resolve().parents[4] / ".music" / "sfx" / "oneshots" -if not LIB.is_dir(): # a worktree: the library lives in the main checkout - LIB = Path("/Users/asaucedo/Programming/ethical/ethical/.music/sfx/oneshots") -T0 = 32.0 # the cell starts here, well after the reel's own cues and their reverb tails -PALETTES = {"a": "current (a)", "b": "organic (b)", "c": "minimal (c)", "d": "electronic (d)"} - -# cue kind -> (default event fields, one-line hint of when the reels use it) -CUES = { - "type": ({"s": "fetch ok", "n": 8, "rate": 0.016, "x": 960}, "text typing on, one tick per character"), - "slam": ({"s": "SHIP IT", "n": 7, "st": 0.026, "size": 160}, "a big headline slams in"), - "para": ({"s": "a paragraph fades in", "size": 20}, "body copy appears"), - "pop": ({"s": "pill"}, "a pill, dot or chip pops in"), - "ring": ({"r": 330}, "a ring or circle draws out"), - "heart": ({}, "a heartbeat pulse"), - "drop": ({"d": 0.8}, "an object drops and bounces"), - "whoosh": ({"dir": "in", "d": 0.6, "s": 0.6}, "a scene or panel moves in"), - "riser": ({"d": 1.5}, "tension into the next hit"), - "shock": ({}, "a shockwave from a hit"), - "impact": ({"s": 1.0}, "a scene-change hit"), - "boom": ({"s": 1.2}, "the drop: the biggest hit"), - "flip": ({"to": "paper"}, "a colour-field flip"), - "sweep": ({"d": 1.0}, "a light sweep across"), - "snap": ({}, "a hard snap into place"), - "sparkle": ({"n": 6, "st": 0.08}, "a little sparkle accent"), - "draw": ({"d": 0.8, "s": 1}, "a pen line draws"), - "scatter": ({"d": 0.7, "n": 70}, "particles scatter"), - "strike": ({"d": 0.4}, "a strike-through line"), - "shimmer": ({"d": 1.2}, "a long shimmering wash"), - "bells": ({"final": True}, "the final lock-up chord"), - "line": ({"d": 0.6}, "a rule line extends"), - "stream": ({}, "a data stream ticks by"), - "flap": ({"d": 0.6}, "a split-flap display settles"), - "burst": ({}, "a burst of fragments"), - "swirl": ({"d": 1.2, "from": 400, "to": 1500, "s": 1.0}, "grains swirl across"), - "sheen": ({"d": 0.8, "s": 1.0}, "a glint across a logo or card"), - "lock": ({}, "a logo locks into place"), - "halo": ({"d": 3.0}, "a held end, so it is never silent"), - "blip": ({}, "a small accent blip"), - "count": ({"to": 42, "d": 1.2, "ease": "outCubic", "x": 960}, "a number counts up"), - "land": ({}, "items land one after another"), -} - -# cue kind -> recorded one-shots that fit it, best first (judged by role, name and length) -LIB_PICKS = { - "type": ["type/*-03.wav", "glitch/sbvr-mode-select-wheel-006-*-01.wav", "glitch/sbvr-mode-select-wheel-006-*-02.wav", - "type/comtype-typewriter-space-key-*", "impact/mechltch-*"], - "slam": ["whoosh/woosh-sweep-slide-*", "impact/impact-cut-sweep-*", "impact/mechltch-*", - "impact/transition-frantic-shaker-snap-*", "whoosh/metlmisc-metal-slow-whoosh-*"], - "para": ["whoosh/woosh-sweep-slide-*", "paper/encyclopedia-glossy-page-turn-*", "paper/*-990659d9-01.wav", - "whoosh/winddsgn-wind-rush-*"], - "pop": ["ui/interface-pop-high-short-*", "ui/interface-percussion-snap-*", "ui/game-entry-happy-short-*", - "glitch/sbvr-power-button-*"], - "ring": ["sheen/uimisc-kalimba-3-up-*", "ui/interface-sci-fi-ping-down-*", "ui/interface-accept-glassy-snap-*", - "sheen/button-arp-twinkle-*"], - "heart": ["impact/mechltch-*", "ui/interface-deny-low-fat-dark-*", "ui/deny-muted-*"], - "drop": ["impact/mechltch-*", "ui/deny-muted-*", "impact/metltonl-item-spring-*"], - "whoosh": ["whoosh/winddsgn-wind-rush-*", "whoosh/woosh-sweep-slide-*", "whoosh/metlmisc-metal-slow-whoosh-*", - "whoosh/woosh-debris-*", "whoosh/glasmvmt-*"], - "riser": ["riser/aerojet-*", "riser/elecarc-*"], - "shock": ["impact/impact-cut-sweep-*", "impact/watrmisc-*", "whoosh/firewhsh-*"], - "impact": ["impact/impact-cut-sweep-*", "impact/watrmisc-*", "impact/impact-hit-rapid-chord-*", "impact/watrimpt-*", - "impact/mechltch-*"], - "boom": ["boom/*-80091dc9-01.wav", "boom/*-5499869a-01.wav", "boom/effectivetrailer-booms-vol2-011-*", - "boom/dsgnbass-jump-start-drop-*", "boom/transition-braam-slow-dark-*"], - "flip": ["glitch/sbvr-power-button-*", "ui/interface-percussion-snap-*", "glitch/sbvr-mode-select-wheel-006-*-03.wav", - "paper/encyclopedia-glossy-page-turn-*"], - "sweep": ["glitch/sbvr-am-eq-sweeping-*", "whoosh/woosh-sweep-slide-*", "whoosh/winddsgn-wind-rush-*"], - "snap": ["ui/interface-percussion-snap-*", "ui/interface-accept-glassy-snap-*", - "impact/transition-frantic-shaker-snap-*", "impact/mechltch-*"], - "sparkle": ["sheen/button-arp-twinkle-*", "ui/interface-plucks-happy-*", "sheen/uimisc-kalimba-3-up-*", - "ui/ting-coins-*", "sheen/uimisc-xylophone-ringtone-*"], - "draw": ["ui/mechmisc-tool-tape-measure-*", "paper/a4-printing-paper-rattle-*", "whoosh/woosh-sweep-slide-*"], - "scatter": ["whoosh/cofetti-whoosh-pluck-spill-*", "whoosh/woosh-debris-*", "whoosh/glasmvmt-*", - "impact/metltonl-item-spring-*"], - "strike": ["whoosh/woosh-sweep-slide-*", "whoosh/metlmisc-metal-slow-whoosh-*", "impact/impact-cut-sweep-*"], - "shimmer": ["sheen/magic-energy-flow-astonishment-*", "sheen/magic-energy-flow-astral-travel-*", - "sheen/gamemisc-magic-creation-*", "sheen/button-arp-twinkle-*", "sheen/toonmisc-bird-flutes-*"], - "bells": ["sheen/uimisc-xylophone-ringtone-*", "ui/interface-plucks-happy-*", "sheen/uimisc-kalimba-3-up-*", - "ui/interface-arp-reveal-down-long-*", "sheen/button-arp-twinkle-*"], - "line": ["whoosh/woosh-sweep-slide-*", "ui/readout-thin-long-*", "glitch/sbvr-am-eq-sweeping-*"], - "stream": ["ui/readout-thin-long-*", "glitch/uiglitch-user-interface-glitch-high-*", "ui/machmech-*", - "glitch/elecbuzz-*"], - "flap": ["ui/machmech-*", "paper/*-990659d9-02.wav", "type/*-01.wav", "paper/a4-printing-paper-rattle-*"], - "burst": ["glitch/uiglitch-designed-glitch-*", "whoosh/cofetti-whoosh-pluck-spill-*", "glitch/elecarc-*", - "glitch/psycho-glitched-*", "impact/transition-frantic-shaker-snap-*"], - "swirl": ["sheen/magic-energy-flow-astral-travel-*", "whoosh/glasmvmt-*", "sheen/gamemisc-magic-creation-*", - "glitch/sbvr-am-eq-sweeping-*"], - "sheen": ["sheen/magic-energy-flow-astonishment-*", "sheen/button-arp-twinkle-*", "sheen/uimisc-kalimba-3-up-*", - "whoosh/winddsgn-wind-rush-*", "ui/interface-accept-glassy-snap-*"], - "lock": ["impact/mechltch-*", "ui/interface-accept-glassy-snap-*", "glitch/sbvr-power-button-*", - "ui/interface-percussion-snap-*", "type/comtype-typewriter-space-key-*"], - "halo": ["drone/roomtone-space-ship-*", "drone/magic-drone-tension-*", "drone/dark-industrial-ambience-*"], - "blip": ["ui/interface-pop-high-short-*", "glitch/sbvr-mode-select-wheel-006-*-01.wav", "ui/game-entry-happy-short-*", - "ui/interface-sci-fi-ping-down-*"], - "count": ["ui/machmech-*", "glitch/sbvr-mode-select-wheel-006-*-02.wav", "ui/interface-pop-high-short-*", - "type/*-03.wav"], - "land": ["ui/interface-pop-high-short-*", "glitch/sbvr-mode-select-wheel-006-*-03.wav", "impact/mechltch-*", - "ui/interface-percussion-snap-*"], -} - - -def cell_events(cue): - fields, _ = CUES[cue] - if cue == "land": # one land is a single pop; five show the rising run - return [{"k": "land", "t": T0 + i * 0.12, "x": 600 + 180 * i} for i in range(5)] - return [{"k": cue, "t": T0, **fields}] - - -def trim(y, start, tail=0.2, cap=6.0): - seg = y[:, int((start - 0.02) * M.SR) : int((start + cap) * M.SR)] - loud = np.nonzero(np.abs(seg).max(0) > 10 ** (-58 / 20))[0] - end = min(seg.shape[1], (loud[-1] if len(loud) else 0) + int(tail * M.SR)) - seg = seg[:, :end].copy() - f = min(end, int(0.05 * M.SR)) - seg[:, end - f :] *= np.linspace(1, 0, f) - return seg - - -def render_palette(job): - palette, work, out = job - os.environ["SFX_PALETTE"] = palette - import sfx # after SFX_PALETTE is set; build() reads it per call - - base = json.loads((Path(work) / "cues.json").read_text()) - info = json.loads((Path(work) / "music.json").read_text()) - peaks = {} - with tempfile.TemporaryDirectory(dir=out) as tmp: - w = Path(tmp) - (w / "music.json").write_text(json.dumps({**info, "dur": T0 + 7})) - M.write_mp3(w / "music.mp3", np.zeros((2, int((T0 + 7) * M.SR)))) - for cue in CUES: - events = (base["events"] if isinstance(base, dict) else base) + cell_events(cue) - (w / "cues.json").write_text(json.dumps({**(base if isinstance(base, dict) else {}), "events": events})) - sfx.build(w) - clip = trim(M.read_audio(w / "sfx.mp3"), T0) - dst = Path(out) / "audio" / palette / f"{cue}.mp3" - dst.parent.mkdir(parents=True, exist_ok=True) - M.write_mp3(dst, clip) - peaks[cue] = float(np.abs(clip).max()) - return palette, peaks - - -def render_lib(out, peaks_a): - picks = {} - for cue, pats in LIB_PICKS.items(): - files = [] - for pat in pats: - for f in sorted(LIB.glob(pat)): - if f not in files: - files.append(f) - break - picks[cue] = [] - for i, f in enumerate(files[:5]): - y = M.read_audio(f)[:, : int(4 * M.SR)] - f_out = min(y.shape[1], int(0.05 * M.SR)) - y[:, y.shape[1] - f_out :] *= np.linspace(1, 0, f_out) - y *= peaks_a.get(cue, 0.5) / (np.abs(y).max() + 1e-9) # peak-matched to palette a's clip - dst = Path(out) / "audio" / "lib" / cue / f"{i + 1}.mp3" - dst.parent.mkdir(parents=True, exist_ok=True) - M.write_mp3(dst, y) - picks[cue].append({"file": f"audio/lib/{cue}/{i + 1}.mp3", "source": str(f), "name": f.name}) - return picks - - -def main(): - work = Path(sys.argv[1]).resolve() - out = Path(sys.argv[2] if len(sys.argv) > 2 else "tmp/sfx-lab").resolve() - out.mkdir(parents=True, exist_ok=True) - with Pool(len(PALETTES)) as pool: - peaks = dict(pool.map(render_palette, [(p, str(work), str(out)) for p in PALETTES])) - lib = render_lib(out, peaks["a"]) - manifest = { - "key": json.loads((work / "music.json").read_text()).get("key"), - "rows": [{"id": p, "label": label} for p, label in PALETTES.items()] + [{"id": "lib", "label": "recorded library"}], - "cues": [{"id": c, "hint": h} for c, (_, h) in CUES.items()], - "lib": lib, - } - (out / "manifest.json").write_text(json.dumps(manifest, indent=1)) - print(f"sfx-lab: {len(PALETTES)} x {len(CUES)} cells + {sum(len(v) for v in lib.values())} library clips -> {out}") - - -if __name__ == "__main__": - main() From 5eb7d912f6b5dff4f5b08b8a1ac65c0490f4c103 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:10 +0100 Subject: [PATCH 12/23] Revert "feat(newsletter-reels): gallery offers m3 and shows the audio buttons for every reel it plays" This reverts commit 848b486ef68f5eb6fab94f1d03d4ebee1285b070. --- .claude/skills/newsletter-reels/scripts/gallery.html | 9 ++++----- .claude/skills/newsletter-reels/scripts/gallery.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/gallery.html b/.claude/skills/newsletter-reels/scripts/gallery.html index b3785934..66701af6 100644 --- a/.claude/skills/newsletter-reels/scripts/gallery.html +++ b/.claude/skills/newsletter-reels/scripts/gallery.html @@ -15,7 +15,7 @@ .box{display:flex;flex-direction:column;align-items:center;gap:10px}iframe{border:0;width:65.6vh;max-width:92vw;aspect-ratio:var(--w,4)/var(--h,5);background:#000;border-radius:8px} .bar{display:flex;gap:8px;align-items:center}.bar>button{padding:8px 14px;border-radius:8px;border:1px solid #555;background:#222;color:#eee;cursor:pointer}#cap{color:#ccc;min-width:220px;text-align:center} -

Newsletter reels

Pick one reel per topic and the row collapses to it, with its stock-track and recorded-SFX options (m1 / m2 / m3, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

+

Newsletter reels

Pick one reel per topic and the row collapses to it, with its two stock-track options (m1 / m2, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

@@ -29,7 +29,7 @@

Newsletter reels

Pick one reel per topic and function dims(slug){ const f=formats[slug]||formats[slug.replace(/-m\d+$/,'')]||'1080x1350', m=f.match(/^(\d+)x(\d+)$/); return m?[Number(m[1]),Number(m[2]),f]:[4,5,f]; } function ratio(slug){ const [w,h]=dims(slug); return `--w:${w};--h:${h}`; } function fitFrame(fr,w,h){ fr.style.width=Math.min(innerWidth*.92,innerHeight*.82*w/h)+'px'; fr.style.setProperty('--w',w); fr.style.setProperty('--h',h); fr.dataset.w=w; fr.dataset.h=h; } -function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')]),music.has(pk+'-m3')?[['m3','m1 + recorded sfx']]:[]) +function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) .map(([k,l])=>``).join(''); } function card(i,collapsed){ const ok=have.has(i.slug), t=i.topic, isSel=S[t]===i.slug; return `

`+(ok?`
▶ Play with audio
`:`
rendering...
`)+ @@ -41,12 +41,11 @@

Newsletter reels

Pick one reel per topic and async function save(){ await fetch('/select',{method:'POST',body:JSON.stringify(S)}); } async function pick(slug){ const t=item(slug).topic; if(S[t]===slug){ delete S[t]; } else { S[t]=slug; open_[t]=false; } await save(); draw(); } async function setAud(t,k){ S.audio[t]=k; await save(); draw(); if(cur&&item(cur).topic===t&&S[t]===cur) play(cur); } -function playButtons(slug,a){ const t=item(slug).topic; return ['m1','m2','m3'].filter(m=>music.has(slug+'-'+m)).map(m=>``).join(''); } -function play(slug,a0){ cur=slug; const t=item(slug).topic, a=a0||(S[t]===slug?aud(t):'m1'), fr=$('fr'); +function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'m1', fr=$('fr'); fr.onload=()=>{ try{ fr.contentWindow.addEventListener('keydown',e=>{ if(e.key==='Escape') close_(); }); const b=fr.contentDocument.getElementById('start'); b&&b.click(); fr.contentWindow.focus(); }catch(e){} }; const playSlug=slug+'-'+a, [w,h,format]=dims(playSlug); fitFrame(fr,w,h); fr.src=`out/${playSlug}.html?v=${V()}`; $('modal').classList.add('on'); - $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=playButtons(slug,a); } + $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } function step(d){ const list=ITEMS.filter(i=>have.has(i.slug)&&(!S[i.topic]||open_[i.topic]||S[i.topic]===i.slug)).map(i=>i.slug); let k=list.indexOf(cur); play(list[(k+d+list.length)%list.length]); } function close_(){ $('fr').src='about:blank'; $('modal').classList.remove('on'); cur=null; refresh(); } $('close').onclick=close_; $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); diff --git a/.claude/skills/newsletter-reels/scripts/gallery.py b/.claude/skills/newsletter-reels/scripts/gallery.py index 2ee9c1b1..e1348163 100755 --- a/.claude/skills/newsletter-reels/scripts/gallery.py +++ b/.claude/skills/newsletter-reels/scripts/gallery.py @@ -78,7 +78,7 @@ def do_GET(self) -> None: shutil.copy2(source, destination) out = self.reels / "out" base = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]\.html$") - music = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]-m[123]\.html$") + music = re.compile(rf"{re.escape(self.issue)}-\d+[a-z]-m\d+\.html$") names = [entry.name for entry in out.iterdir()] if out.exists() else [] formats = {} for directory in work.iterdir() if work.exists() else []: From e7296bff7a83abbb1415a746213123ed02893a8d Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:10 +0100 Subject: [PATCH 13/23] Revert "feat(newsletter-reels): build m3 variants (m1 track plus recorded sfx) for A/B review" This reverts commit 755f0dedaccb6741b6ee93a0aa2b54a5ce807d94. --- .../newsletter-reels/scripts/recorded_sfx.py | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100755 .claude/skills/newsletter-reels/scripts/recorded_sfx.py diff --git a/.claude/skills/newsletter-reels/scripts/recorded_sfx.py b/.claude/skills/newsletter-reels/scripts/recorded_sfx.py deleted file mode 100755 index abf91771..00000000 --- a/.claude/skills/newsletter-reels/scripts/recorded_sfx.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Build m3 variants: m1 stock music with recorded sound effects.""" - -import os -import subprocess -import sys -from pathlib import Path - -from _common import engine_dir, main_root, music_dir, reels_dir - - -def main() -> None: - if len(sys.argv) < 3: - raise SystemExit("usage: recorded_sfx.py ...") - issue, *slugs = sys.argv[1:] - root = reels_dir(issue) - variants = {} - for line in (root / "music-variants.txt").read_text().splitlines(): - fields = line.split(maxsplit=2) - if len(fields) == 3 and fields[1] == "m1": - variants[fields[0]] = fields[2] - sfx_lib = Path(os.environ.get("SFX_LIB", main_root() / ".music" / "sfx" / "oneshots")).resolve() - if not (sfx_lib / "index.json").is_file(): - raise SystemExit(f"missing recorded SFX library: {sfx_lib}") - scripts = Path(__file__).resolve().parent - env = dict( - os.environ, - REEL_HOME=str(root / "work"), - REEL_OUT=str(root / "out"), - SFX_LIB=str(sfx_lib), - ) - engine_venv = Path.home() / ".config" / "slt-agentic-marketplace" / "motion-reel" / "venv" / "bin" - if (engine_venv / "python3").exists(): - env["PATH"] = str(engine_venv) + os.pathsep + env.get("PATH", "") - reported_samples = False - for slug in slugs: - if not (root / "out" / f"{slug}.html").is_file(): - print(f"{slug}: skipped (missing out/{slug}.html)") - continue - track = variants.get(slug) - if not track: - print(f"{slug}: skipped (no m1 line in music-variants.txt)") - continue - subprocess.run( - [sys.executable, str(scripts / "swap_music.py"), issue, slug, "m3", str(music_dir() / track)], - env=env, - check=True, - stdin=subprocess.DEVNULL, - ) - new_slug = f"{slug}-m3" - build_env = dict(env) - if not reported_samples: - build_env["SFX_REPORT_SAMPLES"] = "1" - subprocess.run( - ["python3", "reel.py", "sfx", new_slug], cwd=engine_dir(), env=build_env, check=True, stdin=subprocess.DEVNULL - ) - reported_samples = True - subprocess.run( - ["python3", "reel.py", "build", new_slug], cwd=engine_dir(), env=env, check=True, stdin=subprocess.DEVNULL - ) - print(f"{new_slug}: {track} with recorded sfx -> out/{new_slug}.html") - - -if __name__ == "__main__": - main() From 043c8a8747e6c29d74169daa0e3b497c49fcfaee Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:10 +0100 Subject: [PATCH 14/23] Revert "feat(newsletter-reels): recorded-sample mode in sfx.py behind SFX_LIB, synth path byte-identical when unset" This reverts commit 0dd89cacdd951bc94f0ee652fc57ac6a4c29c1f3. --- .claude/skills/newsletter-reels/engine/sfx.py | 170 +++++------------- 1 file changed, 43 insertions(+), 127 deletions(-) diff --git a/.claude/skills/newsletter-reels/engine/sfx.py b/.claude/skills/newsletter-reels/engine/sfx.py index 66142b8a..c8315bdf 100644 --- a/.claude/skills/newsletter-reels/engine/sfx.py +++ b/.claude/skills/newsletter-reels/engine/sfx.py @@ -22,9 +22,7 @@ pass -2 dBFS. The stem is the same length as the music. Unknown cue kinds are skipped with a warning. """ -import hashlib import json -import os import sys import time from pathlib import Path @@ -40,7 +38,6 @@ PLAYER_GAIN = 0.8 # engine.js MIX: music 1, sfx 1, master .8 -> each stem reaches the output at 0.8 BUS_HP = {"ui": 250, "move": 180, "tone": 200, "hit": 110} BUS_GAIN = {"ui": 1.6, "move": 1.25, "tone": 1.25, "hit": 0.8, "low": 0.7} # booms own the peaks, not the loudness -SAMPLE_GAIN = 10 ** (-4 / 20) EASE = { "linear": lambda x: x, "outCubic": lambda x: 1 - (1 - x) ** 3, @@ -222,24 +219,6 @@ def build(work): bus = {k: np.zeros((2, N), np.float32) for k in ("ui", "move", "hit", "low", "tone")} send = np.zeros((2, N), np.float32) - sample_root = Path(os.environ["SFX_LIB"]).expanduser().resolve() if os.environ.get("SFX_LIB") else None - samples = {} - sample_rng = None - sample_last = {} - sample_used = {} - if sample_root: - try: - sample_index = json.loads((sample_root / "index.json").read_text()) - samples = { - role: sorted(sample_root / item["file"] for item in items if (sample_root / item["file"]).is_file()) - for role, items in sample_index.get("roles", {}).items() - } - seed_text = work.name + "\n" + json.dumps(cues, sort_keys=True, separators=(",", ":")) - sample_rng = np.random.default_rng(int(hashlib.sha256(seed_text.encode()).hexdigest()[:16], 16)) - except (OSError, ValueError, TypeError, KeyError) as exc: - print(f"sfx: warning: could not load sample library {sample_root}: {exc}; using synthesis") - sample_root = None - def put(b, sig, t0, gain=1.0, pan=0.0, rev=0.0): i = int(round(t0 * SR)) sig = np.atleast_2d(sig) @@ -258,27 +237,6 @@ def put(b, sig, t0, gain=1.0, pan=0.0, rev=0.0): def xpan(e, amt=0.55): return ((e.get("x", FW / 2) / FW) * 2 - 1) * amt - def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=None): - choices = samples.get(role, []) - if not choices: - return False - previous = sample_last.get((kind, role)) - available = [path for path in choices if path != previous] or choices - path = available[int(sample_rng.integers(len(available)))] - sample_last[(kind, role)] = path - relative = path.relative_to(sample_root).as_posix() - if relative not in sample_used.setdefault(kind, []): - sample_used[kind].append(relative) - sig = M.read_audio(path).mean(axis=0) - if max_d and len(sig) > int(max_d * SR): - sig = sig[: int(max_d * SR)].copy() - fade = min(len(sig), int(0.006 * SR)) - sig[-fade:] *= np.linspace(1, 0, fade) - put(b, sig, t0, gain * SAMPLE_GAIN, pan, rev) - if also: - put(also[0], sig, t0, also[1] * SAMPLE_GAIN, also[2], also[3]) - return True - popf = { "pill": deg(4), "input": deg(2), @@ -297,26 +255,17 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N counts[k] = counts.get(k, 0) + 1 if k == "type": txt, n, rate = str(e.get("s", "")), int(e.get("n", len(str(e.get("s", ""))))), float(e.get("rate", 0.016)) - if samples.get("type"): - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - put_sample( - k, "type", "ui", s + i * rate, 0.09 * (0.6 + 0.4 * sample_rng.random()), - xpan(e) + (i / max(n, 1) - 0.5) * 0.2, max_d=min(0.09, rate * 1.8), - ) - else: - for i in range(n): - if i < len(txt) and txt[i] == " ": - continue - put( - "ui", - P.click(0.005, 3000, 8000, seed=int(s * 1000) + i), - s + i * rate, - 0.09 * (0.6 + 0.4 * rng.random()), - xpan(e) + (i / max(n, 1) - 0.5) * 0.2, - ) - put("ui", P.tick(deg(9 + n % 3), 0.04), s + n * rate, 0.025, xpan(e)) + for i in range(n): + if i < len(txt) and txt[i] == " ": + continue + put( + "ui", + P.click(0.005, 3000, 8000, seed=int(s * 1000) + i), + s + i * rate, + 0.09 * (0.6 + 0.4 * rng.random()), + xpan(e) + (i / max(n, 1) - 0.5) * 0.2, + ) + put("ui", P.tick(deg(9 + n % 3), 0.04), s + n * rate, 0.025, xpan(e)) elif k == "slam": n, st, size = ( int(e.get("n", len(str(e.get("s", "x"))))), @@ -324,10 +273,6 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N float(e.get("size", 90)), ) big = size >= 90 - if samples.get("whoosh") and samples.get("impact"): - put_sample(k, "whoosh", "move", s, 0.10 if big else 0.05, xpan(e), rev=0.15) - put_sample(k, "impact", "hit", s + 0.06, 0.35 if size >= 150 else 0.18, xpan(e), rev=0.12) - continue put( "move", P.swish(n * st + 0.35, 1500, 6000 if big else 4500, 0.8, 0.25, seed=int(s * 997)), @@ -347,64 +292,53 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N if size >= 150: put("hit", P.thock(150 if size > 200 else 190), s + 0.06, 0.35, xpan(e)) elif k == "para": - if not put_sample(k, "whoosh", "move", s, 0.025, xpan(e)): - put("move", P.swish(0.35, 2500, 5000, 0.9, 0.3, seed=int(s * 71)), s, 0.025, xpan(e)) + put("move", P.swish(0.35, 2500, 5000, 0.9, 0.3, seed=int(s * 71)), s, 0.025, xpan(e)) elif k == "pop": - if not put_sample(k, "ui", "ui", s, 0.16, xpan(e, 0.3), rev=0.12): - put("ui", P.pop(popf.get(e.get("s"), deg(5))), s, 0.16, xpan(e, 0.3), rev=0.12) + put("ui", P.pop(popf.get(e.get("s"), deg(5))), s, 0.16, xpan(e, 0.3), rev=0.12) elif k == "ring": if e.get("r", 100) < 40: continue - if not put_sample(k, "sheen", "tone", s, 0.035, xpan(e, 0.4), rev=0.35): - put( - "tone", - P.bell(deg(10 + int(e.get("r", 100) > 300) * 2), 0.9, 0.6, 2.0), - s, - 0.035, - xpan(e, 0.4), - rev=0.35, - ) + put( + "tone", + P.bell(deg(10 + int(e.get("r", 100) > 300) * 2), 0.9, 0.6, 2.0), + s, + 0.035, + xpan(e, 0.4), + rev=0.35, + ) elif k == "heart": put("hit", P.heart(), s, 0.5) elif k == "drop": # outBounce contacts for x, a in [(1 / 2.75, 1), (2 / 2.75, 0.35), (2.5 / 2.75, 0.15), (1, 0.06)]: put("hit", P.thock(180), s + x * d, 0.45 * a) elif k == "whoosh": - if not put_sample(k, "whoosh", "move", s - (0.02 if e.get("dir") != "in" else 0), 0.22, rev=0.12): - put( - "move", - P.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), - s - (0.02 if e.get("dir") != "in" else 0), - 0.22, - rev=0.12, - ) + put( + "move", + P.whoosh(d, float(e.get("s", 0.6)), e.get("dir", "side"), seed=int(s * 313)), + s - (0.02 if e.get("dir") != "in" else 0), + 0.22, + rev=0.12, + ) elif k == "riser": - if not put_sample(k, "riser", "move", s, 0.18, rev=0.2, max_d=max(d, 0.3)): - put("move", P.riser(max(d, 0.3)), s, 0.18, rev=0.2) + put("move", P.riser(max(d, 0.3)), s, 0.18, rev=0.2) elif k == "shock": put("move", P.swish(0.55, 400, 2500, 1.2, 0.08, seed=int(s * 17)), s, 0.10) put("hit", P.thock(130, 0.2), s, 0.25) elif k == "impact": st = float(e.get("s", 1.0)) - if put_sample(k, "impact", "hit", s, 0.55 * max(0.3, st), rev=0.18): - pass - elif st >= 0.95: + if st >= 0.95: put("hit", P.impact(1), s, 0.55, rev=0.18) elif st >= 0.5: put("hit", P.impact(0.6), s, 0.5 * st, rev=0.12) else: put("hit", P.thock(180, 0.12), s, 0.5 * st + 0.1) elif k == "boom": - if samples.get("boom"): - put_sample(k, "boom", "hit", s, 0.75, rev=0.3, also=("low", 0.9, 0.0, 0.0)) - continue st, sub = P.boom(min(1.2, float(e.get("s", 1.2)) / 1.2)) put("hit", st, s, 0.75, rev=0.3) put("low", sub, s, 0.9) put("move", P.swish(0.9, 9000, 3000, 1.0, 0.02, seed=int(s)), s, 0.08, rev=0.3) elif k == "flip": - if not put_sample(k, "glitch", "ui", s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08): - put("ui", P.flip(), s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08) + put("ui", P.flip(), s, 0.22 if e.get("to") != "ink" else 0.13, rev=0.08) elif k == "sweep": n = int(d * SR) t = np.arange(n) / SR @@ -474,9 +408,8 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N ) put("move", P.whoosh(d, 0.5, "down" if e.get("dir") == "down" else "side", seed=77), s, 0.15) elif k == "strike": - if not put_sample(k, "whoosh", "move", s, 0.22, rev=0.1, max_d=d + 0.12): - put("move", P.swish(d + 0.12, 2000, 9000, 0.6, 0.8, seed=81), s, 0.22, rev=0.1) - put("ui", P.click(0.006, 1500, 9000, 82), s + d, 0.35) + put("move", P.swish(d + 0.12, 2000, 9000, 0.6, 0.8, seed=81), s, 0.22, rev=0.1) + put("ui", P.click(0.006, 1500, 9000, 82), s + d, 0.35) elif k == "shimmer": for i in range(22): put( @@ -488,8 +421,6 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N rev=0.5, ) elif k == "bells": - if put_sample(k, "sheen", "tone", s, 0.10, rev=0.6): - continue final = bool(e.get("final")) for i, m in enumerate([0, 4, 7, 9, 12] if final else [0, 2, 4, 5, 7, 9]): put( @@ -518,8 +449,6 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N put("ui", P.click(0.0025, 2500, 9000, 200 + j), s + j / 30, 0.03, xpan(e, 0.4)) put("ui", P.thock(900, 0.04), s + d, 0.06, xpan(e, 0.4)) elif k == "burst": - if put_sample(k, "glitch", "ui", s, 0.22, rev=0.12): - continue for i in range(90): put( "ui", @@ -532,8 +461,6 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N put("move", P.swish(0.7, 7000, 1500, 0.9, 0.05, seed=301), s, 0.1, 0.3, rev=0.15) elif k == "swirl": g = float(e.get("s", 1.0)) - if put_sample(k, "sheen", "tone", s, 0.07 * g, rev=0.35, max_d=d): - continue p0, p1 = (e.get("from", FW / 2) / FW) * 2 - 1, (e.get("to", FW / 2) / FW) * 2 - 1 nn = int(60 + 90 * d) for i in range(nn): @@ -560,17 +487,13 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N ) elif k == "sheen": g = float(e.get("s", 1.0)) if isinstance(e.get("s"), (int, float)) else 1.0 - if not put_sample(k, "sheen", "tone", s, 0.07 * g, -0.4, rev=0.35, max_d=d): - put("move", P.swish(d, 3500, 11000, 0.6, 0.7, seed=321), s, 0.07 * g, -0.4, rev=0.2) - for j, m in enumerate((12, 14)): - put("tone", P.bell(deg(m), 1.4, 0.8, 2.0), s + d * 0.6 + j * 0.06, 0.035 * g, 0.4, rev=0.5) + put("move", P.swish(d, 3500, 11000, 0.6, 0.7, seed=321), s, 0.07 * g, -0.4, rev=0.2) + for j, m in enumerate((12, 14)): + put("tone", P.bell(deg(m), 1.4, 0.8, 2.0), s + d * 0.6 + j * 0.06, 0.035 * g, 0.4, rev=0.5) elif k == "lock": - if not put_sample(k, "ui", "ui", s, 0.3, rev=0.2): - put("ui", P.click(0.006, 2000, 10000, 331), s, 0.3) - put("move", P.swish(0.5, 9000, 2500, 0.9, 0.02, seed=332), s, 0.08, rev=0.3) + put("ui", P.click(0.006, 2000, 10000, 331), s, 0.3) + put("move", P.swish(0.5, 9000, 2500, 0.9, 0.02, seed=332), s, 0.08, rev=0.3) elif k == "halo": - if put_sample(k, "drone", "tone", s, 0.025, rev=0.4, max_d=d): - continue n = int(d * SR) t = np.arange(n) / SR env = np.minimum(1, t / 0.6) * np.minimum(1, np.maximum(0, d - t) / 0.4) @@ -584,9 +507,8 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N ) put("tone", np.stack([pad * env, np.roll(pad, 300) * env]), s, 0.025, rev=0.4) elif k == "blip": - if not put_sample(k, "ui", "ui", s, 0.12, rev=0.4): - put("tone", P.bell(deg(10), 1.6, 0.8, 2.0), s, 0.12, rev=0.6) - put("ui", P.tick(deg(10), 0.06), s, 0.08) + put("tone", P.bell(deg(10), 1.6, 0.8, 2.0), s, 0.12, rev=0.6) + put("ui", P.tick(deg(10), 0.06), s, 0.08) elif ( k == "count" ): # pitched ticks climbing the scale with the value, thinned to >= 30 ms, a bell on the final value @@ -607,24 +529,18 @@ def put_sample(kind, role, b, t0, gain=1.0, pan=0.0, rev=0.0, max_d=None, also=N continue last = tk frac = min(1, float(v) / to) if to else 0.5 - if not put_sample(k, "ui", "ui", tk, 0.07 + 0.05 * frac, xpan(e, 0.35), max_d=0.05): - put("ui", P.tick(deg(int(frac * 9)), 0.03), tk, 0.07 + 0.05 * frac, xpan(e, 0.35)) - if not samples.get("ui") and ticks and ("to" in e or "d" in e): + put("ui", P.tick(deg(int(frac * 9)), 0.03), tk, 0.07 + 0.05 * frac, xpan(e, 0.35)) + if ticks and ("to" in e or "d" in e): for m, a in [(10, 0.12), (14, 0.07)]: put("tone", P.bell(deg(m), 1.8, 1.4), ticks[-1][0], a, 0, rev=0.4) elif k == "land": j = lands.index(e) f = deg(5 + int(j / max(len(lands), 1) * 10)) - if not put_sample(k, "ui", "ui", s, 0.028, xpan(e, 0.6), max_d=0.06): - put("ui", P.pop(f * (1 + 0.02 * rng.standard_normal()), 0.03), s, 0.028, xpan(e, 0.6)) + put("ui", P.pop(f * (1 + 0.02 * rng.standard_normal()), 0.03), s, 0.028, xpan(e, 0.6)) else: unknown[k] = unknown.get(k, 0) + 1 for k, n in unknown.items(): print(f"sfx: warning: skipped {n} '{k}' cue(s): unknown kind") - if sample_used and os.environ.get("SFX_REPORT_SAMPLES"): - print("sfx samples:") - for kind, paths in sorted(sample_used.items()): - print(f" {kind} -> {', '.join(paths)}") # ---- mix: carve, duck the sub under the music's kick, reverb, level stem = {b: M.fftfilt(bus[b].astype(np.float64), M.hpg(fc, 2)) for b, fc in BUS_HP.items()} From 8918d1bd38cbead9b8aa9471e366ae007aa4577c Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:23:10 +0100 Subject: [PATCH 15/23] Revert "feat(newsletter-reels): slice the licensed Sonniss library into role-tagged one-shots" This reverts commit 3c06c766ba10331b0ba0ef67a03ebaba7cefa92f. --- .../newsletter-reels/scripts/sfx_library.py | 146 ------------------ 1 file changed, 146 deletions(-) delete mode 100755 .claude/skills/newsletter-reels/scripts/sfx_library.py diff --git a/.claude/skills/newsletter-reels/scripts/sfx_library.py b/.claude/skills/newsletter-reels/scripts/sfx_library.py deleted file mode 100755 index f60ca851..00000000 --- a/.claude/skills/newsletter-reels/scripts/sfx_library.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""Turn a folder of licensed WAV files into a small, role-based one-shot library.""" - -import argparse -import hashlib -import json -import re -import subprocess -from pathlib import Path - -from _common import main_root - - -ROLES = ("ui", "type", "paper", "whoosh", "riser", "impact", "boom", "glitch", "sheen", "alarm", "drone") -LONG_ROLES = {"riser", "boom", "drone"} - - -def role_for(path: Path) -> str | None: - text = " ".join(path.parts[-2:]).lower() - name = path.name.lower() - if any(word in text for word in ("paper", "newspaper", "page turn", "page movement")): - return "paper" - if "typewriter" in text: - return "type" - if "alarm" in text: - return "alarm" - if any(word in text for word in ("boom", "bass drop", "downer", "braam")): - return "boom" - if any(word in text for word in ("powerup", "power up", "blast off", "riser")): - return "riser" - if any(word in text for word in ("glitch", " arc", "arc_", "radio", "eq sweeping", "buzz")): - return "glitch" - if any(word in text for word in ("drone", "ambience", "roomtone", "sinister texture", "screechy tones")): - return "drone" - if any(word in text for word in ("magic", "sparkle", "shimmer", "twinkle", "kalimba", "xylophone", "bird flute")): - return "sheen" - if any(word in name for word in ("whoosh", "woosh", "rush", "pass by")): - return "whoosh" - if any(word in name for word in ("impact", "impt", " hit", "hit ", "thunk", "clatter", "shaker snap")): - return "impact" - if any(word in text for word in (" ui ", "ui_", "uimisc", "interface", "button", "click", "mechanism", "coin flip")): - return "ui" - return None - - -def probe_duration(path: Path) -> float: - out = subprocess.check_output( - ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)], - text=True, - ) - return float(out.strip()) - - -def takes(path: Path, duration: float) -> list[tuple[float, float]]: - run = subprocess.run( - ["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-af", "silencedetect=noise=-46dB:d=0.22", "-f", "null", "-"], - capture_output=True, - text=True, - check=True, - ) - spans, start = [], 0.0 - for line in run.stderr.splitlines(): - found = re.search(r"silence_(start|end):\s*([0-9.]+)", line) - if not found: - continue - when = float(found.group(2)) - if found.group(1) == "start": - if when - start >= 0.035: - spans.append((start, when)) - else: - start = when - if duration - start >= 0.035: - spans.append((start, duration)) - return spans or [(0.0, duration)] - - -def safe_name(path: Path, take: int) -> str: - stem = re.sub(r"[^a-z0-9]+", "-", path.stem.lower()).strip("-")[:64] - digest = hashlib.sha1(str(path).encode()).hexdigest()[:8] - return f"{stem}-{digest}-{take:02d}.wav" - - -def render(source: Path, destination: Path, start: float, end: float, role: str) -> float: - limit = 6.0 if role in LONG_ROLES else 3.0 - duration = min(limit, end - start) - fade = min(0.012, duration / 4) - filters = ( - "aresample=48000," - "loudnorm=I=-20:LRA=7:TP=-1.5," - f"afade=t=in:st=0:d={fade:.4f}," - f"afade=t=out:st={max(0, duration - fade):.4f}:d={fade:.4f}" - ) - subprocess.run( - [ - "ffmpeg", "-v", "error", "-y", "-ss", f"{start:.5f}", "-t", f"{duration:.5f}", "-i", str(source), - "-af", filters, "-ar", "48000", "-ac", "2", "-c:a", "pcm_s24le", str(destination), - ], - check=True, - stdin=subprocess.DEVNULL, - ) - return duration - - -def main() -> None: - default_source = main_root() / ".music" / "sfx" / "sonniss-gdc2026" - default_output = main_root() / ".music" / "sfx" / "oneshots" - parser = argparse.ArgumentParser() - parser.add_argument("source", nargs="?", type=Path, default=default_source) - parser.add_argument("output", nargs="?", type=Path, default=default_output) - args = parser.parse_args() - source, output = args.source.resolve(), args.output.resolve() - if not source.is_dir(): - raise SystemExit(f"missing sound library: {source}") - output.mkdir(parents=True, exist_ok=True) - index = {"sample_rate": 48000, "roles": {role: [] for role in ROLES}, "dropped": []} - for wav in sorted(source.rglob("*.wav")): - role = role_for(wav.relative_to(source)) - if not role: - index["dropped"].append(str(wav.relative_to(source))) - continue - role_dir = output / role - role_dir.mkdir(exist_ok=True) - duration = probe_duration(wav) - for number, (start, end) in enumerate(takes(wav, duration), 1): - name = safe_name(wav.relative_to(source), number) - relative = Path(role) / name - rendered = render(wav, output / relative, start, end, role) - index["roles"][role].append({ - "file": relative.as_posix(), - "source": str(wav.relative_to(source)), - "take": number, - "duration": round(rendered, 4), - }) - (output / "index.json").write_text(json.dumps(index, indent=2) + "\n") - for role in ROLES: - print(f"{role}: {len(index['roles'][role])}") - print(f"dropped: {len(index['dropped'])}") - for path in index["dropped"]: - print(f" {path}") - missing = [role for role in ("ui", "whoosh", "impact") if len(index["roles"][role]) < 3] - if missing: - raise SystemExit("too few samples for: " + ", ".join(missing)) - - -if __name__ == "__main__": - main() From 318c0bbf9dd60f87a0d951628d73a38d3e87d2fd Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:24:24 +0100 Subject: [PATCH 16/23] feat(newsletter-reels): make motion-first the default - 1-2 s beats, decorative layers, free type and camera, a five-style menu that overrides SOCIAL.md's calm pacing --- .claude/skills/newsletter-reels/SKILL.md | 2 +- .../newsletter-reels/briefs/issue-reel.md | 2 +- .../skills/newsletter-reels/briefs/worker.md | 22 ++++++++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.claude/skills/newsletter-reels/SKILL.md b/.claude/skills/newsletter-reels/SKILL.md index ec45a90b..fc8ba2b1 100644 --- a/.claude/skills/newsletter-reels/SKILL.md +++ b/.claude/skills/newsletter-reels/SKILL.md @@ -20,7 +20,7 @@ In the commands below `$SK` is this skill's directory and `$R` is `tmp/reels/ ## 2. Write the directions -Read `references/directions.md` and `references/learnings.md`. Write `$R/directions.md` in the same format: per topic a palette line and five directions `- ** Name (capability):** mechanism`. Each direction names the mechanism the film acts out, and the five per topic show off different capabilities (3D, UI simulation, data viz, kinetic type, particles, hand-made). Directions are written for the article, never reused across topics. +Read `references/directions.md` and `references/learnings.md`. Write `$R/directions.md` in the same format: per topic a palette line and five directions `- ** Name (capability):** mechanism`. Each direction names the mechanism the film acts out, and the five per topic each commit to a different primary style from the worker brief's Motion-first menu (kinetic type, HUD/technical, 3D camera fly-through, generative/organic, device in scene), so the owner compares five genuinely different films. Directions are written for the article, never reused across topics. Do not write a calm explainer direction: the worker brief's Motion-first section is the default because the owner picked it over the calm reels in four of five topics on 410. Then run `python3 $SK/scripts/briefs.py `. It renders one brief per direction from `briefs/worker.md` into `$R/briefs/-.md`; the `0-issue` briefs get `briefs/issue-reel.md` appended and the 1920x1080 format. Existing briefs are kept unless you pass `--force` (`--only 3a` renders one). diff --git a/.claude/skills/newsletter-reels/briefs/issue-reel.md b/.claude/skills/newsletter-reels/briefs/issue-reel.md index ea1e6920..629755c4 100644 --- a/.claude/skills/newsletter-reels/briefs/issue-reel.md +++ b/.claude/skills/newsletter-reels/briefs/issue-reel.md @@ -1,7 +1,7 @@ ## This reel is different: it promotes the whole issue - Format 1920x1080 (16:9), 30 fps: `python3 reel.py init {{SLUG}} --format 1920x1080`. It posts as LinkedIn's landscape feed video, so use the width: visual on one side, headline and logo on the other, like a keynote title card. -- It is the newsletter's own reel: issue #{{N}} and its five stories, then the call to action. 25-30 s, ~3-4 s per story (headline + its real logo + at most one fact from facts.md), paced for reading. +- It is the newsletter's own reel: issue #{{N}} and its five stories, then the call to action. 25-30 s, ~3-4 s per story (headline + its real logo + at most one fact from facts.md). The 409 issue reel with this skeleton reached ~25x the earlier issue posts, so keep it: each story's headline and logo stay readable, while the motion inside each story follows the Motion-first section (stamps on the beat, living detail, camera moves). - Frame 0 (thumbnail, read at feed size): the Institute logo (horizontal lockup from assets/logos) + "ML Engineering Newsletter #{{N}}" + a hook like "5 stories this week", fully formed at t=0. - The story logos are in assets/logos too. Use them as SVG vectors and transform them through their own anatomy, one into the next; no particle dissolves. Every logo must be exact at rest (render it from its SVG path; the 409 Meta logo broke when it was redrawn by hand). - End card: the Institute logo, the subscriber call to action from post.txt, "ethical.institute/newsletter". The final motion settles before the last frame; nothing is left half-finished. diff --git a/.claude/skills/newsletter-reels/briefs/worker.md b/.claude/skills/newsletter-reels/briefs/worker.md index 174bd1af..f503b466 100644 --- a/.claude/skills/newsletter-reels/briefs/worker.md +++ b/.claude/skills/newsletter-reels/briefs/worker.md @@ -10,21 +10,37 @@ You are ONE of {{COUNT}} parallel workers. Each is building one short social ree {{DIRECTION}} ## How to work -1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first (it is your operating mode and overrides the keynote defaults), then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. +1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first (it is your operating mode and overrides the keynote defaults, except where the Motion-first section below overrides it), then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. 2. Run the pipeline exactly as SOCIAL.md says, from the engine dir, with: export REEL_HOME={{REELS}}/work export REEL_OUT={{REELS}}/out and `python3 reel.py init {{SLUG}} --format 1080x1350`, run from {{ENGINE}}. There is no user sign-off: write beatmap.md and proceed. 3. Compose a score.py for this direction at 118-130 bpm, but only as the beat grid your cuts and sound effects lock to. The owner never hears it: every reel ships with one of the owner's stock tracks, cut from its first full-energy beat to your film's length, so land your hits on a steady 4/4 grid and keep the sound effects carrying the accents. -4. At least 3 review rounds on the check sheets (look at them with Read), with the SOCIAL.md checklist and the prompts.md feedback lens. Every headline held >= 1.5-2 s, key numbers 2-3 s. Captions on for sound-off viewing. No text clipping, overlaps or tiny type at phone size. +4. At least 3 review rounds on the check sheets (look at them with Read), with the SOCIAL.md checklist and the prompts.md feedback lens, read through the Motion-first section. Every word on screen at least 0.7 s and readable on a phone; no text clipping, overlaps or tiny type. 5. `build` only. Do NOT export: the owner reviews the HTML page, and the mp4 is exported once at the end. ## Frame 0 and the brand -- Frame 0 is the feed thumbnail. At t=0 a viewer instantly sees the brand (the real logo) and the topic in big punchy type, fully formed, held ~0.8 s. No generic labels ("SCHEMATIC") on it. +- Frame 0 is the feed thumbnail. At t=0 a viewer instantly sees the brand (the real logo) and the topic in big punchy type, fully formed; it may start moving after 0.3 s. No generic labels ("SCHEMATIC") on it. - The logos in assets/logos/ are SVG: embed their path data as Path2D vectors in film.js, never as bitmaps, and keep them crisp and correctly proportioned at rest. - The logo transforms at least 3 times (out of the thumbnail, on the drop, back on the end card) through its OWN anatomy: its real pieces move as solid parts, its outline deforms with morphPath, strokes draw and undraw, it flips or extrudes in 3D. No particle dissolves of the logo (at most one small particle accent in the film). The reference the owner loved: Mistral's pixel-M blocks flying into the finish-line squares and the 50.4 digits, then re-stacking into the M. - End card: the brand mark next to the "ethical.institute" wordmark. +## Motion-first (overrides SOCIAL.md wherever they conflict) +The calm explainer is the failure case: 5-7 scenes of 4 s, a held headline, a caption, a number, an end card. Every reel in the first runs came out like that, all alike. The references the owner loves are motion-design showreels that change every 1-2 s, fill every frame with living detail and feel like a music video, and on issue 410 he picked the motion-first reel over the calm one in four of five topics. Think "what would make a motion designer stop scrolling", not "how do I explain the article". +- **Pace:** a new visual idea every 1-2 s (half a bar to a bar at 120-128 bpm). Hard cuts on the beat are allowed and encouraged, mixed with continuous carries. A `cuts` or `holds` WARN from `check` may stand when the cut or hold is a deliberate beat: list it in REPORT.md with the reason. FAILs still get fixed. +- **Story:** one claim and at most two numbers from the article, carried by the style. No five-scene fact tour. +- **Captions:** optional; use one only where a word is the beat. +- **Decorative motion is wanted:** layered living detail (HUD furniture, fields, particles, grids, ticking readouts, light sweeps, parallax planes) is what makes these feel rich. SOCIAL.md's "no decorative motion" rule does not apply. Keep text legible over it with contrast plates, dimming or depth of field. +- **Type:** free. Giant heavy display words, mono micro-labels, words wrapped around shapes, letters resolving one by one, inverted words, mixed scale in one line. +- **Camera:** move it. Dolly, orbit, push through layers, whip pans with motion blur (blend 2-4 offset frames), rack focus. + +Your direction names a primary style; borrow from the others freely: +1. **Kinetic type / lyric video:** one giant word stamped per beat with scale overshoot, small words typing in beside it, words wrapping a ring or path, inverted highlight words, a terminal prompt typing a line with a block cursor. +2. **HUD / technical:** crop marks, hairline grids, a mono code or coordinates column, ticking timecodes, odometer digits (clipped digit columns easing in, faint vertical blur copies), callout boxes on leader lines growing out of an object, dimension arrows, line art drawn stroke by stroke with a glowing comet head (lineDashOffset on Path2D, additive dot, shadowBlur bloom). +3. **3D camera fly-through:** the subject as a three.js set (tilted UI panels, architecture, a chip, a corridor of layers) on a continuous camera spline with depth of field and whip pans; flat UI extruded into 3D slabs. +4. **Generative / organic:** noise-displaced contour-ring fields, flow fields, branching networks, swarms, with a glowing path cutting through and drifting on the beat. +5. **Device in scene:** the film playing on a screen inside a dark three.js scene (a laptop or phone on a glossy black table, lit only by its screen, with a mirror reflection and a slow camera move), cutting between full screen and the device. + ## Rules - Facts only from post.txt and facts*.md; copy figures exactly; never invent numbers, names or claims. Illustrative visuals that are not data get labelled as such. - On-screen text ASCII punctuation only (straight quotes, "-", "..."). From 44ec62eff4598ee0e704d7a1195adb4ae5774339 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:24:25 +0100 Subject: [PATCH 17/23] docs(newsletter-reels): record why motion-first won on 410 and catalogue the picks --- .../skills/newsletter-reels/references/directions.md | 10 ++++++++++ .../skills/newsletter-reels/references/learnings.md | 3 +++ 2 files changed, 13 insertions(+) diff --git a/.claude/skills/newsletter-reels/references/directions.md b/.claude/skills/newsletter-reels/references/directions.md index 00f744c0..e0566d19 100644 --- a/.claude/skills/newsletter-reels/references/directions.md +++ b/.claude/skills/newsletter-reels/references/directions.md @@ -4,6 +4,16 @@ Each direction names the MECHANISM the film acts out, in one line, and the five Capabilities to rotate through: force-directed or 3D graph, infinite zoom, UI simulation (terminal, Slack, GitHub PR), particles to order, blueprint draw-on, data viz as sport, oscilloscope/HUD, Swiss kinetic type, 3D terrain flythrough, leaderboard tower, isometric exploded architecture, counter avalanche, cut-paper explainer, layered parallax, one transforming object, scaling-law sculpture, mechanical sorting machine, hand-drawn notebook, infinite wall. +## Issue 410 (2026-10-25), motion-first wins + +The owner's picks, in motion-first mode unless marked: +- **1g Phone call, motion-first (kinetic type):** live call captions turned into a lyric video, words leaping out of the phone and slamming full screen on the beat, the waveform driving the type. +- **2f Dive through the containment (3D camera fly-through):** the camera dives through the nested sandbox, microVM, host and perimeter layers as a corridor; alarms on the escape, a covert cache-timing signal between two glass rooms, HUD readouts. +- **3g Word doc, motion-first (3D camera fly-through):** the contract in Word as a tilted 3D set the camera flies across, whip-panning between tracked changes, the agent pane extruding into slabs. +- **4d Manual flip-through (paper 3D, calm batch):** the 144-page manual riffling and stopping on its key pages, with comet-drawn lines and odometers. +- **5f Cog to die (3D + kinetic):** the Rust cog extruded in 3D unfolding into a GPU die the camera flies over, borrow-checker error stamps on the beat, 1,024 threads snapping into tiles. +- **0d Terminal digest (UI simulation, calm batch):** posted as the issue reel before the motion-first batch existed. + ## Issue 409 (2026-10-18), the first run Winners marked [PICKED]; the owner picked them for the mechanism, not the polish. diff --git a/.claude/skills/newsletter-reels/references/learnings.md b/.claude/skills/newsletter-reels/references/learnings.md index 5d014c1c..8e6ca0ac 100644 --- a/.claude/skills/newsletter-reels/references/learnings.md +++ b/.claude/skills/newsletter-reels/references/learnings.md @@ -4,6 +4,9 @@ Newest first. Fold a rule into the step or brief where it applies once it has he ## Issue 410 (2026-10-25) +- **Motion-first beats the calm explainer.** The first 30 reels all followed the same structure because SOCIAL.md hardcodes it: 5-7 scenes of 4 s, held headlines, a caption per scene, and "no decorative motion". The owner compared them against motion-design reels on TikTok that change every 1-2 s and asked why ours were so much calmer. A second batch of 12 ran with those rules switched off (1-2 s beats, one claim, decorative layers allowed, camera moves, a style menu of kinetic type, HUD, 3D fly-through, generative and device-in-scene), as six new directions plus six motion-first revisions of the picks. He picked motion-first reels in four of five topics (1g, 2f, 3g, 5f; 4d stayed). The worker brief's Motion-first section is now the default. +- **Optional menus at the bottom of a brief get ignored.** The motion references were first appended as an optional "pick 2-3" list under the hard rules, and only 2 of the 6 picks used them visibly. Rules that change the result belong in the body of the brief, stated as overrides. + - **Never play the generated score.** The gallery defaulted to each worker's composed score and the m1/m2 stock variants were only built for picks, so the owner reviewed every reel with generated music and the first issue-reel export defaulted to `score`. The owner's rule is absolute: stock tracks only. The gallery now has no score option, export refuses it, and `stock_music.sh` builds both variants for all 30 reels as they land. - **A stale gallery server serves the wrong issue.** Last week's gallery server was still bound to 8767, the new one died with "Address already in use", and the browser showed 409's reels. Check the port before starting. - **The issue reel is the one that earns reach.** From Buffer's metrics: the 409 issue reel got ~56k LinkedIn impressions, about 25x the earlier issue posts, while the story reels sat around the image-post median. Only the photo-finish race held viewers (92% of impressions became views against 27-38% for the rest). Briefs now carry both points; the issue reel keeps the 409 skeleton. From effcb9e62d4b60ba67a15f1487b5a08d766b40ad Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:24:25 +0100 Subject: [PATCH 18/23] feat(newsletter-reels): gallery shows the m1/m2 buttons for every reel it plays, not only the pick --- .claude/skills/newsletter-reels/scripts/gallery.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/newsletter-reels/scripts/gallery.html b/.claude/skills/newsletter-reels/scripts/gallery.html index 66701af6..1a8c20d1 100644 --- a/.claude/skills/newsletter-reels/scripts/gallery.html +++ b/.claude/skills/newsletter-reels/scripts/gallery.html @@ -41,11 +41,12 @@

Newsletter reels

Pick one reel per topic and async function save(){ await fetch('/select',{method:'POST',body:JSON.stringify(S)}); } async function pick(slug){ const t=item(slug).topic; if(S[t]===slug){ delete S[t]; } else { S[t]=slug; open_[t]=false; } await save(); draw(); } async function setAud(t,k){ S.audio[t]=k; await save(); draw(); if(cur&&item(cur).topic===t&&S[t]===cur) play(cur); } -function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'m1', fr=$('fr'); +function playButtons(slug,a){ const t=item(slug).topic; return ['m1','m2'].filter(m=>music.has(slug+'-'+m)).map(m=>``).join(''); } +function play(slug,a0){ cur=slug; const t=item(slug).topic, a=a0||(S[t]===slug?aud(t):'m1'), fr=$('fr'); fr.onload=()=>{ try{ fr.contentWindow.addEventListener('keydown',e=>{ if(e.key==='Escape') close_(); }); const b=fr.contentDocument.getElementById('start'); b&&b.click(); fr.contentWindow.focus(); }catch(e){} }; const playSlug=slug+'-'+a, [w,h,format]=dims(playSlug); fitFrame(fr,w,h); fr.src=`out/${playSlug}.html?v=${V()}`; $('modal').classList.add('on'); - $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } + $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=playButtons(slug,a); } function step(d){ const list=ITEMS.filter(i=>have.has(i.slug)&&(!S[i.topic]||open_[i.topic]||S[i.topic]===i.slug)).map(i=>i.slug); let k=list.indexOf(cur); play(list[(k+d+list.length)%list.length]); } function close_(){ $('fr').src='about:blank'; $('modal').classList.remove('on'); cur=null; refresh(); } $('close').onclick=close_; $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); From e3a8983801b1e5fd689a37c05ff17f1bac92e928 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:24:25 +0100 Subject: [PATCH 19/23] feat(newsletter): buffer-post prefers a post folder's reel.mp4 over its image --- scripts/newsletter/buffer-post.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/newsletter/buffer-post.mjs b/scripts/newsletter/buffer-post.mjs index 9dcc702f..0b18b36c 100644 --- a/scripts/newsletter/buffer-post.mjs +++ b/scripts/newsletter/buffer-post.mjs @@ -181,6 +181,7 @@ async function main() { // Best format first, not whatever readdir hands back: a folder holding both a walk and its // GIF fallback would otherwise post the fallback, purely because "gif" sorts before "mp4". const mediaOrder = [ + 'reel.mp4', 'image.mp4', 'image.gif', 'image.png', From 5da2d56a1f45c292c69b02e0ea93c94fe2417deb Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:30:03 +0100 Subject: [PATCH 20/23] fix(newsletter): buffer-post refuses media over jsDelivr's 20 MB limit before pushing it --- scripts/newsletter/buffer-post.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/newsletter/buffer-post.mjs b/scripts/newsletter/buffer-post.mjs index 0b18b36c..2b4104c1 100644 --- a/scripts/newsletter/buffer-post.mjs +++ b/scripts/newsletter/buffer-post.mjs @@ -29,7 +29,7 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -191,6 +191,13 @@ async function main() { ]; const present = new Set(readdirSync(dir)); const media = mediaOrder.find((name) => present.has(name)); + // jsDelivr refuses files over 20 MB with a 403, which Buffer reports only as "Video could not + // be read from its URL", and it caches that 403 for the path. Fail before pushing anything. + if (media && statSync(path.join(dir, media)).size >= 20_000_000) { + throw new Error( + `${slugDir}/${media} is over jsDelivr's 20 MB limit; re-encode it smaller first (e.g. ffmpeg -b:v 5000k)`, + ); + } const wanted = options.channel ? String(options.channel).split(',') : ['linkedin']; const available = await channels(); From a6c400706c752addedb5f113f521b9f408fc2aaa Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Sun, 25 Oct 2026 21:30:03 +0100 Subject: [PATCH 21/23] docs(newsletter-reels): re-encode post reels under 20 MB for jsDelivr, and record the 410 failure --- .claude/skills/newsletter-reels/SKILL.md | 2 +- .claude/skills/newsletter-reels/references/learnings.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/newsletter-reels/SKILL.md b/.claude/skills/newsletter-reels/SKILL.md index fc8ba2b1..8c0e25e8 100644 --- a/.claude/skills/newsletter-reels/SKILL.md +++ b/.claude/skills/newsletter-reels/SKILL.md @@ -56,7 +56,7 @@ Before you resume, explain to the owner which reels you will resume and with wha ## 7. Export and hand over -Only when the owner says the set is final, run `python3 $SK/scripts/export.py `. It exports each pick with its chosen audio, sequentially, and copies it to `tmp/issue-/posts/-/reel.mp4` next to the post text Buffer takes (the issue reel to `posts/0-issue/`). It also copies every reel, named by topic, to `~/Desktop/newsletter--reels/`, which is where the owner uploads from. Look at one frame of each mp4, then `open` that Desktop folder for the owner. +Only when the owner says the set is final, run `python3 $SK/scripts/export.py `. It exports each pick with its chosen audio, sequentially, and copies it to `tmp/issue-/posts/-/reel.mp4` next to the post text Buffer takes (the issue reel to `posts/0-issue/`). It also copies every reel, named by topic, to `~/Desktop/newsletter--reels/`, which is where the owner uploads from. Buffer fetches the post copy through jsDelivr, which refuses anything over 20 MB, and the exports run 25-30 MB: re-encode each `posts/-/reel.mp4` to about 18 MB (`ffmpeg -c:v libx264 -preset slow -b:v 5000k -c:a aac -b:a 160k -movflags +faststart`), keeping the full-quality Desktop copy for direct uploads. `buffer-post.mjs` refuses a file over the limit. Look at one frame of each mp4, then `open` that Desktop folder for the owner. Nothing from this skill is committed: `tmp/` and `.music/` are gitignored, and the logos and tracks are other people's work borrowed for the post. diff --git a/.claude/skills/newsletter-reels/references/learnings.md b/.claude/skills/newsletter-reels/references/learnings.md index 8e6ca0ac..2315f0c6 100644 --- a/.claude/skills/newsletter-reels/references/learnings.md +++ b/.claude/skills/newsletter-reels/references/learnings.md @@ -5,6 +5,7 @@ Newest first. Fold a rule into the step or brief where it applies once it has he ## Issue 410 (2026-10-25) - **Motion-first beats the calm explainer.** The first 30 reels all followed the same structure because SOCIAL.md hardcodes it: 5-7 scenes of 4 s, held headlines, a caption per scene, and "no decorative motion". The owner compared them against motion-design reels on TikTok that change every 1-2 s and asked why ours were so much calmer. A second batch of 12 ran with those rules switched off (1-2 s beats, one claim, decorative layers allowed, camera moves, a style menu of kinetic type, HUD, 3D fly-through, generative and device-in-scene), as six new directions plus six motion-first revisions of the picks. He picked motion-first reels in four of five topics (1g, 2f, 3g, 5f; 4d stayed). The worker brief's Motion-first section is now the default. +- **jsDelivr caps files at 20 MB.** The 410 reels exported at 25-30 MB, and Buffer rejected all three channels with "Video could not be read from its URL". jsDelivr was answering 403 "File size exceeded the configured limit of 20 MB" and cached that answer, so the URL had to be purged after re-encoding the post copies to 16-19 MB. - **Optional menus at the bottom of a brief get ignored.** The motion references were first appended as an optional "pick 2-3" list under the hard rules, and only 2 of the 6 picks used them visibly. Rules that change the result belong in the body of the brief, stated as overrides. - **Never play the generated score.** The gallery defaulted to each worker's composed score and the m1/m2 stock variants were only built for picks, so the owner reviewed every reel with generated music and the first issue-reel export defaulted to `score`. The owner's rule is absolute: stock tracks only. The gallery now has no score option, export refuses it, and `stock_music.sh` builds both variants for all 30 reels as they land. From 47e930a72baa255dc33bc61ba8c382d08554f6d6 Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Mon, 26 Oct 2026 06:19:15 +0100 Subject: [PATCH 22/23] docs(newsletter-reels): simplify how the worker brief states that Motion-first wins over SOCIAL.md --- .claude/skills/newsletter-reels/briefs/worker.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/newsletter-reels/briefs/worker.md b/.claude/skills/newsletter-reels/briefs/worker.md index f503b466..7444462c 100644 --- a/.claude/skills/newsletter-reels/briefs/worker.md +++ b/.claude/skills/newsletter-reels/briefs/worker.md @@ -10,7 +10,7 @@ You are ONE of {{COUNT}} parallel workers. Each is building one short social ree {{DIRECTION}} ## How to work -1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first (it is your operating mode and overrides the keynote defaults, except where the Motion-first section below overrides it), then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. +1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first, then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. 2. Run the pipeline exactly as SOCIAL.md says, from the engine dir, with: export REEL_HOME={{REELS}}/work export REEL_OUT={{REELS}}/out @@ -25,7 +25,8 @@ You are ONE of {{COUNT}} parallel workers. Each is building one short social ree - The logo transforms at least 3 times (out of the thumbnail, on the drop, back on the end card) through its OWN anatomy: its real pieces move as solid parts, its outline deforms with morphPath, strokes draw and undraw, it flips or extrudes in 3D. No particle dissolves of the logo (at most one small particle accent in the film). The reference the owner loved: Mistral's pixel-M blocks flying into the finish-line squares and the 50.4 digits, then re-stacking into the M. - End card: the brand mark next to the "ethical.institute" wordmark. -## Motion-first (overrides SOCIAL.md wherever they conflict) +## Motion-first +This section wins over SOCIAL.md wherever the two disagree. The calm explainer is the failure case: 5-7 scenes of 4 s, a held headline, a caption, a number, an end card. Every reel in the first runs came out like that, all alike. The references the owner loves are motion-design showreels that change every 1-2 s, fill every frame with living detail and feel like a music video, and on issue 410 he picked the motion-first reel over the calm one in four of five topics. Think "what would make a motion designer stop scrolling", not "how do I explain the article". - **Pace:** a new visual idea every 1-2 s (half a bar to a bar at 120-128 bpm). Hard cuts on the beat are allowed and encouraged, mixed with continuous carries. A `cuts` or `holds` WARN from `check` may stand when the cut or hold is a deliberate beat: list it in REPORT.md with the reason. FAILs still get fixed. - **Story:** one claim and at most two numbers from the article, carried by the style. No five-scene fact tour. From c71c3a9336bea74d99edc7c8f7781711625dc7de Mon Sep 17 00:00:00 2001 From: axsaucedo Date: Mon, 26 Oct 2026 07:02:02 +0100 Subject: [PATCH 23/23] feat(newsletter-reels): base vs motion-max experiment - directions d-e get hard motion targets and the reference shot lists --- .claude/skills/newsletter-reels/SKILL.md | 2 ++ .../newsletter-reels/briefs/motion-max.md | 9 ++++++ .../references/motion-references.md | 29 +++++++++++++++++++ .../skills/newsletter-reels/scripts/briefs.py | 6 +++- 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/newsletter-reels/briefs/motion-max.md create mode 100644 .claude/skills/newsletter-reels/references/motion-references.md diff --git a/.claude/skills/newsletter-reels/SKILL.md b/.claude/skills/newsletter-reels/SKILL.md index 8c0e25e8..5bea2d43 100644 --- a/.claude/skills/newsletter-reels/SKILL.md +++ b/.claude/skills/newsletter-reels/SKILL.md @@ -22,6 +22,8 @@ In the commands below `$SK` is this skill's directory and `$R` is `tmp/reels/ Read `references/directions.md` and `references/learnings.md`. Write `$R/directions.md` in the same format: per topic a palette line and five directions `- ** Name (capability):** mechanism`. Each direction names the mechanism the film acts out, and the five per topic each commit to a different primary style from the worker brief's Motion-first menu (kinetic type, HUD/technical, 3D camera fly-through, generative/organic, device in scene), so the owner compares five genuinely different films. Directions are written for the article, never reused across topics. Do not write a calm explainer direction: the worker brief's Motion-first section is the default because the owner picked it over the calm reels in four of five topics on 410. +**Running experiment (from issue 411): base vs motion-max.** In every topic, write directions a-c on the base brief and directions d-e as the motion-max arm, by putting `motion-max` in the capability brackets, e.g. `- **2d Dive through the stack (motion-max, 3D fly-through):** ...`. `briefs.py` appends `briefs/motion-max.md` to those two: hard targets (12+ visual states, 3+ moving layers on every frame, a camera move per scene, 3+ transition kinds), cuts allowed, and the shot lists in `references/motion-references.md`. After the owner picks, record in `references/learnings.md` how many picks came from each arm. Once two issues agree, fold the winner into `worker.md` and drop the experiment from this step. + Then run `python3 $SK/scripts/briefs.py `. It renders one brief per direction from `briefs/worker.md` into `$R/briefs/-.md`; the `0-issue` briefs get `briefs/issue-reel.md` appended and the 1920x1080 format. Existing briefs are kept unless you pass `--force` (`--only 3a` renders one). ## 3. Build all 30 diff --git a/.claude/skills/newsletter-reels/briefs/motion-max.md b/.claude/skills/newsletter-reels/briefs/motion-max.md new file mode 100644 index 00000000..7dc322f0 --- /dev/null +++ b/.claude/skills/newsletter-reels/briefs/motion-max.md @@ -0,0 +1,9 @@ + +## Motion-max (experiment arm: this reel goes further than Motion-first) +This reel is in the experiment arm that tests whether even more motion wins. Read `{{SKILL}}/references/motion-references.md` before you write the beat map: those shot lists are the bar, so your film should feel like them, not like a faster explainer. +- **At least 12 distinct visual states in 25-30 s.** A state is a new composition, not the same layout with new text. List them with timestamps in `beatmap.md`. +- **At least 3 moving layers on every frame:** a foreground subject, a living mid layer (field, grid, particles, readouts) and a background or camera move. Nothing on screen is ever fully still, the end card included (a slow drift or a breathing light is enough there). +- **A camera move in every scene:** dolly, orbit, push-through, rack focus or a parallax pan. +- **Use at least 3 kinds of transition:** a hard cut on the beat, a match cut (a shape in one shot becomes a shape in the next), a masked wipe, a whip pan with motion blur, or a scale-through (the camera flies into an object to reach the next scene). +- **craft.md's "nothing cuts" rule does not apply to you.** Hard cuts on the beat are part of the vocabulary. Keep its carries for the moments where a match cut is the point. +- In REPORT.md, give your counts of states, layers and transition kinds, so the owner's pick can be compared against them. diff --git a/.claude/skills/newsletter-reels/references/motion-references.md b/.claude/skills/newsletter-reels/references/motion-references.md new file mode 100644 index 00000000..622e2f41 --- /dev/null +++ b/.claude/skills/newsletter-reels/references/motion-references.md @@ -0,0 +1,29 @@ +# Motion references + +The motion-design reels the owner pointed at on 2026-10-25 (two from @motionlovee on TikTok, with captions claiming a model made them). They are the bar for the Motion-max arm. Shot lists are inferred from frames captured 0.5 s apart, so exact easing and beat sync are not known. + +## Reference 1: AI-doom lyric video (38 s) +The film plays on a dark laptop on a glossy black table, lit only by its screen, with a mirror reflection. Near-black slate ground, one hot orange accent with bloom, white heavy grotesk words, small mono labels, grey hairline linework. HUD furniture in every shot: corner crop marks, a hairline grid, a code or coordinates column, tiny readouts. A new line every 1-2 s, cut on the beat. + +1. 0-1 s: a hand taps the laptop and the screen wakes (the hook). +2. 1-5 s: a construction grid with a circle and radial guides; an orange glowing rectangle is measured with dimension arrows; line art builds stroke by stroke into a figure while matching code types in on the left. +3. 5-10 s: one giant word stamped full width; small words type in one by one beside it; an orange comet head traces each stroke as it draws. +4. 10-14 s: an orange line chart draws on, then the camera flies through it. +5. 14-22 s: a topographic field of wobbly concentric rings with a glowing orange path through it; a word resolves letter by letter; words rotate 90 degrees and wrap around the ring centre. +6. 22-26 s: a terminal prompt types a full sentence with an orange block cursor over the pulsing rings; some words flip to inverted orange; then hard cuts to full-screen stamps, one a number in glowing mono whose last digit is caught mid-roll like an odometer. +7. 25-29 s: a phrase beside a branching orange lightning network; a word whose O's are drawn as concentric rings. +8. 29-31 s: a wireframe 3D corridor fly-through with words set into its walls. +9. 31-38 s: a glitched mask on a datamoshed ground, then a hatched 3D creature with orange eyes and HUD callout boxes on leader lines pointing at kinetic words, with mono timecodes. It holds on that composition. + +Count: about 15 distinct states in 38 s, never fewer than 3 moving layers, transitions mixing hard cuts, draw-on reveals and camera fly-throughs. + +## Reference 2: editor fly-through (18 s) +The same laptop framing. On screen a virtual camera flies through a 3D-tilted motion-design editor: dark UI, a white artboard, one orange accent, a strip of coloured layer bars, heavy motion blur and shallow depth of field. + +1. 0-2 s: a small voxel mascot drops onto the tilted artboard. +2. 2-4 s: it scribbles a mark that resolves into the brand logo. +3. 4-8 s: the camera pulls back and banks across the editor panels; a "made with" frame appears with fat 3D ribbon swooshes. +4. 8-14 s: the mascot runs along the timeline while the coloured layer bars extrude into 3D slabs it runs on, with whip pans and blur. +5. 14-18 s: the logo blooms over a starfield and settles on an end card. + +What carries over: UI treated as a 3D set, a character or object leading the eye through it, a logo resolved out of a scribble, whip pans carrying blur, flat UI extruded into slabs. diff --git a/.claude/skills/newsletter-reels/scripts/briefs.py b/.claude/skills/newsletter-reels/scripts/briefs.py index 436a6fc9..d15fb044 100644 --- a/.claude/skills/newsletter-reels/scripts/briefs.py +++ b/.claude/skills/newsletter-reels/scripts/briefs.py @@ -3,6 +3,7 @@ usage: briefs.py [--only ...] [--force] Topic 0 (the issue reel) gets briefs/issue-reel.md appended and the 1920x1080 format; the stories stay 1080x1350. +Directions whose name carries "(motion-max" get briefs/motion-max.md appended (the experiment arm). """ import argparse @@ -42,6 +43,7 @@ def main() -> None: sys.exit(f"no directions found in {directions}: expected lines like '- **1a Name (capability):** mechanism'") worker = (SKILL / "briefs" / "worker.md").read_text() issue_extra = (SKILL / "briefs" / "issue-reel.md").read_text() + max_extra = (SKILL / "briefs" / "motion-max.md").read_text() out = root / "briefs" out.mkdir(parents=True, exist_ok=True) written = 0 @@ -62,8 +64,10 @@ def main() -> None: text = worker if n == "0": text = text.replace("--format 1080x1350", "--format 1920x1080") + issue_extra + if "(motion-max" in name.lower(): + text += max_extra for key, value in {"{{SLUG}}": slug, "{{TOPIC}}": topic_dir, "{{DIRECTION}}": direction, "{{REELS}}": str(root), - "{{ENGINE}}": str(engine_dir()), "{{N}}": str(args.issue), "{{COUNT}}": str(len(items))}.items(): + "{{ENGINE}}": str(engine_dir()), "{{N}}": str(args.issue), "{{COUNT}}": str(len(items)), "{{SKILL}}": str(SKILL)}.items(): text = text.replace(key, value) if "{{" in text: sys.exit(f"{path.name}: unfilled placeholder {re.search(r'{{[A-Z]+}}', text).group(0)}")