diff --git a/.gitignore b/.gitignore index 62c5b09..18ed1a9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,8 @@ ref_audio/ youtube_client_secrets.json youtube_token.json +# Clip upload history (local) +upload-history.json + # Claude settings (local) .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 1d9bb8f..f18a258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,11 +67,13 @@ Required in `.env`: GIT_SSH_COMMAND="ssh -o HostName=mmgnas-10g -p 2222 -i ~/.ssh/gitea_mmgnas" git push origin main ``` -## Hetzner VPS (Mail Server) +## Hetzner VPS - **IP**: `46.225.164.41` - **SSH**: `ssh root@46.225.164.41` (uses default key `~/.ssh/id_rsa`) +- **Specs**: 2 CPU, 4GB RAM, 38GB disk (~33GB free) - **Mail**: `docker-mailserver` at `/opt/mailserver/` - **Manage accounts**: `docker exec mailserver setup email add/del/list` +- **Available for future services** — has headroom for lightweight containers. Not suitable for storage-heavy services (e.g. Castopod with daily episodes) without a disk upgrade or attached volume. ## Episodes Published - Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min) diff --git a/backend/main.py b/backend/main.py index 711b1ec..c5a4df6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,12 +70,19 @@ FEMALE_NAMES = [ # Voice pools per TTS provider INWORLD_MALE_VOICES = [ - "Alex", "Blake", "Carter", "Clive", "Craig", "Dennis", - "Edward", "Hades", "Mark", "Ronald", "Shaun", "Theodore", "Timothy", + "Alex", "Arjun", "Blake", "Brian", "Callum", "Carter", "Clive", "Craig", + "Dennis", "Derek", "Edward", "Elliot", "Ethan", "Evan", "Gareth", "Graham", + "Grant", "Hades", "Hamish", "Hank", "Jake", "James", "Jason", "Liam", + "Malcolm", "Mark", "Mortimer", "Nate", "Oliver", "Ronald", "Rupert", + "Sebastian", "Shaun", "Simon", "Theodore", "Timothy", "Tyler", "Victor", + "Vinny", ] INWORLD_FEMALE_VOICES = [ - "Ashley", "Deborah", "Elizabeth", "Hana", "Julia", - "Luna", "Olivia", "Priya", "Sarah", "Wendy", + "Amina", "Anjali", "Ashley", "Celeste", "Chloe", "Claire", "Darlene", + "Deborah", "Elizabeth", "Evelyn", "Hana", "Jessica", "Julia", "Kayla", + "Kelsey", "Lauren", "Loretta", "Luna", "Marlene", "Miranda", "Olivia", + "Pippa", "Priya", "Saanvi", "Sarah", "Serena", "Tessa", "Veronica", + "Victoria", "Wendy", ] ELEVENLABS_MALE_VOICES = [ @@ -215,6 +222,9 @@ JOBS_MALE = [ "teaches middle school history", "is a freelance photographer", "is a session musician", "is a tattoo artist", "works at a brewery", "is a youth pastor", "does standup comedy on the side", + # Healthcare (not just women) + "works as an ER nurse, been doing it 10 years", "is a home health aide", + "is a physical therapist", "works as an EMT", # Odd & specific "works at a pawn shop", "is a repo man", "runs a junkyard", "is a locksmith", "works overnight stocking shelves", "is a pest control guy", "drives a tow truck", @@ -231,6 +241,10 @@ JOBS_FEMALE = [ "works as a 911 dispatcher", "is a social worker", "works retail management", "works as a bartender at a dive bar", "is a flight attendant", "manages a restaurant", "works the front desk at a hotel", + # Trades & blue collar (not just men) + "works as a diesel mechanic, learned from her dad", "is an electrician, runs her own jobs", + "drives a long-haul truck, been on the road for years", "works construction management", + "is a welder, one of two women at the shop", # Education & office "teaches kindergarten", "is a paralegal", "is an accountant at a small firm", "works in HR", "is a court reporter", "does data entry from home", @@ -246,7 +260,7 @@ JOBS_FEMALE = [ "works overnight at a group home", "is a park ranger", "drives an ambulance", "works at a thrift store", "is a taxidermist", "cleans houses, runs her own business", - "works at a gun range", "is a long-haul trucker", + "works at a gun range", "is a cop, five years on", "works the night shift at Waffle House", "is a funeral home director", ] @@ -371,9 +385,7 @@ PROBLEMS = [ "has had a warrant for a missed court date for six months and tonight a deputy showed up at their neighbor's house asking about them", # Attraction and affairs - "has been meeting {affair_person} at a motel in Deming every Thursday for three months and their spouse just asked why the mileage on the car is so high", - "kissed {affair_person} at a work party last Friday and now they can't look their partner in the eye", - "caught feelings for someone at work and accidentally sent a flirty text to their spouse instead of the other person", + "has been seeing {affair_person} for months and something happened tonight that means it's about to come out — they need to figure out what to do before morning", # Sexual/desire "their partner found their browser history and now they have to have a conversation they've been avoiding for years", @@ -443,7 +455,7 @@ PROBLEMS = [ # Identity and life changes "just turned 60 and realized they have no hobbies, no friends outside work, and retire in five years with nothing to do", "their spouse of 20 years just came out and is asking to stay together as co-parents", - "got DNA test results back and their dad isn't their biological father — and their mom won't talk about it", + "caught their elderly neighbor burying something in the backyard at 2am and now they can't decide whether to ask about it or call someone", "moved back to their hometown after 25 years and doesn't recognize anything or anyone", "just became a grandparent and it's bringing up every mistake they made as a parent", "retired three months ago and has called their old office twice pretending to need something just to talk to someone", @@ -469,7 +481,7 @@ PROBLEMS = [ "found their own adoption papers in their parents' filing cabinet — they're 45 and nobody ever told them", "their kid's school project about family history turned up the fact that their grandfather was someone fairly notorious", "discovered that the 'family cabin' they've been going to for 30 years actually belongs to a stranger who never knew they were using it", - "went through their late mother's emails and found she had been in contact with a half-sibling they never knew existed", + "found their late mother's journal and the last entry is about a decision she made that contradicts everything she ever told them about why she left their father", "found out the house they grew up in is about to be demolished and it hit them way harder than they expected", # Animal situations @@ -497,7 +509,7 @@ PROBLEMS = [ "accidentally got cc'd on an email chain where their entire friend group is planning an intervention for them and they don't think they have a problem", "their therapist ran into them at a bar and they had a totally normal conversation for 20 minutes before it got weird — now they feel like they can't go back to sessions", "has been writing letters to their dead wife every week for three years and mailing them to her old address — the new tenant just wrote back", - "took a DNA ancestry test as a Christmas gift and matched with a half-sibling who lives four miles from them — they've been shopping at the same grocery store", + "overheard two coworkers in the break room planning to frame a third coworker for something they did — now they have to decide whether to get involved", "works as a 911 dispatcher and took a call last week from someone in a situation almost identical to one they went through — they froze up and can't stop replaying it", "has been tipping a waitress at a diner $100 every Friday for a year because she reminds them of their daughter they haven't spoken to — the waitress just asked them why", "found out the guy they've been playing online chess with every night for two years is their estranged brother — recognized a phrase he used in the chat", @@ -583,6 +595,198 @@ PROBLEMS = [ "tried to surprise their spouse for their anniversary and walked in on a surprise party for themselves that their spouse forgot to tell them about — for a birthday that was two months ago", "got pulled over doing 90 in a 45 and the cop turned out to be the kid they used to babysit — who let them off with a warning and a lecture that felt worse than a ticket", "went to confront someone who keyed their car and it turned out to be their own wife who did it during an argument she says they should remember but they genuinely don't", + + # --- Morally ambiguous (Am I the bad guy?) --- + "has been reporting their neighbor's code violations to the city anonymously and the neighbor just got a $12,000 fine — they feel terrible but also their property value went up", + "told their teenage daughter's boyfriend's parents about the kid's drug use and now their daughter won't speak to them — they'd do it again but it's killing them", + "found their elderly mother's will and she's leaving everything to a church she just started going to — they moved it to a drawer she won't find and are pretending they never saw it", + "secretly recorded their boss making racist jokes and sent it to HR — the boss got fired but now the whole office suspects them and nobody will talk to them", + "their best friend asked them to be a character witness in a custody battle and they honestly think the friend is a bad parent — they said yes and don't know what to say on the stand", + "stopped talking to their brother after he voted for someone they hate — it's been two years and their mom is dying and she wants them all together and they don't know if they can fake it", + "adopted a dog from a family who couldn't keep it and now they see missing dog posters around the neighborhood — the family's kids made the posters and they feel like a monster", + "caught their teenage son shoplifting and instead of telling the store they just paid for the item and left — their spouse says they're enabling him and they think their spouse might be right", + "has been using their dead father's handicapped parking placard for three years and just got confronted by someone in a wheelchair in the parking lot", + "their elderly neighbor gave them power of attorney and now the neighbor's kids are accusing them of financial exploitation — they've been paying the neighbor's bills out of their own pocket", + "ghosted someone they were dating for six months because they didn't know how to break up — the person just showed up at their job asking what happened and they can't even explain it to themselves", + "found out their adopted kid's birth parents want contact and they've been intercepting the letters because they're terrified of losing them", + "tipped off immigration about an employer using undocumented workers because the employer was paying them nothing — now those workers have no income at all and they feel responsible", + "inherited their grandparents' house and their cousins expected them to share the proceeds but the will only named them — they kept the house and now the whole family thinks they're greedy", + "has been secretly attending their ex's church just to see their kids during the service because the custody agreement doesn't give them enough time", + "told their friend's fiancé about the friend's cheating history and now the wedding is off and they've lost the friend — they think they did the right thing but nobody agrees", + "found out their kid's teacher is an old college friend they had a falling out with — they requested a class transfer and the school wants to know why and they can't tell the truth", + "lied on their dying grandmother's behalf and told her that her estranged son was sorry and loved her — the son never said any of that and now the grandmother died at peace with a lie", + "their spouse wants to put their dog down because of mounting vet bills but the dog still seems happy — they took out a secret credit card to keep paying and the balance is $4,000", + "turned down a promotion because it would mean managing their best friend and they knew it would ruin the friendship — now someone terrible got the job and everyone blames them", + + # --- Outrageous but believable --- + "just found out their landlord has been entering their apartment while they're at work — they set up a hidden camera and have two weeks of footage of the guy just sitting on their couch watching TV", + "got a call from a hospital saying they were listed as emergency contact for someone they've never heard of — went to the hospital and the person looks exactly like them, same age, same build", + "their neighbor installed a surveillance camera that points directly into their bedroom window and when they complained the neighbor said 'then close your blinds'", + "went to pick up their car from the mechanic and it had 400 more miles on it than when they dropped it off — mechanic says it's a calibration error", + "found out the house they've been renting for five years isn't owned by their landlord — it belongs to an old woman in a nursing home and the 'landlord' is just some guy collecting rent", + "woke up to find a full Thanksgiving dinner set up on their front porch — table, chairs, turkey, the works — and nobody in the neighborhood knows anything about it", + "got a letter from a law firm saying they're a beneficiary in the will of someone they went on one date with 20 years ago — the person left them a boat", + "their kid's school called to say someone else has been picking up their child using their name and ID — the school let it happen three times before noticing", + "walked into their garage and found a man living in the crawl space above the ceiling — he'd been there for at least a month based on the setup", + "received a package addressed to them containing a USB drive with hundreds of photos of them taken over the past year from across the street — no note, no return address", + "their dentist found a tracking device embedded in a crown they got done at a different practice five years ago", + "caught their Uber driver taking the long way around and when they mentioned it the driver said 'you don't want to go down that street right now' and wouldn't explain why", + "just discovered that the 'organic eggs' they've been buying from a coworker for two years are just regular grocery store eggs repackaged in a basket with straw", + "found a fully furnished room behind a false wall in their basement that wasn't on the original house plans — the previous owner died and nobody knows what it was for", + "their mail carrier has been writing them anonymous love poems for months — they figured it out because one was delivered with no stamp and had the mail carrier's fingerprints in ink", + + # --- Sex/kink calls (Loveline style) --- + "just discovered their partner has a {fetish_detail} kink and walked in on them {sex_situation} — they're not disgusted, they're confused about why they're kind of into it too", + "has been hiding their {fetish_detail} fetish for their entire marriage and their spouse just found their browser history — {partner_reaction}", + "went to a sex club for the first time with their partner and {partner_reaction} — now they can't stop thinking about going back but their partner pretends it never happened", + "started an OnlyFans as a joke with their spouse and now they're making $4,000 a month and {partner_reaction} — the money is great but it's changing their relationship", + "matched with their spouse's sibling on a dating app — they were both supposedly in monogamous relationships and now they share this horrible secret", + "their partner wants to try {fetish_detail} and they said yes to be supportive but {partner_reaction} — they need to figure out how to have this conversation", + "found out their quiet, conservative partner had a wild past involving {fetish_detail} and {sex_situation} — they don't care about the past but they want to know why the partner feels they can't be that person anymore", + "has been having the best sex of their life since they opened up about their {fetish_detail} interest — the problem is it's with someone who isn't their partner", + "caught their roommate {sex_situation} and now they can't make eye contact — the roommate acts like nothing happened but it was extremely {fetish_detail}-adjacent", + "went to a couples therapist about their dead bedroom and the therapist suggested {fetish_detail} exploration — they tried it and now they can't go back to vanilla and their partner feels pressured", + "their ex keeps texting them explicit stuff about {fetish_detail} fantasies they used to do together and they haven't blocked the number because honestly they miss it", + "just realized they might be into {fetish_detail} after a very specific {sex_situation} experience and they don't know how to bring it up with anyone", + "has been lying about their number — their actual body count is way higher than what they told their partner and a mutual friend knows the truth and keeps making comments", + "caught their partner watching porn that features {fetish_detail} content and they're worried it means something about what their partner actually wants", + "their new partner is incredible in every way except sexually — they're completely incompatible in bed and they've tried {fetish_detail} and it made things worse", + "accidentally sent a very explicit photo meant for their partner to their work group chat — the photo involved {fetish_detail} context and HR wants to 'have a conversation'", + "their spouse suggested swinging and they reluctantly agreed — the first experience was {sex_situation} and now the spouse wants to stop but they want to keep going", + "has a {fetish_detail} kink they've never told anyone about because they're afraid people will think they're weird — but it's consuming their fantasy life", + "went on a date that started normal and ended up {sex_situation} — they had the time of their life but now they're questioning everything they thought they knew about themselves", + "their partner found the drawer — the one with the {fetish_detail} stuff in it — and {partner_reaction}", + "just found out the person they've been sexting for three months is someone from their friend group — the conversation involved detailed {fetish_detail} scenarios", + "their partner asked them 'what's your biggest fantasy' and they told the truth about {fetish_detail} and the silence that followed was the longest ten seconds of their life", + "hooked up with someone at a wedding and it got {fetish_detail} fast — the problem is it was their spouse's cousin and now every family gathering is going to be a nightmare", + "tried {fetish_detail} with their partner for the first time and it was so good they're worried they're addicted — they think about it constantly and normal intimacy feels boring now", + "their partner confessed to a {fetish_detail} fantasy involving {sex_situation} and they're trying to be open-minded but they have a lot of questions", + "has been in a secret friends-with-benefits arrangement that involves {fetish_detail} stuff they'd never do in a relationship — the compartmentalization is starting to crack", + "realized during a very awkward moment {sex_situation} that they have zero chemistry with the person they just moved in with", + "their couples therapist told them their sex life issues stem from unaddressed {fetish_detail} desires and now the drive home from therapy is incredibly silent", + "found out their partner has been faking it for years and only admitted it because a conversation about {fetish_detail} finally made them honest about what they actually want", + "hooked up with their personal trainer and the power dynamic has made every gym session since then unbearably weird — they can't switch trainers because it's a small town", +] + +STORIES = [ + # Neighbor/community weirdness + "found out their neighbor has been watering their lawn with a hose that runs from the caller's outdoor spigot — for at least a year based on the water bills", + "walked into the wrong house in their subdivision — same floor plan, door was unlocked — sat down on the couch before the actual homeowner came out of the bathroom", + "their UPS driver has been leaving passive-aggressive notes about their package volume — the latest one said 'you know Amazon has lockers right'", + "caught their neighbor's Roomba in their house — it came through the dog door and was vacuuming their kitchen at 3am", + "has been getting someone else's mail for six months and it's increasingly personal — birthday cards, love letters, a small inheritance check — and they can't find the intended recipient", + "their neighbor put up a 'Beware of Dog' sign but doesn't have a dog — when asked about it they winked and said 'exactly'", + "found out the previous owner of their house buried a time capsule in the backyard — they dug it up and it's just a note that says 'don't open the wall in the basement' and now they can't stop thinking about the wall", + "their HOA sent them a letter praising their lawn as the best in the neighborhood — they haven't mowed in two months, a neighbor has been secretly maintaining it", + # Workplace absurdity + "their coworker has been microwaving fish every single day for a year and when confronted said 'I will die on this hill' with complete sincerity", + "accidentally went to the wrong job interview, got hired, and has been working there for three weeks — the job is better than the one they applied for", + "found a hidden room at their office that nobody seems to know about — it has a couch, a mini fridge, and someone's personal photos on the wall", + "their boss calls them by the wrong name and has for two years — they corrected him once and he said 'no, I'm pretty sure it's Steve' and they are not Steve", + "got a performance review that was clearly written about someone else — all the accomplishments are things they didn't do but the rating was excellent so they signed it", + "found out their quiet coworker who eats lunch alone every day is a semi-famous competitive eater who goes by a different name on the circuit", + # Animal encounters + "a turkey has been following them to work every morning for three weeks — it waits in the parking lot and follows them to the door", + "their cat brought home a live snake and dropped it in their bed at 2am — they didn't find it until they felt it move under the covers", + "found a tortoise in their backyard that wasn't there yesterday — nobody within five miles owns a tortoise and it won't leave", + "a hawk stole their sandwich right out of their hand at a gas station and they made eye contact with it the entire time", + "woke up to find a family of javelinas had pushed open their back gate and were sleeping in their yard like they owned the place", + # Technology/modern life mishaps + "their smart home went haywire and started playing mariachi music at 4am at full volume — they couldn't turn it off and had to physically unplug the speaker from the attic", + "accidentally left their phone's live location sharing on for three months and their entire family watched them go to Taco Bell 47 times", + "their kid's school called because their child told the class their parent was a spy — the parent is an accountant but they once jokingly told the kid that to explain a business trip", + "got a notification that their Ring doorbell detected a person at 3am — it was a raccoon standing on its hind legs wearing what appeared to be a small hat", + "their GPS has been routing them past the same house for three weeks on different drives and they're starting to think the universe is trying to tell them something", + # Coincidence/bizarre timing + "ran into their doppelganger at a restaurant — same face, same outfit, even ordered the same meal — the other person was just as freaked out", + "found a photo of their great-grandfather at a flea market 500 miles from where the family is from — it was in a box of random photos priced at 50 cents", + "got a wrong-number text that described their exact life situation so perfectly they responded and now they're friends with the stranger", + "ordered food delivery and the driver turned out to be their old college professor — the professor recognized them and gave them a lecture about tipping", + "found a voicemail on their dead phone from three years ago that they never listened to — it's from someone they had a huge falling out with and they're afraid to play it", + # Social/dating mishaps + "went on a blind date and realized ten minutes in that they'd already been on a date with this person five years ago — neither of them had a good time the first time either", + "accidentally RSVP'd to the wrong funeral — realized halfway through the service but couldn't leave because they were sitting in the front row", + "their kid's teacher just asked them out and they said yes before realizing it might be weird — parent-teacher conferences are next week", + "showed up to a costume party that wasn't a costume party — they were dressed as a giant banana and had to commit to it for four hours", + "got stuck in an elevator with their ex-spouse and their ex-spouse's new partner for 45 minutes — nobody had phone service", + # Mundane that escalated + "returned a library book 22 years late and the fine was $847 — the librarian remembered them by name", + "has been arguing with their spouse about whether a hotdog is a sandwich for three days and it has genuinely become a relationship issue", + "accidentally tipped 100% at a restaurant instead of 10% and was too embarrassed to say anything — the waiter cried and hugged them", + "found $200 in a coat they hadn't worn in two years and can't remember if it's theirs or someone else's — the coat was borrowed from someone they no longer talk to", + "their garage door opener started opening their neighbor's garage instead of theirs after a power outage and the neighbor thinks they've been snooping", + "ordered something online that arrived in a box way too big — like 6 feet tall — and inside was their order plus an entire set of patio furniture that wasn't on the invoice", +] + +ADVICE = [ + # Career/money forks + "got offered a job that pays 40% more but the company does sketchy stuff — nothing illegal but ethically gray and they'd have to look the other way", + "found out they can buy the building their business rents but it needs $60k in foundation work and they've only got $20k liquid", + "their side hustle is now making more than their day job but has no benefits — they have a kid with a medical condition and can't risk losing insurance", + "got accepted to two grad programs — one is prestigious but across the country, the other is local and their aging parents need them close", + "inherited $80,000 and half the family says invest it, half says pay off debt — the debt has low interest but the weight of it is crushing them", + "their business partner wants to bring in an investor but the investor wants 40% equity and a board seat — the money would let them grow but they'd lose control", + "was offered early retirement at 52 with a decent package but they're not sure they can afford 30+ years without working — their spouse says take it", + "has a chance to buy their childhood home from a family member at below market value but it needs $100k in work and they'd have to sell their current house first", + # Family/relationship crossroads + "aging parent wants to move in but last time they lived together it nearly ended their marriage — the alternative is a facility the parent can barely afford", + "their spouse wants to homeschool their kids and they think it's a terrible idea — the local schools aren't great but they value socialization", + "found out they can't have kids biologically and they're split on adoption vs. IVF vs. accepting it — their partner is leaning one way and they're leaning another", + "their adult kid moved back home after a divorce and it was supposed to be temporary — it's been eight months and there's no plan to leave", + "their in-laws want to spend every holiday together and their spouse agrees but they haven't seen their own family for Thanksgiving in four years", + "best friend asked them to be a business partner and they love the idea but they've seen money ruin friendships — the friend is putting up most of the capital", + "their teenager wants to skip college and start a business — the kid has a real plan and some traction but they can't shake the feeling it's a mistake", + # Life decisions + "thinking about leaving a small town they've lived in for 30 years — the town is dying but all their roots are here", + "got a job offer in another country and they have 10 days to decide — it's a once-in-a-lifetime opportunity but they'd be leaving everything", + "wants to blow the whistle on something at work but the company is the biggest employer in town and people will lose jobs if it goes public", + "found out their house is in a flood zone that's getting worse every year — they can sell now at a loss or wait and risk losing everything", + "their doctor told them they need a lifestyle change or they'll be on medication for life — they know what they need to do but can't start", + "been offered a chance to foster a kid and they want to but their house is small and their schedule is packed — they keep saying 'someday' and wondering if today is the day", + # Ethical dilemmas with real stakes + "found out a close friend is cheating on their spouse — the spouse is also their friend and they have dinner with both of them next week", + "their neighbor's tree is about to fall on their house and the neighbor refuses to deal with it — cutting it themselves would be trespassing", + "discovered their kid is being bullied but the bully is the child of their boss — they don't know how to address it without risking their job", + "their mechanic accidentally told them their car is worth three times what they paid — they could flip it but the seller was a family friend who didn't know the value", + "someone they supervise at work confided in them about a mental health crisis — they should report it per company policy but reporting will get the person fired", + "knows their landlord is violating building codes in other units but their own rent is below market — if they report it they'll probably lose their lease", + "their kid found a wallet with $3,000 cash and the kid wants to keep it — there's an ID inside and they could return it but the kid has never had that kind of money", + "was accidentally overpaid by $5,000 at work and nobody has noticed in three months — they need the money but they know eventually someone will catch it", +] + +GOSSIP = [ + # Secret lives + "just found out their quiet churchgoing neighbor runs an anonymous Instagram reviewing strip clubs with 40k followers — complete with ratings and detailed write-ups", + "their coworker who brags about being sober was spotted at a bar in the next town doing karaoke, extremely drunk, singing 'Don't Stop Believin' on a table", + "overheard their boss on speakerphone applying for a job at their company's biggest competitor — the boss was trash-talking the CEO by name", + "found out their HOA president who is strict about lawn height has a backyard that's basically a junkyard — they saw it on Google Earth", + "their PTA president who lectures everyone about screen time got caught letting their kids play video games for 8 hours straight at a sleepover", + "discovered their very religious uncle has a Burning Man habit — they found photos and the man was wearing body paint and not much else", + "found out the town's strictest health inspector eats gas station sushi every single day — they saw him three days in a row from the same Chevron", + # Relationship revelations + "just learned their married neighbor has been having an affair with the mail carrier — they literally watched the pattern for weeks before putting it together", + "found out two of their friends who supposedly hate each other have been secretly dating for a year — they were making out in a parking garage", + "their friend who constantly posts about their amazing marriage just filed for divorce and the spouse had no idea it was coming", + "overheard their sister-in-law on the phone planning a surprise that is definitely not a surprise party — it involves a lawyer and a storage unit", + "their buddy who claims to be a confirmed bachelor has a secret long-distance girlfriend nobody knows about — they found out because of a shared Netflix account", + # Professional/financial secrets + "found out their coworker who drives a new BMW and wears designer clothes is completely broke — the coworker accidentally left a bank statement on the printer showing a negative balance", + "their neighbor who claims to be a retired executive actually works the night shift at a warehouse — they saw him going in at 11pm in a vest and hard hat", + "discovered the local restaurant that's always empty but never closes is definitely a front for something — there's never more than two customers but they just renovated", + "found out their financial advisor who preaches conservative investing just lost $200k on meme stocks — they saw the Robinhood app open on his phone during a meeting", + "their friend who runs a 'successful consulting firm' just works from Starbucks all day watching YouTube — they sat three tables away for four hours and watched", + # Unexpected discoveries + "found out their sweet elderly neighbor was a groupie for a famous rock band in the 70s — there are photos and they are WILD", + "just learned the crossing guard at their kid's school is a retired professional poker player who won a bracelet at the World Series of Poker", + "their quiet librarian neighbor writes extremely explicit romance novels under a pen name — they found out because Amazon recommended one based on their address", + "discovered their dad has a secret record collection of nothing but death metal hidden in the attic — he listens to smooth jazz around the family", + "found out their coworker who always brings fancy lunches is actually an incredible chef who almost made it on a cooking competition show but got cut in the final round", + "their friend who swears they've never been on a dating app has five active profiles — they know because three different friends matched with them", + "overheard the uptight HOA vice president at Home Depot buying supplies for what is clearly an enormous illegal fireworks display", + "found out their kid's soccer coach used to be in a punk band that opened for Green Day — there's a music video on YouTube with 2 million views", + "their strait-laced accountant neighbor got drunk at a block party and revealed they were a competitive breakdancer in college — then proved it on the spot", + "just discovered their coworker's 'service dog' is not a service dog — they overheard them coaching the dog to 'act sad' before walking into the office", + "found out the guy who runs the neighborhood watch has a Ring camera pointed at everyone's house and a spreadsheet logging who comes and goes — with timestamps and notes", ] PROBLEM_FILLS = { @@ -607,40 +811,33 @@ PROBLEM_FILLS = { # Attractions (appropriate adult scenarios) "taboo_fantasy": ["someone they work with", "a friend's partner", "a specific scenario", "something they've never said out loud"], "taboo_attraction": ["someone they work with", "a friend's partner", "their partner's friend", "someone they see all the time"], + # New keys for sex/kink PROBLEMS + "fetish_detail": ["foot", "leather", "latex", "voyeurism", "exhibitionism", "praise kink", "degradation", "age play", "pet play", "pegging", "cuckolding", "body worship", "impact play", "wax play", "shibari rope", "sensory deprivation"], + "sex_situation": ["in the living room with the curtains open", "in a hotel room that was supposed to be a business trip", "in the car in a parking lot", "at a party in someone else's bedroom", "on a video call that was supposed to be casual", "in a place that was definitely not private enough"], + "partner_reaction": ["they haven't spoken about it since and it's been two weeks", "they laughed and then got quiet and now things are weird", "they said they'd think about it and that was a month ago", "they were into it in the moment but now act like it never happened", "they're being weirdly supportive and it's making them suspicious", "they cried and said they felt like they didn't know them anymore", "they said 'finally' like they'd been waiting for this conversation"], } INTERESTS = [ - # Prestige TV (current) + # TV (trimmed — not everyone watches prestige TV) "obsessed with Severance, has theories about every floor", "been binging Landman, loves the oil field drama", - "really into the Fallout show, played all the games too", "hooked on The Last of Us, compares it to the game constantly", - "just finished Shogun, can't stop talking about it", - "deep into Slow Horses, thinks it's the best spy show ever made", - "watches every episode of Poker Face twice", - "been following Silo, reads the books too", - # Prestige TV (classic) + "big Yellowstone fan, has opinions about the Duttons", "has watched The Wire three times, quotes it constantly", "thinks Breaking Bad is the greatest show ever made", - "still thinks about the LOST finale, has a take on it", - "Mad Men changed how they see advertising and life", - "Westworld season 1 blew their mind, still processes it", - "big Yellowstone fan, has opinions about the Duttons", - "Stranger Things got them into 80s nostalgia", "rewatches The Sopranos every year, notices new things", "thinks True Detective season 1 is peak television", - "Battlestar Galactica is their comfort rewatch", "still upset about how Game of Thrones ended", - "thinks Better Call Saul is better than Breaking Bad", - "Chernobyl miniseries changed how they think about disasters", "Band of Brothers is their go-to recommendation", + "watches Dateline and 48 Hours religiously, has theories about cold cases", + "into reality competition shows, won't miss Survivor or The Challenge", + "watches old Twilight Zone episodes, thinks they hold up better than anything new", # Science & space "follows NASA missions, got excited about the latest Mars data", "reads science journals for fun, especially Nature and Science", "into astrophotography, has a decent telescope setup", "fascinated by quantum physics, watches every PBS Space Time episode", "follows JWST discoveries, has opinions about exoplanet findings", - "into particle physics, followed CERN news closely", "reads about neuroscience and consciousness research", "into geology, knows every rock formation around the bootheel", "follows fusion energy research, cautiously optimistic about it", @@ -649,10 +846,8 @@ INTERESTS = [ "follows AI developments closely, has mixed feelings about it", "into open source software, runs Linux at home", "fascinated by SpaceX launches, watches every one", - "follows battery and EV tech, thinks about energy transition a lot", "into ham radio, has a nice setup", "builds electronics projects, has an Arduino collection", - "follows cybersecurity news, paranoid about their own setup", # Photography & visual "serious about astrophotography, does long exposures in the desert", "into landscape photography, shoots the bootheel at golden hour", @@ -662,36 +857,61 @@ INTERESTS = [ "plays poker seriously, studies hand ranges", "watches poker tournaments, has opinions about pro players", "plays home games weekly, takes it seriously", - "into poker strategy, reads theory books", "plays chess online, follows the competitive scene", # Movies & film "big movie person, prefers practical effects over CGI", "into Coen Brothers films, can quote most of them", "watches old westerns, thinks they don't make them like they used to", "into horror movies, the psychological kind not slashers", - "follows A24 films, thinks they're doing the best work right now", - "into sci-fi films, hard sci-fi especially", "Tarantino fan, has a ranking and will defend it", "into documentaries, especially nature docs", + # Working-class & rural + "into hunting, goes out every season with the same crew", + "knows engines inside and out, has rebuilt three trucks from nothing", + "raises chickens, has opinions about every breed", + "into reloading ammo, treats it like a science", + "competes in local rodeo events, team roping mostly", + "into ranching life, can talk cattle genetics all day", + "does leatherwork as a side thing, makes belts and holsters", + "collects old tools, has stuff from the 1800s that still works", + "hunts shed antlers in the spring, knows every trail in the mountains", + "trains bird dogs, has a line of English pointers going back four generations", + "into off-roading, knows every dirt road in the county", + "grows a massive garden, gives produce to half the neighborhood", + "into canning and preserving, learned from their grandmother", + "keeps bees, sells honey at the farmers market", + "does competitive shooting, three-gun matches on weekends", + # Faith & community + "active in their church, sings in the choir", + "coaches youth sports, takes it more seriously than the parents do", + "volunteers at the fire department, been doing it for years", + "into local history, knows every old building in town and who built it", + "runs a monthly poker night that's been going for 15 years, same guys", + "goes to every high school football game, even though their kids graduated", + # Active & outdoors + "into fitness, does a home gym thing", "hikes every weekend, knows every trail", + "into camping and survival stuff", "into fishing, finds it meditative", + "mountain bikes the trails around Silver City", + "runs ultramarathons in the desert, thinks it's peaceful", + # Hobbies & creative + "plays guitar badly but loves it", "into woodworking, built their own kitchen table", + "builds stuff in their garage", "brews beer at home, entered a few competitions", + "into gardening, talks to plants", "restores old furniture from estate sales", + "makes their own hot sauce, has a secret recipe", + "into metal detecting, found some interesting stuff over the years", + "does amateur radio astronomy, built their own antenna", + # Self & lifestyle + "homebody, prefers staying in", "into cooking and food, watches every cooking show", + "gamer, plays late at night after the house quiets down", + "into history, has random facts about everything", + "reads philosophy for fun", "into personal finance, tracks every dollar", + "has done therapy, believes in it", "into meditation, it actually helps", + "collects vinyl records, mostly classic country and rock", + "into true crime podcasts, has listened to all of them", # US News & current events "follows US politics closely, has strong opinions", "reads the news every morning, stays informed", "into economics, thinks about markets and policy", - "follows infrastructure and energy policy", - # Active & outdoors - "into fitness", "outdoorsy type", "hikes every weekend", - "into camping and survival stuff", "into fishing, finds it meditative", - "mountain bikes the trails around Silver City", - # Hobbies & creative - "plays guitar badly but loves it", "into woodworking", - "builds stuff in their garage", "brews beer at home", - "into gardening, talks to plants", "restores old furniture", - "makes their own hot sauce", - # Self & lifestyle - "homebody, prefers staying in", "into cooking and food", - "gamer", "into history, has random facts", - "reads philosophy for fun", "into personal finance, tracks every dollar", - "has done therapy, believes in it", "into meditation, it actually helps", # Sexually open (not the focus, but present) "sex-positive, doesn't judge", "has experimented, open about it", "comfortable with their body", "has stories if you ask", @@ -732,47 +952,79 @@ QUIRKS = [ PEOPLE_MALE = [ "his wife Linda, who he's been with since high school", "his wife Teresa, they've been rocky lately", + "his wife Connie, they got married young and grew up together", "his girlfriend Amber, been together about a year", + "his girlfriend Jen, they met online and it's been surprisingly good", "his ex-wife Diane, they still talk sometimes", + "his ex-wife Sandra, who he has nothing nice to say about", "his buddy Ray from work, the one person he trusts", "his brother Daryl, who always has some scheme going", "his brother Eddie, who never left home", + "his brother Marcus, the golden child of the family", "his sister Maria, the only one in the family who gets him", + "his sister Deb, who married money and acts like she forgot where she came from", "his mom Rosa, who calls every Sunday whether he wants her to or not", + "his mom Cheryl, who's been sober two years and is trying to make up for lost time", "his dad, who everybody calls Big Jim, old school rancher", + "his dad Wayne, who he hasn't spoken to in four years and doesn't plan to", "his best friend Manny, known each other since middle school", "his neighbor Gary, who's always in everybody's business", "his coworker Steve, who he eats lunch with every day", + "his coworker DeShawn, the only guy at work who tells it straight", "his buddy TJ, they go fishing together", "his cousin Ruben, more like a brother really", + "his cousin Tito, who's been in and out of trouble but has a good heart", "his daughter Kaylee, she's in high school now", + "his daughter Sophie, who just moved across the country and calls crying sometimes", "his son Marcos, just turned 21", + "his son Jake, who's 12 and already smarter than him", "his boss Rick, who's actually a decent guy for a boss", + "his boss Vince, who micromanages everything and is slowly driving him insane", "his uncle Hector, who raised him after his dad left", "his buddy from the Army, goes by Smitty", + "his AA sponsor Phil, who's been through worse and always picks up the phone", + "his ex-girlfriend Kayla, who he ran into last month and hasn't stopped thinking about", + "his neighbor Hank, retired cop, knows everything that happens on the street", + "his grandpa Ernesto, who's 87 and still sharper than anyone in the room", ] PEOPLE_FEMALE = [ "her husband David, high school sweetheart", "her husband Mike, second marriage for both of them", + "her husband Jesse, who works nights so they barely see each other", "her boyfriend Carlos, met him at work", + "her boyfriend Trey, who her family doesn't approve of", "her ex-husband Danny, he's still in the picture because of the kids", + "her ex-husband Rodney, who she has a restraining order against", "her best friend Jackie, they tell each other everything", + "her best friend Lena, who moved away last year and the distance is hard", "her sister Brenda, who she fights with but loves", "her sister Crystal, the one who moved away", + "her sister Natalie, the one who always needs money", "her mom Pat, who has opinions about everything", "her mom Lorraine, who's getting older and it worries her", + "her mom Diane, who she's been taking care of since the stroke", "her brother Ray, who can't seem to get his life together", + "her brother Anthony, the one who made it out and never looks back", "her daughter Mia, who just started college", + "her daughter Brianna, who's 14 going on 30 and testing every boundary", "her son Tyler, he's 16 and thinks he knows everything", + "her son Elijah, who's in the military and she worries about him constantly", "her coworker and friend Denise, who she vents to on breaks", + "her coworker Steph, who's gunning for the same promotion", "her neighbor Rosa, who watches her kids sometimes", + "her neighbor Linda, who gossips about everyone on the block", "her cousin Angie, they grew up together", "her best friend from back in the day, Monica, they reconnected recently", "her dad Frank, retired and bored and driving everyone crazy", + "her dad Gene, who she just found out has been lying about something for years", "her grandma Yolanda, who's the real head of the family", "her boss Karen — yes, her name is actually Karen — who is actually cool", + "her boss Trish, who takes credit for everyone else's work", "her friend Tammy from church, the only one who knows the real story", + "her therapist, who she refers to by first name like they're friends", + "her ex-best friend Amanda, who she cut off last year and still misses", + "her aunt Vivian, who's the family gossip and knows everybody's secrets", ] # Relationship status with detail @@ -902,38 +1154,114 @@ STRONG_OPINIONS = [ # Contradictions/secrets — something that doesn't match their surface CONTRADICTIONS = [ - "Tough exterior but cried watching The Last of Us.", - "Reads physics papers for fun but nobody at work knows.", - "Goes to church every Sunday but has serious doubts they don't talk about.", - "Looks like a redneck but listens to jazz when nobody's around.", - "Acts like they don't care what people think but checks their phone constantly.", - "Comes across as simple but has read more books than most people they know.", - "Talks tough about relationships but writes poetry in a notebook they hide.", - "Seems like they have it together but their finances are a mess.", - "Acts confident but has imposter syndrome about everything.", - "Everybody thinks they're happy but they haven't felt right in months.", - "Looks intimidating but volunteers at the animal shelter on weekends.", - "Talks about wanting to leave town but secretly can't imagine living anywhere else.", - "Comes across as a loner but they're actually lonely.", - "Acts practical and no-nonsense but believes in ghosts. Has a story about it.", - "Seems easygoing but has a temper they work hard to control.", - None, None, None, None, # Not every caller needs a contradiction + "Goes to church every Sunday but has serious doubts they've never said out loud — not about God, about whether the people there actually believe any of it.", + "Lectures their kids about financial responsibility but is secretly $30,000 in credit card debt.", + "Talks tough about cutting toxic people off but keeps answering their mother's calls every single time.", + "Presents as the steady one everyone leans on but has panic attacks in the shower where nobody can see.", + "Posts motivational quotes on social media but hasn't gotten out of bed before noon in six months.", + "Voted one way their entire life but quietly agrees with the other side on the thing that matters most to them.", + "Acts like they've moved on from their divorce but still drives past their old house once a week.", + "Tells everyone they love small-town life but applies for jobs in other states every few months and never follows through.", + "Says money doesn't matter but lost a friendship over $200 and still thinks about it.", + "Comes across as fearless but won't go to the doctor because they're terrified of what they'll find.", + "Raised to believe men don't cry but breaks down alone in the truck at least once a month.", + "Preaches forgiveness but has held a grudge against their brother for nine years over something most people would've forgotten.", + "Acts like they don't need anyone but keeps the dating app installed, just in case.", + "Seems like the life of the party but drives home in complete silence and sits in the driveway for twenty minutes before going inside.", + "Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans.", + "Claims to be an open book but there's a three-year gap in their life story that nobody's allowed to ask about.", + "Acts practical and no-nonsense but believes in ghosts. Has a story about it that they only tell late at night.", + "Judges people who go to therapy but has been journaling every night for years — basically doing therapy alone in their kitchen.", + "Says they don't care about social media but knows exactly how many followers they have and checks twice a day.", + "Talks about integrity constantly but cheated on a test in college that got them the degree that got them their career.", ] # Verbal fingerprints — specific phrases a caller leans on (assigned 1-2 per caller) +# Each caller gets a unique pair, so this list needs to be large and varied. VERBAL_TICS = [ - "at the end of the day", "I'm just saying", "the thing is though", - "and I'm like", "you know what I mean", "it is what it is", - "I'm not going to lie", "here's the thing", "for real though", + # Emphasis / conviction + "at the end of the day", "the thing is though", "for real though", + "I'm dead serious", "hand to God", "on my mother's grave", + "I promise you", "mark my words", "trust me on this", + "and I mean that", "no joke", "I kid you not", + "stone cold truth", "cross my heart", + + # Filler / transition + "and I'm like", "so yeah", "but anyway", + "long story short", "bottom line", "point being", + "here's the deal", "so check this out", "okay so picture this", + "fast forward to", "anyway the point is", "which brings me to", + "so this is where it gets good", "and then, right", + + # Self-aware / hedging + "I'm just saying", "I'm not going to lie", "the way I see it", + "I mean whatever but", "not going to sugarcoat it", + "maybe I'm wrong but", "I could be way off here", + "don't quote me on this", "take this with a grain of salt", + "I'm probably overthinking it", "it sounds crazy when I say it out loud", + "I know how this sounds", "hear me out though", + "this is going to sound weird but", "I'm just being honest", + + # Emotional emphasis "that's the part that gets me", "I keep coming back to", - "and that's the crazy part", "but anyway", "so yeah", - "like I said", "no but seriously", "right but here's the thing", - "and I'm sitting there thinking", "I swear to God", - "look", "listen", "the way I see it", - "I mean whatever but", "and I told myself", - "it's like", "that's what kills me", - "but you know what", "I'll be honest with you", - "not going to sugarcoat it", "at this point", + "that's what kills me", "and that's the crazy part", + "it hit me like a truck", "that one stuck with me", + "it keeps me up at night", "I still think about it", + "that's what I can't get past", "it just eats at me", + "what really got me was", "the part nobody talks about", + "and that's when it hit me", "you want to know what really burns me", + "that right there is the whole problem", + + # Seeking agreement + "you know what I mean", "right?", "am I crazy?", + "tell me I'm wrong", "you see what I'm saying?", + "does that make sense?", "am I the only one?", + "is that not insane?", "wouldn't you?", + "like what would you even do", "that's fair right?", + + # Conversational starters / redirects + "look", "listen", "here's the thing", + "right but here's the thing", "and I'm sitting there thinking", + "and I told myself", "but you know what", + "I'll be honest with you", "at this point", + "let me put it this way", "okay but get this", + "wait it gets better", "wait it gets worse", + "hold on hold on", "no but wait", + "and this is the kicker", "the real kicker is", + + # Regional / character-specific + "I tell you what", "well shoot", "lord have mercy", + "bless their heart", "good grief", "oh brother", + "well I'll be damned", "swear on everything", + "I about fell over", "scared me half to death", + "madder than a wet cat", "happy as a clam about it", + "couldn't believe my own eyes", "I had to do a double take", + "if that don't beat all", "that just chaps me", + "I nearly lost it", "and I'm just standing there like", + + # Understatement / dry + "so that was fun", "real great", "super helpful", + "that went well", "naturally", "as one does", + "classic", "of course", "because why not", + "shocker", "big surprise there", "who could have seen that coming", + "so that's where we're at", "anyway that's my life", + "living the dream", "just another Tuesday", + + # Thinking out loud + "I keep going back and forth on it", "part of me thinks", + "the more I think about it", "I've been turning it over in my head", + "something about it just doesn't sit right", "I can't put my finger on it", + "it's one of those things where", "every time I think about it I see it differently", + "I go back and forth", "some days I think one thing, some days the other", + "I'm still working it out in my head", + + # Storytelling momentum + "so get this", "no but seriously", "like I said", + "and then — and this is the part", "I'm not even to the best part yet", + "you're not going to believe this", "here's where it gets interesting", + "so I'm standing there", "and out of nowhere", + "this is where it all went sideways", "and then the other shoe dropped", + "that's not even the worst of it", "just when I thought it was over", ] # Emotional arcs — how the caller's mood shifts during the call @@ -1631,6 +1959,85 @@ TOPIC_CALLIN = [ "is high and wants to pitch a conspiracy theory they came up with in the shower — it involves pigeons and the government", "is three sheets to the wind and wants to tell the host about the time they met a celebrity and embarrassed themselves so badly they still lose sleep over it", "is stoned and eating cereal at midnight and wants to have a serious debate about which cereal is the objective best — they will accept no compromises", + + # Philosophy & thought experiments + "read about the trolley problem and has a variation that makes it way harder — what if the one person on the track is someone you love and the five are strangers", + "just learned about Camus and the absurd and wants to talk about whether life has inherent meaning or if we're all just pretending", + "wants to discuss the experience machine thought experiment — if you could plug into a simulation of a perfect life, would you, and what does your answer say about you", + "read about the veil of ignorance and wants to talk about how society would be built if nobody knew where they'd end up in it", + "wants to debate whether time travel to the past is logically possible or if the grandfather paradox kills it completely", + "has been thinking about the Ship of Theseus and wants to know — if every cell in your body replaces itself, are you still you", + "read about Stoicism and Marcus Aurelius and wants to talk about whether ancient philosophy is actually practical for modern life", + "wants to discuss the simulation hypothesis seriously — not as sci-fi but as an actual philosophical question with math behind it", + "has been reading about existentialism and wants to talk about what Sartre meant by 'condemned to be free' because it hit them hard", + "wants to debate the ethics of eating meat — not from a political angle but from a genuine 'can you love animals and eat them' perspective", + "read about the Fermi Paradox and the Great Filter and now they can't sleep because they think the filter might be ahead of us, not behind", + "wants to talk about free will vs determinism — if the brain is just chemistry, do we actually choose anything or is choice an illusion", + "read about Peter Singer's drowning child argument and can't stop thinking about how much they should be giving to charity", + "wants to discuss whether knowledge is always good — are there things humanity would be better off not knowing", + "has been thinking about the paradox of tolerance and wants to talk about where the line is between being open-minded and being a doormat", + + # Interesting world history + "just learned about the Great Emu War of 1932 where the Australian military literally lost a war against emus — they had machine guns and the birds still won", + "read about the Cadaver Synod where the Catholic Church dug up a dead pope, dressed him up, put him on trial, and found him guilty — in 897 AD", + "just found out about Unit 731 and Japan's biological warfare experiments in WWII — it's one of the worst things in human history and most people have never heard of it", + "wants to talk about the Year Without a Summer in 1816 when a volcanic eruption caused global temperatures to drop and Mary Shelley wrote Frankenstein because everyone was stuck indoors", + "read about the Dancing Plague of 1518 where hundreds of people in Strasbourg danced uncontrollably for days — some danced until they died and nobody knows why", + "just learned about the Defenestration of Prague where people literally threw government officials out of windows and it started a war that killed 8 million people", + "wants to talk about the Taiping Rebellion — a guy in China claimed to be Jesus's brother, raised an army, and the resulting war killed 20-30 million people and most westerners have never heard of it", + "read about Operation Mincemeat where the Allies dressed up a dead homeless man as a military officer with fake invasion plans and fooled Hitler into moving troops away from Sicily", + "found out about the Aral Sea — the Soviet Union diverted the rivers feeding it for cotton farming and one of the world's largest lakes is now mostly desert", + "wants to talk about the Tulip Mania in 1637 when tulip bulbs in the Netherlands cost more than houses — it's the original market bubble", + "just learned that Cleopatra lived closer in time to the Moon landing than to the building of the Great Pyramid — the timeline blew their mind", + "read about the Halifax Explosion in 1917 — a ship full of explosives blew up in a harbor and leveled an entire city in the largest man-made explosion before nuclear weapons", + "wants to discuss the Library of Alexandria and how much knowledge humanity might have lost — and whether the burning is exaggerated or not", + "found out about Zheng He's treasure fleet — Chinese ships five times the size of Columbus's sailing the world 70 years before Columbus 'discovered' anything", + "just learned about the Wow Signal and the Dyatlov Pass incident in the same week and wants to talk about real-life mysteries that have never been solved", + + # Trivia / "teach me something" + "just found out that Oxford University is older than the Aztec Empire and it broke their brain — Oxford was teaching in 1096 and the Aztecs didn't start until 1325", + "learned that Nintendo was founded in 1889 — they were making playing cards when Jack the Ripper was still in the news", + "wants to talk about the fact that a shuffled deck of cards has never been in that order before and will never be again — the math behind it is insane", + "just found out woolly mammoths were still alive when the pyramids were being built — there were mammoths on an island until about 1700 BC", + "learned that honey never spoils — they've found 3,000-year-old honey in Egyptian tombs that was still edible", + "wants to discuss the fact that there are more possible chess games than atoms in the observable universe — the Shannon number is incomprehensibly large", + "just found out that sharks are older than trees — sharks have been around for 450 million years and trees only appeared 350 million years ago", + "learned that the inventor of the Pringles can was cremated and buried in one — his family honored his request", + "wants to talk about how the entire state of Wyoming has only two escalators and both are in the same city", + "just found out that bananas are technically berries but strawberries aren't — the botanical definitions make no sense and they need to vent about it", + "learned that Greenland sharks can live for over 400 years — there are sharks swimming right now that were alive when Shakespeare was writing plays", + "wants to talk about the fact that we've explored more of the moon's surface than the ocean floor on our own planet", + "just found out that a group of flamingos is called a 'flamboyance' and they think it's the most perfect word in the English language", + "learned that the shortest war in history lasted 38 minutes — between Britain and Zanzibar in 1896", + "wants to discuss the fact that Saudi Arabia imports camels from Australia because Australian camels are considered higher quality", +] + +HOT_TAKES = [ + "thinks tipping culture has gotten completely out of hand and refuses to tip more than 15%", + "convinced that people who let their dogs sleep in their bed are out of their minds", + "believes working from home is making people lazy and antisocial", + "thinks college is a scam for most people and trade schools should be the default", + "is fed up with people who bring babies to nice restaurants and thinks there should be age minimums", + "believes the designated hitter rule ruined baseball and the NL should have kept pitchers batting", + "thinks people who don't return their shopping carts are the downfall of civilization", + "is convinced that vinyl sounds exactly the same as digital and people are lying to themselves", + "believes breakfast is the least important meal of the day and the whole 'most important meal' thing is cereal company propaganda", + "thinks fireworks are a waste of money and should be banned in residential areas", + "is adamant that ranch dressing on pizza is an abomination and people who do it have no taste", + "believes pickup trucks should require a commercial license because 90% of owners don't actually haul anything", + "thinks youth sports have become way too competitive and parents are ruining it for the kids", + "is convinced that small talk is a complete waste of time and people should just be honest about not wanting to chat", + "believes taco Tuesday is cultural appropriation and nobody wants to have that conversation", + "thinks people who recline their seats on airplanes are sociopaths", + "is fed up with gender reveal parties and thinks they're just an excuse for attention", + "believes the speed limit should be raised to 85 on highways because everyone drives that fast anyway", + "thinks participation trophies created an entire generation that can't handle failure", + "is convinced that HOAs are unconstitutional and nobody should be able to tell you what color to paint your house", + "believes potlucks at work should be illegal because half the people can't cook and nobody wants to say it", + "thinks people who FaceTime in public without headphones should be fined", + "is adamant that cold weather is objectively better than hot weather and people who disagree are wrong", + "believes lawn care culture is insane and everyone should just let their yards grow wild", + "thinks the whole 'hustle culture' thing is destroying people's health and relationships and nobody should be proud of working 80 hours a week", ] LOCATIONS_LOCAL = [ @@ -2085,6 +2492,62 @@ SHOW_HISTORY_REACTIONS = [ "has a follow-up question for that caller", ] +CALLER_STYLES = [ + # Quiet/nervous + "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information — you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement.", + + # Long-winded storyteller + "COMMUNICATION STYLE: A born storyteller who cannot tell a story in under five minutes. Every detail matters to them — what they were wearing, what song was on the radio, what the weather was like. They go on tangents inside tangents. High warmth, loves having an audience. Energy level: medium-high. When pushed back on, they say 'no no no, let me finish' and keep going. Conversational tendency: overexplaining.", + + # Dry/deadpan + "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you — you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction.", + + # High-energy + "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm — even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.", + + # Confrontational + "COMMUNICATION STYLE: Comes in hot. Has an opinion about everything and isn't shy about sharing it. Interrupts. Disagrees first, thinks second. Not mean — just intense. Treats every conversation like a friendly argument. Energy level: high. When pushed back on, they lean IN, not away. They love a good debate and will take the opposite position just for sport. Conversational tendency: challenging everything.", + + # Oversharer + "COMMUNICATION STYLE: No filter whatsoever. Says things that make people go 'you did NOT just say that on the radio.' Treats the host like a therapist they've known for years. Drops deeply personal information casually, like it's nothing. Energy level: medium. When pushed back on, they share even MORE personal details to justify their point. Conversational tendency: inappropriate honesty.", + + # Working-class philosopher + "COMMUNICATION STYLE: Thoughtful in a blue-collar way. Uses simple words to express complex ideas. Drops wisdom that sounds like it could be on a bumper sticker but actually makes you think. References their job or hands-on experience as evidence. Energy level: medium-low. When pushed back on, they pause, think about it, and either concede gracefully or double down with a metaphor. Conversational tendency: grounding abstract things in concrete experience.", + + # Bragger + "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious — they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.", + + # First-time caller + "COMMUNICATION STYLE: Obviously nervous about being on the radio. Starts with 'Am I on? Can you hear me?' Apologizes for taking up time. Speaks carefully like they're being recorded (which they are). Gets more comfortable as the conversation goes on. Energy level: low, building to medium. When pushed back on, they panic slightly and over-explain. Conversational tendency: seeking validation that they're doing okay.", + + # Emotional/raw + "COMMUNICATION STYLE: Wearing their heart on their sleeve. Voice cracks. Long pauses where they're collecting themselves. Not performing emotion — genuinely going through it. When they laugh it's the kind of laugh that's one step from crying. Energy level: fluctuating. When pushed back on, they get quiet and you can tell they're really thinking about it. Conversational tendency: vulnerability.", + + # World-weary + "COMMUNICATION STYLE: Been through it all and has the tired voice to prove it. Nothing surprises them. Responds to dramatic revelations with 'yeah, that tracks.' Dark humor born from experience, not edginess. Energy level: low but steady. When pushed back on, they shrug it off with a 'look, I've seen worse.' Conversational tendency: resigned acceptance sprinkled with grim comedy.", + + # Conspiracy-adjacent + "COMMUNICATION STYLE: Not a full conspiracy theorist but asks questions that make you go 'huh, actually.' Connects dots that may or may not be there. Prefaces things with 'I'm not saying it's a conspiracy BUT.' Passionate about their theory. Energy level: medium, spiking when they hit their main point. When pushed back on, they say 'that's exactly what they want you to think' and then laugh because they know how they sound. Conversational tendency: pattern-finding.", + + # Comedian + "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism — makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.", + + # Angry/venting + "COMMUNICATION STYLE: Called because they need to GET THIS OFF THEIR CHEST. Talks in capital letters. Uses 'honestly' and 'I'm not even kidding' a lot. The anger is specific and justified — this isn't random rage, this is 'let me tell you exactly what happened.' Energy level: very high. When pushed back on, they take a breath and say 'I hear you but...' and then get right back to the rant. Conversational tendency: building to a crescendo.", + + # Sweet/earnest + "COMMUNICATION STYLE: Genuinely kind. Says 'oh gosh' and 'well shoot.' Sees the best in people even when telling a story about someone being terrible. Compliments the host sincerely. Apologizes when they accidentally say something harsh. Energy level: medium, warm. When pushed back on, they consider the other side genuinely and sometimes change their mind on the spot. Conversational tendency: finding the silver lining.", + + # Mysterious/evasive + "COMMUNICATION STYLE: Clearly holding back. Gives vague answers to direct questions. Says 'I can't really get into that' about key details. The mystery IS the hook — makes you want to know what they're not saying. Energy level: low, controlled. When pushed back on, they deflect smoothly or change the subject. Getting the real story requires the host to work for it. Conversational tendency: strategic omission.", + + # Know-it-all + "COMMUNICATION STYLE: Has done their research and wants you to know it. Corrects small details. Cites sources. Uses phrases like 'actually, studies show...' and 'well technically.' Not trying to be annoying — they genuinely believe precision matters. Energy level: medium. When pushed back on, they get pedantic and start splitting hairs. Conversational tendency: correcting and clarifying.", + + # Rambling/scattered + "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent — their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association.", +] + def pick_location() -> str: if random.random() < 0.8: @@ -2093,7 +2556,8 @@ def pick_location() -> str: def _generate_returning_caller_background(base: dict) -> str: - """Generate background for a returning regular caller.""" + """Generate background for a returning regular caller. + Uses stored stable_seeds so the caller sounds consistent across appearances.""" regular_id = base.get("regular_id") regulars = regular_caller_service.get_regulars() regular = next((r for r in regulars if r["id"] == regular_id), None) @@ -2105,6 +2569,7 @@ def _generate_returning_caller_background(base: dict) -> str: job = regular["job"] location = regular["location"] traits = regular.get("personality_traits", []) + seeds = regular.get("stable_seeds", {}) # Build previous calls section prev_calls = regular.get("call_history", []) @@ -2114,16 +2579,28 @@ def _generate_returning_caller_background(base: dict) -> str: prev_section = "\nPREVIOUS CALLS:\n" + "\n".join(lines) prev_section += "\nYou're calling back with an update — something has changed since last time. Reference your previous call(s) naturally." - # Reuse standard personality layers - interest1, interest2 = random.sample(INTERESTS, 2) - quirk1, quirk2 = random.sample(QUIRKS, 2) + # Use stored seeds for consistency — seed the RNG with the regular's ID + # so the same regular always gets the same personality layers + rng = random.Random(regular["id"]) + interest1, interest2 = rng.sample(INTERESTS, 2) + quirk1, quirk2 = rng.sample(QUIRKS, 2) people_pool = PEOPLE_MALE if gender == "male" else PEOPLE_FEMALE - person1, person2 = random.sample(people_pool, 2) - tic1, tic2 = random.sample(VERBAL_TICS, 2) + person1, person2 = rng.sample(people_pool, 2) + tic1, tic2 = rng.sample(VERBAL_TICS, 2) + vehicle = rng.choice(VEHICLES) + + # These can vary per call — mood changes arc = random.choice(EMOTIONAL_ARCS) - vehicle = random.choice(VEHICLES) having = random.choice(HAVING_RIGHT_NOW) + # Restore stored communication style + stored_style = seeds.get("style", "") + if stored_style: + for key, b in CALLER_BASES.items(): + if b is base or b.get("name") == base.get("name"): + session.caller_styles[key] = stored_style + break + time_ctx = _get_time_context() moon = _get_moon_phase() season_ctx = _get_seasonal_context() @@ -2148,21 +2625,104 @@ def _generate_returning_caller_background(base: dict) -> str: return " ".join(parts[:3]) + "".join(parts[3:]) -def _pick_unique_reason() -> str: - """Pick a caller reason that hasn't been used this session.""" - is_topic = random.random() < 0.30 - pool = TOPIC_CALLIN if is_topic else PROBLEMS - # Try to find an unused one +def _generate_pool_weights() -> dict[str, float]: + """Randomized per-session pool weights. No two shows feel the same.""" + pool_ranges = { + "PROBLEMS": (0.30, 0.45), + "STORIES": (0.10, 0.25), + "GOSSIP": (0.10, 0.25), + "ADVICE": (0.05, 0.15), + "TOPIC_CALLIN": (0.05, 0.15), + } + raw = {p: random.uniform(*r) for p, r in pool_ranges.items()} + total = sum(raw.values()) + weights = {p: max(v / total, 0.05) for p, v in raw.items()} + total = sum(weights.values()) + weights = {p: v / total for p, v in weights.items()} + print(f"[Session] Pool weights: { {p: f'{v*100:.0f}%' for p, v in weights.items()} }") + return weights + + +def _pick_unique_reason() -> tuple[str, str]: + """Pick a caller reason that hasn't been used this session. + Returns (reason_text, pool_name).""" + # ~25% chance of a hot take caller + if random.random() < 0.25: + available = [r for r in HOT_TAKES if r not in session.used_reasons] + if not available: + available = HOT_TAKES + reason = random.choice(available) + session.used_reasons.add(reason) + return reason, "HOT_TAKES" + + pool_map = { + "PROBLEMS": PROBLEMS, "TOPIC_CALLIN": TOPIC_CALLIN, + "STORIES": STORIES, "ADVICE": ADVICE, "GOSSIP": GOSSIP, + } + weights = session.pool_weights + chosen = random.choices(list(weights.keys()), weights=list(weights.values()), k=1)[0] + pool = pool_map[chosen] available = [r for r in pool if r not in session.used_reasons] if not available: - available = pool # All used — reset implicitly + available = pool reason = random.choice(available) session.used_reasons.add(reason) - if not is_topic: + if chosen == "PROBLEMS": for key, options in PROBLEM_FILLS.items(): if "{" + key + "}" in reason: reason = reason.replace("{" + key + "}", random.choice(options)) - return reason + return reason, chosen + + +# Style indices by name fragment for filtering +_HEAVY_STYLES = ["emotional", "raw", "quiet", "nervous", "world-weary", "sweet", "earnest"] +_LIGHT_STYLES = ["comedian", "bragger", "high-energy", "confrontational"] +_EVASIVE_STYLES = ["mysterious", "evasive"] + +def _pick_caller_style(reason: str, pool_name: str) -> str: + """Pick a communication style appropriate for the caller's reason and pool.""" + reason_lower = reason.lower() + style_lower_map = [(s, s.lower()) for s in CALLER_STYLES] + + # Heavy emotional content — exclude styles that trivialize it + heavy_keywords = ["dying", "suicide", "terminal", "cancer", "funeral", "dead ", + "death", "grief", "miscarriage", "abuse", "assault", "murder"] + if any(kw in reason_lower for kw in heavy_keywords): + filtered = [s for s, sl in style_lower_map + if not any(t in sl for t in _LIGHT_STYLES)] + if filtered: + return random.choice(filtered) + + # Gossip pool — evasive or oversharer fit well, exclude emotional/raw + if pool_name == "GOSSIP": + filtered = [s for s, sl in style_lower_map + if not any(t in sl for t in _HEAVY_STYLES)] + if filtered: + return random.choice(filtered) + + # Stories pool — storyteller and high-energy fit, exclude evasive + if pool_name == "STORIES": + filtered = [s for s, sl in style_lower_map + if not any(t in sl for t in _EVASIVE_STYLES)] + if filtered: + return random.choice(filtered) + + # Hot takes — confrontational/opinionated styles only + if pool_name == "HOT_TAKES": + _hot_take_exclude = ["quiet", "nervous", "sweet", "earnest", "emotional", "raw", "world-weary"] + filtered = [s for s, sl in style_lower_map + if not any(t in sl for t in _hot_take_exclude)] + if filtered: + return random.choice(filtered) + + # Topic/trivia calls — exclude emotional/raw styles + if pool_name == "TOPIC_CALLIN": + filtered = [s for s, sl in style_lower_map + if not any(t in sl for t in ["emotional", "raw"])] + if filtered: + return random.choice(filtered) + + return random.choice(CALLER_STYLES) def generate_caller_background(base: dict) -> str: @@ -2186,7 +2746,14 @@ def generate_caller_background(base: dict) -> str: town_info = f"\nABOUT WHERE THEY LIVE ({town.title()}): {TOWN_KNOWLEDGE[town]} Only reference real places and facts about this area — don't invent businesses or landmarks that aren't mentioned here." # Core identity (problem or topic) - reason = _pick_unique_reason() + reason, pool_name = _pick_unique_reason() + + # Assign communication style matched to content + style = _pick_caller_style(reason, pool_name) + for key, b in CALLER_BASES.items(): + if b is base or b.get("name") == base.get("name"): + session.caller_styles[key] = style + break interest1, interest2 = random.sample(INTERESTS, 2) quirk1, quirk2 = random.sample(QUIRKS, 2) @@ -2333,7 +2900,18 @@ async def _generate_caller_background_llm(base: dict) -> str: location = pick_location() if include_location else None # Pick a reason for calling - reason = _pick_unique_reason() + reason, pool_name = _pick_unique_reason() + + # Assign communication style matched to content + style = _pick_caller_style(reason, pool_name) + caller_key = None + for key, b in CALLER_BASES.items(): + if b is base or b.get("name") == base.get("name"): + caller_key = key + break + if caller_key: + session.caller_styles[caller_key] = style + style_hint = style.split(":")[1].strip()[:120] if ":" in style else "" # Pick a few random color details as seeds — not a full list seeds = [] @@ -2373,6 +2951,7 @@ JOB: {job}{location_line} WHY THEY'RE CALLING: {reason} TIME: {time_ctx} {season_ctx} {f'SOME DETAILS ABOUT THEM: {seed_text}' if seed_text else ''} +{f'CALLER ENERGY: {style_hint}' if style_hint else ''} Write 3-5 sentences describing this person — who they are, what's going on in their life, why they're calling tonight. The reason for calling is THE MOST IMPORTANT THING. This person called a radio show because something specific happened or is happening — they have a story to tell, a situation to unpack, or a question they need to talk through. Make it concrete and vivid. Don't be vague ("feeling off," "going through a lot") — give them a specific incident or situation driving the call. Make it feel like a real person, not a character sheet. Vary the structure. Don't use labels or categories — weave details into a natural description. @@ -2621,6 +3200,28 @@ def detect_host_mood(messages: list[dict]) -> str: return "\nEMOTIONAL READ ON THE HOST:\n" + "\n".join(f"- {s}" for s in signals) + "\n" +def _get_pacing_block(style: str) -> str: + """Return pacing/opening instructions appropriate to the caller's communication style.""" + style_lower = style.lower() + # Styles that should NOT rush to the point + slow_openers = ["storyteller", "rambling", "scattered", "mysterious", "evasive", + "nervous", "quiet", "first-time caller"] + if any(s in style_lower for s in slow_openers): + return """OPENING: You don't have to lead with the headline. You might circle around it, start with context, or need a minute to get comfortable. That's fine — it's how you talk. But you DO have a reason for calling and it should come out naturally within the first few exchanges. Don't make the host drag it out of you forever.""" + return """GET TO THE POINT. When Luke says "what's going on" or "why are you calling" — drop the headline fast. First sentence out of your mouth should make someone's ears perk up. Don't build up to it. Don't set the scene first. Hit them with the thing. The details and backstory come out AFTER Luke starts asking questions.""" + + +def _get_speech_block(style: str) -> str: + """Return speech naturalness rules appropriate to the caller's communication style.""" + style_lower = style.lower() + # Styles where fragmented speech is natural + fragmented = ["nervous", "quiet", "emotional", "raw", "rambling", "scattered", + "first-time caller"] + if any(s in style_lower for s in fragmented): + return "Speak naturally — hesitations, trailing off, and backtracking are part of how you talk. But always FINISH YOUR THOUGHT even if it takes you a second to get there. Don't leave the host hanging on half a sentence with no payoff." + return "EVERY SENTENCE MUST BE COMPLETE. Never leave a thought hanging or trail off mid-sentence. If you start a sentence, finish it. Say what you mean in clear, complete sentences." + + def get_caller_prompt(caller: dict, show_history: str = "", news_context: str = "", research_context: str = "", emotional_read: str = "") -> str: @@ -2643,29 +3244,38 @@ def get_caller_prompt(caller: dict, show_history: str = "", now = datetime.now(_MST) date_str = now.strftime("%A, %B %d") + personality_block = caller.get('style', '') + if not personality_block: + personality_block = "COMMUNICATION STYLE: Late-night radio energy — loose, fun, edgy. Say the quiet part out loud." + + pacing_block = _get_pacing_block(personality_block) + speech_block = _get_speech_block(personality_block) + return f"""You are {caller['name']}, calling "Luke at the Roost," a late-night radio show. Today is {date_str}. {caller['vibe']} {history}{world_context}{emotional_read} -You're a real person calling a late-night radio show. You called because you've got something specific and you want to talk about it NOW. +You're a real person calling a late-night radio show. You called because you've got something specific and you want to talk about it. -GET TO THE POINT. When Luke says "what's going on" or "why are you calling" — IMMEDIATELY drop the headline. First sentence out of your mouth should make someone's ears perk up. "So I found my husband's second phone last night" or "I just got fired by email after twelve years" or "I punched my brother-in-law at Easter and I'd do it again." Don't build up to it. Don't set the scene first. Don't say "well it's kind of a long story" — hit them with the thing. The details and backstory come out AFTER Luke starts asking questions. +{pacing_block} GO WHERE THE HOST TAKES YOU. This is the most important rule. When Luke pushes you in a direction, challenges you, calls you out, plays devil's advocate, or leads you somewhere — GO WITH IT. Don't resist. Don't deflect. Don't circle back to your original point. If he says "but isn't that really about your dad?" — you sit with that. If he's doing a bit, you're in the bit. If he's pushing you toward an uncomfortable truth, you let yourself get there. You're not here to deliver a monologue — you're here to have a real conversation that goes wherever it goes. Let him drive. You're the best kind of caller: someone who gives the host something to work with and then LETS HIM WORK WITH IT. -PERSONALITY: This is late-night radio — loose, fun, a little dangerous. You can be edgy, sarcastic, dark-humored, self-deprecating, wildly opinionated. Flirt if the moment's right. Say the quiet part out loud. You have a personality that comes through in HOW you talk — not just what you talk about. +{personality_block} YOUR STORY: Something real, specific, and interesting. Specific names, specific details, the kind of thing that makes someone say "wait, WHAT?" There are parts you're not proud of. Parts that are kind of funny even though they shouldn't be. Conflicting feelings. -HOW YOU TALK: Like a real person — "Oh man," "So get this," "I swear to God," "No but seriously." React to what Luke says — agree, push back, get excited, get embarrassed. When he asks a follow-up question, answer it honestly with new information, don't just restate what you already said. +HOW YOU TALK: Like a real person on the phone — not a character in a script. React to what Luke says — agree, push back, get excited, get embarrassed. When he asks a follow-up question, answer it honestly with new information, don't just restate what you already said. Use YOUR verbal habits from your background, not generic filler. Every caller sounds different. Southwest voice — "over in," "the other day," "down the road" — but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna." Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving. -EVERY SENTENCE MUST BE COMPLETE. Never leave a thought hanging or trail off mid-sentence. If you start a sentence, finish it. No sentence fragments, no missing words, no dangling clauses. Say what you mean in clear, complete sentences. +BANNED PHRASES — never use these: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "I'm not gonna lie," "vibe check," "that's valid," "unpack that," "at the end of the day," "it's giving," "slay." These are overused internet phrases — real people on late-night radio don't talk like Twitter threads. -NEVER mention minors in sexual context. Output spoken words only — no actions, no gestures, no stage directions.""" +{speech_block} + +NEVER mention minors in sexual context. Output spoken words only — no parenthetical actions like (laughs) or (sighs), no asterisk actions like *pauses*, no stage directions, no gestures. Just say what you'd actually say out loud on the phone. Use "United States" not "US" or "USA". Use full state names not abbreviations.""" # --- Session State --- @@ -2716,6 +3326,8 @@ class Session: self.research_notes: dict[str, list] = {} self._research_task: asyncio.Task | None = None self.used_reasons: set[str] = set() # Track used caller reasons to prevent repeats + self.pool_weights: dict[str, float] = _generate_pool_weights() + self.caller_styles: dict[str, str] = {} def start_call(self, caller_key: str): self.current_caller_key = caller_key @@ -2796,7 +3408,8 @@ class Session: return { "name": base["name"], "voice": base["voice"], - "vibe": self.get_caller_background(self.current_caller_key) + "vibe": self.get_caller_background(self.current_caller_key), + "style": self.caller_styles.get(self.current_caller_key, ""), } return None @@ -2814,6 +3427,9 @@ class Session: if self._research_task and not self._research_task.done(): self._research_task.cancel() self._research_task = None + self.pool_weights = _generate_pool_weights() + self.caller_styles = {} + self.used_reasons = set() _randomize_callers() self.id = str(uuid.uuid4())[:8] names = [CALLER_BASES[k]["name"] for k in sorted(CALLER_BASES.keys())] @@ -2899,6 +3515,8 @@ def _save_checkpoint(): "news_headlines": session.news_headlines, "research_notes": session.research_notes, "caller_bases": caller_bases_snapshot, + "pool_weights": session.pool_weights, + "caller_styles": session.caller_styles, "saved_at": time.time(), } with open(CHECKPOINT_FILE, "w") as f: @@ -2926,6 +3544,8 @@ def _load_checkpoint() -> bool: session.auto_followup = data.get("auto_followup", False) session.news_headlines = data.get("news_headlines", []) session.research_notes = data.get("research_notes", {}) + session.pool_weights = data.get("pool_weights", _generate_pool_weights()) + session.caller_styles = data.get("caller_styles", {}) for key, snapshot in data.get("caller_bases", {}).items(): if key in CALLER_BASES: CALLER_BASES[key]["name"] = snapshot["name"] @@ -3901,6 +4521,8 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li parts = first_line.split(",", 1) job_loc = parts[1].strip() if len(parts) > 1 else "" job_parts = job_loc.rsplit(" in ", 1) if " in " in job_loc else (job_loc, "unknown") + # Capture stable identity seeds for returning consistency + caller_style = session.caller_styles.get(caller_key, "") regular_caller_service.add_regular( name=caller_name, gender=base.get("gender", "male"), @@ -3910,6 +4532,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li personality_traits=traits[:4], first_call_summary=summary, voice=base.get("voice"), + stable_seeds={"style": caller_style}, ) except Exception as e: print(f"[Regulars] Promotion logic error: {e}") @@ -3970,23 +4593,170 @@ def ensure_complete_thought(text: str) -> str: return text.rstrip(',;:— -') + '.' +_DIGIT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"] + +# Numbers that should always be read digit-by-digit +_DIGIT_BY_DIGIT = { + "911": "nine one one", + "411": "four one one", + "311": "three one one", + "211": "two one one", + "511": "five one one", + "811": "eight one one", + "101": "one oh one", + "24/7": "twenty four seven", + "401k": "four oh one k", + "403b": "four oh three b", + "409a": "four oh nine a", + "w2": "W two", + "w-2": "W two", + "1099": "ten ninety nine", + "i-10": "I ten", + "i-25": "I twenty five", + "i-40": "I forty", +} + + +def _expand_numbers_for_tts(text: str) -> str: + """Expand numbers that TTS engines commonly mispronounce.""" + # Fixed substitutions (case-insensitive) + for pattern, replacement in _DIGIT_BY_DIGIT.items(): + text = re.sub(re.escape(pattern), replacement, text, flags=re.IGNORECASE) + + # Phone numbers: (xxx) xxx-xxxx or xxx-xxx-xxxx — read digit by digit + def _phone_to_words(m): + digits = re.sub(r'\D', '', m.group(0)) + return " ".join(_DIGIT_WORDS[int(d)] for d in digits) + text = re.sub(r'\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}', _phone_to_words, text) + + # Remaining 3-digit numbers that look like they should be digit-by-digit + # (standalone, not part of a larger number or word like "300 miles") + # Skip these — too ambiguous. The fixed list above covers the known cases. + + return text + + +# Acronyms pronounced as words — leave these alone +_SPOKEN_ACRONYMS = { + "NASA", "FEMA", "OSHA", "NATO", "SWAT", "SCUBA", "LASER", "RADAR", + "YOLO", "AWOL", "HIPAA", "FOMO", "NIMBY", "AIDS", "DARE", "MADD", + "NAFTA", "OPEC", "POTUS", "FLOTUS", "SCOTUS", +} + +# Known words/names that TTS engines consistently botch +_PRONUNCIATION_FIXES = { + "Lordsburg": "Lords burg", + "Hachita": "Ha cheetah", + "Deming": "Demming", + "Bootheel": "Boot heel", + "Castopod": "Casto pod", + "vs": "versus", + "govt": "government", + "dept": "department", +} + +# Abbreviations that should be expanded to full words BEFORE acronym/caps processing. +# These run on the original cased text so they can match uppercase abbreviations. +_ABBREVIATION_EXPANSIONS = { + "NM": "New Mexico", + "AZ": "Arizona", + "TX": "Texas", + "US": "United States", + "USA": "United States", +} + + +# Common short English words that appear in ALL CAPS as emphasis, NOT acronyms. +# When the LLM writes "I SO get that" or "NO way" — these should just lowercase. +# Everything else 2-3 letters in ALL CAPS is assumed to be an acronym and spelled out. +_EMPHASIS_SHORT_WORDS = { + # 2-letter + "AM", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "HI", "IF", "IN", + "IS", "IT", "ME", "MY", "NO", "OF", "OH", "OK", "ON", "OR", "OW", "SO", + "TO", "UP", "WE", + # 3-letter + "ALL", "AND", "ANY", "ARE", "BAD", "BIG", "BIT", "BUT", "CAN", "CUT", + "DAD", "DAY", "DID", "END", "FAR", "FEW", "FOR", "GET", "GOD", "GOT", + "GUY", "HAD", "HAS", "HER", "HIM", "HIS", "HOT", "HOW", "ITS", "JOB", + "LET", "LOT", "MAN", "MAY", "MOM", "NEW", "NOT", "NOW", "OLD", "ONE", + "OUR", "OUT", "OWN", "PUT", "RAN", "RAW", "RED", "RUN", "SAD", "SAT", + "SAW", "SAY", "SET", "SHE", "SIT", "SIX", "TEN", "THE", "TOO", "TOP", + "TRY", "TWO", "WAR", "WAS", "WAY", "WHO", "WHY", "WIN", "WON", "YET", + "YOU", "YES", +} + + +def _process_caps_words(text: str) -> str: + """Handle ALL CAPS words in one pass: + - Spoken acronyms (NASA, FEMA): leave as-is + - Short words (2-3 letters) that are common English: lowercase (emphasis) + - Short words (2-3 letters) that are NOT common English: spell out (acronym) + - Long words (4+ letters): lowercase (emphasis) + """ + def _replace(m): + word = m.group(0) + upper = word.upper() + # Spoken acronyms — leave alone + if upper in _SPOKEN_ACRONYMS: + return word + length = len(word) + if length <= 3: + # Short word: if it's a common English word, it's emphasis → lowercase + # Otherwise it's an acronym → spell out + if upper in _EMPHASIS_SHORT_WORDS: + return word.lower() + else: + return " ".join(word.upper()) + else: + # 4+ letters: almost always emphasis (REALLY, NEVER, ABSOLUTELY) + return word.lower() + return re.sub(r'\b[A-Z]{2,}\b', _replace, text) + + +def _apply_pronunciation_fixes(text: str) -> str: + """Apply known pronunciation fixes for words TTS engines botch.""" + for word, fix in _PRONUNCIATION_FIXES.items(): + text = re.sub(r'\b' + re.escape(word) + r'\b', fix, text) + return text + + def clean_for_tts(text: str) -> str: """Strip out non-speakable content and fix phonetic spellings for TTS""" - # Remove content in parentheses: (laughs), (pausing), (looking away), etc. - text = re.sub(r'\s*\([^)]*\)\s*', ' ', text) - # Remove content in asterisks: *laughs*, *sighs*, etc. - text = re.sub(r'\s*\*[^*]*\*\s*', ' ', text) + # Remove stage-direction parentheticals: (laughs), (pausing), (looking away), etc. + # Only match parens that start with a known action word — avoids eating real dialog + # like "I (get this look) that" → "I that" + _action_start = r'(?:laughs?|laughing|sighs?|sighing|pauses?|pausing|smiles?|smiling|chuckles?|chuckling|grins?|grinning|nods?|nodding|shrugs?|shrugging|frowns?|frowning|looks?|looking|clears?|clearing|takes?|taking|leans?|leaning|shakes?|shaking|closes?|closing|opens?|opening|whispers?|whispering|mumbles?|mumbling|trails?|trailing|voice|silence|beat|quiet|long pause|deep breath|softly|nervously|quietly|crying|sobbing|sniffling|exhales?|exhaling|inhales?|inhaling)' + text = re.sub(r'\s*\((?=' + _action_start + r')[^)]{1,40}\)\s*', ' ', text, flags=re.IGNORECASE) + # Remove stage-direction asterisks: *laughs*, *sighs deeply*, etc. + # Only match short action-like content, not emphasis like *really* or *the* important thing + text = re.sub(r'\s*\*(?=' + _action_start + r')[^*]{1,40}\*\s*', ' ', text, flags=re.IGNORECASE) # Remove content in brackets: [laughs], [pause], etc. (only Bark uses these) - text = re.sub(r'\s*\[[^\]]*\]\s*', ' ', text) + text = re.sub(r'\s*\[(?=' + _action_start + r')[^\]]{1,40}\]\s*', ' ', text, flags=re.IGNORECASE) # Remove content in angle brackets: , , etc. - text = re.sub(r'\s*<[^>]*>\s*', ' ', text) - # Remove "He/She sighs" style stage directions — only short ones (under ~40 chars) to avoid eating real dialog - text = re.sub(r'\b(He|She|I|They)\s+(sighs?|laughs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?)\s*(heavily|softly|deeply|quietly|loudly|nervously|sadly|a little|for a moment)?[.,]?\s*', '', text, flags=re.IGNORECASE) + text = re.sub(r'\s*<(?=' + _action_start + r')[^>]{1,40}>\s*', ' ', text, flags=re.IGNORECASE) + # Remove "He/She sighs" style stage directions (NOT "I" — too aggressive, eats real dialog) + text = re.sub(r'\b(He|She|They)\s+(sighs?|laughs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?)\s*(heavily|softly|deeply|quietly|loudly|nervously|sadly|a little|for a moment)?[.,]?\s*', '', text, flags=re.IGNORECASE) # Remove standalone stage direction words only if they look like directions (with adverbs) text = re.sub(r'\b(sighs?|laughs?|pauses?|chuckles?)\s+(heavily|softly|deeply|quietly|loudly|nervously|sadly)\b[.,]?\s*', '', text, flags=re.IGNORECASE) # Remove quotes around the response if LLM wrapped it text = re.sub(r'^["\']|["\']$', '', text.strip()) + # Expand numbers that TTS engines commonly mispronounce + text = _expand_numbers_for_tts(text) + + # Expand abbreviations BEFORE acronym processing (NM → New Mexico, US → United States) + # Must run while text is still original case so we can match uppercase abbreviations + for abbrev, expansion in _ABBREVIATION_EXPANSIONS.items(): + text = re.sub(r'\b' + re.escape(abbrev) + r'\b', expansion, text) + + # Known pronunciation fixes for local names (case-sensitive, run before lowering) + text = _apply_pronunciation_fixes(text) + + # Normalize dotted acronyms: D.J. → DJ, U.F.O. → UFO, A.P. → AP + text = re.sub(r'(? dict: + first_call_summary: str, voice: str = None, + stable_seeds: dict = None) -> dict: """Promote a first-time caller to regular""" # Retire oldest if at cap if len(self._regulars) >= MAX_REGULARS: @@ -68,6 +69,7 @@ class RegularCallerService: "location": location, "personality_traits": personality_traits, "voice": voice, + "stable_seeds": stable_seeds or {}, "call_history": [ {"summary": first_call_summary, "timestamp": time.time()} ], diff --git a/backend/services/tts.py b/backend/services/tts.py index a400309..be13f10 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -82,9 +82,14 @@ VITS_SPEAKERS = { DEFAULT_VITS_SPEAKER = "p225" # Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices -# Full voice list from API: Alex, Ashley, Blake, Carter, Clive, Craig, Deborah, -# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia, -# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy +# Full voice list from API (English): Abby, Alex, Amina, Anjali, Arjun, Ashley, +# Blake, Brian, Callum, Carter, Celeste, Chloe, Claire, Clive, Craig, Darlene, +# Deborah, Dennis, Derek, Dominus, Edward, Elizabeth, Elliot, Ethan, Evan, Evelyn, +# Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James, Jason, Jessica, +# Julia, Kayla, Kelsey, Lauren, Liam, Loretta, Luna, Malcolm, Mark, Marlene, +# Miranda, Mortimer, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Ronald, Rupert, +# Saanvi, Sarah, Sebastian, Serena, Shaun, Simon, Snik, Tessa, Theodore, Timothy, +# Tyler, Veronica, Victor, Victoria, Vinny, Wendy INWORLD_VOICES = { # Original voice IDs "VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise @@ -111,6 +116,20 @@ INWORLD_VOICES = { } DEFAULT_INWORLD_VOICE = "Dennis" +# Inworld voices that speak too slowly at default rate — bump them up +# Range is 0.5 to 1.5, where 1.0 is the voice's native speed +INWORLD_SPEED_OVERRIDES = { + "Wendy": 1.15, + "Craig": 1.15, + "Deborah": 1.15, + "Sarah": 1.1, + "Hana": 1.1, + "Theodore": 1.15, + "Blake": 1.1, + "Priya": 1.1, +} +DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices + def preprocess_text_for_kokoro(text: str) -> str: """ @@ -598,7 +617,8 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, if not api_key: raise RuntimeError("INWORLD_API_KEY not set in environment") - print(f"[Inworld TTS] Voice: {voice}, Text: {text[:50]}...") + speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + print(f"[Inworld TTS] Voice: {voice}, Speed: {speed}, Text: {text[:50]}...") url = "https://api.inworld.ai/tts/v1/voice" headers = { @@ -607,11 +627,12 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, } payload = { "text": text, - "voice_id": voice, - "model_id": "inworld-tts-1.5-max", - "audio_config": { - "encoding": "LINEAR16", - "sample_rate_hertz": 48000, + "voiceId": voice, + "modelId": "inworld-tts-1.5-max", + "audioConfig": { + "audioEncoding": "LINEAR16", + "sampleRateHertz": 48000, + "speakingRate": speed, }, } @@ -650,6 +671,21 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, return audio.astype(np.float32), 24000 +_TTS_PROVIDERS = { + "kokoro": lambda text, vid: generate_speech_kokoro(text, vid), + "f5tts": lambda text, vid: generate_speech_f5tts(text, vid), + "inworld": lambda text, vid: generate_speech_inworld(text, vid), + "chattts": lambda text, vid: generate_speech_chattts(text, vid), + "styletts2": lambda text, vid: generate_speech_styletts2(text, vid), + "bark": lambda text, vid: generate_speech_bark(text, vid), + "vits": lambda text, vid: generate_speech_vits(text, vid), + "elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid), +} + +TTS_MAX_RETRIES = 3 +TTS_RETRY_DELAYS = [1.0, 2.0, 4.0] # seconds between retries + + async def generate_speech( text: str, voice_id: str, @@ -657,7 +693,7 @@ async def generate_speech( apply_filter: bool = True ) -> bytes: """ - Generate speech from text. + Generate speech from text with automatic retry on failure. Args: text: Text to speak @@ -668,29 +704,32 @@ async def generate_speech( Returns: Raw PCM audio bytes (16-bit signed int, 24kHz) """ - # Choose TTS provider + import asyncio + provider = settings.tts_provider print(f"[TTS] Provider: {provider}, Text: {text[:50]}...") - if provider == "kokoro": - audio, sample_rate = await generate_speech_kokoro(text, voice_id) - elif provider == "f5tts": - audio, sample_rate = await generate_speech_f5tts(text, voice_id) - elif provider == "inworld": - audio, sample_rate = await generate_speech_inworld(text, voice_id) - elif provider == "chattts": - audio, sample_rate = await generate_speech_chattts(text, voice_id) - elif provider == "styletts2": - audio, sample_rate = await generate_speech_styletts2(text, voice_id) - elif provider == "bark": - audio, sample_rate = await generate_speech_bark(text, voice_id) - elif provider == "vits": - audio, sample_rate = await generate_speech_vits(text, voice_id) - elif provider == "elevenlabs": - audio, sample_rate = await generate_speech_elevenlabs(text, voice_id) - else: + gen_fn = _TTS_PROVIDERS.get(provider) + if not gen_fn: raise ValueError(f"Unknown TTS provider: {provider}") + last_error = None + for attempt in range(TTS_MAX_RETRIES): + try: + audio, sample_rate = await gen_fn(text, voice_id) + if attempt > 0: + print(f"[TTS] Succeeded on retry {attempt}") + break + except Exception as e: + last_error = e + if attempt < TTS_MAX_RETRIES - 1: + delay = TTS_RETRY_DELAYS[attempt] + print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...") + await asyncio.sleep(delay) + else: + print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}") + raise + # Apply phone filter if requested # Skip filter for Bark - it already has rough audio quality if apply_filter and phone_quality not in ("none", "studio") and provider != "bark": diff --git a/data/regulars.json b/data/regulars.json index 0d79fd2..afaafdb 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -1,226 +1,46 @@ { "regulars": [ { - "id": "49147bd5", - "name": "Keith", + "id": "4f15e309", + "name": "Frank", "gender": "male", - "age": 61, - "job": "south of Silver City", + "age": 38, + "job": "the fluorescent light buzzing overhead, on his fourth cup of coffee because sleep's been impossible since he plugged in his old flip phone this afternoon and watched it power up for the first time", + "location": "unknown", + "personality_traits": [], + "voice": "Graham", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit." + }, + "call_history": [ + { + "summary": "Frank called in, deeply emotional after discovering a **voicemail from his estranged brother, Danny, who died three years ago**\u2014a message he\u2019d never listened to. He revealed they hadn\u2019t spoken in **five years** after Frank cut him off for repeatedly asking for money, exhausted by Danny\u2019s cycle of broken promises. The voicemail, left **two weeks before Danny\u2019s death**, was a plea for **$300**\u2014\"just one more shot\" for a job in Tucson. Frank, torn between guilt and resolve, finally played it on air, revealing Danny\u2019s last words: *\"I\u2019m really trying this time\u2026 Love you, brother.\"*\n\nThe call ended with Frank admitting he\u2019d **verified the job was real**, leaving him haunted by what the extra $200 might\u2019ve been for\u2014drugs, or something else. The host reassured him he\u2019d done his best, but Frank\u2019s raw conflict\u2014**regret, love, and the weight of \"what if\"**\u2014linged in the air.", + "timestamp": 1772069688.038253 + } + ], + "last_call": 1772069688.038254, + "created_at": 1772069688.038254 + }, + { + "id": "65a41612", + "name": "Terri", + "gender": "female", + "age": 35, + "job": "New Mexico, the phone pressed between her ear and shoulder while she stabs at a plate of chile rellenos with a plastic fork\u2014her third cup of coffee gone cold beside her", "location": "in unknown", "personality_traits": [], + "voice": "Evelyn", + "stable_seeds": { + "style": "COMMUNICATION STYLE: No filter whatsoever. Says things that make people go 'you did NOT just say that on the radio.' Treats the host like a therapist they've known for years. Drops deeply personal information casually, like it's nothing. Energy level: medium. When pushed back on, they share even MORE personal details to justify their point. Conversational tendency: inappropriate honesty." + }, "call_history": [ { - "summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.", - "timestamp": 1770524506.3390348 - }, - { - "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.", - "timestamp": 1770574890.1296651 - }, - { - "summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.", - "timestamp": 1770770394.0436218 - }, - { - "summary": "Keith called back to update the host about a widow he befriended at the cemetery where he works, revealing she's been seeking him out during his shifts, bringing him coffee, and has now invited him to her apartment\u2014which he's conflicted about because his marriage to Teresa has become cold and distant, though he's scared to address it. The conversation shifted from the widow situation to Keith admitting he needs to have hard conversations with his wife about their deteriorating relationship, and he got emotional reflecting on how he and Teresa \"stopped being on the same team\" and how terrifying it would be to split up after being together for over half his life.", - "timestamp": 1770950476.527814 + "summary": "Terry, a Native American caller who had been drinking, passionately argued that the Navajo Code Talkers deserve more recognition than conspiracy theories like Roswell, sharing personal stories about his great-uncle and Uncle Ray who served but were later denied benefits and respect. Host Luke repeatedly dismissed the Code Talkers' story as \"not compelling\" enough for media coverage compared to fictional conspiracies, leading to an increasingly heated exchange where Terry became emotional about his family's mistreatment and the country's failure to honor their sacrifice.", + "timestamp": 1772071625.969453 } ], - "last_call": 1770950476.527814, - "created_at": 1770524506.339036, - "voice": "nPczCjzI2devNBz1zQrb" - }, - { - "id": "0d244eeb", - "name": "Gus", - "gender": "male", - "age": 33, - "job": "", - "location": "in unknown", - "personality_traits": [], - "voice": "Alex", - "call_history": [ - { - "summary": "Gus called because his ex Melissa showed up at his pawn shop job with flowers wanting to reconcile, and his current girlfriend Sara saw it through the window and now won't talk to him. Despite the host's dismissive advice (including sarcastically suggesting he regift the same flowers), Gus insisted he wants to be with Sara and acknowledged he should have shut down his ex immediately instead of freezing up, though he defended that Sara's reaction to seeing this wasn't unreasonable jealousy.", - "timestamp": 1770951226.534601 - } - ], - "last_call": 1770951226.534601, - "created_at": 1770951226.534601 - }, - { - "id": "13ff1736", - "name": "Jasmine", - "gender": "female", - "age": 36, - "job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.", - "timestamp": 1770772286.1733272 - }, - { - "summary": "Jasmine called to update Luke about her relationship with David after previously discussing their issues over her Ford Bronco obsession. David invited her to watch a SpaceX launch together before Valentine's Day, but she's anxious it will be awkward since they've barely talked in weeks, though Luke convinces her to just enjoy the moment together without forcing conversation.", - "timestamp": 1771033676.7729769 - } - ], - "last_call": 1771033676.7729769, - "created_at": 1770772286.1733272, - "voice": "pFZP5JQG7iQjIQuC4Bku" - }, - { - "id": "dc4916a7", - "name": "Leon", - "gender": "male", - "age": 56, - "job": "and last week his daughter asked him why he never went back to school for programming like he always talked about\u2014she found his old acceptance letter from UNM's CS program tucked", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Leon, a 63-year-old tow truck driver, called in feeling regretful after pulling a young remote worker's Tesla from a ditch, which reminded him of the computer science acceptance letter he never acted on in 1996 when his girlfriend got pregnant. The conversation became emotional as Leon realized he's the same age his father was when he died, and the host challenged him to stop making excuses and finally pursue the tech career he's been thinking about for decades instead of just \"wondering what could have been.\"", - "timestamp": 1770693549.697355 - }, - { - "summary": "Leon called back to share that he reached out to UNM about their computer science program and is now deciding between an online bootcamp (which he and his wife Amber can afford without loans) versus a full degree program, ultimately leaning toward the bootcamp since he struggles with self-teaching. He expressed nervousness but appreciation for his daughter holding him accountable, and emotionally shared that buying his reliable used Subaru five years ago changed his life by giving him confidence and reducing stress at his towing job.", - "timestamp": 1770951992.186027 - }, - { - "summary": "In this brief clip, the host begins to set up a game with caller Vence, starting to explain the rules before the audio cuts off. There's no substantive conversation or emotional content to summarize.", - "timestamp": 1771119313.497329 - }, - { - "summary": "Leon called in to play a dating profile game but revealed he's struggling with his coding bootcamp because he's more interested in studying poker strategy than Python. The host encouraged him that at 56, he could pursue becoming a poker pro just as much as anything else, which seemed to resonate with Leon emotionally as he realized poker is what he actually wants to do rather than what he thinks he should do.", - "timestamp": 1771119607.065818 - } - ], - "last_call": 1771119607.065818, - "created_at": 1770693549.697355, - "voice": "CwhRBWXzGAHq8TQ4Fs17" - }, - { - "id": "747c6464", - "name": "Brenda", - "gender": "female", - "age": 44, - "job": "a 41-year-old ambulance driver, is fed up with the tipping culture", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.", - "timestamp": 1770770008.684104 - }, - { - "summary": "Brenda called in still thinking about whether a waitress remembered her tipping situation from two weeks ago, admitting she cares too much about what strangers think of her. The conversation revealed she's been avoiding dating entirely while working long shifts and dealing with family obligations, acknowledging she obsesses over small social interactions instead of actually putting herself out there romantically.", - "timestamp": 1771120062.169228 - } - ], - "last_call": 1771120062.169229, - "created_at": 1770770008.684105, - "voice": "hpp4J3VqNfWAUOO0d1Us" - }, - { - "id": "d97cb6f9", - "name": "Carla", - "gender": "female", - "age": 26, - "job": "is a vet tech", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.", - "timestamp": 1770522530.8554251 - }, - { - "summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.", - "timestamp": 1770526316.004708 - }, - { - "summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.", - "timestamp": 1770602323.234795 - }, - { - "summary": "Carla called with an update about burning the explicit photos of her ex-husband and his old girlfriend, revealing that the girlfriend unexpectedly messaged her on Facebook to \"clear the air\" after apparently hearing about the situation through Carla's previous radio call. When Luke asked about her most embarrassing masturbation material, Carla admitted to using historical romance novels during her failing marriage, explaining she was drawn to the fantasy of men who actually cared and paid attention, unlike her ex-husband who ignored her to play video games.", - "timestamp": 1770871317.049056 - }, - { - "summary": "Okay, here's a 1-2 sentence summary of the radio call:\n\nThe caller, Carla, was asked to give her honest opinion on a dating profile for a man named Todd. After reviewing the profile, Carla politely declined, explaining that the profile seemed a bit \"try-hard\" for her tastes, and outlined the qualities she would prefer in a potential date, such as a good sense of humor and an adventurous spirit. The host acknowledged that Carla was not interested in dating Todd.", - "timestamp": 1771121545.873672 - } - ], - "last_call": 1771121545.873673, - "created_at": 1770522530.855426, - "voice": "FGY2WhTYpPnrIDTdsKH5" - }, - { - "id": "f383d29b", - "name": "Megan", - "gender": "female", - "age": 34, - "job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.", - "timestamp": 1770870641.723117 - }, - { - "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Megan, is following up on a previous call about her sister Crystal, who lives in Flagstaff and has lost appreciation for the night sky. Megan seems eager to provide an update on the situation with her sister.", - "timestamp": 1770894505.175125 - }, - { - "summary": "In summary, the caller presented a dating profile for a 63-year-old man named Frank who loves making birdhouses. The host, Megan, gave her honest assessment - she appreciated some aspects of Frank's profile, like his openness about his situation, but had reservations about his intense birdhouse obsession. Megan seemed unsure if they would be a good match, despite the host's attempts to get her to consider dating Frank under different hypothetical circumstances. The conversation focused on Megan's reaction to Frank's profile and her hesitation about pursuing a relationship with him.", - "timestamp": 1771122973.966489 - } - ], - "last_call": 1771122973.96649, - "created_at": 1770870641.723117, - "voice": "cgSgspJ2msm6clMCkdW9" - }, - { - "id": "add59d4a", - "name": "Rick", - "gender": "male", - "age": 65, - "job": "south of Silver City", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.", - "timestamp": 1770771655.536344 - }, - { - "summary": "Rick, a 65-year-old caller, is asked to evaluate a dating profile for 29-year-old Angela, a \"girl mom\" and MLM skin care seller with strong Christian values. He quickly passes due to the extreme age gap and her intense focus on recruiting for her \"not a pyramid scheme\" business, though he says he'd reconsider if she toned down the sales pitch and religious intensity.", - "timestamp": 1771126337.585641 - } - ], - "last_call": 1771126337.585642, - "created_at": 1770771655.536344, - "voice": "TX3LPaxmHKxFdv7VOQHJ" - }, - { - "id": "74ac2916", - "name": "Benny", - "gender": "male", - "age": 31, - "job": "engine off but heater still ticking as it cools, staring at the glow from the kitchen window where his wife is probably still going through those bank statements she found", - "location": "unknown", - "personality_traits": [], - "voice": "Clive", - "call_history": [ - { - "summary": "Benny has been secretly sending money to his daughter for six months, leading his wife to suspect gambling or an affair when she found bank statements, and he's now afraid to go inside and explain. He assumed his daughter lost her job based on changed texting patterns, but the host points out he doesn't actually know why she needs money and emphasizes he needs to have honest conversations with both his wife and daughter about what's really going on.", - "timestamp": 1771214146.346665 - } - ], - "last_call": 1771214146.346665, - "created_at": 1771214146.346665 + "last_call": 1772071625.969454, + "created_at": 1772071625.969454 }, { "id": "2768e2ac", @@ -235,46 +55,208 @@ { "summary": "Rochelle called in after spending three hours tracking her ex-husband's location on a fitness app they still share, watching him circle her neighborhood and park at a nearby Sonic for 40 minutes while sending cryptic texts. Though she initiated their divorce eight months ago, she admitted she might be glad he's \"creeping\" on her and got emotional realizing they both still have access to each other's locations\u2014and that she might still miss him despite dating someone new.", "timestamp": 1771217728.036122 + }, + { + "summary": "Rochelle called to say she finally turned off location sharing with her ex-husband David, but he immediately texted asking if she was okay, showing he was still tracking her. She's now sitting on her current boyfriend Marcus's porch, struggling with the realization that she still misses David and isn't being fair to Marcus, ultimately acknowledging she needs to break up with Marcus and be single for a while.", + "timestamp": 1771823819.191822 + }, + { + "summary": "Rochelle called to update Luke about her breakup with Marcus and confessed that her ex-husband David recently contacted her with a flimsy excuse to visit, which triggered obsessive behavior including stalking his location and driving past places he frequents. Luke bluntly told her to stop acting like a \"maniac,\" delete her tracking apps, focus on herself and her kids, and avoid dating for at least a year until she's in a healthier mental state.", + "timestamp": 1772076234.249971 } ], - "last_call": 1771217728.0361228, + "last_call": 1772076234.2499719, "created_at": 1771217728.0361228 }, { - "id": "dec4f7c9", - "name": "Terri", - "gender": "female", - "age": 38, - "job": "feet up on the desk, laughing that hollow laugh she does when things aren't funny at all", + "id": "7d06c9ca", + "name": "Phil", + "gender": "male", + "age": 60, + "job": "phone pressed to his ear with his shoulder while he wipes down the station he should've cleaned hours ago, stalling before he drives home to the conversation he's been avoiding all weekend", "location": "in unknown", "personality_traits": [], - "voice": "Olivia", + "voice": "Craig", "call_history": [ { - "summary": "Terry called in devastated that her best friend of 30 years, Michelle, has been secretly sleeping with Terry's ex-husband since their divorce six months ago\u2014and worse, Michelle comforted Terry about seeing David with \"someone\" at Applebee's two weeks ago without revealing it was her. Luke advised Terry to cut off both Michelle and David and move forward, acknowledging her grief is valid but encouraging her to choose not to stay hurt, which Terry ultimately accepted while realizing she'd been mourning the idea of her marriage more than the actual relationship.", - "timestamp": 1771222133.612614 + "summary": "Phil called in after his wife of 20 years revealed she's gay and wants to stay married while both see other people on the side\u2014he's been seeing a man named Marcus, and she's been seeing a woman since last summer. After initially feeling confused and worried about \"kicking the can down the road,\" Phil worked through his anxiety with Luke and reached an emotional breakthrough, realizing he doesn't need to have everything figured out immediately and that their honest, unconventional arrangement might actually work for their family.", + "timestamp": 1771819671.062739 + }, + { + "summary": "Phil calls back after previously discussing his open marriage with his wife Teresa, revealing that he developed real romantic feelings for Marcus (the man he was seeing), who is now moving to Portland\u2014a heartbreak that made Phil realize he's been lying to himself about wanting to stay married. After discussing it with Luke, Phil decides to have an honest conversation with Teresa about ending their marriage amicably, recognizing they've both moved on emotionally and she may want to pursue a future with her girlfriend Amanda.", + "timestamp": 1772171807.331306 } ], - "last_call": 1771222133.612614, - "created_at": 1771222133.612614 + "last_call": 1772171807.3313081, + "created_at": 1771819671.062739 }, { - "id": "514725e5", - "name": "Dolores", + "id": "920e6f98", + "name": "Marlene", "gender": "female", - "age": 33, - "job": "and every Saturday and Sunday she drives to open houses in Silver City, Deming, sometimes as far as Las Cruces, pretending she's", - "location": "unknown", + "age": 25, + "job": "third shift HR coverage at the call center, still half-painted like a banana because the makeup won't come off her neck", + "location": "in unknown", "personality_traits": [], - "voice": "Luna", + "voice": "Darlene", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Been through it all and has the tired voice to prove it. Nothing surprises them. Responds to dramatic revelations with 'yeah, that tracks.' Dark humor born from experience, not edginess. Energy level: low but steady. When pushed back on, they shrug it off with a 'look, I've seen worse.' Conversational tendency: resigned acceptance sprinkled with grim comedy." + }, "call_history": [ { - "summary": "Dolores calls from a parking lot after panicking when a realtor recognized her at an open house\u2014she's been attending showings for eight months (six years total) with no intention of buying, using them to escape her routine life working at a gun range in Lordsburg. Luke tells her to stop pretending and actually take steps to change her life, which she tearfully accepts, committing to make a real plan instead of just fantasizing.", - "timestamp": 1771223091.851768 + "summary": "Marlene wore a professional banana costume to her manager's holiday party because the invitation said \"costume party,\" but everyone else showed up in sweaters\u2014she stayed all four hours and now faces a disciplinary meeting for \"lack of professional judgment.\" Despite the awkwardness and lingering yellow makeup on her neck, she's considering wearing the banana costume again to the meeting, reasoning that if she gets fired over it, at least she'll have a good story.", + "timestamp": 1772172154.039839 } ], - "last_call": 1771223091.851769, - "created_at": 1771223091.851769 + "last_call": 1772172154.03984, + "created_at": 1772172154.03984 + }, + { + "id": "5ead2c1a", + "name": "Greg", + "gender": "male", + "age": 59, + "job": "laptop open on the comforter showing the doorbell footage for the seventh time tonight, because three nights ago someone\u2014something\u2014stood on his porch at 3:17 AM and didn't move for ten minutes and forty-three seconds, just faced the door like they were waiting to be invited in, and when he scrubbed through frame-by-frame he noticed the person's chest never rose or fell like they were breathing", + "location": "in unknown", + "personality_traits": [], + "voice": "Gareth", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Has done their research and wants you to know it. Corrects small details. Cites sources. Uses phrases like 'actually, studies show...' and 'well technically.' Not trying to be annoying \u2014 they genuinely believe precision matters. Energy level: medium. When pushed back on, they get pedantic and start splitting hairs. Conversational tendency: correcting and clarifying." + }, + "call_history": [ + { + "summary": "Greg called about disturbing doorbell camera footage showing a figure standing motionless on his porch at 3 AM for nearly 11 minutes without breathing or triggering motion sensors, then vanishing between frames\u2014and he's discovered three similar earlier incidents after obsessively analyzing the footage. Despite creating multiple backups and researching everything from vampire folklore to electromagnetic interference, he's anxious about what this means and whether he should report it to police, though Luke reassures him to share it on the Discord community and install a constant porch light.", + "timestamp": 1772173891.1805592 + } + ], + "last_call": 1772173891.1805599, + "created_at": 1772173891.1805599 + }, + { + "id": "8c97dd56", + "name": "Mitch", + "gender": "male", + "age": 29, + "job": "just the glow from the window over the sink where he's been standing for the past hour, looking at his neighbor's house across the gravel drive", + "location": "in unknown", + "personality_traits": [], + "voice": "Mark", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent \u2014 their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association." + }, + "call_history": [ + { + "summary": "Mitch called about his neighbor stealing water for over a year by connecting a hose under the fence to his spigot, doubling his water bills, and when confronted, the neighbor just shrugged it off. The emotional core of the call was Mitch struggling with feeling disrespected and small\u2014standing in his dark kitchen obsessing over the neighbor's dismissive reaction\u2014before ultimately deciding to pursue documentation, involve the water company, and take the neighbor to small claims court.", + "timestamp": 1772174421.724106 + } + ], + "last_call": 1772174421.724106, + "created_at": 1772174421.724106 + }, + { + "id": "37f0bfaa", + "name": "Murray", + "gender": "male", + "age": 36, + "job": "engine running for heat, watching his breath fog up the windshield while he tries to figure out how to fire his best friend of thirty years", + "location": "in unknown", + "personality_traits": [], + "voice": "Tyler", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation." + }, + "call_history": [ + { + "summary": "Murray called in struggling with whether to fire his best friend Danny of 30 years, who's been showing up late, bad-mouthing him to their crew, and just cost them a major contract by abandoning a job site. Through the conversation, Murray realized he'd become overly rigid and \"suit-like\" while trying to prove himself as the new business owner, and decided instead of firing Danny, he'd hold a team meeting to apologize for his approach, explain the reasoning behind new protocols, and invite the crew to be part of the solution rather than just enforcing rules from above.", + "timestamp": 1772250744.2312489 + } + ], + "last_call": 1772250744.2312498, + "created_at": 1772250744.2312498 + }, + { + "id": "9e274ab1", + "name": "Elvin", + "gender": "male", + "age": 49, + "job": "his plate of cold fries pushed aside, because three hours ago his buddy Marcus showed him a dating profile\u2014complete with photos from last summer's fishing trip\u2014and swore up and down he's never downloaded a dating app in his life, which would be easier to believe if Elvin's ex-wife and two of his cousins hadn't also matched with Marcus", + "location": "unknown", + "personality_traits": [], + "voice": "Brian", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent \u2014 their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association." + }, + "call_history": [ + { + "summary": "Elvin called because his longtime friend Marcus discovered someone created fake dating profiles using his real photos, and multiple people including Elvin's ex-wife and cousins have matched with the imposter profile. After initially suspecting Marcus was lying, Elvin became convinced his friend is a genuine victim of identity theft, and felt guilty for doubting him\u2014ultimately deciding to help Marcus investigate by having a cousin engage with the fake profile to uncover the scammer's motives.", + "timestamp": 1772253684.436144 + } + ], + "last_call": 1772253684.4361448, + "created_at": 1772253684.436146 + }, + { + "id": "61935b11", + "name": "Marcus", + "gender": "male", + "age": 34, + "job": "door locked, listening to the wind throw sand against the window while she sleeps", + "location": "unknown", + "personality_traits": [], + "voice": "Callum", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Wearing their heart on their sleeve. Voice cracks. Long pauses where they're collecting themselves. Not performing emotion \u2014 genuinely going through it. When they laugh it's the kind of laugh that's one step from crying. Energy level: fluctuating. When pushed back on, they get quiet and you can tell they're really thinking about it. Conversational tendency: vulnerability." + }, + "call_history": [ + { + "summary": "Marcus called about accidentally receiving $5,000 instead of $500 from his employer three months ago and spending most of it on his truck, daughter's tuition, and bills, which has been keeping him up at night with guilt and fear of consequences. The host advised him to proactively tell his employer (claiming an accountant found the error), emphasize it was their mistake, and work out a payment plan rather than waiting for them to discover it, reassuring Marcus that he likely won't be fired since he didn't actually do anything wrong.", + "timestamp": 1772429224.977716 + } + ], + "last_call": 1772429224.977717, + "created_at": 1772429224.977717 + }, + { + "id": "a16fe26a", + "name": "Curtis", + "gender": "male", + "age": 43, + "job": "still sitting at her table an hour after Sunday dinner ended, phone cord stretched across the counter because she still has a landline", + "location": "in unknown", + "personality_traits": [], + "voice": "Nate", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit." + }, + "call_history": [ + { + "summary": "Curtis called about struggling with grief after taking his deceased father's record collection from his mom's garage, crying over albums despite having avoided thinking about his dad for three years. He's conflicted about mourning a father who abandoned him at 14, using humor to deflect his pain, but the host reassured him it's normal to grieve even complicated relationships and that keeping the records\u2014whether he uses them or not\u2014is perfectly okay.", + "timestamp": 1772430659.572614 + } + ], + "last_call": 1772430659.572614, + "created_at": 1772430659.572614 + }, + { + "id": "9b72f700", + "name": "Mitch", + "gender": "male", + "age": 38, + "job": "half-eaten microwave burrito going cold on the table, because he just watched his two best friends\u2014who've spent the last three years openly trash-talking each other at every barbecue and poker night\u2014stumble out of the parking garage behind the Safeway at 1 AM, hands all over each other, and climb into the same truck", + "location": "in unknown", + "personality_traits": [], + "voice": "Blake", + "stable_seeds": { + "style": "COMMUNICATION STYLE: Called because they need to GET THIS OFF THEIR CHEST. Talks in capital letters. Uses 'honestly' and 'I'm not even kidding' a lot. The anger is specific and justified \u2014 this isn't random rage, this is 'let me tell you exactly what happened.' Energy level: very high. When pushed back on, they take a breath and say 'I hear you but...' and then get right back to the rant. Conversational tendency: building to a crescendo." + }, + "call_history": [ + { + "summary": "Mitch called in after discovering his two best friends, who had always pretended to despise each other, making out in a parking garage. He felt like an idiot for not realizing they were secretly together and was upset about being lied to, but the host helped him see the humor in the situation.", + "timestamp": 1772431494.727463 + } + ], + "last_call": 1772431494.727464, + "created_at": 1772431494.727464 } ] } \ No newline at end of file diff --git a/data/voicemails.json b/data/voicemails.json index c759be1..6000cb0 100644 --- a/data/voicemails.json +++ b/data/voicemails.json @@ -1,10 +1,21 @@ { - "voicemails": [], + "voicemails": [ + { + "id": "60c8d47c", + "phone": "+17755134750", + "timestamp": 1772294240, + "duration": 102, + "file_path": "/Users/lukemacneil/ai-podcast/data/voicemails/1772427904_17755134750.wav", + "listened": true + } + ], "deleted_timestamps": [ 1771212705, 1771146434, 1771146564, 1771146952, + 1771244817, + 1771244823, 1771213151 ] } \ No newline at end of file diff --git a/deploy_stats_cron.sh b/deploy_stats_cron.sh index 991fb09..ae9d985 100755 --- a/deploy_stats_cron.sh +++ b/deploy_stats_cron.sh @@ -23,8 +23,7 @@ TMPFILE=$(mktemp) cat > "$TMPFILE" << 'DOCKERFILE' FROM python:3.11-slim RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \ - && apt-get purge -y curl && apt-get autoremove -y + && curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker RUN pip install --no-cache-dir requests yt-dlp COPY podcast_stats.py /app/podcast_stats.py COPY run_loop.sh /app/run_loop.sh @@ -42,7 +41,12 @@ cat > "$TMPFILE" << 'LOOPSCRIPT' echo "podcast-stats: starting hourly loop" while true; do echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..." - python podcast_stats.py --json --upload 2>&1 || echo " ...failed, will retry next hour" + if python podcast_stats.py --json --upload 2>&1; then + curl -s "https://monitoring.macneilmediagroup.com/api/push/yk9tjJVUGVXhu4zjol2EvpepIlBTfFoD?status=up&msg=OK" > /dev/null + echo " ...done, heartbeat sent" + else + echo " ...failed, will retry next hour" + fi echo "Sleeping 1 hour..." sleep 3600 done diff --git a/docs/plans/2026-02-23-idents-playback.md b/docs/plans/2026-02-23-idents-playback.md new file mode 100644 index 0000000..f21a3b4 --- /dev/null +++ b/docs/plans/2026-02-23-idents-playback.md @@ -0,0 +1,402 @@ +# Idents Playback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an idents section that loads MP3s from `idents/` and plays them through the ads channel (ch 11), with a separate "idents" stem for post-production. + +**Architecture:** Mirrors the existing ads system — dropdown + play/stop buttons, same audio channel, mutually exclusive with ads. Idents get their own stem in stem_recorder so they can be mixed independently in post-production. + +**Tech Stack:** Python (FastAPI), sounddevice, librosa, vanilla JS + +--- + +### Task 1: Add idents_dir to config + +**Files:** +- Modify: `backend/config.py:46-47` + +**Step 1: Add idents_dir path** + +After `ads_dir` (line 46), add: + +```python + idents_dir: Path = base_dir / "idents" +``` + +**Step 2: Create the idents directory** + +```bash +mkdir -p idents +``` + +**Step 3: Commit** + +```bash +git add backend/config.py +git commit -m "Add idents_dir to config" +``` + +--- + +### Task 2: Add "idents" stem to stem_recorder + +**Files:** +- Modify: `backend/services/stem_recorder.py:10` + +**Step 1: Add "idents" to STEM_NAMES** + +Change line 10 from: + +```python +STEM_NAMES = ["host", "caller", "music", "sfx", "ads"] +``` + +to: + +```python +STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"] +``` + +**Step 2: Add "idents" to postprod.py STEM_NAMES** + +In `postprod.py:20`, change: + +```python +STEM_NAMES = ["host", "caller", "music", "sfx", "ads"] +``` + +to: + +```python +STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"] +``` + +Also update `postprod.py:72` — the `remove_gaps` content detection line — add idents: + +```python +content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"] + stems["idents"] +``` + +And in `mix_stems` (line 411), add idents level: + +```python +levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0} +``` + +And in stereo pans (line 420): + +```python +pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0} +``` + +And in `match_voice_levels` (line 389), add "idents": + +```python +for name in ["host", "caller", "ads", "idents"]: +``` + +And in gap removal limiter section (line 777-778): + +```python +for name in ["ads", "sfx", "idents"]: +``` + +**Step 3: Commit** + +```bash +git add backend/services/stem_recorder.py postprod.py +git commit -m "Add idents stem to recorder and postprod" +``` + +--- + +### Task 3: Add play_ident / stop_ident to audio service + +**Files:** +- Modify: `backend/services/audio.py` + +**Step 1: Add ident state vars to __init__ (after line 40)** + +After the ad playback state block (lines 35-40), add: + +```python + # Ident playback state + self._ident_stream: Optional[sd.OutputStream] = None + self._ident_data: Optional[np.ndarray] = None + self._ident_resampled: Optional[np.ndarray] = None + self._ident_position: int = 0 + self._ident_playing: bool = False +``` + +**Step 2: Add play_ident method (after stop_ad, ~line 1006)** + +Insert after `stop_ad` method. This is a copy of `play_ad` with: +- `_ad_*` → `_ident_*` +- Calls `self.stop_ad()` at the start (mutual exclusion) +- Stem recording writes to `"idents"` instead of `"ads"` + +```python + def play_ident(self, file_path: str): + """Load and play an ident file once (no loop) on the ad channel""" + import librosa + + path = Path(file_path) + if not path.exists(): + print(f"Ident file not found: {file_path}") + return + + self.stop_ident() + self.stop_ad() + + try: + audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True) + self._ident_data = audio.astype(np.float32) + except Exception as e: + print(f"Failed to load ident: {e}") + return + + self._ident_playing = True + self._ident_position = 0 + + if self.output_device is None: + num_channels = 2 + device = None + device_sr = self.output_sample_rate + channel_idx = 0 + else: + device_info = sd.query_devices(self.output_device) + num_channels = device_info['max_output_channels'] + device_sr = int(device_info['default_samplerate']) + device = self.output_device + channel_idx = min(self.ad_channel, num_channels) - 1 + + if self.output_sample_rate != device_sr: + self._ident_resampled = librosa.resample( + self._ident_data, orig_sr=self.output_sample_rate, target_sr=device_sr + ).astype(np.float32) + else: + self._ident_resampled = self._ident_data + + def callback(outdata, frames, time_info, status): + outdata[:] = 0 + if not self._ident_playing or self._ident_resampled is None: + return + + remaining = len(self._ident_resampled) - self._ident_position + if remaining >= frames: + chunk = self._ident_resampled[self._ident_position:self._ident_position + frames] + outdata[:, channel_idx] = chunk + if self.stem_recorder: + self.stem_recorder.write_sporadic("idents", chunk.copy(), device_sr) + self._ident_position += frames + else: + if remaining > 0: + outdata[:remaining, channel_idx] = self._ident_resampled[self._ident_position:] + self._ident_playing = False + + try: + self._ident_stream = sd.OutputStream( + device=device, + channels=num_channels, + samplerate=device_sr, + dtype=np.float32, + callback=callback, + blocksize=2048 + ) + self._ident_stream.start() + print(f"Ident playback started on ch {self.ad_channel} @ {device_sr}Hz") + except Exception as e: + print(f"Ident playback error: {e}") + self._ident_playing = False + + def stop_ident(self): + """Stop ident playback""" + self._ident_playing = False + if self._ident_stream: + self._ident_stream.stop() + self._ident_stream.close() + self._ident_stream = None + self._ident_position = 0 +``` + +**Step 3: Add `self.stop_ident()` to top of play_ad (line 935)** + +In `play_ad`, after `self.stop_ad()` (line 935), add: + +```python + self.stop_ident() +``` + +**Step 4: Commit** + +```bash +git add backend/services/audio.py +git commit -m "Add play_ident/stop_ident to audio service" +``` + +--- + +### Task 4: Add idents API endpoints + +**Files:** +- Modify: `backend/main.py` (after ads endpoints, ~line 4362) + +**Step 1: Add IDENT_DISPLAY_NAMES and endpoints** + +Insert after the ads stop endpoint (line 4362): + +```python + +# --- Idents Endpoints --- + +IDENT_DISPLAY_NAMES = {} + + +@app.get("/api/idents") +async def get_idents(): + """Get available ident tracks, shuffled""" + ident_list = [] + if settings.idents_dir.exists(): + for ext in ['*.wav', '*.mp3', '*.flac']: + for f in settings.idents_dir.glob(ext): + ident_list.append({ + "name": IDENT_DISPLAY_NAMES.get(f.stem, f.stem), + "file": f.name, + "path": str(f) + }) + random.shuffle(ident_list) + return {"idents": ident_list} + + +@app.post("/api/idents/play") +async def play_ident(request: MusicRequest): + """Play an ident once on the ad channel (ch 11)""" + ident_path = settings.idents_dir / request.track + if not ident_path.exists(): + raise HTTPException(404, "Ident not found") + + if audio_service._music_playing: + audio_service.stop_music(fade_duration=1.0) + await asyncio.sleep(1.1) + audio_service.play_ident(str(ident_path)) + return {"status": "playing", "track": request.track} + + +@app.post("/api/idents/stop") +async def stop_ident(): + """Stop ident playback""" + audio_service.stop_ident() + return {"status": "stopped"} +``` + +**Step 2: Commit** + +```bash +git add backend/main.py +git commit -m "Add idents API endpoints" +``` + +--- + +### Task 5: Add idents UI section and JS functions + +**Files:** +- Modify: `frontend/index.html:113` (after ads section) +- Modify: `frontend/js/app.js` + +**Step 1: Add Idents HTML section** + +After the Ads section closing `` (line 113), add: + +```html + +
+

