diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a12e102 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/nerdletter-cypher.iml b/.idea/nerdletter-cypher.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/nerdletter-cypher.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..78a7413 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Nerdletter Cypher is a collection of bash scripts that generate "numbers station" style MP4 videos for the Nerdletter newsletter. Each video displays a ciphertext message with retro green-on-black terminal aesthetics and a synthesized voice reading the message aloud, mimicking cold-war shortwave numbers stations (specifically the Lincolnshire Poacher style). + +Subscribers use a "Day Book" (a one-time pad or codebook) to decode the ciphertext messages. + +## Script Variants + +All scripts live in `scripts/` and share a common interface: + +```bash +./script.sh "CIPHERTEXT GROUPS" PAGE_NUMBER [EPISODE_NUMBER] +# Example: ./script.sh "HLXIB JACJP ERXHN EFM" 001 01 +``` + +- **nerdletter_numbers_station.sh** — Original basic version (espeak-ng + ffmpeg, single pass) +- **nerdletter_numbers_station_linux.sh** — Linux variant with letter-by-letter pronunciation +- **nerdletter_numbers_station_mac.sh** / **_mac_old.sh** — macOS variants (uses `Courier New` font for Homebrew ffmpeg compatibility) +- **nerdletter_numbers_station_poacher.sh** through **poacher10.sh** — Iterative refinements adding: Lincolnshire Poacher intro music, shortwave static mixing, letter-by-letter speech with inter-group pauses, longer durations, and audio layering + +The `poacher` series represents progressive iterations. The highest-numbered poacher script (`poacher10.sh`) is the most refined version with letter-by-letter speech, pauses between cipher groups, and full audio mixing (poacher intro + static + voice). + +## Dependencies + +- **espeak-ng** — text-to-speech synthesis (voice: `en+f3`, female) +- **ffmpeg** — video generation, audio mixing, and final MP4 encoding +- **wget** — (poacher scripts only) for downloading the Lincolnshire Poacher intro audio from archive.org + +## Assets + +- `assets/poacher_intro.wav` — Lincolnshire Poacher music intro clip +- `assets/static.wav` — Shortwave radio static noise +- `output/` — Generated MP4 files land here (though some scripts output to the current working directory) + +## Key Parameters Across Scripts + +espeak-ng flags control the voice character: +- `-s` speech rate (lower = slower/more dramatic, ranges from 74-120 across variants) +- `-k` capitals emphasis +- `-a` amplitude/volume +- `-ven+f3` English female voice variant 3 diff --git a/output/nerdletter_secret_01_page001.mp4 b/output/nerdletter_secret_01_page001.mp4 index a744860..311f5aa 100644 Binary files a/output/nerdletter_secret_01_page001.mp4 and b/output/nerdletter_secret_01_page001.mp4 differ diff --git a/scripts/nerdletter_numbers_station_final.sh b/scripts/nerdletter_numbers_station_final.sh new file mode 100755 index 0000000..778065b --- /dev/null +++ b/scripts/nerdletter_numbers_station_final.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# NERDLETTER NUMBERS STATION MP4 GENERATOR +# Lincolnshire Poacher style with letter-by-letter cipher readout +# +# Audio structure: +# Base layer (full duration): low-volume shortwave static +# On top, in sequence: +# 1. Lincolnshire Poacher theme +# 2. Voice: announcement + letters (0.5s between letters, 1.0s between groups) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ASSETS_DIR="${SCRIPT_DIR}/../assets" + +MESSAGE="$1" # e.g. "HLXIB JACJP ERXHN EFM" +PAGE="$2" # e.g. 001 +EPISODE="${3:-01}" + +OUTFILE="nerdletter_secret_${EPISODE}_page${PAGE}.mp4" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +SAMPLE_RATE=22050 + +echo "=== NERDLETTER SECRET TRANSMISSION #${EPISODE} ===" +echo "Page ${PAGE} — Generating MP4..." + +# --- 1. Generate voice announcement --- +INTRO_TEXT="NERDLETTER SECRET MESSAGE ${EPISODE}. USE DAY BOOK PAGE ${PAGE}." +echo "$INTRO_TEXT" | \ + espeak-ng -ven+f3 -s88 -k50 -a 200 -w "${TMPDIR}/intro.wav" --punct="" 2>/dev/null + +# --- 2. Generate letter-by-letter audio with proper gaps --- +# Build an ffmpeg concat list: each letter as a wav, with silence between +CONCAT_LIST="${TMPDIR}/concat.txt" +> "$CONCAT_LIST" +PART=0 + +# Pre-generate silence files +ffmpeg -f lavfi -i "anullsrc=r=${SAMPLE_RATE}:cl=mono" \ + -t 0.45 -c:a pcm_s16le "${TMPDIR}/gap_letter.wav" -y 2>/dev/null +ffmpeg -f lavfi -i "anullsrc=r=${SAMPLE_RATE}:cl=mono" \ + -t 0.9 -c:a pcm_s16le "${TMPDIR}/gap_group.wav" -y 2>/dev/null + +for group in $MESSAGE; do + echo " Group: $group" + for ((i=0; i<${#group}; i++)); do + letter="${group:$i:1}" + PART_FILE="${TMPDIR}/letter_${PART}.wav" + echo "$letter" | espeak-ng -ven+f3 -s81 -k65 -a 210 -w "$PART_FILE" 2>/dev/null + echo "file '${PART_FILE}'" >> "$CONCAT_LIST" + PART=$((PART + 1)) + + # 0.5s gap after each letter (except last letter of last group) + if [ $i -lt $((${#group} - 1)) ]; then + echo "file '${TMPDIR}/gap_letter.wav'" >> "$CONCAT_LIST" + fi + done + # 1.0s gap after each group + echo "file '${TMPDIR}/gap_group.wav'" >> "$CONCAT_LIST" +done + +# Concatenate all letter parts into one track +ffmpeg -f concat -safe 0 -i "$CONCAT_LIST" \ + -c:a pcm_s16le "${TMPDIR}/letters.wav" -y 2>/dev/null + +# --- 3. Generate closing announcement --- +OUTRO_TEXT="END TRANSMISSION. TUNE TO OLD COMPUTER NERD DOT COM FOR FURTHER INSTRUCTIONS." +echo "$OUTRO_TEXT" | \ + espeak-ng -ven+f3 -s88 -k50 -a 200 -w "${TMPDIR}/outro.wav" --punct="" 2>/dev/null + +# --- 4. Concatenate intro + letters + outro --- +VOICE_LIST="${TMPDIR}/voice_concat.txt" +echo "file '${TMPDIR}/intro.wav'" > "$VOICE_LIST" +echo "file '${TMPDIR}/gap_group.wav'" >> "$VOICE_LIST" +echo "file '${TMPDIR}/letters.wav'" >> "$VOICE_LIST" +echo "file '${TMPDIR}/gap_group.wav'" >> "$VOICE_LIST" +echo "file '${TMPDIR}/outro.wav'" >> "$VOICE_LIST" + +ffmpeg -f concat -safe 0 -i "$VOICE_LIST" \ + -ar ${SAMPLE_RATE} -ac 1 -c:a pcm_s16le "${TMPDIR}/voice.wav" -y 2>/dev/null + +# --- 5. Concatenate poacher theme + voice into foreground track --- +FG_LIST="${TMPDIR}/fg_concat.txt" +echo "file '${TMPDIR}/poacher_resampled.wav'" > "$FG_LIST" +echo "file '${TMPDIR}/voice.wav'" >> "$FG_LIST" + +# Resample poacher intro to match +ffmpeg -i "${ASSETS_DIR}/poacher_intro.wav" \ + -ar ${SAMPLE_RATE} -ac 1 -c:a pcm_s16le "${TMPDIR}/poacher_resampled.wav" -y 2>/dev/null + +ffmpeg -f concat -safe 0 -i "$FG_LIST" \ + -c:a pcm_s16le "${TMPDIR}/foreground.wav" -y 2>/dev/null + +# --- 6. Get foreground duration, generate static to match --- +FG_DUR=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${TMPDIR}/foreground.wav") +echo " Audio duration: ${FG_DUR}s" + +ffmpeg -f lavfi -i "anoisesrc=d=${FG_DUR}:c=0.22" \ + -af "highpass=f=2500,lowpass=f=9000,volume=0.18" \ + -ar ${SAMPLE_RATE} -ac 1 -t "${FG_DUR}" \ + -c:a pcm_s16le "${TMPDIR}/static.wav" -y 2>/dev/null + +# --- 7. Mix static underneath foreground --- +ffmpeg -i "${TMPDIR}/foreground.wav" -i "${TMPDIR}/static.wav" \ + -filter_complex "[0:a][1:a]amix=inputs=2:duration=longest:normalize=0" \ + -c:a pcm_s16le "${TMPDIR}/final_audio.wav" -y 2>/dev/null + +# --- 8. Build video to match audio duration --- +# Round up to nearest second for video duration +VIDEO_DUR=$(echo "$FG_DUR" | awk '{printf "%d", $1 + 1}') + +ffmpeg -f lavfi -i "color=black:s=1280x720:d=${VIDEO_DUR}" \ + -i "${TMPDIR}/final_audio.wav" \ + -vf "drawtext=font='Courier':fontsize=50:fontcolor=green@0.95:text='NERDLETTER SECRET TRANSMISSION #${EPISODE}':x=(w-text_w)/2:y=80, \ +drawtext=font='Courier':fontsize=36:fontcolor=green@0.85:text='USE DAY BOOK PAGE ${PAGE}':x=(w-text_w)/2:y=170, \ +drawtext=font='Courier':fontsize=44:fontcolor=lime@0.95:text='${MESSAGE}':x=(w-text_w)/2:y=270:box=1:boxcolor=black@0.75:boxborderw=15, \ +drawtext=font='Courier':fontsize=30:fontcolor=green@0.75:text='DESTROY PAGE AFTER USE — SUBSCRIBERS ONLY':x=(w-text_w)/2:y=640" \ + -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest "$OUTFILE" -y + +echo "Done: ${OUTFILE}"