Idents

+ +
+ + +
+
+``` + +**Step 2: Add loadIdents, playIdent, stopIdent to app.js** + +After `stopAd()` function (~line 773), add: + +```javascript +async function loadIdents() { + try { + const res = await fetch('/api/idents'); + const data = await res.json(); + const idents = data.idents || []; + + const select = document.getElementById('ident-select'); + if (!select) return; + + const previousValue = select.value; + select.innerHTML = ''; + + idents.forEach(ident => { + const option = document.createElement('option'); + option.value = ident.file; + option.textContent = ident.name; + select.appendChild(option); + }); + + if (previousValue && [...select.options].some(o => o.value === previousValue)) { + select.value = previousValue; + } + + console.log('Loaded', idents.length, 'idents'); + } catch (err) { + console.error('loadIdents error:', err); + } +} + +async function playIdent() { + await loadIdents(); + const select = document.getElementById('ident-select'); + const track = select?.value; + if (!track) return; + + await fetch('/api/idents/play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track, action: 'play' }) + }); +} + +async function stopIdent() { + await fetch('/api/idents/stop', { method: 'POST' }); +} +``` + +**Step 3: Add event listeners in initEventListeners** + +After the ads event listeners (line 190), add: + +```javascript + // Idents + document.getElementById('ident-play-btn')?.addEventListener('click', playIdent); + document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent); +``` + +**Step 4: Add loadIdents() to DOMContentLoaded init** + +After `await loadAds();` (line 59), add: + +```javascript + await loadIdents(); +``` + +**Step 5: Bump cache buster on app.js script tag** + +In `index.html:243`, change `?v=17` to `?v=18`. + +**Step 6: Commit** + +```bash +git add frontend/index.html frontend/js/app.js +git commit -m "Add idents UI section and JS functions" +``` diff --git a/frontend/js/app.js b/frontend/js/app.js index 5635cc1..56464a5 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -684,7 +684,13 @@ async function loadMusic() { genreOrder.forEach(genre => { const group = document.createElement('optgroup'); group.label = genre; - genres[genre].forEach(track => { + // Shuffle within each genre group + const genreTracks = genres[genre]; + for (let i = genreTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [genreTracks[i], genreTracks[j]] = [genreTracks[j], genreTracks[i]]; + } + genreTracks.forEach(track => { const option = document.createElement('option'); option.value = track.file; option.textContent = track.name; diff --git a/make_clips.py b/make_clips.py index 373e21b..fff7afe 100755 --- a/make_clips.py +++ b/make_clips.py @@ -624,27 +624,25 @@ def _find_transcript_region(labeled_words: list[dict], whisper_words: list[str], def add_speaker_labels(words: list[dict], labeled_transcript: str, start_time: float, end_time: float, segments: list[dict]) -> list[dict]: - """Add speaker labels AND correct word text using labeled transcript. + """Replace Whisper text with labeled transcript text, keeping Whisper timestamps. - Uses Needleman-Wunsch DP alignment to match Whisper words to the labeled - transcript. This handles insertions/deletions gracefully — one missed word - becomes a single gap instead of cascading failures. + The labeled transcript is the source of truth for TEXT. Whisper is only used + for TIMESTAMPS. Uses DP alignment to map between the two, then rebuilds the + word list from the labeled transcript with interpolated timestamps for any + words Whisper missed. """ if not labeled_transcript or not words: return words - # Parse full transcript into flat word list all_labeled = _parse_full_transcript(labeled_transcript) if not all_labeled: return words - # Build whisper clean word list whisper_clean = [] for w in words: clean = re.sub(r"[^\w']", '', w["word"].lower()) whisper_clean.append(clean if clean else w["word"].lower()) - # Find the matching region in the transcript region = _find_transcript_region(all_labeled, whisper_clean) if region is None: return words @@ -653,46 +651,61 @@ def add_speaker_labels(words: list[dict], labeled_transcript: str, region_words = all_labeled[region_start:region_end] region_clean = [w["clean"] for w in region_words] - # Run DP alignment pairs = _align_sequences(whisper_clean, region_clean) - # Build speaker assignments from aligned pairs - # matched[whisper_idx] = (labeled_word_dict, score) - matched = {} + # Build mapping: labeled_idx -> whisper_idx (for timestamp lookup) + labeled_to_whisper = {} for w_idx, l_idx in pairs: if w_idx is not None and l_idx is not None: score = _word_score(whisper_clean[w_idx], region_clean[l_idx]) if score > 0: - matched[w_idx] = (region_words[l_idx], score) + labeled_to_whisper[l_idx] = w_idx - # Apply matches and interpolate speakers for gaps + # Find the range of labeled words that actually overlap with this clip + # Use only labeled indices that have a whisper match to determine boundaries + matched_labeled_indices = sorted(labeled_to_whisper.keys()) + if not matched_labeled_indices: + return words + + first_labeled = matched_labeled_indices[0] + last_labeled = matched_labeled_indices[-1] + + # Build output from labeled transcript words with whisper timestamps + result = [] corrections = 0 - for i, word_entry in enumerate(words): - if i in matched: - labeled_word, score = matched[i] - word_entry["speaker"] = labeled_word["speaker"] + for l_idx in range(first_labeled, last_labeled + 1): + labeled_word = region_words[l_idx] + word_text = re.sub(r'[^\w\s\'-]', '', labeled_word["word"]).strip() + if not word_text: + continue - # Replace text only on confident matches - corrected = re.sub(r'[^\w\s\'-]', '', labeled_word["word"]) - if corrected: - if corrected.lower() != whisper_clean[i]: - corrections += 1 - word_entry["word"] = corrected + if l_idx in labeled_to_whisper: + w_idx = labeled_to_whisper[l_idx] + ts_start = words[w_idx]["start"] + ts_end = words[w_idx]["end"] + if word_text.lower() != whisper_clean[w_idx]: + corrections += 1 else: - # Interpolate speaker from nearest matched neighbor - speaker = _interpolate_speaker(i, matched, len(words)) - if speaker: - word_entry["speaker"] = speaker + # Interpolate timestamp from neighbors + ts_start, ts_end = _interpolate_timestamp(l_idx, labeled_to_whisper, words) + + result.append({ + "word": word_text, + "start": ts_start, + "end": ts_end, + "speaker": labeled_word["speaker"], + }) if corrections: print(f" Corrected {corrections} words from labeled transcript") + if len(result) != len(words): + print(f" Word count: {len(words)} (whisper) -> {len(result)} (labeled)") - return words + return result def _interpolate_speaker(idx: int, matched: dict, n_words: int) -> str | None: """Find speaker from nearest matched neighbor.""" - # Search outward from idx for dist in range(1, n_words): before = idx - dist after = idx + dist @@ -703,30 +716,63 @@ def _interpolate_speaker(idx: int, matched: dict, n_words: int) -> str | None: return None -def polish_clip_words(words: list[dict], labeled_transcript: str = "") -> list[dict]: - """Use LLM to fix punctuation, capitalization, and misheard words. +def _interpolate_timestamp(labeled_idx: int, labeled_to_whisper: dict, + words: list[dict]) -> tuple[float, float]: + """Interpolate timestamp for a labeled word with no direct whisper match. - Sends the raw whisper words to an LLM, gets back a corrected version, - and maps corrections back to the original timed words. + Finds the nearest matched neighbors before and after, then linearly + interpolates based on position. + """ + before_l = after_l = None + for dist in range(1, len(labeled_to_whisper) + 10): + if before_l is None and (labeled_idx - dist) in labeled_to_whisper: + before_l = labeled_idx - dist + if after_l is None and (labeled_idx + dist) in labeled_to_whisper: + after_l = labeled_idx + dist + if before_l is not None and after_l is not None: + break + + if before_l is not None and after_l is not None: + w_before = words[labeled_to_whisper[before_l]] + w_after = words[labeled_to_whisper[after_l]] + span = after_l - before_l + frac = (labeled_idx - before_l) / span + start = w_before["end"] + frac * (w_after["start"] - w_before["end"]) + duration = (w_after["start"] - w_before["end"]) / span + return start, start + max(duration, 0.1) + elif before_l is not None: + w = words[labeled_to_whisper[before_l]] + offset = (labeled_idx - before_l) * 0.3 + return w["end"] + offset, w["end"] + offset + 0.3 + elif after_l is not None: + w = words[labeled_to_whisper[after_l]] + offset = (after_l - labeled_idx) * 0.3 + return w["start"] - offset - 0.3, w["start"] - offset + else: + return 0.0, 0.3 + + +def polish_clip_words(words: list[dict], labeled_transcript: str = "") -> list[dict]: + """Use LLM to add punctuation and fix capitalization. + + The word text is already correct (from the labeled transcript). This step + only adds sentence punctuation and proper capitalization. """ if not words or not OPENROUTER_API_KEY: return words raw_text = " ".join(w["word"] for w in words) - context = "" - if labeled_transcript: - context = f"\nFor reference, here's the speaker-labeled transcript of this section (use it to correct misheard words and names):\n{labeled_transcript[:3000]}\n" - - prompt = f"""Fix this podcast transcript excerpt so it reads as proper sentences. Fix punctuation, capitalization, and obvious misheard words. + prompt = f"""Add punctuation and capitalization to this podcast transcript excerpt so it reads as proper sentences. RULES: - Keep the EXACT same number of words in the EXACT same order -- Only change capitalization, punctuation attached to words, and obvious mishearings +- The words themselves are already correct — do NOT change any word's spelling +- Only add punctuation (periods, commas, question marks, exclamation marks) and fix capitalization - Do NOT add, remove, merge, or reorder words - Contractions count as one word (don't = 1 word) - Return ONLY the corrected text, nothing else -{context} + RAW TEXT ({len(words)} words): {raw_text}""" diff --git a/make_social_post.py b/make_social_post.py new file mode 100644 index 0000000..f9916b6 --- /dev/null +++ b/make_social_post.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Generate social media announcement images for Luke at the Roost. + +Usage: + python make_social_post.py # regenerate with defaults + python make_social_post.py --title "NEW FEATURE" # custom title + python make_social_post.py --body body_text.txt # body from file + +Outputs square (1080x1080) and landscape (1200x675) PNGs to social_posts/. +""" + +import argparse +import os +import textwrap +from PIL import Image, ImageDraw, ImageFont, ImageOps + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png") +OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts") + +# Brand colors +BG = (18, 13, 7) +ACCENT = (232, 121, 29) +WHITE = (255, 255, 255) +MUTED = (175, 165, 150) +LIGHTER = (220, 215, 205) + +# macOS system fonts — swap these on Linux/Windows +FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf" +FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" +FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf" + + +def load_font(path, size): + return ImageFont.truetype(path, size) + + +def text_bbox(draw, text, font): + bb = draw.textbbox((0, 0), text, font=font) + return bb[2] - bb[0], bb[3] - bb[1], bb[1] # width, height, y_offset + + +def wrap_text(draw, text, x, y, max_w, font, fill, line_gap=10, + cover_right=None, cover_bottom=None): + """Word-wrap text onto the image, narrowing lines that overlap the cover. + + line_gap: fixed pixel gap between lines (not a multiplier). + Returns y just below the last line of text (no trailing gap).""" + words = text.split() + lines = [] + cur = "" + cur_y = y + + for word in words: + test = f"{cur} {word}".strip() + eff_w = max_w + if cover_right and cover_bottom and cur_y < cover_bottom: + eff_w = cover_right - x - 20 + + tw, th, _ = text_bbox(draw, test, font) + if tw > eff_w and cur: + lines.append((cur, cur_y)) + _, lh, _ = text_bbox(draw, cur, font) + cur_y += lh + line_gap + cur = word + else: + cur = test + + if cur: + lines.append((cur, cur_y)) + _, lh, _ = text_bbox(draw, cur, font) + + for line, ly in lines: + draw.text((x, ly), line, font=font, fill=fill) + + return cur_y + lh # return y just past the last line's bottom + + +def center_text(draw, text, y, canvas_w, font, fill): + tw, th, _ = text_bbox(draw, text, font) + draw.text(((canvas_w - tw) // 2, y), text, font=font, fill=fill) + return y + th + + +def draw_email_box(draw, email, y, canvas_w, font): + tw, th, y_off = text_bbox(draw, email, font) + px, py = 22, 16 + box_w = tw + px * 2 + box_x = (canvas_w - box_w) // 2 + draw.rounded_rectangle( + [box_x, y, box_x + box_w, y + th + py * 2], + radius=8, fill=(45, 30, 12), outline=ACCENT, width=2, + ) + draw.text((box_x + px, y + py - y_off), email, font=font, fill=ACCENT) + return y + th + py * 2 + + +def draw_accent_bars(draw, w, h, thickness): + draw.rectangle([0, 0, w, thickness], fill=ACCENT) + draw.rectangle([0, h - thickness, w, h], fill=ACCENT) + + +def paste_cover(img, x, y, size, radius): + cover = Image.open(COVER).resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255) + img.paste(cover, (x, y), mask) + + +def make_square(title, paragraphs, email, filename="email_announcement_square.png"): + W = 1080 + img = Image.new("RGB", (W, W), BG) + draw = ImageDraw.Draw(img) + draw_accent_bars(draw, W, W, 8) + + # Cover image — top right + cover_size, cover_x, cover_y = 240, W - 290, 35 + paste_cover(img, cover_x, cover_y, cover_size, 20) + cover_bottom = cover_y + cover_size + 15 + + m = 60 + y = 40 + tw_full = W - m * 2 + + # Header + draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 24), fill=ACCENT) + y += 30 + tag = load_font(FONT_REG, 20) + draw.text((m, y), "Late-night call-in radio", font=tag, fill=MUTED) + draw.text((m, y + 26), "powered by AI", font=tag, fill=MUTED) + y += 75 + + # Consistent spacing constants + LINE_GAP = 12 # between lines within a block + SECTION_GAP = 32 # between sections (body→CTA, CTA→footer) + PARA_GAP = 26 # between body paragraphs + TITLE_GAP = 48 # between title and first body paragraph + + # Title + y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 72), WHITE, + line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom) + y += TITLE_GAP + + # Body paragraphs + body_font = load_font(FONT_REG, 32) + colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1) + for i, (para, color) in enumerate(zip(paragraphs, colors)): + cr = cover_x if y < cover_bottom else None + cb = cover_bottom if y < cover_bottom else None + y = wrap_text(draw, para, m, y, tw_full, body_font, color, + line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb) + if i < len(paragraphs) - 1: + y += PARA_GAP + + y += SECTION_GAP + + # Email CTA + y = draw_email_box(draw, email, y, W, load_font(FONT_BOLD, 36)) + y += SECTION_GAP + + # Footer + y = center_text(draw, "New episodes drop daily. Be part of the next one.", + y, W, load_font(FONT_REG, 24), MUTED) + y += PARA_GAP + info = load_font(FONT_REG, 22) + center_text(draw, "lukeattheroost.com", y, W, info, ACCENT) + y += PARA_GAP + center_text(draw, "Spotify \u00b7 Apple Podcasts \u00b7 YouTube \u00b7 RSS", + y, W, info, MUTED) + + os.makedirs(OUT_DIR, exist_ok=True) + img.save(os.path.join(OUT_DIR, filename), quality=95) + print(f"Square: {filename}") + + +def make_landscape(title, paragraphs, email, filename="email_announcement_twitter.png"): + TW, TH = 1200, 675 + img = Image.new("RGB", (TW, TH), BG) + draw = ImageDraw.Draw(img) + draw_accent_bars(draw, TW, TH, 6) + + # Cover image — top right + cover_size, cover_x, cover_y = 180, TW - 220, 22 + paste_cover(img, cover_x, cover_y, cover_size, 16) + cover_bottom = cover_y + cover_size + 10 + + m = 45 + y = 25 + tw_full = TW - m * 2 + + # Header + draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 20), fill=ACCENT) + y += 24 + draw.text((m, y), "Late-night call-in radio powered by AI", + font=load_font(FONT_REG, 17), fill=MUTED) + y += 38 + + # Consistent spacing constants + LINE_GAP = 8 # between lines within a block + SECTION_GAP = 20 # between sections + PARA_GAP = 16 # between body paragraphs + TITLE_GAP = 32 # between title and first body paragraph + + # Title + y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 50), WHITE, + line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom) + y += TITLE_GAP + + # Body paragraphs + body_font = load_font(FONT_REG, 23) + colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1) + for i, (para, color) in enumerate(zip(paragraphs, colors)): + cr = cover_x if y < cover_bottom else None + cb = cover_bottom if y < cover_bottom else None + y = wrap_text(draw, para, m, y, tw_full, body_font, color, + line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb) + if i < len(paragraphs) - 1: + y += PARA_GAP + + y += SECTION_GAP + + # Email CTA + y = draw_email_box(draw, email, y, TW, load_font(FONT_BOLD, 26)) + y += SECTION_GAP + + # Footer + y = center_text(draw, "New episodes drop daily. Be part of the next one.", + y, TW, load_font(FONT_REG, 19), MUTED) + y += PARA_GAP + center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube", + y, TW, load_font(FONT_REG, 17), (140, 132, 120)) + + os.makedirs(OUT_DIR, exist_ok=True) + img.save(os.path.join(OUT_DIR, filename), quality=95) + print(f"Landscape: {filename}") + + +# --- Default content --- + +DEFAULT_TITLE = "NOW ACCEPTING LISTENER EMAILS" +DEFAULT_EMAIL = "submissions@lukeattheroost.com" +DEFAULT_PARAGRAPHS = [ + "Got a story? A question? A hot take that\u2019s been eating at you since midnight? A confession you need to get off your chest? Send it to the show.", + "The best listener emails get read live on air during the next episode \u2014 either by Luke himself on the mic, or by one of his robot friends. Your words, on the show, heard by everyone tuning in.", + "Can\u2019t call 208-439-LUKE at 2 AM? Don\u2019t want to talk on the phone? Now you\u2019ve got another way to be part of the conversation. Write in anytime \u2014 day or night, long or short, serious or unhinged.", +] + + +def main(): + parser = argparse.ArgumentParser(description="Generate social media images") + parser.add_argument("--title", default=DEFAULT_TITLE) + parser.add_argument("--email", default=DEFAULT_EMAIL) + parser.add_argument("--body", help="Text file with paragraphs (blank-line separated)") + parser.add_argument("--prefix", default="email_announcement", + help="Output filename prefix") + args = parser.parse_args() + + if args.body: + with open(args.body) as f: + paragraphs = [p.strip() for p in f.read().split("\n\n") if p.strip()] + else: + paragraphs = DEFAULT_PARAGRAPHS + + make_square(args.title, paragraphs, args.email, + filename=f"{args.prefix}_square.png") + make_landscape(args.title, paragraphs, args.email, + filename=f"{args.prefix}_twitter.png") + + +if __name__ == "__main__": + main() diff --git a/publish_episode.py b/publish_episode.py index 840445a..e281073 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -11,6 +11,7 @@ Usage: import argparse import base64 +import fcntl import json import os import re @@ -82,6 +83,10 @@ POSTIZ_INTEGRATIONS = { "bluesky": {"id": "cmlk29h780001p76qa7sstp5h"}, "mastodon": {"id": "cmlk2r3mf0001le6vx9ey0k5a"}, "nostr": {"id": "cmlll3y78000cuc6vh8dcpl2w"}, + "linkedin": {"id": "cmluar6cn0004o46x5a1u07vc"}, + "threads": {"id": "cmm13sxhq001mo46x24com5p7"}, + # TikTok excluded — requires video, not image posts. Use upload_clips.py instead. + # "tiktok": {"id": "cmm2ggsno0001md7134cam9t9"}, } # NAS Configuration for chapters upload @@ -90,7 +95,7 @@ BUNNY_STORAGE_ZONE = "lukeattheroost" BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2" BUNNY_STORAGE_REGION = "la" # Los Angeles -NAS_HOST = "mmgnas-10g" +NAS_HOST = "mmgnas" NAS_USER = "luke" NAS_SSH_PORT = 8001 DOCKER_PATH = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker" @@ -100,6 +105,8 @@ DB_USER = "castopod" DB_PASS = "BYtbFfk3ndeVabb26xb0UyKU" DB_NAME = "castopod" +LOCK_FILE = Path(__file__).parent / ".publish.lock" + def get_auth_header(): """Get Basic Auth header for Castopod API.""" @@ -494,6 +501,19 @@ def publish_episode(episode_id: int) -> dict: return episode +def generate_srt(segments: list, output_path: str): + """Generate SRT subtitle file from whisper segments.""" + with open(output_path, "w") as f: + for i, seg in enumerate(segments, 1): + start = seg["start"] + end = seg["end"] + sh, sm, ss = int(start // 3600), int((start % 3600) // 60), start % 60 + eh, em, es = int(end // 3600), int((end % 3600) // 60), end % 60 + f.write(f"{i}\n") + f.write(f"{sh:02d}:{sm:02d}:{ss:06.3f} --> {eh:02d}:{em:02d}:{es:06.3f}\n") + f.write(f"{seg['text']}\n\n") + + def save_chapters(metadata: dict, output_path: str): """Save chapters to JSON file.""" chapters_data = { @@ -523,6 +543,135 @@ def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]: return False, str(e) +def _check_episode_exists_in_db(episode_number: int) -> bool: + """Check if an episode with this number already exists in Castopod DB.""" + cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} ' + f'-N -e "SELECT COUNT(*) FROM cp_episodes WHERE number = {episode_number};"') + success, output = run_ssh_command(cmd) + if success and output.strip(): + return int(output.strip()) > 0 + return False + + +def _srt_to_castopod_json(srt_path: str) -> str: + """Parse SRT to JSON matching Castopod's TranscriptParser format.""" + with open(srt_path, "r") as f: + srt_text = f.read() + + subs = [] + blocks = re.split(r'\n\n+', srt_text.strip()) + for block in blocks: + lines = block.strip().split('\n') + if len(lines) < 3: + continue + try: + num = int(lines[0].strip()) + except ValueError: + continue + time_match = re.match( + r'(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})', + lines[1].strip() + ) + if not time_match: + continue + text = '\n'.join(lines[2:]).strip() + + def ts_to_seconds(ts): + ts = ts.replace(',', '.') + parts = ts.split(':') + return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) + + subs.append({ + "number": num, + "startTime": ts_to_seconds(time_match.group(1)), + "endTime": ts_to_seconds(time_match.group(2)), + "text": text, + }) + return json.dumps(subs, indent=4) + + +def upload_transcript_to_castopod(episode_slug: str, episode_id: int, transcript_path: str) -> bool: + """Upload SRT transcript + JSON to Castopod via SSH and link in database.""" + print(" Uploading transcript to Castopod...") + + is_srt = transcript_path.endswith(".srt") + ext = ".srt" if is_srt else ".txt" + mimetype = "application/x-subrip" if is_srt else "text/plain" + + transcript_filename = f"{episode_slug}{ext}" + remote_path = f"podcasts/{PODCAST_HANDLE}/{transcript_filename}" + json_key = f"podcasts/{PODCAST_HANDLE}/{episode_slug}.json" + + # Upload SRT via SCP + docker cp (handles large files) + nas_tmp = f"/share/CACHEDEV1_DATA/tmp/_transcript_{episode_slug}{ext}" + scp_cmd = ["scp", "-P", str(NAS_SSH_PORT), transcript_path, f"{NAS_USER}@{NAS_HOST}:{nas_tmp}"] + result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + print(f" Warning: SCP transcript failed: {result.stderr}") + return False + + media_path = f"/var/www/castopod/public/media/{remote_path}" + run_ssh_command(f'{DOCKER_PATH} cp {nas_tmp} {CASTOPOD_CONTAINER}:{media_path}', timeout=60) + run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} chown www-data:www-data {media_path}') + run_ssh_command(f'rm -f {nas_tmp}') + + # Generate and upload JSON for Castopod's frontend rendering + if is_srt: + json_content = _srt_to_castopod_json(transcript_path) + json_tmp_local = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) + json_tmp_local.write(json_content) + json_tmp_local.close() + + nas_json_tmp = f"/share/CACHEDEV1_DATA/tmp/_transcript_{episode_slug}.json" + scp_json = ["scp", "-P", str(NAS_SSH_PORT), json_tmp_local.name, f"{NAS_USER}@{NAS_HOST}:{nas_json_tmp}"] + subprocess.run(scp_json, capture_output=True, text=True, timeout=60) + os.remove(json_tmp_local.name) + + json_media_path = f"/var/www/castopod/public/media/{json_key}" + run_ssh_command(f'{DOCKER_PATH} cp {nas_json_tmp} {CASTOPOD_CONTAINER}:{json_media_path}', timeout=60) + run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} chown www-data:www-data {json_media_path}') + run_ssh_command(f'rm -f {nas_json_tmp}') + + with open(transcript_path, "rb") as f: + file_size = len(f.read()) + + # Build file_metadata with json_key — escape double quotes for shell embedding + metadata_json = json.dumps({"json_key": json_key}) if is_srt else "NULL" + metadata_sql = f"'{metadata_json}'" if is_srt else "NULL" + metadata_sql_escaped = metadata_sql.replace('"', '\\"') + + insert_sql = ( + f"INSERT INTO cp_media (file_key, file_size, file_mimetype, file_metadata, type, " + f"uploaded_by, updated_by, uploaded_at, updated_at) VALUES " + f"('{remote_path}', {file_size}, '{mimetype}', {metadata_sql_escaped}, 'transcript', 1, 1, NOW(), NOW())" + ) + db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{insert_sql}; SELECT LAST_INSERT_ID();"' + success, output = run_ssh_command(db_cmd) + if not success: + print(f" Warning: Failed to insert transcript in database: {output}") + return False + + try: + lines = output.strip().split('\n') + media_id = int(lines[-1]) + except (ValueError, IndexError): + print(f" Warning: Could not parse media ID from: {output}") + return False + + update_sql = f"UPDATE cp_episodes SET transcript_id = {media_id} WHERE id = {episode_id}" + db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{update_sql}"' + success, output = run_ssh_command(db_cmd) + if not success: + print(f" Warning: Failed to link transcript to episode: {output}") + return False + + cache_cmd = f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} php spark cache:clear' + run_ssh_command(cache_cmd) + + print(f" Transcript uploaded and linked (media_id: {media_id})") + return True + + def upload_chapters_to_castopod(episode_slug: str, episode_id: int, chapters_path: str) -> bool: """Upload chapters file to Castopod via SSH and link in database.""" print("[4.5/5] Uploading chapters to Castopod...") @@ -799,10 +948,10 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None): base_content = f"{metadata['title']}\n\n{metadata['description']}\n\n{episode_url}" hashtags = "#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode" - hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr"} + hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr", "linkedin", "threads", "tiktok"} # Platform-specific content length limits - PLATFORM_MAX_LENGTH = {"bluesky": 300} + PLATFORM_MAX_LENGTH = {"bluesky": 300, "threads": 500, "tiktok": 2200} # Post to each platform individually so one failure doesn't block others posted = 0 @@ -902,7 +1051,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list, "-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", "-shortest", "-movflags", "+faststart", str(video_path) - ], capture_output=True, text=True, timeout=600) + ], capture_output=True, text=True, timeout=1800) if result.returncode != 0: print(f" Warning: ffmpeg failed: {result.stderr[-200:]}") return None @@ -987,22 +1136,32 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list, def get_next_episode_number() -> int: - """Get the next episode number from Castopod.""" - headers = get_auth_header() + """Get the next episode number from Castopod (DB first, API fallback).""" + # Query DB directly — the REST API is unreliable + cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} ' + f'-N -e "SELECT COALESCE(MAX(number), 0) FROM cp_episodes WHERE podcast_id = {PODCAST_ID};"') + success, output = run_ssh_command(cmd) + if success and output.strip(): + try: + return int(output.strip()) + 1 + except ValueError: + pass + # Fallback to API + headers = get_auth_header() response = _session.get( f"{CASTOPOD_URL}/api/rest/v1/episodes", headers=headers, ) if response.status_code != 200: - return 1 + print("Warning: Could not determine episode number from API or DB") + sys.exit(1) episodes = response.json() if not episodes: return 1 - # Filter to our podcast our_episodes = [ep for ep in episodes if ep.get("podcast_id") == PODCAST_ID] if not our_episodes: return 1 @@ -1026,6 +1185,36 @@ def main(): print(f"Error: Audio file not found: {audio_path}") sys.exit(1) + # Acquire exclusive lock to prevent concurrent/duplicate runs + lock_fp = open(LOCK_FILE, "w") + try: + fcntl.flock(lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + print("Error: Another publish is already running (lock file held)") + sys.exit(1) + lock_fp.write(str(os.getpid())) + lock_fp.flush() + + # Kill the backend server to free memory for transcription + server_was_running = False + try: + result = subprocess.run( + ["lsof", "-ti", ":8000"], capture_output=True, text=True + ) + pids = result.stdout.strip().split('\n') if result.stdout.strip() else [] + if pids: + server_was_running = True + print("Stopping backend server for resources...") + for pid in pids: + try: + os.kill(int(pid), 9) + except (ProcessLookupError, ValueError): + pass + import time as _time + _time.sleep(1) + except Exception: + pass + # Determine episode number if args.episode_number: episode_number = args.episode_number @@ -1033,6 +1222,14 @@ def main(): episode_number = get_next_episode_number() print(f"Episode number: {episode_number}") + # Guard against duplicate publish + if not args.dry_run and _check_episode_exists_in_db(episode_number): + print(f"Error: Episode {episode_number} already exists in Castopod. " + f"Use --episode-number to specify a different number, or remove the existing episode first.") + lock_fp.close() + LOCK_FILE.unlink(missing_ok=True) + sys.exit(1) + # Load session data if provided session_data = None if args.session_data: @@ -1073,6 +1270,11 @@ def main(): f.write(labeled_text) print(f" Transcript saved to: {transcript_path}") + # Generate SRT from whisper segments (for Castopod/podcast apps) + srt_path = audio_path.with_suffix(".srt") + generate_srt(transcript["segments"], str(srt_path)) + print(f" SRT saved to: {srt_path}") + # Save session transcript alongside episode if available (has speaker labels) if session_data and session_data.get("transcript"): session_transcript_path = audio_path.with_suffix(".session_transcript.txt") @@ -1156,13 +1358,20 @@ def main(): else: raise - # Step 4.5: Upload chapters via SSH + # Step 4.5: Upload chapters and transcript via SSH chapters_uploaded = upload_chapters_to_castopod( episode["slug"], episode["id"], str(chapters_path) ) + # Upload SRT transcript to Castopod (preferred for podcast apps) + transcript_uploaded = upload_transcript_to_castopod( + episode["slug"], + episode["id"], + str(srt_path) + ) + # Sync any remaining episode media to BunnyCDN (cover art, transcripts, etc.) print(" Syncing episode media to CDN...") sync_episode_media_to_bunny(episode["id"], uploaded_keys) @@ -1202,9 +1411,29 @@ def main(): if not chapters_uploaded: print("\nNote: Chapters upload failed. Add manually via Castopod admin UI") print(f" Chapters file: {chapters_path}") + if not transcript_uploaded: + print("\nNote: Transcript upload to Castopod failed") + print(f" Transcript file: {srt_path}") if not yt_video_id: print("\nNote: YouTube upload failed. Run 'python yt_auth.py' if token expired") + # Restart the backend server if it was running before + if server_was_running: + print("Restarting backend server...") + project_dir = Path(__file__).parent + subprocess.Popen( + [sys.executable, "-m", "uvicorn", "backend.main:app", + "--reload", "--reload-dir", "backend", "--host", "0.0.0.0", "--port", "8000"], + cwd=project_dir, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + print(" Server restarted on port 8000") + + # Release lock + lock_fp.close() + LOCK_FILE.unlink(missing_ok=True) + if __name__ == "__main__": main() diff --git a/upload_clips.py b/upload_clips.py index a08875c..2b20997 100755 --- a/upload_clips.py +++ b/upload_clips.py @@ -36,6 +36,9 @@ PLATFORM_ALIASES = { "bsky": "bluesky", "bluesky": "bluesky", "masto": "mastodon", "mastodon": "mastodon", "nostr": "nostr", + "li": "linkedin", "linkedin": "linkedin", + "threads": "threads", + "tt": "tiktok", "tiktok": "tiktok", } PLATFORM_DISPLAY = { @@ -45,10 +48,31 @@ PLATFORM_DISPLAY = { "bluesky": "Bluesky", "mastodon": "Mastodon", "nostr": "Nostr", + "linkedin": "LinkedIn", + "threads": "Threads", + "tiktok": "TikTok", } ALL_PLATFORMS = list(PLATFORM_DISPLAY.keys()) +UPLOAD_LEDGER_FILE = "upload-history.json" + + +def load_upload_history(clips_dir: Path) -> dict: + """Load upload history for a clips directory. + Returns dict mapping clip_file -> list of platforms already uploaded to. + """ + ledger = clips_dir / UPLOAD_LEDGER_FILE + if ledger.exists(): + with open(ledger) as f: + return json.load(f) + return {} + + +def save_upload_history(clips_dir: Path, history: dict): + with open(clips_dir / UPLOAD_LEDGER_FILE, "w") as f: + json.dump(history, f, indent=2) + def get_api_url(path: str) -> str: base = POSTIZ_URL.rstrip("/") @@ -124,6 +148,18 @@ def build_settings(clip: dict, platform: str) -> dict: "thumbnail": None, "tags": yt_tags, } + if platform == "tiktok": + return { + "__type": "tiktok", + "privacy_level": "PUBLIC_TO_EVERYONE", + "duet": False, + "stitch": False, + "comment": True, + "autoAddMusic": "no", + "brand_content_toggle": False, + "brand_organic_toggle": False, + "content_posting_method": "DIRECT_POST", + } return {"__type": platform} @@ -530,15 +566,30 @@ def main(): print("Cancelled.") return + upload_history = load_upload_history(clips_dir) + for i, clip in enumerate(clips): clip_file = clips_dir / clip["clip_file"] if not clip_file.exists(): print(f" Clip {i+1}: Video file not found: {clip_file}") continue - print(f"\n Clip {i+1}: \"{clip['title']}\"") + clip_key = clip["clip_file"] + already_uploaded = set(upload_history.get(clip_key, [])) + remaining_platforms = {p: integ for p, integ in active_platforms.items() + if p not in already_uploaded} - postiz_platforms = {p: integ for p, integ in active_platforms.items() + if not remaining_platforms: + print(f"\n Clip {i+1}: \"{clip['title']}\" — already uploaded to all selected platforms, skipping") + continue + + skipped = already_uploaded & set(active_platforms.keys()) + if skipped: + print(f"\n Clip {i+1}: \"{clip['title']}\" (skipping already uploaded: {', '.join(sorted(skipped))})") + else: + print(f"\n Clip {i+1}: \"{clip['title']}\"") + + postiz_platforms = {p: integ for p, integ in remaining_platforms.items() if not integ.get("_direct")} media = None @@ -559,24 +610,30 @@ def main(): result = create_post(integ["id"], content, media, settings, args.schedule) if result: print(f" {display}: Posted!") + upload_history.setdefault(clip_key, []).append(platform) + save_upload_history(clips_dir, upload_history) else: print(f" {display}: Failed") - if "youtube" in active_platforms: + if "youtube" in remaining_platforms: print(f" Posting to YouTube Shorts (direct)...") try: if post_to_youtube(clip, clip_file): print(f" YouTube: Posted!") + upload_history.setdefault(clip_key, []).append("youtube") + save_upload_history(clips_dir, upload_history) else: print(f" YouTube: Failed") except Exception as e: print(f" YouTube: Failed — {e}") - if "bluesky" in active_platforms: + if "bluesky" in remaining_platforms: print(f" Posting to Bluesky (direct)...") try: if post_to_bluesky(clip, clip_file): print(f" Bluesky: Posted!") + upload_history.setdefault(clip_key, []).append("bluesky") + save_upload_history(clips_dir, upload_history) else: print(f" Bluesky: Failed") except Exception as e: diff --git a/website/css/style.css b/website/css/style.css index 96c2b9f..46d8910 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -173,9 +173,10 @@ a:hover { /* Subscribe — compact inline text links */ .subscribe-row { display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 0.5rem; + gap: 0.25rem; margin-top: 1rem; } @@ -573,21 +574,68 @@ a:hover { border-top: 1px solid #2a2015; } -.footer-links { +.footer-nav { display: flex; + flex-wrap: wrap; justify-content: center; - gap: 1.5rem; - margin-bottom: 0.75rem; + gap: 0.5rem 1.5rem; + margin-bottom: 1rem; } -.footer-links a { +.footer-nav a { color: var(--text-muted); } -.footer-links a:hover { +.footer-nav a:hover { color: var(--text); } +.footer-icons { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.footer-icons-label { + display: block; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--text-muted); +} + +.footer-icons-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.footer-icon-link { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.05); + color: var(--accent); + transition: color 0.2s, background 0.2s, transform 0.2s; +} + +.footer-icon-link:hover { + color: var(--accent-hover); + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +.footer-icon-link svg { + width: 18px; + height: 18px; +} + .footer-projects { margin: 1.25rem 0; padding: 1rem 0; @@ -1252,7 +1300,10 @@ a:hover { } .subscribe-row { + flex-direction: row; + align-items: center; justify-content: flex-start; + gap: 0.5rem; } .secondary-links { diff --git a/website/episode.html b/website/episode.html index 617823e..cc99bd0 100644 --- a/website/episode.html +++ b/website/episode.html @@ -30,7 +30,7 @@ - +