diff --git a/CLAUDE.md b/CLAUDE.md index c36b735..e5bd7af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,8 @@ ## Running the App ```bash -# Start backend -cd /Users/lukemacneil/ai-podcast -python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 +# Start backend — ALWAYS use --reload-dir to avoid CPU thrashing from file watchers +python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000 # Or use run.sh ./run.sh diff --git a/audio_settings.json b/audio_settings.json index f71eae6..58f7b19 100644 --- a/audio_settings.json +++ b/audio_settings.json @@ -7,5 +7,7 @@ "music_channel": 5, "sfx_channel": 7, "ad_channel": 11, + "monitor_device": 14, + "monitor_channel": 1, "phone_filter": false } \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 30def63..265aeac 100644 --- a/backend/main.py +++ b/backend/main.py @@ -71,11 +71,11 @@ FEMALE_NAMES = [ # Voice pools per TTS provider INWORLD_MALE_VOICES = [ "Alex", "Blake", "Carter", "Clive", "Craig", "Dennis", - "Dominus", "Edward", "Hades", "Mark", "Ronald", "Shaun", "Theodore", "Timothy", + "Edward", "Hades", "Mark", "Ronald", "Shaun", "Theodore", "Timothy", ] INWORLD_FEMALE_VOICES = [ "Ashley", "Deborah", "Elizabeth", "Hana", "Julia", - "Luna", "Olivia", "Pixie", "Priya", "Sarah", "Wendy", + "Luna", "Olivia", "Priya", "Sarah", "Wendy", ] ELEVENLABS_MALE_VOICES = [ @@ -139,7 +139,7 @@ def _randomize_callers(): # Get returning callers first so we can exclude their names from random pool returning = [] try: - returning = regular_caller_service.get_returning_callers(random.randint(2, 3)) + returning = regular_caller_service.get_returning_callers(random.randint(0, 1)) except Exception as e: print(f"[Regulars] Failed to get returning callers: {e}") @@ -513,6 +513,76 @@ PROBLEMS = [ "their elderly neighbor asked them to be their emergency contact because they have no family — it's been six months and they're basically this person's whole support system now and it's a lot", "found their dad's old ham radio in the attic, got it working, and has been talking to strangers at 2am — one of them just said something that makes them think it's someone they know", "won a local chili cookoff with their dead mother's recipe and now everyone wants it — but sharing it feels like giving away the last private thing they have of hers", + + # Secrets and double lives + "has been pretending to go to work every day for three weeks but they actually got fired — they sit in their car at the library until 5pm", + "found out they have a kid they never knew about — the mother showed up at their job with a 12-year-old who looks exactly like them", + "has been living under a fake name for 15 years and their spouse doesn't know their real one", + "their spouse thinks they're sober but they've been keeping a bottle in their truck toolbox and drinking in parking lots after work", + "has been telling everyone they went to college but they dropped out after one semester — now their kid wants to go to the same school", + "got a DM from someone claiming to be their father's other kid — there are apparently four of them across three states", + + # Escalated neighbor/community situations + "their neighbor built a fence six inches onto their property and when they brought it up the guy pulled out a surveyor's report that might actually prove it's his land", + "woke up to find their truck on blocks with all four tires stolen and the security camera footage shows their cousin's boyfriend doing it", + "someone has been leaving dead animals on their porch once a week for a month and the cops say there's nothing they can do", + "their neighbor has been running an unlicensed daycare with 15 kids and the noise is destroying their life but calling the city feels wrong", + "got into a road rage incident and the other driver followed them home — now they see the same truck drive past their house every night", + + # Workplace chaos + "walked in on their boss crying in the bathroom and now the boss won't make eye contact with them and they think they're about to get fired for it", + "accidentally discovered their company has been billing clients for work that was never done and they have the receipts on a USB drive in their glove box", + "their coworker died on the job last month and the company hasn't changed a single safety protocol — they're scared to go back in", + "just found out the 'charity' their company donates to every year is a shell company owned by the CEO's wife", + "has been sleeping in their office for two weeks because they can't afford an apartment in the city they transferred to", + "got promoted to manage their former peers and two of them have made their life a living hell since — one of them is their best friend", + + # Unhinged confessions + "has been going to open houses every weekend for three years pretending to be a buyer — it's the only time they feel like they have a future", + "ate their roommate's leftovers and found a note in the fridge the next day that said 'I know it was you. This isn't over.'", + "has been anonymously sending flowers to a coworker every week for six months and the coworker just announced at a meeting that they're filing a police report about it", + "stole a garden gnome from someone's yard as a joke ten years ago and has been moving it around their house ever since — their spouse thinks they bought it", + "has been writing one-star Yelp reviews for their ex's business under fake names and just found out their ex figured out it's them", + "catfished their own spouse to see if they'd cheat — and they did, immediately", + + # Existential and philosophical crises + "had a near-death experience during a routine surgery and now they can't shake the feeling that nothing they do at work matters", + "went to their own high school reunion and nobody remembered them — not a single person — and they were there for four years", + "realized they've been on autopilot for ten years and can't remember a single thing that happened in 2021", + "drove past the house they grew up in and someone had torn out the tree their dad planted when they were born", + "hit their 10,000th day alive and spent the whole night calculating how many they probably have left", + "overheard their kid describe them to a friend and didn't recognize the person their kid was describing", + + # Outrageous situations + "got a cease and desist letter from Disney because their kid's birthday party decorations went viral on TikTok", + "found out their Airbnb guest has been living in their rental for three months and won't leave — and legally they might be a tenant now", + "their ex started a podcast specifically to talk about their relationship and it's getting popular in their town", + "accidentally RSVP'd yes to their ex's wedding thinking it was a joke and now they have a seat at table 6", + "their HOA fined them $500 for having a 'non-approved shade of beige' on their front door and they're ready to burn the whole neighborhood down", + "bid on a storage unit at auction and found a box of love letters between their mother and a man who isn't their father", + + # Betrayal and trust + "their business partner has been siphoning money for a year and when they confronted them, the partner said 'prove it' and smiled", + "trusted their brother to housesit and came back to find he'd thrown a party that caused $8,000 in damage and he's acting like nothing happened", + "found out their therapist has been discussing their sessions with a mutual friend", + "lent their car to a friend who got a DUI in it and now they're being sued by the other driver", + "their best friend of 30 years slept with their ex-wife the week the divorce was finalized and just told them 'it's been six months, you should be over it'", + "discovered their financial advisor put their retirement into investments that benefit the advisor's other company", + + # Crossroads moments + "got two job offers on the same day — one pays double but means moving away from their dying father, the other keeps them close but they'll be broke", + "their teenage kid just told them they want to go live with their other parent and they have to decide whether to fight it or let go", + "a developer offered them $800,000 for their family ranch and their siblings want to sell but they'd rather die than let it go", + "just got the paternity test results back and they haven't opened the envelope — it's sitting on the kitchen table", + "has to testify against their childhood best friend in court next week and the friend's family has been calling them a traitor", + + # Dark humor situations + "accidentally liked their ex's Instagram photo from 2019 at 2am and the panic spiral has been going for six hours", + "their date went so badly the restaurant comped the meal and the waiter said 'I'm sorry' on the way out", + "got a fortune cookie that said 'it's too late' and nothing else — no lucky numbers, no smiley face, just those three words", + "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", ] PROBLEM_FILLS = { @@ -930,6 +1000,18 @@ TOPIC_CALLIN = [ "thinks Silo is the most underrated show on TV right now", "wants to talk about why prestige TV peaked and where it's going", "has been watching The Last of Us and can't stop thinking about the third episode", + "just discovered Peaky Blinders and wants to argue it's the most stylish show ever made", + "thinks The Bear is the most stressful show on television and they can't stop watching — wants to talk about why people are drawn to anxiety", + "rewatched The Office for the eighth time and has a theory that Michael Scott is actually a genius who plays dumb", + "wants to talk about Shogun and how it's the best historical drama they've ever seen — the production quality alone is staggering", + "just finished Chernobyl and can't believe what the Soviet Union covered up — wants to talk about the real people behind the show", + "thinks Fargo the TV show is better than the movie and wants to defend that position", + "has been watching Reacher and wants to talk about why simple action shows with big dudes punching bad guys are exactly what they need right now", + "wants to argue that Band of Brothers is the greatest piece of war media ever created and it's not even close", + "just binged Beef and the spiral both characters go on is the most realistic portrayal of road rage consequences they've ever seen", + "thinks Andor is better than any Star Wars movie and the fact that it's a 'TV show about space' is underselling it by a mile", + "wants to talk about why they cried during a specific scene in This Is Us and they're not someone who usually cries at TV", + "just finished Dark on Netflix and the German time-travel show melted their brain — wants someone to help them understand what they just watched", # Science & space "read something about a new exoplanet discovery and is genuinely excited", @@ -944,6 +1026,77 @@ TOPIC_CALLIN = [ "read about new battery technology that could change everything", "wants to talk about gravitational waves and what they mean for physics", "fascinated by the search for extraterrestrial life, thinks we're close", + "just learned about extremophiles — organisms living in boiling acid vents on the ocean floor — and thinks it changes what we should be looking for on other planets", + "read about how Saturn's moon Enceladus is shooting geysers of water into space and probably has a warm ocean under the ice — wants to talk about why we aren't sending a submarine there", + "learned about magnetars — neutron stars with magnetic fields so strong they could erase your credit card from halfway across the solar system — and can't believe they're real", + "found out about rogue planets — billions of them just drifting through the galaxy with no star — and wants to talk about whether life could exist on one", + "read about the Wow! signal from 1977 — a 72-second radio burst from space that's never been explained — and has opinions about what it could have been", + "just learned that Titan has methane lakes and rain and weather just like Earth but with liquid natural gas instead of water — wants to talk about how weird that is", + "read about how the sun loses 4 million tons of mass every second through fusion and will still burn for another 5 billion years — the scale broke their brain", + "found out about the Great Attractor — something massive and invisible is pulling our entire galaxy toward it at 600 km per second and we can't see what it is because the Milky Way is in the way", + "learned about panspermia — the idea that life on Earth might have started from bacteria riding asteroids — and thinks it's more plausible than people realize", + "read about the Kuiper Belt object Arrokoth that New Horizons flew past — it's shaped like a snowman and hasn't changed in 4.5 billion years, basically a fossil from when the solar system formed", + "just learned about fast radio bursts — millisecond blasts of energy from billions of light years away — some of them repeat and nobody knows what's causing them", + "found out about the Boötes Void — a region of space 330 million light years across with almost nothing in it — and wants to talk about what could cause that", + + # Biology & nature + "just learned about the immortal jellyfish — Turritopsis dohrnii — it can literally reverse its aging and go back to being a polyp, and scientists are studying it for human aging research", + "read about how cuttlefish can change their skin color, texture, AND pattern in milliseconds using cells called chromatophores — they're basically living TV screens", + "found out that the axolotl can regenerate entire limbs, parts of its brain, heart, and spinal cord — and scientists are trying to figure out how to unlock that in humans", + "learned about mycorrhizal networks — fungi that connect trees underground and let them share nutrients and even send chemical warnings about insect attacks to each other", + "read about the pistol shrimp — it snaps its claw so fast it creates a bubble that reaches 4,700 degrees Celsius, hotter than the surface of the sun, for a split second", + "just found out about CRISPR and how scientists can now edit DNA like a word processor — they used it to make mosquitoes that can't carry malaria", + "learned about the bombardier beetle — it mixes two chemicals in its abdomen that create a boiling toxic spray it shoots at predators at 500 pulses per second", + "read about how dolphins sleep with half their brain at a time so one eye stays open watching for sharks — it's called unihemispheric sleep", + "found out that the honey badger has skin so loose and thick that if something bites it, it can literally turn around inside its own skin and bite back", + "just learned about the deep sea anglerfish mating system — the male is tiny, bites onto the female, and literally fuses into her body until he's just a pair of gonads she carries around", + "read about how slime mold — a single-celled organism with no brain — can solve mazes and recreate the Tokyo subway map when given food at each station location", + "learned about epigenetics — how your experiences can chemically modify your DNA and those changes can be inherited by your grandchildren — trauma can literally be passed down", + "found out about the Cordyceps fungus that takes over ant brains and makes them climb to the perfect height before bursting out of their head to spread spores", + "read that there's a species of flatworm that shoots its own head off when threatened, then grows a new one — it can be cut into 200 pieces and each piece grows into a complete worm", + + # Psychology & the brain + "just learned about the McGurk effect — where what you see overrides what you hear — if you watch someone mouth 'fa' while hearing 'ba' your brain hears 'fa' — and wants to talk about how unreliable our senses are", + "read about hemispatial neglect — people with specific brain damage literally cannot perceive one half of everything, they'll only eat food from one side of their plate and only shave half their face", + "found out about the rubber hand illusion — scientists can trick your brain into thinking a rubber hand is yours in under two minutes — and it has wild implications for what 'self' means", + "learned about blindsight — some people who are completely blind can still catch a ball thrown at them because a separate visual pathway bypasses conscious awareness", + "read about the split-brain experiments where they cut the connection between brain hemispheres and each half developed its own personality and preferences", + "just found out about phantom limb syndrome and how mirror box therapy tricks the brain into releasing pain from a limb that doesn't exist anymore", + "learned about the tetrachromacy mutation — some women have four types of color receptors instead of three and can see millions more colors than everyone else", + "read about how London taxi drivers who memorize the entire city map literally grow a larger hippocampus — their brain physically changes shape from studying", + "found out about change blindness — you can swap out a person someone is talking to mid-conversation and most people won't notice — and it makes them question everything", + "just learned about synesthesia — some people literally taste words or see numbers as colors — and one guy experiences every number as having its own personality", + + # Engineering & invention + "learned about how the SR-71 Blackbird leaked fuel on the ground because the titanium panels were designed with gaps that only sealed when the plane heated up from flying at Mach 3", + "read about the Antikythera mechanism — a 2000-year-old Greek device that predicted eclipses and tracked the Olympics — it's basically an ancient analog computer and nobody knows who built it", + "found out about the engineering behind the Panama Canal locks — they use no pumps, everything works by gravity, and they move 14,000 ships a year through a mountain range", + "just learned that the Hoover Dam's concrete is STILL curing — it generates heat as it hardens and engineers calculated it would take 125 years to fully cure without the cooling pipes they built into it", + "read about how the Apollo 13 engineers had to build a CO2 scrubber from duct tape, cardboard, and a sock — using only materials they knew were on the spacecraft — in hours, or the crew would die", + "learned about the Falkirk Wheel in Scotland — it's a rotating boat lift that moves canal boats 80 feet up using less energy than boiling eight kettles of water", + "found out about Project Orion — a serious 1950s NASA plan to propel a spacecraft by dropping nuclear bombs behind it — the math actually worked and Freeman Dyson was involved", + "just learned about the Tacoma Narrows Bridge collapse — it twisted itself apart in a mild wind because of resonance — and the video is the most terrifying engineering failure they've ever seen", + "read about the Great Wall of China's mortar — they mixed sticky rice into the lime morite and it's what made sections survive 600 years — the chemistry is actually brilliant", + "found out about the Svalbard Global Seed Vault — a doomsday bunker in the Arctic that stores copies of every crop seed on Earth — it's humanity's backup plan for agriculture", + "learned about how the Chunnel under the English Channel was built — crews dug from both sides simultaneously and met in the middle with only 2 inches of error over 31 miles", + "read about the engineering of the International Space Station — 16 countries built pieces independently on Earth and assembled them in orbit — and the cooling system alone would blow your mind", + "just found out about Japan's earthquake-proof skyscrapers — they use massive pendulum dampers and can sway 6 feet without structural damage — and wants to talk about engineering for survival", + "learned about the Arecibo telescope collapse — for 57 years it was the largest radio telescope on Earth and when the cables snapped the 900-ton platform fell 450 feet into the dish — and they're not rebuilding it", + + # Mathematics + "just learned about Benford's Law — in naturally occurring datasets, the number 1 appears as the first digit about 30% of the time, not 11% like you'd expect — and it's used to catch tax fraud", + "read about the Monty Hall problem and wants to argue about it because the answer still doesn't feel right even though they know the math proves it", + "found out about Gabriel's Horn — a shape with finite volume but infinite surface area — you could fill it with paint but never paint its surface — and it's messing with their head", + "learned about Euler's identity — e to the i pi plus one equals zero — and someone told them it's the most beautiful equation in mathematics, wants to understand why", + "read about the Coastline Paradox — the measured length of a coastline depends on the length of your ruler, and as your ruler gets smaller the coastline approaches infinity", + "just learned about Gödel's incompleteness theorems — any mathematical system complex enough to do arithmetic will contain true statements it can never prove — and wants to talk about what that means for knowledge itself", + "found out about the Four Color Theorem — you only ever need four colors to color any map so no adjacent regions share a color — and it was the first major theorem proved by a computer", + "just learned about the Collatz Conjecture — pick any number, if it's even halve it, if it's odd triple it and add one, repeat — it always reaches 1 eventually and nobody can prove why", + "read about Ramanujan — a self-taught Indian mathematician who mailed his work to Cambridge and turned out to be one of the greatest mathematical minds in history — some of his formulas are still being proven correct 100 years later", + "found out about the concept of different sizes of infinity — there are more real numbers between 0 and 1 than there are whole numbers in existence — and Georg Cantor proved it and it drove him insane", + "learned about the Fibonacci sequence in nature — sunflower seeds, hurricane spirals, galaxy arms, pinecone scales — the same ratio keeps appearing and nobody fully understands why", + "read about the Traveling Salesman Problem — finding the shortest route through a list of cities sounds simple but it's so hard that every computer on Earth running together couldn't solve it for more than a few hundred cities", + "just found out about Bayesian reasoning — the idea that you should update your beliefs based on new evidence, not throw them out — and it's used in everything from spam filters to cancer screening", # Technology "wants to talk about AI and whether it's going to change everything or if it's overhyped", @@ -952,6 +1105,21 @@ TOPIC_CALLIN = [ "wants to discuss the ethics of AI-generated content", "thinks about energy grid problems and has ideas about solutions", "into open source and wants to talk about why it matters", + "read about quantum computing hitting a new milestone and wants to know if it actually matters or if it's all hype for another decade", + "has been following brain-computer interface trials and is equal parts fascinated and terrified about where it's going", + "wants to talk about CRISPR being used to edit genes in living patients — the sickle cell cure is real and they have thoughts about what comes next", + "is worried about autonomous vehicles being tested on public roads after reading about a close call in their area", + "read about a breakthrough in nuclear fusion and wants to know why we keep saying it's 10 years away every 10 years", + "saw a deepfake video of a politician that was so convincing they almost shared it — and now they don't trust anything online", + "thinks blockchain has legitimate uses beyond crypto that nobody talks about — supply chain tracking, land registries, voting", + "lives in a rural area that just got Starlink and it completely changed their life — wants to talk about satellite internet closing the gap", + "read about lab-grown meat getting FDA approval for more products and wants to know if anyone would actually eat it", + "thinks nuclear power is making a comeback and wants to argue that it's actually the greenest option we have", + "wants to talk about the e-waste crisis — billions of dollars of electronics in landfills leaching chemicals and nobody seems to care", + "read about 3D-printed organs being successfully transplanted and thinks it's the most important medical breakthrough nobody is talking about", + "has been following the asteroid mining industry and thinks whoever figures it out first becomes the richest entity in human history", + "wants to talk about how vulnerable undersea internet cables are — 97% of global data travels through them and they're basically unprotected", + "thinks the tech monopoly situation is worse than Standard Oil ever was and wants to know why nobody is doing anything about it", # Poker "just had the most insane hand at their home game and needs to tell someone", @@ -959,12 +1127,32 @@ TOPIC_CALLIN = [ "has been studying poker theory and thinks they figured out why they keep losing", "wants to talk about whether poker is more skill or luck", "played in a tournament and made a call they can't stop thinking about", + "flopped a set against two players who both had flush draws and the board ran out the worst possible way — still fuming about it", + "has been reading about physical tells and caught someone at their home game doing the exact thing Mike Caro described in his book", + "switched from live poker to online and it feels like a completely different game — the aggression is insane", + "blew through their bankroll in a week because they moved up stakes too fast and wants to talk about the discipline it takes", + "watched the 2003 WSOP where Moneymaker won and it changed their life — they've been chasing that feeling ever since", + "wants to debate GTO versus exploitative play — they think the math nerds are ruining poker but also admits it works", + "just started playing Pot-Limit Omaha after years of Hold'em and their brain is melting trying to adjust to four hole cards", + "hosts a weekly home game and two regulars almost got in a fistfight over a ruling last week — needs advice on how to handle it", + "has been on a brutal losing streak for three months playing solid poker and wants to talk about how running bad messes with your head", + "watched Rounders for the hundredth time and has a theory about why it's still the best poker movie ever made despite the bad accents", # Photography & astrophotography "got an amazing astrophotography shot of the Milky Way from the desert and is stoked", "wants to talk about how dark the skies are out in the bootheel for photography", "just got into astrophotography and is overwhelmed by how much there is to learn", "shot the most incredible sunset over the Peloncillo Mountains", + "just captured the Orion Nebula for the first time with a cheap tracker and a DSLR and it actually looks like the photos online — they're hooked", + "spent three nights trying to photograph the Andromeda Galaxy and when they finally stacked the frames and saw the spiral arms they almost cried", + "wants to talk about the Ring Nebula and how a dying star can be the most beautiful thing in the sky", + "just bought a star tracking mount and the difference between tracked and untracked shots is blowing their mind", + "found a light pollution map and drove two hours to a Bortle 2 zone and the sky looked fake — they could see the zodiacal light for the first time", + "wants to reignite the film vs digital debate — they've been shooting medium format film and think digital still can't match the look", + "just got a 200-600mm lens and the moon shots are incredible but now they want to talk about what telescope to buy next", + "has been doing golden hour portraits in the desert and the light out here is unlike anything they've shot anywhere else", + "set up a trail cam near their property and got photos of a coatimundi, two bobcats, and something they swear is a jaguar", + "spent six months learning image stacking software and their deep sky photos went from blurry blobs to actual detail — wants to talk about the processing side of astrophotography", # US News & current events "wants to talk about something they saw in the news that's been bugging them", @@ -974,6 +1162,16 @@ TOPIC_CALLIN = [ "saw a news story about their town and wants to set the record straight", "concerned about water rights in the southwest and wants to talk about it", "has thoughts about rural broadband and how it affects small towns", + "went to a county commission meeting about a zoning change and what they witnessed made them question local democracy entirely", + "got a medical bill for a 20-minute ER visit that was more than their mortgage payment and wants to talk about how the system is broken", + "is a veteran who's been waiting nine months for a VA appointment and wants to talk about how the people who served are being forgotten", + "works at a school where they just cut the art and music programs to fund standardized test prep and it's gutting the kids", + "lives in a border town and is tired of people who've never been here telling them what the immigration situation is actually like", + "has watched housing prices in their small town triple since the pandemic because remote workers bought everything and locals can't afford rent", + "drives past a growing homeless encampment every day on their way to work and nobody in city government will even acknowledge it exists", + "runs a small business and just got hit with a new regulation that's going to cost them $15,000 to comply with — wants to talk about how regulations crush small operators", + "lives in a food desert — nearest grocery store is 45 minutes away — and the dollar store is the only option, which means processed junk for their kids", + "wants to talk about the public land debate out west — ranchers need grazing leases, hikers want access, and the feds keep changing the rules", # Physics & big questions "can't stop thinking about the nature of time after reading about it", @@ -982,6 +1180,18 @@ TOPIC_CALLIN = [ "wants to discuss whether free will is real or if physics says otherwise", "fascinated by black holes after watching a documentary", "wants to talk about the simulation theory and why smart people take it seriously", + "just learned about the delayed choice quantum eraser experiment — it seems like a measurement made NOW can affect what a particle did in the PAST — and it broke their understanding of time", + "read about the holographic principle — the idea that our entire 3D universe might be information encoded on a 2D surface — and some physicists take this seriously", + "found out about the Boltzmann brain problem — statistically it's more likely that a random conscious brain would fluctuate into existence in empty space than that our entire universe would form — and wants to talk about what that means", + "learned about time crystals — a new phase of matter that repeats in time instead of space — they were theoretical until 2017 and now Google has made one in a quantum computer", + "read about the quantum Zeno effect — observing a particle frequently enough can literally freeze it in place, preventing it from changing state — watched pots really don't boil at the quantum level", + "just found out about the Casimir effect — two metal plates placed very close together in a vacuum get pushed together by literally nothing — empty space has energy and it exerts force", + "learned about Wheeler's delayed choice experiment and it suggests that observation might retroactively determine whether a photon acted as a wave or particle — even after it's already traveled", + "read about the black hole information paradox — Hawking showed black holes evaporate but the information that fell in should be conserved by quantum mechanics — the two biggest theories in physics directly contradict each other", + "found out about the measurement problem — nobody actually knows what constitutes a 'measurement' in quantum mechanics or why observing something changes it — it's physics' biggest unsolved problem", + "just learned about quantum tunneling — particles can pass through solid barriers they shouldn't be able to cross — and it's the reason the sun works, because hydrogen atoms tunnel through their repulsion to fuse", + "read about the arrow of time problem — the laws of physics work the same forward and backward but time clearly only goes one direction and nobody can explain why from first principles", + "learned about the fine-tuning problem — if any of about 26 fundamental constants of the universe were off by even a tiny fraction, atoms couldn't form and the universe would be empty", # Fun facts and knowledge — callers who learned something cool and want to share it "just learned about the birthday paradox — you only need 23 people in a room for a 50% chance two share a birthday — and wants to blow the host's mind", @@ -1024,6 +1234,69 @@ TOPIC_CALLIN = [ "read about how the color orange was named after the fruit, not the other way around — before that, English speakers just called it 'red-yellow'", "learned that trees in a forest share nutrients through underground fungal networks — they call it the 'wood wide web' — and it made them emotional", "just found out that the total length of DNA in one human body, if uncoiled, would stretch from here to Pluto and back", + "learned about the Mpemba effect's cousin — the Leidenfrost effect — where water dropped on a surface hot enough actually floats on a vapor cushion and skitters around instead of boiling", + "read about how the platypus is venomous, lays eggs, has a bill that detects electric fields, sweats milk, and glows under UV light — it's like nature threw a dart at every category", + "just found out about the blue whale's aorta — it's so large a small child could crawl through it — and its heart is the size of a golf cart", + "learned that the Library of Alexandria wasn't destroyed in one dramatic fire — it declined slowly over centuries through budget cuts and neglect — which is somehow sadder", + "read about linguistic relativity — the Hopi language has no past or future tense, the Pirahã people have no words for specific numbers, and the language you speak literally shapes how you perceive time and quantity", + "found out about the Ames room illusion — a room built at specific angles that makes one person look like a giant and another like a dwarf — and every haunted house and movie set uses this trick", + "just learned about supercooling — you can cool purified water below freezing without it freezing, and then tap the bottle and it crystallizes instantly in front of your eyes", + "read that there's a species of parasitic wasp that turns cockroaches into zombies by stinging their brain in two precise spots, then leads them by the antenna like a dog on a leash", + "learned about the pale blue dot photo's backstory — Carl Sagan had to fight NASA to turn Voyager's camera around for one last photo because engineers said it served no scientific purpose", + "found out about heteropaternal superfecundation — twins can have different fathers — it happens more often than people think", + "just learned that glass isn't actually a slow-moving liquid — that's a myth — old windows are thicker at the bottom because of how they were manufactured, not because the glass flowed", + "read about how the US military spent millions developing a space pen while the Soviets just used a pencil — except that's actually a myth too, both sides needed the pen because pencil graphite in zero gravity is dangerous in electronics", + "learned about the Bystander Effect and the real story of Kitty Genovese — the famous '38 witnesses who did nothing' story turns out to be mostly made up by the New York Times, and several people actually did call police", + "found out about the Overview Institute — they study how seeing Earth from space permanently changes astronauts' psychology — some come back unable to care about national borders or politics", + "read about how Venice is built on millions of wooden pilings driven into mud — and the wood hasn't rotted in 600 years because it's underwater and the salt petrified it", + "just learned about the Strandbeest — a Dutch artist named Theo Jansen builds massive skeletal creatures from PVC pipe that walk on the beach powered only by wind, and he calls them a new form of life", + + # Geology & Earth science + "just learned about Yellowstone's supervolcano — the caldera is 44 miles wide and the last eruption covered most of North America in ash — and it's technically overdue", + "read about how the Mariana Trench is so deep that if you dropped Mount Everest into it, the peak would still be over a mile underwater", + "found out about the Great Oxygenation Event — 2.4 billion years ago, cyanobacteria started producing oxygen and it was toxic to almost everything alive at the time — it was the biggest mass extinction ever and we exist because of it", + "learned about the Chicxulub impact — the asteroid that killed the dinosaurs hit with the force of 10 billion Hiroshima bombs and the shockwave circled the Earth multiple times — and they found the crater under the Yucatan", + "read about how Iceland is literally splitting apart — you can dive between the North American and Eurasian tectonic plates in the Silfra fissure and touch both continents at once", + "just found out about Earth's inner core — it's a solid iron ball the size of the moon, hotter than the surface of the sun, and it rotates slightly faster than the rest of the planet", + "learned about the Permian-Triassic extinction — it killed 96% of all marine species and 70% of land vertebrates — scientists call it 'The Great Dying' and it was caused by volcanic CO2, basically what we're doing now but faster", + "just found out about the Snowball Earth hypothesis — about 700 million years ago the entire planet may have frozen over completely, even the equator, and the only reason life survived is volcanic CO2 eventually creating a greenhouse effect", + "read about how rivers can flow uphill through a process called tidal bores — when a tide is strong enough the ocean pushes upriver in a visible wave, and people surf them", + "learned about the Door to Hell in Turkmenistan — Soviet geologists lit a natural gas crater on fire in 1971 expecting it to burn out in weeks and it's been burning continuously for over 50 years", + "found out about limnic eruptions — a lake in Cameroon suddenly released a cloud of CO2 in 1986 that silently killed 1,700 people in their sleep — the gas just rolled downhill and suffocated entire villages", + "read about how the Sahara Desert used to be a lush green savanna with lakes and hippos only 6,000 years ago — the shift happened because Earth's orbital wobble changed the monsoon patterns", + "just learned that there's a continuously burning underground coal fire in Centralia, Pennsylvania that has been burning since 1962 — the town was condemned and most of it demolished but the fire could burn for 250 more years", + + # History deep cuts + "just learned about the Dancing Plague of 1518 — hundreds of people in Strasbourg danced uncontrollably for days, some until they died, and nobody has ever fully explained it", + "read about Project MKUltra — the CIA literally dosed random Americans with LSD without their knowledge to study mind control — and most of the records were intentionally destroyed", + "found out about the Great Molasses Flood of 1919 — a storage tank burst in Boston and a 25-foot wave of molasses traveling 35 mph killed 21 people and injured 150", + "learned about the Wow Signal's less-known cousin — the 1967 discovery of pulsars, which astronomers initially labeled LGM-1 for 'Little Green Men' because the signal was so regular they thought it had to be artificial", + "read about how the Roman Empire had a concrete recipe that was actually better than modern concrete for marine use — it gets stronger in seawater while ours degrades — and we only recently figured out their formula", + "just found out about the Solutrean hypothesis debates — some archaeologists think ancient Europeans crossed the Atlantic ice shelf to North America before Asian migration across Beringia — it's controversial but the spearpoint styles are eerily similar", + "read about the Tunguska Event of 1908 — something exploded over Siberia with 1,000 times the force of Hiroshima, flattened 80 million trees, and nobody has ever found a crater or debris", + "just learned about the Voynich Manuscript — a 600-year-old book written in a language nobody can read with illustrations of plants that don't exist — and nobody knows if it's genius or gibberish", + "found out about the Toledo War — Michigan and Ohio almost went to war over a strip of land in 1835 and Michigan lost Toledo but got the Upper Peninsula as a consolation prize, which turned out to be way more valuable", + "read about how the US government tested nuclear weapons on American soil 928 times in Nevada and the 'downwinders' in Utah and NM are still dying from the fallout decades later", + "just learned about the Radium Girls — women who painted watch dials with radioactive paint and were told it was safe, licked the brushes, and their jaws literally fell off — their lawsuit changed worker safety laws forever", + "found out about the Aral Sea — it was the 4th largest lake in the world until the Soviet Union diverted the rivers to grow cotton, and now it's basically gone — ship graveyards sitting in the middle of a desert", + "read about the real story behind the Trojan Horse — most historians think it's myth, but there's a theory it was actually a battering ram or an earthquake, and wants to talk about how stories replace facts", + "just learned that Easter Island's civilization didn't collapse from cutting down trees like the popular story says — the real collapse came from European disease and slave raids — and the popular version is basically victim-blaming", + "found out about Zheng He — a Chinese admiral who commanded 300 ships and 28,000 sailors across the Indian Ocean 80 years before Columbus — and then China destroyed all the ships and records because a new emperor decided exploration was wasteful", + + # Human body + "just learned that your stomach acid is strong enough to dissolve metal — your stomach lining replaces itself every 3-4 days just to keep up — and wants to talk about how wild the body is", + "read about proprioception — the sense that lets you know where your body parts are without looking — it's technically a sixth sense and when it fails people can't walk or feed themselves", + "found out that humans are bioluminescent — we glow in the dark — but the light is 1,000 times weaker than what our eyes can detect", + "learned about the vagus nerve — it connects your brain to your gut and is why you can feel emotions in your stomach — stimulating it can treat depression and epilepsy", + "read that human bone is stronger than steel pound for pound and can withstand 19,000 pounds per square inch — but it's the internal structure that makes it work, like a building's I-beams", + "just found out about the enteric nervous system — your gut has 500 million neurons and can operate completely independently from your brain — scientists literally call it the 'second brain'", + "learned about referred pain — the reason a heart attack hurts in your left arm is because the heart and arm share nerve pathways to the spine and the brain gets confused about where the signal came from", + "read that your cornea is the only part of your body with no blood supply — it gets oxygen directly from the air — which is why contact lenses need to be breathable", + "found out that when you blush, the lining of your stomach blushes too — nobody knows why — but it suggests the connection between emotions and digestion is deeper than we think", + "just learned that your body produces about 1-1.5 liters of saliva per day — enough to fill two bathtubs a year — and without it you literally couldn't taste anything", + "read about how the human eye can distinguish about 10 million different colors but has no way to communicate most of them — we only have names for a tiny fraction", + "learned about the diving reflex — when your face hits cold water, your heart rate drops, blood vessels constrict, and your spleen releases extra red blood cells — it's an ancient mammalian survival mechanism we still have", + "found out that babies are born with about 300 bones but adults only have 206 — the bones fuse together as you grow — and wants to know what else changes about our bodies without us noticing", # History and culture "just visited the Trinity Site and can't stop thinking about what happened there", @@ -1034,6 +1307,16 @@ TOPIC_CALLIN = [ "wants to discuss why the Southwest has such a complicated relationship with water and what happens when it runs out", "just learned about the Manhattan Project's connection to New Mexico and went down a rabbit hole", "wants to talk about how the mining industry shaped these towns and what happens now that the mines are closing", + "just read about the Camino Real — the trade route from Mexico City to Santa Fe that was used for 300 years — and realized they drive on parts of it without knowing", + "read about the Buffalo Soldiers stationed at Fort Bayard and thinks their story is one of the most overlooked chapters of Western history", + "just visited Tombstone and thinks the real story of the Earps and the Cowboys is way more morally gray than the movies make it", + "learned about the Chinese Exclusion Act and how it affected the mining towns of southern Arizona — there were thriving Chinese communities here that got erased", + "read about how the Gadsden Purchase — the reason southern NM and AZ are part of America — was basically a back-room railroad deal and wants to talk about how borders are more arbitrary than people think", + "found out about the Civilian Conservation Corps camps in the Gila and how those young men during the Depression built trails and structures that are still standing 90 years later", + "thinks the Apache Wars get oversimplified into cowboys-vs-Indians and the actual story of Geronimo and Cochise is way more complicated and fascinating", + "wants to talk about how Prohibition played out differently in border towns — everyone just crossed to Mexico — and the remnants of that era are still visible", + "just learned about the great New Mexico tuberculosis migration — thousands of people moved here in the early 1900s because doctors thought the dry air would cure TB — and it shaped entire towns", + "read about the Bataan Death March survivors from New Mexico — the 200th Coast Artillery was mostly New Mexican soldiers and many never came home — and their state barely talks about it", # Food and cooking "got into an argument at a family dinner about whether flour or corn tortillas are better and it almost came to blows", @@ -1042,6 +1325,16 @@ TOPIC_CALLIN = [ "tried to make tamales from their grandmother's recipe and it was a complete disaster — wants to know what they did wrong", "has a theory that you can tell everything about a town by the quality of its gas station burritos", "went to a fancy restaurant in Tucson and paid $40 for something worse than what their neighbor makes", + "spent 14 hours smoking a brisket and it came out perfect — wants to talk about the low-and-slow philosophy and why people who rush it are wrong", + "got into a heated argument about whether you should wash a cast iron skillet with soap and they are prepared to die on this hill", + "has been on a sourdough journey for six months and their starter has a name and a feeding schedule and they know how that sounds", + "found a food truck in the middle of nowhere between Deming and Hatch that makes the best tacos they've ever had and they need to tell someone", + "wants to talk about the green chile versus red chile rivalry and why choosing 'Christmas' is a cop-out", + "went to the grocery store and spent $180 on what used to cost $90 and wants to rant about food prices and shrinkflation", + "started a garden this year and growing their own food in desert soil has been humbling — half of it died but the tomatoes are incredible", + "just processed an elk they hunted and wants to talk about the whole field-to-freezer experience and why more people should understand where meat comes from", + "has been meal prepping every Sunday and it saved them time and money but they're eating the same five things and losing their mind", + "worked in restaurants for 15 years and has stories about what goes on in kitchens that would make people never eat out again", # Cars and mechanical stuff "just bought a truck sight unseen off the internet and it arrived on a flatbed missing the engine", @@ -1049,6 +1342,16 @@ TOPIC_CALLIN = [ "broke down on I-10 between Lordsburg and Deming at 2am and the person who stopped to help them changed their perspective on something", "has a theory about why modern trucks are overengineered garbage compared to what they used to make", "found their dad's old truck in a barn — been sitting there since he died — and is trying to decide whether to restore it or let it go", + "wants to debate EV trucks versus gas trucks for actual ranch work — they test drove a Lightning and have thoughts", + "has been running diesel for 20 years and someone told them gas trucks have caught up — they want to argue about it", + "just hauled a horse trailer through the Rockies and the transmission story alone is worth calling about", + "took their Jeep through a trail near Pinos Altos that they definitely should not have attempted and has the body damage to prove it", + "put together a vehicle survival kit after getting stranded in the desert and wants to talk about what people should actually carry", + "broke down 40 miles from the nearest cell service and the way they got home is a story they need to tell someone", + "thinks their 1997 Toyota with 300,000 miles is more reliable than anything made after 2015 and wants to fight about it", + "has put $22,000 into a project car that was supposed to cost $5,000 and their spouse doesn't know the real number yet", + "will die on the hill that Snap-on tools are worth triple the price and got into it with a Harbor Freight guy at the parts store", + "took their truck to a chain shop for an oil change and they cross-threaded the drain plug — the nightmare that followed needs to be heard", # Desert and outdoor life "was hiking alone near the Gila and had an experience they can't explain and wants to talk about it", @@ -1059,6 +1362,17 @@ TOPIC_CALLIN = [ "was out stargazing and saw something in the sky they can't explain — not saying aliens, but also not not saying aliens", "wants to talk about what climate change is actually doing to the desert — the creosote is moving, the water table is dropping", "almost stepped on a Mojave rattlesnake today and it made them think about how close they live to things that can kill them", + "has been living off-grid for two years and wants to talk about the reality versus what people see on YouTube — it's harder and better than they expected", + "their well went dry in August and the process of getting water hauled and drilling deeper changed how they think about everything", + "spent all summer prepping their property for wildfire season and wants to talk about what most people out here aren't doing that could save their home", + "just backpacked the Gila Wilderness solo for five days and the hot springs and the silence did something to their head they can't explain", + "had a javelina family move into their yard and one of them charged their dog — wants to talk about coexisting with wildlife that doesn't care about boundaries", + "spotted a coatimundi near their property which is way outside their normal range and wonders if anyone else in the bootheel has been seeing them", + "lives near the border and the dynamics between ranchers, Border Patrol, and the people crossing through their land are more complicated than anyone on the news understands", + "was working outside when it hit 115 degrees and the heat did something to them physically they didn't expect — wants to talk about heat survival that goes beyond 'drink water'", + "hauled water for three months while their well was being repaired and it gave them a completely different relationship with every drop that comes out of the tap", + "was hiking near a ruin in the Gila and found pottery shards and a grinding stone just sitting on the surface — left everything in place but can't stop thinking about who was there", + "has been ranching in the bootheel for 40 years and wants to talk about how the land has changed — where there used to be grass there's creosote and where there used to be water there's dust", # Music and entertainment "heard a song on the radio tonight that their late father used to sing and they had to pull over", @@ -1068,6 +1382,16 @@ TOPIC_CALLIN = [ "has been learning guitar for a year and just played their first song all the way through — it was terrible but they're proud", "thinks podcasts are killing radio and wants to argue the other side", "wants to recommend an album that nobody they know has heard of and it's driving them crazy", + "wants to make the case that outlaw country — Waylon, Willie, Merle — was the last time country music was actually dangerous and real", + "has been deep-diving Delta blues and thinks Robert Johnson's recordings sound like they were made by someone who actually sold their soul", + "got into folk music through their grandparents' record collection and now they can't stop listening to Woody Guthrie and wants to talk about protest music", + "spent $300 on a vinyl they found at a shop in Tucson and their spouse thinks they're insane but it's an original pressing and it sounds incredible", + "wants to talk about one-hit wonders and defend the idea that some artists said everything they needed to say in one perfect song", + "went to a show in a barn outside Las Cruces with maybe 30 people and it was the best concert experience of their life — the band was three feet away", + "set up a home recording studio in their garage and has been making music nobody will ever hear but it's the most fulfilling thing they've done", + "thinks there are criminally underrated musicians nobody knows about and wants to shout out a few before they disappear", + "has a theory that the music you listen to between ages 14 and 22 becomes hardwired into your brain and everything after that is just noise — wants to argue about it", + "wants to debate whether live recordings are superior to studio albums — they think the imperfections are what make music real", # Philosophy and late-night thoughts "has been thinking about whether you're obligated to forgive someone who never apologized", @@ -1078,6 +1402,18 @@ TOPIC_CALLIN = [ "wants to talk about why Americans are so bad at being alone and what that says about us", "thinks the concept of 'the American Dream' is fundamentally broken and wants to hear if anyone still believes in it", "has been reading about stoicism and wants to talk about whether it's actually helpful or just emotional suppression", + "was lying awake at 3am thinking about how every decision they've ever made led to this exact moment and whether any of it was actually a choice", + "wants to talk about the paradox of tolerance — should a tolerant society tolerate intolerance — and they've been going in circles about it", + "thinks most people are living lives they didn't choose and just drifted into and wants to know if anyone else feels that way", + "read about the concept of 'sonder' — the realization that every stranger has a life as vivid as your own — and can't stop seeing it everywhere", + "wants to discuss Nietzsche's eternal recurrence — would you live your exact life again, every detail, forever — and what your answer reveals about you", + "has been thinking about whether loyalty is a virtue or a trap and something happened recently that made them question it", + "thinks the fear of missing out has been replaced by the fear of not mattering and wants to talk about what that does to people", + "wants to argue that boredom is actually good for you and that we've engineered it out of our lives to our detriment", + "has been thinking about whether nostalgia is a lie — we miss a version of the past that never really existed", + "read about the trolley problem and the fat man variant and wants to know where the host draws the line", + "thinks we've lost the ability to have uncomfortable conversations and it's making everything worse", + "wants to talk about whether gratitude is a genuine emotion or something we perform because we're told to", # Conspiracy and unexplained "lives near the border and has seen lights in the desert at night that don't match any aircraft they know of", @@ -1085,6 +1421,16 @@ TOPIC_CALLIN = [ "has a neighbor who worked at Los Alamos and told them something before he died that they've never been able to verify", "drove past the VLA last week and got thinking about whether anyone is actually listening and what happens if someone answers", "thinks there's something weird about the old mine shafts around Silver City and has stories from people who've gone in", + "wants to talk about the Phoenix Lights — thousands of people saw the same thing in 1997 including the governor, and the Air Force explanation was laughable", + "lives on a ranch and found a cattle mutilation — surgical precision, no blood, no tracks — and the sheriff basically told them not to bother filing a report", + "read about Dulce Base — the alleged underground facility in northern New Mexico — and the fact that it's an hour from Los Alamos makes them suspicious", + "grew up hearing Roswell stories from people who were actually there and thinks the official story has changed too many times to be credible", + "has been binge-watching Skinwalker Ranch content and wants to talk about the Bigelow Aerospace connection and why the government bought the property", + "stumbled onto numbers stations while scanning shortwave radio — encrypted broadcasts that nobody claims and nobody can decode — and it's creeping them out", + "read about Operation Paperclip and can't believe the US government recruited over 1,600 Nazi scientists after the war and gave them new identities", + "has been reading the recently declassified Area 51 documents and while most of it is about U-2 spy planes, there are still huge redacted sections that make them wonder", + "noticed that ancient structures across different continents — pyramids, temples, megaliths — share alignments to the same star systems and wants to know how cultures with no contact built the same things", + "wants to make the case that the Bermuda Triangle is actually debunked — the area doesn't have more disappearances than any other busy shipping lane — but they're open to being convinced otherwise", # Opinions and hot takes "thinks tipping culture in America has gotten completely out of control and had an experience today that set them off", @@ -1094,6 +1440,18 @@ TOPIC_CALLIN = [ "wants to make the case that trade schools should be free and four-year universities are a scam for most people", "thinks the interstate highway system was the worst thing that happened to small-town America and wants to explain why", "has been thinking about whether it's ethical to eat meat and they're a rancher which makes it complicated", + "thinks daylight saving time is pointless and Arizona has it right by not participating — wants to rant about it", + "believes self-checkout is just making customers do free labor for corporations and refuses to use them", + "thinks the 40-hour work week is an arbitrary relic from the 1930s and a 4-day week would make everyone more productive", + "has a theory that subscription services for everything — cars, software, even light bulbs — are turning ownership into a myth", + "thinks standardized testing destroyed American education and has stories from their kid's school to back it up", + "wants to argue that the best food in any town is at the gas station or the dive bar, never the fancy place", + "thinks we should bring back third places — diners, lodges, barbershops — because everyone sits at home now and it's killing communities", + "believes landlords shouldn't exist as a profession and wants to make the case without sounding like a radical", + "thinks the news media is more addicted to outrage than their viewers are and it's rotting everyone's brains from both sides", + "has a hot take that participation trophies weren't the problem — the problem was adults who couldn't handle their kid losing", + "thinks the way we treat elderly people in this country — parking them in facilities and visiting twice a year — is a national disgrace", + "wants to argue that small-town gossip is actually a form of social accountability that cities lost and are worse off for", # Experiences and stories "just drove cross-country alone for the first time and something happened at a truck stop in Texas they need to tell someone about", @@ -1104,6 +1462,161 @@ TOPIC_CALLIN = [ "taught their kid to drive today and it made them realize their kid is about to leave and the house is going to be empty", "went to a funeral today for someone they hated and doesn't know how to feel about the fact that they felt nothing", "rode a horse for the first time in 20 years today and it brought back every memory of growing up on the ranch", + "a stranger paid for their groceries when their card got declined and they've been thinking about it all day — wants to talk about unexpected kindness", + "had a near-death experience during a flash flood in a wash and the way time slowed down changed something fundamental in how they see each day", + "ran into their estranged sibling at a gas station after 12 years of no contact and neither of them knew what to say", + "got laid off from a job they hated and it turned out to be the best thing that ever happened to them — wants to talk about how the worst moments become turning points", + "keeps having the same bizarre coincidence — running into the same stranger in completely different cities — and it's starting to feel like the universe is trying to tell them something", + "moved from Phoenix to a town of 200 people and the culture shock was nothing compared to what they didn't expect to love about it", + "grew up in rural New Mexico, moved to New York for 15 years, and just moved back — the reverse culture shock is real and nobody talks about it", + "found their childhood diary in a box and reading who they were at 13 was like meeting a stranger — wants to talk about how much people change without realizing it", + "was driving a back road at 2am and had an encounter with something — a person, an animal, they're not sure — that they've never told anyone about", + "is 25 and feels like they have nothing in common with people their parents' age, but then they started talking to the old-timers at the diner and realized the gap isn't as wide as they thought", + "had a mentor who changed the entire trajectory of their life with one conversation and they just found out that person passed away — wants to talk about people who shape you without knowing it", + "has been carrying guilt about something they did 20 years ago and finally apologized to the person this week — wants to talk about what it's like to make amends when you don't know if you'll be forgiven", + + # Animals & pets + "adopted a dog from the shelter that turned out to be part coyote and the chaos that's followed is a whole story", + "has a ranch dog that won't herd cattle but has appointed itself guardian of the barn cats and takes the job extremely seriously", + "found an injured hawk on their property and nursed it back to health and now it won't leave — it sits on the fence post every morning waiting for them", + "wants to talk about how their cat clearly has a more complex inner life than anyone gives cats credit for — they watched it solve a problem yesterday", + "has been keeping bees for two years and wants to talk about how it completely changed how they see the natural world", + "rescued a burro from a kill pen and it has more personality than most people they know", + "their dog predicted a monsoon storm three hours before it hit and they want to talk about what animals can sense that we can't", + "has been raising chickens and the social dynamics of the flock are like a reality TV show — there's drama, alliances, betrayal", + "wants to talk about pack dynamics because their three dogs have a power structure and the smallest one runs everything", + "found a tarantula in their boot this morning and instead of killing it they relocated it — then spent an hour reading about tarantulas and now they think they're cool", + "had to put down their dog of 16 years yesterday and wants to talk about why losing a pet can hurt as much as losing a person", + "thinks the bond between a rancher and their working dogs is one of the most underappreciated relationships in American life", + + # Work & career + "just quit a job they've had for 15 years without a plan and feels terrified and free at the same time", + "works night shifts and wants to talk about the weird subculture of people who are awake when everyone else is asleep", + "has been a truck driver for 20 years and the stories from the road could fill a book — wants to share the weirdest one", + "started their own business six months ago and the reality versus the dream is something nobody warns you about", + "works in a trade and is tired of people acting like they're less intelligent because they didn't go to college", + "just found out they're being replaced by automation at their factory and wants to talk about what that means for people like them", + "has been a bartender in a small town for 12 years and knows everyone's secrets — wants to talk about what you learn about people from behind the bar", + "works remote from rural NM and the disconnect between their tech job and their physical surroundings is surreal", + "inherited the family ranch and doesn't know if they want it but feels like they can't say no — wants to talk about obligation versus desire", + "is a wildland firefighter and wants to talk about what that job actually looks like versus what people imagine", + "just got their first raise in four years and it doesn't even cover the increase in grocery prices — wants to talk about wage stagnation", + "works at a school and the gap between what kids need and what the system provides keeps them up at night", + + # Money & personal finance + "just calculated how much they've spent on lottery tickets over 20 years and the number made them sit down", + "wants to talk about the hidden costs of living rural — the gas, the travel, the lack of competition keeping prices high", + "paid off their house this year and wants to talk about what financial freedom actually feels like versus what they expected", + "thinks the credit system is designed to keep poor people poor and has the math to back it up", + "just found out what their parents' house cost in 1985 versus what houses cost now and wants to rant about the housing market", + "has been living cash-only for a year after a fraud scare and the reactions they get from businesses are fascinating", + "wants to talk about the economics of small-town businesses — how do places with 200 customers even survive", + "inherited money they didn't expect and it's creating problems in their family they never anticipated", + "thinks financial literacy should be mandatory in high school — they didn't understand compound interest until they were 35", + "wants to discuss whether the stock market is just legalized gambling with better marketing", + + # Books & reading + "just finished Blood Meridian and needs to process it with another human being because that book did something to them", + "has been reading Marcus Aurelius and wants to talk about how a Roman emperor's journal from 170 AD is somehow the most relevant thing they've read this year", + "read The Road by Cormac McCarthy and it wrecked them — wants to talk about whether great books should be required to leave you gutted", + "just discovered Edward Abbey's Desert Solitaire and it put into words everything they feel about living out here", + "wants to argue that audiobooks count as reading and will fight anyone who disagrees", + "has been reading about the history of their town at the local library and found newspaper articles that change the story everyone tells", + "thinks Lonesome Dove is the great American novel and wants to make the case", + "read a book about the Dust Bowl and the parallels to what's happening with water in the Southwest right now are terrifying", + "just read Sapiens and it fundamentally changed how they think about human civilization — wants to talk about the parts that messed them up", + "found a box of their grandfather's books in the attic and the notes he wrote in the margins are like having a conversation with a dead man", + "wants to talk about why nobody reads anymore and whether that's actually true or just something people say", + "just finished a book about the Manhattan Project and the ethical weight those scientists carried is something they can't stop thinking about", + + # Movies & film + "just rewatched No Country for Old Men and the Coen Brothers nailed what this part of the country feels like better than anyone", + "wants to argue about the greatest movie ending of all time and has a pick nobody expects", + "has a theory that all the best movies are about ordinary people in extraordinary situations, not the other way around", + "just watched a documentary that made them angry and they need to tell someone about it before they explode", + "thinks the golden age of movies is over and everything now is sequels, reboots, and IP — wants someone to prove them wrong", + "watched an old Western with their kid and was surprised how much of it is actually about the landscape they live in", + "wants to talk about movies that got the rural American experience right versus the ones that treat it like a punchline", + "just saw a movie where the twist ending actually worked and they want to talk about it without spoiling it, which is impossible", + "thinks Denis Villeneuve is the best director working right now and Dune proved it — wants to debate", + "rewatched Sicario and wants to talk about how different the border feels when you actually live near it versus how Hollywood shows it", + + # Relationships & family + "just realized they've become their father and doesn't know how to feel about it", + "wants to talk about long-distance friendships — they moved away 10 years ago and the people they thought they'd never lose touch with are strangers now", + "has been married 30 years and someone asked them what the secret is — they don't have one, they just showed up every day", + "their adult kid moved back home and the dynamic shift from parent-child to two adults under one roof is testing everyone", + "found out a family secret at a holiday dinner that reframes their entire childhood and they're still processing it", + "wants to talk about the difference between loneliness and being alone — they live by themselves and they're fine, but everyone assumes they're lonely", + "has been trying to reconnect with their father after 20 years and the conversations are awkward and painful but they keep showing up", + "thinks modern dating is broken and the apps have turned people into products — has stories from trying to date in a town of 300", + "just became a grandparent and the way they feel about this tiny human caught them completely off guard", + "wants to talk about chosen family versus blood family and why the people you pick sometimes know you better than the ones you were born to", + "had a falling out with their best friend over something stupid two years ago and they're both too proud to call — wants to know when pride becomes self-destruction", + "thinks the way Americans handle grief — the three-day bereavement leave, the 'be strong' mentality — is insane and wants to talk about it", + + # Health & medicine + "just learned about the gut-brain axis — there are more neurons in your digestive system than in your spinal cord — and it explains why stress hits your stomach first", + "read about how chronic sleep deprivation literally causes your brain to eat itself through a process called autophagy — and hasn't slept well in months", + "found out about the placebo effect's weird cousin the nocebo effect — if you EXPECT a side effect you're more likely to get it, even from a sugar pill", + "just learned that the appendix isn't useless — it's a safe house for beneficial gut bacteria during illness — and decades of surgeons removed them unnecessarily", + "read about how cold water immersion triggers the vagus nerve and releases norepinephrine — started doing cold showers and wants to talk about whether it's real or bro science", + "learned about the microbiome — there are more bacterial cells in your body than human cells — and your bacteria might be influencing your food cravings, mood, and even personality", + "found out about the fascia system — a continuous web of connective tissue that wraps every muscle, organ, and nerve — and some researchers think it's a whole sensory organ we've been ignoring", + "just read about how exercise is more effective than medication for mild to moderate depression in multiple studies and wants to talk about why doctors reach for the prescription pad first", + "learned that your immune system has memory cells that can remember a pathogen for decades — some vaccines work because of cells that have been waiting 40 years for a rematch", + "read about neuroplasticity — the brain can rewire itself throughout your entire life, not just childhood — and a stroke patient they know learned to talk again at 70", + + # Language & words + "just learned that the word 'disaster' literally means 'bad star' in Latin because people used to blame catastrophes on planetary alignments", + "found out that English has no word for the feeling of secondhand embarrassment but German does — fremdschämen — and wants to talk about emotions that exist but have no name in English", + "read about how the word 'sinister' comes from the Latin word for 'left' because left-handedness was considered evil — and they're left-handed", + "just learned that the sentence 'Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo' is grammatically correct and wants someone to explain it to them", + "found out about Esperanto — a language invented in 1887 to be a universal second language — and there are still about 2 million speakers today who genuinely use it", + "read about the Rosetta Stone and how one rock unlocked an entire dead language because it had the same text in three scripts — wants to talk about what we'd leave behind if our civilization fell", + "learned about the last speaker of a dying language — when they die, an entire way of seeing the world disappears — and a language dies every two weeks", + "just found out that 'OK' might be the most universally understood word on Earth and nobody can agree on where it came from — there are at least six competing theories", + "read about how deaf communities develop sign languages independently and they're full languages with grammar, poetry, and humor — not just hand gestures for spoken words", + "wants to talk about code-switching — how people unconsciously change how they talk depending on who they're with — and what that says about identity", + + # True crime & justice + "just listened to a cold case podcast about a disappearance near the border and the details don't add up — wants to talk through the case", + "read about how many wrongful convictions have been overturned by DNA evidence and it shook their faith in the justice system", + "wants to talk about the ethics of true crime entertainment — are we honoring victims or exploiting their stories for content", + "learned about the Innocence Project and how they've freed over 375 wrongfully convicted people — some who served 30+ years for crimes they didn't commit", + "has opinions about the death penalty that changed after they read about a specific case and wants to work through it out loud", + "read about a small-town sheriff in the 1970s who ran the county like a personal kingdom and the parallels to places they know are uncomfortable", + "just learned about forensic genealogy — how they caught the Golden State Killer using a relative's DNA from a genealogy website — and wants to talk about the privacy implications", + "thinks the true crime obsession in America is actually a healthy response to a broken justice system — people are doing the work the system won't", + "read about a case where a confession was coerced and the person served 18 years — wants to talk about why innocent people confess", + "wants to discuss jury nullification — the power jurors have to acquit even when the law says guilty — and why almost nobody knows about it", + + # Drunk, high, or unhinged + "is three beers deep and just realized they've been pronouncing a common word wrong their entire life and needs to tell someone RIGHT NOW", + "has been drinking alone and wants to call in to confess that they cried during a truck commercial and they're not even sure why", + "is way too high and just spent 40 minutes staring at their hand and has a theory about fingers that they think is profound", + "has been drinking since 5pm and wants to tell the host they're their best friend even though they've never met", + "is absolutely hammered and wants to pitch their million-dollar invention — it's a terrible idea but they're fully committed", + "is high and got stuck in a thought loop about whether fish know they're wet and needs someone to talk them through it", + "has been at the bar since happy hour and just got into an argument with a stranger about something completely unhinged and wants a tiebreaker", + "is drunk and feeling philosophical — wants to know if the host has ever thought about the fact that your tongue just sits in your mouth all day", + "is way too high and convinced that their neighbor's cat is spying on them — they have evidence and they want to present it", + "has been drinking whiskey and wants to call in just to tell the world that they love their truck and they don't care who knows it", + "is stoned and just watched a nature documentary and is now emotionally devastated by the life cycle of salmon", + "is drunk and just tried to cook something ambitious and the kitchen looks like a crime scene — wants to narrate the aftermath", + "is high and realized that the word 'bed' actually looks like a bed and now they can't unsee it and they need to share this with someone", + "has been at a bonfire drinking and their friends dared them to call a radio show and say something weird — they're going to do it", + "is drunk and wants to leave a voicemail for their ex through the radio because they blocked their number — the host should probably not let this happen", + "is high and has been googling deep sea creatures for three hours and is now afraid of the ocean — needs to talk about the goblin shark", + "is wasted and wants to argue passionately about something incredibly low-stakes like whether a hot dog is a sandwich", + "is stoned and had a full conversation with their dog and is pretty sure the dog understood — wants the host's opinion on animal consciousness", + "is drunk and just found their old yearbook and wants to read what people wrote in it because some of these aged terribly", + "is way too high and keeps forgetting why they called but is having a great time anyway", + "is hammered and wants to sing a song they wrote — it's bad but they are fully committed and nothing will stop them", + "is drunk and just learned their coworker makes more money than them for the same job and they are NOT handling it well", + "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", ] LOCATIONS_LOCAL = [ @@ -2112,19 +2625,17 @@ def get_caller_prompt(caller: dict, show_history: str = "", {caller['vibe']} {history}{world_context}{emotional_read} -You're a real person calling a late-night radio show. You have personality. You're funny, or sharp, or a little messy, or flirty — whatever fits who you are. You're not calling to read a script. You're calling because you've got something to say and you want to be heard. +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. -ENERGY AND VIBE: This is late-night radio — it's loose, it's fun, it's a little dangerous. You can be edgy. You can flirt with the host or joke about things that would make your mother blush. You can be sarcastic, dark-humored, self-deprecating, or wildly opinionated. You have a PERSONALITY that comes through in how you talk — not just what you talk about. Some callers are loud and chaotic. Some are dry and deadpan. Some are sweet but say outrageous things casually. Figure out who you are and commit to 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. -PLAY ALONG WITH THE HOST. This is the most important rule. When Luke is running a game, a bit, or a segment — you are ALL IN. You play the game. You give real answers. You riff with him. You build on what he's doing. If he asks you a question, you answer it with enthusiasm and detail — don't give one-word answers, give him something to work with. If he's being funny, be funny back. If he's setting you up, take the swing. You're a great radio caller — the kind that makes listeners lean in. +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. -When he asks "what's going on" or "what's on your mind," THAT's when you bring your thing. But if he's already steering somewhere, ride with him and bring your energy to HIS topic. Your stuff can come up naturally. +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. -YOUR STORY: You've got something real going on — a situation, a story, a confession, something juicy. It's not generic. It's got specific names, specific details, the kind of thing that makes someone say "wait, WHAT?" Don't just state it flat — tell it like you'd tell your friend at a bar. There are parts you're not proud of. There are parts that are kind of funny even though they shouldn't be. You've got conflicting feelings about it. +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: Talk like a real person on the phone — "Oh man," "So get this," "I swear to God," "No but seriously." Give full answers, not clipped ones. When something's funny, laugh at it. When something's awkward, own it. React to what Luke says — agree, push back, get excited, get embarrassed. You're having a CONVERSATION, not delivering a monologue. - -Be specific. Use real names. Swear if it fits. Be a little inappropriate sometimes — you're calling late-night radio, not a church hotline. Flirt if the moment's right. Say the quiet part out loud once in a while. +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. 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." @@ -2144,6 +2655,28 @@ class CallRecord: ended_at: float = 0.0 +def _serialize_call_record(record: CallRecord) -> dict: + return { + "caller_type": record.caller_type, + "caller_name": record.caller_name, + "summary": record.summary, + "transcript": record.transcript, + "started_at": record.started_at, + "ended_at": record.ended_at, + } + + +def _deserialize_call_record(data: dict) -> CallRecord: + return CallRecord( + caller_type=data["caller_type"], + caller_name=data["caller_name"], + summary=data.get("summary", ""), + transcript=data.get("transcript", []), + started_at=data.get("started_at", 0.0), + ended_at=data.get("ended_at", 0.0), + ) + + class Session: def __init__(self): self.id = str(uuid.uuid4())[:8] @@ -2261,6 +2794,180 @@ caller_service = CallerService() _ai_response_lock = asyncio.Lock() # Prevents concurrent AI responses _session_epoch = 0 # Increments on hangup/call start — stale tasks check this _show_on_air = False # Controls whether phone calls are accepted or get off-air message +_hold_music_tasks: dict[str, asyncio.Task] = {} # caller_id -> hold music streaming task + + +def _stop_hold_music(caller_id: str): + task = _hold_music_tasks.pop(caller_id, None) + if task and not task.done(): + task.cancel() + print(f"[Hold Music] Stopped for {caller_id}") + + +async def _stream_hold_music(caller_id: str): + """Stream music tracks to a queued caller until they go on air or disconnect.""" + import librosa + + tracks = [] + if settings.music_dir.exists(): + for ext in ('*.wav', '*.mp3', '*.flac'): + tracks.extend(settings.music_dir.glob(ext)) + if not tracks: + print("[Hold Music] No tracks found in music directory") + return + + random.shuffle(tracks) + track_idx = 0 + print(f"[Hold Music] Starting for {caller_id} ({len(tracks)} tracks available)") + + try: + while caller_id in caller_service._websockets: + track = tracks[track_idx % len(tracks)] + track_idx += 1 + print(f"[Hold Music] Playing '{track.stem}' for {caller_id}") + + audio, sr = librosa.load(str(track), sr=24000, mono=True) + # Reduce volume to 40% + audio = audio * 0.4 + audio_int16 = (audio * 32767).astype(np.int16) + await caller_service.stream_audio_to_caller(caller_id, audio_int16.tobytes(), 24000) + + # Brief pause between tracks + await asyncio.sleep(1.0) + except asyncio.CancelledError: + pass + except Exception as e: + print(f"[Hold Music] Error for {caller_id}: {e}") + finally: + _hold_music_tasks.pop(caller_id, None) + + +# --- Session Checkpoint --- +CHECKPOINT_FILE = Path(__file__).parent.parent / "data" / "session_checkpoint.json" +CHECKPOINT_MAX_AGE = 12 * 3600 # Ignore checkpoints older than 12 hours + + +def _save_checkpoint(): + try: + CHECKPOINT_FILE.parent.mkdir(parents=True, exist_ok=True) + caller_bases_snapshot = {} + for key, base in CALLER_BASES.items(): + caller_bases_snapshot[key] = { + "name": base.get("name"), + "voice": base.get("voice"), + "returning": base.get("returning", False), + "regular_id": base.get("regular_id"), + } + data = { + "session_id": session.id, + "call_history": [_serialize_call_record(r) for r in session.call_history], + "caller_backgrounds": session.caller_backgrounds, + "used_reasons": list(session.used_reasons), + "ai_respond_mode": session.ai_respond_mode, + "auto_followup": session.auto_followup, + "news_headlines": session.news_headlines, + "research_notes": session.research_notes, + "caller_bases": caller_bases_snapshot, + "saved_at": time.time(), + } + with open(CHECKPOINT_FILE, "w") as f: + json.dump(data, f, indent=2) + print(f"[Checkpoint] Saved session {session.id} ({len(session.call_history)} calls)") + except Exception as e: + print(f"[Checkpoint] Failed to save: {e}") + + +def _load_checkpoint() -> bool: + if not CHECKPOINT_FILE.exists(): + return False + try: + with open(CHECKPOINT_FILE) as f: + data = json.load(f) + age = time.time() - data.get("saved_at", 0) + if age > CHECKPOINT_MAX_AGE: + print(f"[Checkpoint] Stale ({age / 3600:.1f}h old), starting fresh") + return False + session.id = data["session_id"] + session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])] + session.caller_backgrounds = data.get("caller_backgrounds", {}) + session.used_reasons = set(data.get("used_reasons", [])) + session.ai_respond_mode = data.get("ai_respond_mode", "manual") + session.auto_followup = data.get("auto_followup", False) + session.news_headlines = data.get("news_headlines", []) + session.research_notes = data.get("research_notes", {}) + for key, snapshot in data.get("caller_bases", {}).items(): + if key in CALLER_BASES: + CALLER_BASES[key]["name"] = snapshot["name"] + CALLER_BASES[key]["voice"] = snapshot["voice"] + CALLER_BASES[key]["returning"] = snapshot.get("returning", False) + CALLER_BASES[key]["regular_id"] = snapshot.get("regular_id") + mins = age / 60 + print(f"[Checkpoint] Restored session {session.id} ({len(session.call_history)} calls, {mins:.0f}m old)") + return True + except Exception as e: + print(f"[Checkpoint] Failed to load: {e}") + return False + + +# --- Voicemail --- +VOICEMAILS_DIR = Path(__file__).parent.parent / "data" / "voicemails" +VOICEMAILS_SAVED_DIR = Path(__file__).parent.parent / "voicemails" +VOICEMAILS_META = Path(__file__).parent.parent / "data" / "voicemails.json" + + +@dataclass +class Voicemail: + id: str + phone: str + timestamp: float + duration: int + file_path: str + listened: bool = False + + +_voicemails: list[Voicemail] = [] +_deleted_vm_timestamps: set[int] = set() + + +def _load_voicemails(): + global _voicemails, _deleted_vm_timestamps + if VOICEMAILS_META.exists(): + try: + with open(VOICEMAILS_META) as f: + data = json.load(f) + _voicemails = [ + Voicemail( + id=v["id"], phone=v["phone"], timestamp=v["timestamp"], + duration=v["duration"], file_path=v["file_path"], + listened=v.get("listened", False), + ) + for v in data.get("voicemails", []) + ] + _deleted_vm_timestamps = set(data.get("deleted_timestamps", [])) + print(f"[Voicemail] Loaded {len(_voicemails)} voicemails") + except Exception as e: + print(f"[Voicemail] Failed to load: {e}") + _voicemails = [] + + +def _save_voicemails(): + try: + VOICEMAILS_META.parent.mkdir(parents=True, exist_ok=True) + data = { + "voicemails": [ + { + "id": v.id, "phone": v.phone, "timestamp": v.timestamp, + "duration": v.duration, "file_path": v.file_path, + "listened": v.listened, + } + for v in _voicemails + ], + "deleted_timestamps": list(_deleted_vm_timestamps), + } + with open(VOICEMAILS_META, "w") as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"[Voicemail] Failed to save: {e}") # --- News & Research Helpers --- @@ -2317,11 +3024,78 @@ def _build_news_context() -> tuple[str, str]: return news_context, research_context +async def _sync_signalwire_voicemails(): + """Pull any recordings from SignalWire that aren't already tracked locally""" + if not settings.signalwire_project_id or not settings.signalwire_token: + return + try: + url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Recordings.json" + auth = (settings.signalwire_project_id, settings.signalwire_token) + existing_timestamps = {int(v.timestamp) for v in _voicemails} | _deleted_vm_timestamps + + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + resp = await client.get(url, auth=auth) + resp.raise_for_status() + recordings = resp.json().get("recordings", []) + + synced = 0 + for rec in recordings: + call_sid = rec.get("call_sid", "") + duration = int(rec.get("duration", 0)) + date_created = rec.get("date_created", "") + recording_sid = rec.get("sid", "") + + if duration < 2: + continue + + # Parse timestamp from SignalWire's date format + from datetime import datetime + try: + ts = int(datetime.strptime(date_created, "%a, %d %b %Y %H:%M:%S %z").timestamp()) + except (ValueError, TypeError): + ts = int(time.time()) + + if ts in existing_timestamps: + continue + + # Get caller phone from the call details + caller_phone = "Unknown" + try: + call_url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Calls/{call_sid}.json" + async with httpx.AsyncClient(timeout=15.0) as client: + call_resp = await client.get(call_url, auth=auth) + if call_resp.status_code == 200: + caller_phone = call_resp.json().get("from", "Unknown") + except Exception: + pass + + # Download the recording + rec_url = f"https://{settings.signalwire_space}{rec.get('uri', '').replace('.json', '.wav')}" + await _download_voicemail(rec_url, caller_phone, duration) + + # Fix the timestamp to match the original recording time + if _voicemails and _voicemails[-1].phone == caller_phone: + _voicemails[-1].timestamp = ts + _save_voicemails() + + existing_timestamps.add(ts) + synced += 1 + + if synced: + print(f"[Voicemail] Synced {synced} recording(s) from SignalWire") + except Exception as e: + print(f"[Voicemail] SignalWire sync failed: {e}") + + # --- Lifecycle --- @app.on_event("startup") async def startup(): """Pre-generate caller backgrounds on server start""" - asyncio.create_task(_pregenerate_backgrounds()) + _load_voicemails() + asyncio.create_task(_sync_signalwire_voicemails()) + restored = _load_checkpoint() + if not restored: + asyncio.create_task(_pregenerate_backgrounds()) threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() @@ -2329,6 +3103,7 @@ async def startup(): async def shutdown(): """Clean up resources on server shutdown""" global _host_audio_task + _save_checkpoint() print("[Server] Shutting down — cleaning up resources...") _update_on_air_cdn(False) # Stop host mic streaming @@ -2470,10 +3245,18 @@ async def signalwire_voice_webhook(request: Request): print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})") if not _show_on_air: - print(f"[SignalWire] Show is off air — playing off-air message for {caller_phone}") - xml = """ + print(f"[SignalWire] Show is off air — offering voicemail to {caller_phone}") + # Derive host from stream URL config if available, otherwise from request + if settings.signalwire_stream_url: + from urllib.parse import urlparse + host = urlparse(settings.signalwire_stream_url).hostname + else: + host = request.headers.get("host", "radioshow.macneilmediagroup.com") + xml = f""" - Luke at the Roost is off the air right now. Please call back during the show for your chance to talk to Luke. Thanks for calling! + Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show! + + Thank you for calling. Goodbye! """ return Response(content=xml, media_type="application/xml") @@ -2499,6 +3282,142 @@ async def signalwire_voice_webhook(request: Request): return Response(content=xml, media_type="application/xml") +@app.post("/api/signalwire/voicemail-complete") +async def signalwire_voicemail_complete(request: Request): + form = await request.form() + recording_url = form.get("RecordingUrl", "") + caller_phone = form.get("From", "Unknown") + duration = int(form.get("RecordingDuration", "0")) + print(f"[Voicemail] Recording complete from {caller_phone} ({duration}s): {recording_url}") + + if recording_url: + asyncio.create_task(_download_voicemail(recording_url, caller_phone, duration)) + + xml = 'Thank you for calling. Goodbye!' + return Response(content=xml, media_type="application/xml") + + +async def _download_voicemail(recording_url: str, caller_phone: str, duration: int): + try: + VOICEMAILS_DIR.mkdir(parents=True, exist_ok=True) + ts = int(time.time()) + safe_phone = caller_phone.replace("+", "").replace(" ", "") + # Determine extension from URL + ext = Path(recording_url.split("?")[0]).suffix or ".wav" + filename = f"{ts}_{safe_phone}{ext}" + filepath = VOICEMAILS_DIR / filename + + # Try downloading without auth first (pre-signed URL), fall back to basic auth + auth = (settings.signalwire_project_id, settings.signalwire_token) + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + resp = await client.get(recording_url) + if resp.status_code in (401, 403): + resp = await client.get(recording_url, auth=auth) + resp.raise_for_status() + with open(filepath, "wb") as f: + f.write(resp.content) + + vm = Voicemail( + id=str(uuid.uuid4())[:8], + phone=caller_phone, + timestamp=ts, + duration=duration, + file_path=str(filepath), + ) + _voicemails.append(vm) + _save_voicemails() + print(f"[Voicemail] Saved {filename} ({duration}s) from {caller_phone}") + except Exception as e: + print(f"[Voicemail] Failed to download recording: {e}") + + +# --- Voicemail API --- + +@app.get("/api/voicemails") +async def list_voicemails(): + return [ + { + "id": v.id, "phone": v.phone, "timestamp": v.timestamp, + "duration": v.duration, "listened": v.listened, + } + for v in sorted(_voicemails, key=lambda v: v.timestamp, reverse=True) + ] + + +@app.get("/api/voicemail/{vm_id}/audio") +async def get_voicemail_audio(vm_id: str): + vm = next((v for v in _voicemails if v.id == vm_id), None) + if not vm: + raise HTTPException(status_code=404, detail="Voicemail not found") + fp = Path(vm.file_path) + if not fp.exists(): + raise HTTPException(status_code=404, detail="Audio file missing") + media_type = "audio/wav" if fp.suffix == ".wav" else "audio/mpeg" + return FileResponse(fp, media_type=media_type, filename=fp.name) + + +@app.post("/api/voicemail/{vm_id}/play-on-air") +async def play_voicemail_on_air(vm_id: str): + vm = next((v for v in _voicemails if v.id == vm_id), None) + if not vm: + raise HTTPException(status_code=404, detail="Voicemail not found") + fp = Path(vm.file_path) + if not fp.exists(): + raise HTTPException(status_code=404, detail="Audio file missing") + + def _play(): + import librosa + audio, sr = librosa.load(str(fp), sr=24000, mono=True) + audio_int16 = (audio * 32767).astype(np.int16) + audio_service.play_caller_audio(audio_int16.tobytes(), 24000) + + thread = threading.Thread(target=_play, daemon=True) + thread.start() + vm.listened = True + _save_voicemails() + return {"status": "playing"} + + +@app.post("/api/voicemail/{vm_id}/mark-listened") +async def mark_voicemail_listened(vm_id: str): + vm = next((v for v in _voicemails if v.id == vm_id), None) + if not vm: + raise HTTPException(status_code=404, detail="Voicemail not found") + vm.listened = True + _save_voicemails() + return {"status": "ok"} + + +@app.post("/api/voicemail/{vm_id}/save") +async def save_voicemail(vm_id: str): + vm = next((v for v in _voicemails if v.id == vm_id), None) + if not vm: + raise HTTPException(status_code=404, detail="Voicemail not found") + fp = Path(vm.file_path) + if not fp.exists(): + raise HTTPException(status_code=404, detail="Audio file missing") + VOICEMAILS_SAVED_DIR.mkdir(parents=True, exist_ok=True) + dest = VOICEMAILS_SAVED_DIR / fp.name + import shutil + shutil.copy2(fp, dest) + print(f"[Voicemail] Saved {fp.name} to archive") + return {"status": "saved", "path": str(dest)} + + +@app.delete("/api/voicemail/{vm_id}") +async def delete_voicemail(vm_id: str): + vm = next((v for v in _voicemails if v.id == vm_id), None) + if not vm: + raise HTTPException(status_code=404, detail="Voicemail not found") + _deleted_vm_timestamps.add(int(vm.timestamp)) + fp = Path(vm.file_path) + if fp.exists(): + fp.unlink() + _voicemails.remove(vm) + _save_voicemails() + return {"status": "deleted"} + + async def _signalwire_end_call(call_sid: str): """End a phone call via SignalWire REST API""" if not call_sid or not settings.signalwire_space: @@ -2535,6 +3454,8 @@ class AudioDeviceSettings(BaseModel): music_channel: Optional[int] = None sfx_channel: Optional[int] = None ad_channel: Optional[int] = None + monitor_device: Optional[int] = None + monitor_channel: Optional[int] = None phone_filter: Optional[bool] = None class MusicRequest(BaseModel): @@ -2572,6 +3493,8 @@ async def set_audio_settings(settings: AudioDeviceSettings): music_channel=settings.music_channel, sfx_channel=settings.sfx_channel, ad_channel=settings.ad_channel, + monitor_device=settings.monitor_device, + monitor_channel=settings.monitor_channel, phone_filter=settings.phone_filter ) return audio_service.get_device_settings() @@ -2766,6 +3689,8 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li except Exception as e: print(f"[Regulars] Promotion logic error: {e}") + _save_checkpoint() + # --- Chat & TTS Endpoints --- @@ -3010,9 +3935,37 @@ async def stop_tts(): # --- Music Endpoints --- +GENRE_KEYWORDS = { + "rock": "Rock", + "funk": "Funk", + "funky": "Funk", + "hip-hop": "Hip-Hop", + "hip hop": "Hip-Hop", + "rap": "Hip-Hop", + "jazz": "Jazz", + "blues": "Blues", + "latin": "Latin", + "lo-fi": "Lo-Fi", + "lofi": "Lo-Fi", + "coffee": "Lo-Fi", + "radio": "Radio", + "valentine": "Ballad", + "romantic": "Ballad", + "ballad": "Ballad", +} + + +def _detect_genre(name: str) -> str: + lower = name.lower() + for keyword, genre in GENRE_KEYWORDS.items(): + if keyword in lower: + return genre + return "Other" + + @app.get("/api/music") async def get_music(): - """Get available music tracks""" + """Get available music tracks, shuffled and tagged with genre""" tracks = [] if settings.music_dir.exists(): for ext in ['*.wav', '*.mp3', '*.flac']: @@ -3020,8 +3973,10 @@ async def get_music(): tracks.append({ "name": f.stem, "file": f.name, - "path": str(f) + "path": str(f), + "genre": _detect_genre(f.stem), }) + random.shuffle(tracks) return { "tracks": tracks, "playing": audio_service.is_music_playing() @@ -3095,18 +4050,40 @@ async def play_sfx(request: SFXRequest): # --- Ads Endpoints --- +AD_DISPLAY_NAMES = { + "bettermaybe_ad": "Better Maybe", + "bunkhousedns_ad": "Bunkhouse DNS", + "cryptono_ad": "CryptoNo", + "desertgut_ad": "Desert Gut", + "enema_ad": "Enema", + "jamhospitalityad": "Jam Hospitality", + "mealprep_ad": "Meal Prep", + "mediocrecpap": "Mediocre CPAP", + "pillowforever_ad": "Pillow Forever", + "placiboleaf": "Placibo Leaf", + "saddlesoft_ad": "Saddle Soft", + "sandstone_ad": "Sandstone", + "scriptdrift_ad": "Script Drift", + "shoespraycoad": "Shoe Spray Co.", + "squarehole_ad": "Square Hole", + "therapy_ad": "Therapy", + "vpnad": "VPN", +} + + @app.get("/api/ads") async def get_ads(): - """Get available ad tracks""" + """Get available ad tracks, shuffled""" ad_list = [] if settings.ads_dir.exists(): for ext in ['*.wav', '*.mp3', '*.flac']: for f in settings.ads_dir.glob(ext): ad_list.append({ - "name": f.stem, + "name": AD_DISPLAY_NAMES.get(f.stem, f.stem), "file": f.name, "path": str(f) }) + random.shuffle(ad_list) return {"ads": ad_list} @@ -3280,6 +4257,11 @@ Respond with ONLY JSON: {{"name": "their first name or null", "topic": "brief to except Exception as e: print(f"[Screening] Response TTS failed: {e}") + # Start hold music after screening completes and final TTS has played + screening = caller_service.get_screening_state(caller_id) + if screening and screening.get("status") == "complete" and caller_id not in _hold_music_tasks: + _hold_music_tasks[caller_id] = asyncio.create_task(_stream_hold_music(caller_id)) + @app.websocket("/api/signalwire/stream") async def signalwire_audio_stream(websocket: WebSocket): @@ -3397,6 +4379,7 @@ async def signalwire_audio_stream(websocket: WebSocket): else: disconnect_reason = "clean" finally: + _stop_hold_music(caller_id) was_on_air = caller_id in caller_service.active_calls caller_service.unregister_websocket(caller_id) caller_service.unregister_call_sid(caller_id) @@ -3502,6 +4485,7 @@ async def get_call_queue(): @app.post("/api/queue/take/{caller_id}") async def take_call_from_queue(caller_id: str): """Take a caller off hold and put them on air""" + _stop_hold_music(caller_id) try: call_info = caller_service.take_call(caller_id) except ValueError as e: @@ -3522,6 +4506,7 @@ async def take_call_from_queue(caller_id: str): @app.post("/api/queue/drop/{caller_id}") async def drop_from_queue(caller_id: str): """Drop a caller from the queue""" + _stop_hold_music(caller_id) call_sid = caller_service.get_call_sid(caller_id) caller_service.remove_from_queue(caller_id) if call_sid: @@ -3805,6 +4790,8 @@ async def _summarize_real_call(caller_phone: str, conversation: list, started_at )) print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...") + _save_checkpoint() + if auto_followup_enabled: await _auto_followup(summary) diff --git a/backend/services/audio.py b/backend/services/audio.py index 6cb3316..b4d6735 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -19,15 +19,17 @@ class AudioService: def __init__(self): # Device configuration - self.input_device: Optional[int] = None + self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input) self.input_channel: int = 1 # 1-indexed channel - self.output_device: Optional[int] = None # Single output device (multi-channel) - self.caller_channel: int = 1 # Channel for caller TTS + self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output) + self.caller_channel: int = 3 # Channel for caller TTS self.live_caller_channel: int = 9 # Channel for live caller audio - self.music_channel: int = 2 # Channel for music + self.music_channel: int = 5 # Channel for music self.sfx_channel: int = 3 # Channel for SFX self.ad_channel: int = 11 # Channel for ads + self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring) + self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device self.phone_filter: bool = False # Phone filter on caller voices # Ad playback state @@ -78,6 +80,10 @@ class AudioService: self.input_sample_rate = 16000 # For Whisper self.output_sample_rate = 24000 # For TTS + # Mic monitor (input → monitor device passthrough) + self._monitor_stream: Optional[sd.OutputStream] = None + self._monitor_write: Optional[Callable] = None + # Stem recording (opt-in, attached via API) self.stem_recorder = None self._stem_mic_stream: Optional[sd.InputStream] = None @@ -99,8 +105,10 @@ class AudioService: self.music_channel = data.get("music_channel", 2) self.sfx_channel = data.get("sfx_channel", 3) self.ad_channel = data.get("ad_channel", 11) + self.monitor_device = data.get("monitor_device") + self.monitor_channel = data.get("monitor_channel", 1) self.phone_filter = data.get("phone_filter", False) - print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}") + print(f"Loaded audio settings: input={self.input_device}, output={self.output_device}, monitor={self.monitor_device}, phone_filter={self.phone_filter}") except Exception as e: print(f"Failed to load audio settings: {e}") @@ -116,6 +124,8 @@ class AudioService: "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, "ad_channel": self.ad_channel, + "monitor_device": self.monitor_device, + "monitor_channel": self.monitor_channel, "phone_filter": self.phone_filter, } with open(SETTINGS_FILE, "w") as f: @@ -148,6 +158,8 @@ class AudioService: music_channel: Optional[int] = None, sfx_channel: Optional[int] = None, ad_channel: Optional[int] = None, + monitor_device: Optional[int] = None, + monitor_channel: Optional[int] = None, phone_filter: Optional[bool] = None ): """Configure audio devices and channels""" @@ -167,6 +179,10 @@ class AudioService: self.sfx_channel = sfx_channel if ad_channel is not None: self.ad_channel = ad_channel + if monitor_device is not None: + self.monitor_device = monitor_device + if monitor_channel is not None: + self.monitor_channel = monitor_channel if phone_filter is not None: self.phone_filter = phone_filter @@ -184,6 +200,8 @@ class AudioService: "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, "ad_channel": self.ad_channel, + "monitor_device": self.monitor_device, + "monitor_channel": self.monitor_channel, "phone_filter": self.phone_filter, } @@ -542,6 +560,9 @@ class AudioService: host_accum_samples = [0] send_threshold = 1600 # 100ms at 16kHz + # Start mic monitor if monitor device is configured + self._start_monitor(device_sr) + def callback(indata, frames, time_info, status): # Capture for push-to-talk recording if active if self._recording and self._recorded_audio is not None: @@ -551,6 +572,10 @@ class AudioService: if self.stem_recorder: self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr) + # Mic monitor: send to headphone device + if self._monitor_write: + self._monitor_write(indata[:, record_channel].copy()) + if not self._host_send_callback: return mono = indata[:, record_channel] @@ -591,8 +616,84 @@ class AudioService: self._host_stream = None self._host_send_callback = None print("[Audio] Host mic streaming stopped") + self._stop_monitor() self._stop_live_caller_stream() + # --- Mic Monitor (input → headphone device) --- + + def _start_monitor(self, input_sr: int): + """Start mic monitor stream that routes input to monitor device""" + if self._monitor_stream is not None: + return + if self.monitor_device is None: + return + + device_info = sd.query_devices(self.monitor_device) + num_channels = device_info['max_output_channels'] + device_sr = int(device_info['default_samplerate']) + channel_idx = min(self.monitor_channel, num_channels) - 1 + + # Ring buffer for cross-device routing + ring_size = int(device_sr * 2) + ring = np.zeros(ring_size, dtype=np.float32) + state = {"write_pos": 0, "read_pos": 0, "avail": 0} + + # Precompute resample ratio (input device sr → monitor device sr) + resample_ratio = device_sr / input_sr + + def write_audio(data): + # Resample if sample rates differ + if abs(resample_ratio - 1.0) > 0.01: + n_out = int(len(data) * resample_ratio) + indices = np.linspace(0, len(data) - 1, n_out).astype(int) + data = data[indices] + n = len(data) + wp = state["write_pos"] + if wp + n <= ring_size: + ring[wp:wp + n] = data + else: + first = ring_size - wp + ring[wp:] = data[:first] + ring[:n - first] = data[first:] + state["write_pos"] = (wp + n) % ring_size + state["avail"] += n + + def callback(outdata, frames, time_info, status): + outdata.fill(0) + avail = state["avail"] + if avail < frames: + return + rp = state["read_pos"] + if rp + frames <= ring_size: + outdata[:frames, channel_idx] = ring[rp:rp + frames] + else: + first = ring_size - rp + outdata[:first, channel_idx] = ring[rp:] + outdata[first:frames, channel_idx] = ring[:frames - first] + state["read_pos"] = (rp + frames) % ring_size + state["avail"] -= frames + + self._monitor_write = write_audio + self._monitor_stream = sd.OutputStream( + device=self.monitor_device, + samplerate=device_sr, + channels=num_channels, + dtype=np.float32, + blocksize=1024, + callback=callback, + ) + self._monitor_stream.start() + print(f"[Audio] Mic monitor started (device {self.monitor_device} ch {self.monitor_channel} @ {device_sr}Hz)") + + def _stop_monitor(self): + """Stop mic monitor stream""" + if self._monitor_stream: + self._monitor_stream.stop() + self._monitor_stream.close() + self._monitor_stream = None + self._monitor_write = None + print("[Audio] Mic monitor stopped") + # --- Music Playback --- def load_music(self, file_path: str) -> bool: @@ -981,9 +1082,13 @@ class AudioService: device_sr = int(device_info['default_samplerate']) record_channel = min(self.input_channel, max_channels) - 1 + self._start_monitor(device_sr) + def callback(indata, frames, time_info, status): if self.stem_recorder: self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr) + if self._monitor_write: + self._monitor_write(indata[:, record_channel].copy()) self._stem_mic_stream = sd.InputStream( device=self.input_device, @@ -1003,6 +1108,7 @@ class AudioService: self._stem_mic_stream.close() self._stem_mic_stream = None print("[StemRecorder] Host mic capture stopped") + self._stop_monitor() # Global instance diff --git a/backend/services/transcription.py b/backend/services/transcription.py index cef8f85..d5198c0 100644 --- a/backend/services/transcription.py +++ b/backend/services/transcription.py @@ -13,10 +13,8 @@ def get_whisper_model() -> WhisperModel: """Get or create Whisper model instance""" global _whisper_model if _whisper_model is None: - print("Loading Whisper tiny model for fast transcription...") - # Use tiny model for speed - about 3-4x faster than base - # beam_size=1 and best_of=1 for fastest inference - _whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8") + print("Loading Whisper base model...") + _whisper_model = WhisperModel("base", device="cpu", compute_type="int8") print("Whisper model loaded") return _whisper_model @@ -100,13 +98,13 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) -> else: audio_16k = audio - # Transcribe with speed optimizations + # Transcribe segments, info = model.transcribe( audio_16k, - beam_size=1, # Faster, slightly less accurate - best_of=1, - language="en", # Skip language detection - vad_filter=True, # Skip silence + beam_size=3, + language="en", + vad_filter=True, + initial_prompt="Luke at the Roost, a late-night radio talk show. The host Luke talks to callers about life, relationships, sports, politics, and pop culture.", ) segments_list = list(segments) text = " ".join([s.text for s in segments_list]).strip() diff --git a/data/regulars.json b/data/regulars.json index 6b20062..0d79fd2 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -1,249 +1,5 @@ { "regulars": [ - { - "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": "584767e8", - "name": "Carl", - "gender": "male", - "age": 36, - "job": "is a firefighter", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.", - "timestamp": 1770522170.1887732 - }, - { - "summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.", - "timestamp": 1770573289.82847 - }, - { - "summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.", - "timestamp": 1770694065.5629818 - } - ], - "last_call": 1770694065.5629828, - "created_at": 1770522170.1887732, - "voice": "SOYHLrjzK2X1ezoPC6cr" - }, - { - "id": "04b1a69c", - "name": "Reggie", - "gender": "male", - "age": 51, - "job": "a 39-year-old food truck operator, is reeling from a troubling discovery this morning", - "location": "in unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Reggie called in worried because his partner suddenly packed a bag and left for her mom's house without explanation and won't answer his calls, making him fear something is wrong with their relationship. The host advised him to stop calling repeatedly and have a calm conversation with her when she's ready to talk, reassuring him he's likely overreacting.", - "timestamp": 1770769705.511872 - } - ], - "last_call": 1770769705.511872, - "created_at": 1770769705.511872, - "voice": "N2lVS1w4EtoT3dr4eOWO" - }, - { - "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": "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": "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": "f21d1346", - "name": "Andre", - "gender": "male", - "age": 54, - "job": "is a firefighter unknown", - "location": "in unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Andre called into a radio game show but first shared that he's upset about being named in court documents related to a lawsuit involving a family he helped in December by returning $15,000 after a house fire. Though the host reassured him he has nothing to worry about since he did the right thing, Andre expressed frustration that his good deed led to him being dragged into an insurance dispute.", - "timestamp": 1770770944.7940538 - }, - { - "summary": "Andre calls back with an update: the lawsuit against him was dropped, and the family he helped sent him a card with $500 cash, which makes him feel conflicted about accepting payment for doing the right thing. On a positive note, he's been gambling-free for two months and attending meetings, and Luke encourages him to keep the money or donate it, celebrating his progress.", - "timestamp": 1770870907.493257 - } - ], - "last_call": 1770870907.493258, - "created_at": 1770770944.7940538, - "voice": "JBFqnCBsd6RMkjVDRZzb" - }, - { - "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": "7be7317c", - "name": "Jerome", - "gender": "male", - "age": 53, - "job": "phone", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Jerome, a police officer in Texas, called from a DQ parking lot worried about AI writing police reports after his son sent him an article suggesting it might replace him. Through the conversation, he moved from fear about accountability and accuracy in criminal cases to acknowledging that AI handling routine paperwork (like cattle complaints) could free him up to do more meaningful police work in his understaffed county, though he remains uncertain about where this technology will lead.", - "timestamp": 1770692087.560522 - }, - { - "summary": "The caller described a turbulent couple of weeks, mentioning an issue with AI writing police reports, which he suggested was just the beginning of a larger problem. He seemed concerned about the developments and wanted to discuss the topic further with the host.", - "timestamp": 1770892192.893108 - } - ], - "last_call": 1770892192.89311, - "created_at": 1770692087.560523, - "voice": "IKne3meq5aSn9XLyUdCD" - }, - { - "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": "49147bd5", "name": "Keith", @@ -291,6 +47,234 @@ ], "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 + }, + { + "id": "2768e2ac", + "name": "Rochelle", + "gender": "female", + "age": 55, + "job": "and when she opened the fitness app they used to share\u2014back when they were doing that couples' couch-to-5K thing\u2014she realized he's been watching her movements for the eight months since the divorce was finalized", + "location": "in unknown", + "personality_traits": [], + "voice": "Sarah", + "call_history": [ + { + "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 + } + ], + "last_call": 1771217728.0361228, + "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", + "location": "in unknown", + "personality_traits": [], + "voice": "Olivia", + "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 + } + ], + "last_call": 1771222133.612614, + "created_at": 1771222133.612614 + }, + { + "id": "514725e5", + "name": "Dolores", + "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", + "personality_traits": [], + "voice": "Luna", + "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 + } + ], + "last_call": 1771223091.851769, + "created_at": 1771223091.851769 } ] } \ No newline at end of file diff --git a/data/voicemails.json b/data/voicemails.json new file mode 100644 index 0000000..c759be1 --- /dev/null +++ b/data/voicemails.json @@ -0,0 +1,10 @@ +{ + "voicemails": [], + "deleted_timestamps": [ + 1771212705, + 1771146434, + 1771146564, + 1771146952, + 1771213151 + ] +} \ No newline at end of file diff --git a/docs/plans/2026-02-15-clip-social-upload.md b/docs/plans/2026-02-15-clip-social-upload.md new file mode 100644 index 0000000..b7da713 --- /dev/null +++ b/docs/plans/2026-02-15-clip-social-upload.md @@ -0,0 +1,505 @@ +# Clip Social Media Upload Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Generate social media descriptions/hashtags for podcast clips and upload them to Instagram Reels + YouTube Shorts via Postiz API. + +**Architecture:** Two changes — (1) extend `make_clips.py` to add a second LLM call that generates descriptions + hashtags, saved as `clips-metadata.json`, (2) new `upload_clips.py` script that reads that metadata and pushes clips through the self-hosted Postiz instance at `social.lukeattheroost.com`. + +**Tech Stack:** Python, OpenRouter API (Claude Sonnet), Postiz REST API, requests library (already installed) + +--- + +### Task 1: Add `generate_social_metadata()` to `make_clips.py` + +**Files:** +- Modify: `make_clips.py:231-312` (after `select_clips_with_llm`) + +**Step 1: Add the function after `select_clips_with_llm`** + +Add this function at line ~314 (after `select_clips_with_llm` returns): + +```python +def generate_social_metadata(clips: list[dict], labeled_transcript: str, + episode_number: int | None) -> list[dict]: + """Generate social media descriptions and hashtags for each clip.""" + if not OPENROUTER_API_KEY: + print("Error: OPENROUTER_API_KEY not set in .env") + sys.exit(1) + + clips_summary = "\n".join( + f'{i+1}. "{c["title"]}" — {c["caption_text"]}' + for i, c in enumerate(clips) + ) + + episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of " + + prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke. + +Here are {len(clips)} clips selected from this episode: + +{clips_summary} + +For each clip, generate: +1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description. +2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags. + +Respond with ONLY a JSON array matching the clip order: +[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]""" + + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": "anthropic/claude-sonnet-4-5", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 2048, + "temperature": 0.7, + }, + ) + + if response.status_code != 200: + print(f"Error from OpenRouter: {response.text}") + return clips + + content = response.json()["choices"][0]["message"]["content"].strip() + if content.startswith("```"): + content = re.sub(r"^```(?:json)?\n?", "", content) + content = re.sub(r"\n?```$", "", content) + + try: + metadata = json.loads(content) + except json.JSONDecodeError as e: + print(f"Error parsing social metadata: {e}") + return clips + + for i, clip in enumerate(clips): + if i < len(metadata): + clip["description"] = metadata[i].get("description", "") + clip["hashtags"] = metadata[i].get("hashtags", []) + + return clips +``` + +**Step 2: Run existing tests to verify no breakage** + +Run: `pytest tests/ -v` +Expected: All existing tests pass (this is a new function, no side effects yet) + +**Step 3: Commit** + +```bash +git add make_clips.py +git commit -m "Add generate_social_metadata() for clip descriptions and hashtags" +``` + +--- + +### Task 2: Integrate metadata generation + JSON save into `main()` + +**Files:** +- Modify: `make_clips.py:1082-1289` (inside `main()`) + +**Step 1: Add metadata generation call and JSON save** + +After the LLM clip selection step (~line 1196, after the clip summary print loop), add: + +```python + # Step N: Generate social media metadata + print(f"\n[{extract_step - 1}/{step_total}] Generating social media descriptions...") + clips = generate_social_metadata(clips, labeled_transcript, episode_number) + for i, clip in enumerate(clips): + if "description" in clip: + print(f" Clip {i+1}: {clip['description'][:80]}...") + print(f" {' '.join(clip.get('hashtags', []))}") +``` + +Note: This needs to be inserted BEFORE the audio extraction step, and the step numbering needs to be adjusted (total steps goes from 5/6 to 6/7). + +At the end of `main()`, before the summary print, save the metadata JSON: + +```python + # Save clips metadata for social upload + metadata_path = output_dir / "clips-metadata.json" + metadata = [] + for i, clip in enumerate(clips): + slug = slugify(clip["title"]) + metadata.append({ + "title": clip["title"], + "clip_file": f"clip-{i+1}-{slug}.mp4", + "audio_file": f"clip-{i+1}-{slug}.mp3", + "caption_text": clip.get("caption_text", ""), + "description": clip.get("description", ""), + "hashtags": clip.get("hashtags", []), + "start_time": clip["start_time"], + "end_time": clip["end_time"], + "duration": round(clip["end_time"] - clip["start_time"], 1), + "episode_number": episode_number, + }) + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + print(f"\nSocial metadata: {metadata_path}") +``` + +**Step 2: Adjust step numbering** + +The pipeline steps need to account for the new metadata step. Update `step_total` calculation: + +```python + step_total = (7 if two_pass else 6) +``` + +And shift the extract/video step numbers up by 1. + +**Step 3: Test manually** + +Run: `python make_clips.py --help` +Expected: No import errors, help displays normally + +**Step 4: Commit** + +```bash +git add make_clips.py +git commit -m "Save clips-metadata.json with social descriptions and hashtags" +``` + +--- + +### Task 3: Create `upload_clips.py` — core structure and Postiz API helpers + +**Files:** +- Create: `upload_clips.py` + +**Step 1: Write the script** + +```python +#!/usr/bin/env python3 +"""Upload podcast clips to Instagram Reels and YouTube Shorts via Postiz. + +Usage: + python upload_clips.py clips/episode-12/ + python upload_clips.py clips/episode-12/ --clip 1 + python upload_clips.py clips/episode-12/ --youtube-only + python upload_clips.py clips/episode-12/ --instagram-only + python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00" + python upload_clips.py clips/episode-12/ --yes # skip confirmation +""" + +import argparse +import json +import sys +from pathlib import Path + +import requests +from dotenv import load_dotenv +import os + +load_dotenv(Path(__file__).parent / ".env") + +POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY") +POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com") + + +def get_api_url(path: str) -> str: + """Build full Postiz API URL.""" + base = POSTIZ_URL.rstrip("/") + # Postiz self-hosted API is at /api/public/v1 when NEXT_PUBLIC_BACKEND_URL is the app URL + # but the docs say /public/v1 relative to backend URL. Try the standard path. + return f"{base}/api/public/v1{path}" + + +def api_headers() -> dict: + return { + "Authorization": POSTIZ_API_KEY, + "Content-Type": "application/json", + } + + +def fetch_integrations() -> list[dict]: + """Fetch connected social accounts from Postiz.""" + resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15) + if resp.status_code != 200: + print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}") + sys.exit(1) + return resp.json() + + +def find_integration(integrations: list[dict], provider: str) -> dict | None: + """Find integration by provider name (e.g. 'instagram', 'youtube').""" + for integ in integrations: + if integ.get("providerIdentifier", "").startswith(provider): + return integ + if integ.get("provider", "").startswith(provider): + return integ + return None + + +def upload_file(file_path: Path) -> dict: + """Upload a file to Postiz. Returns {id, path}.""" + headers = {"Authorization": POSTIZ_API_KEY} + with open(file_path, "rb") as f: + resp = requests.post( + get_api_url("/upload"), + headers=headers, + files={"file": (file_path.name, f, "video/mp4")}, + timeout=120, + ) + if resp.status_code != 200: + print(f"Upload failed: {resp.status_code} {resp.text[:200]}") + return {} + return resp.json() + + +def create_post(integration_id: str, content: str, media: dict, + settings: dict, schedule: str | None = None) -> dict: + """Create a post on Postiz.""" + post_type = "schedule" if schedule else "now" + + payload = { + "type": post_type, + "posts": [ + { + "integration": {"id": integration_id}, + "value": [ + { + "content": content, + "image": [media] if media else [], + } + ], + "settings": settings, + } + ], + } + if schedule: + payload["date"] = schedule + + resp = requests.post( + get_api_url("/posts"), + headers=api_headers(), + json=payload, + timeout=30, + ) + if resp.status_code not in (200, 201): + print(f"Post creation failed: {resp.status_code} {resp.text[:300]}") + return {} + return resp.json() + + +def build_instagram_content(clip: dict) -> str: + """Build Instagram post content: description + hashtags.""" + parts = [clip.get("description", clip.get("caption_text", ""))] + hashtags = clip.get("hashtags", []) + if hashtags: + parts.append("\n\n" + " ".join(hashtags)) + return "".join(parts) + + +def build_youtube_content(clip: dict) -> str: + """Build YouTube description.""" + parts = [clip.get("description", clip.get("caption_text", ""))] + hashtags = clip.get("hashtags", []) + if hashtags: + parts.append("\n\n" + " ".join(hashtags)) + parts.append("\n\nListen to the full episode: lukeattheroost.com") + return "".join(parts) + + +def main(): + parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz") + parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)") + parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)") + parser.add_argument("--instagram-only", action="store_true", help="Upload to Instagram only") + parser.add_argument("--youtube-only", action="store_true", help="Upload to YouTube only") + parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)") + parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt") + parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting") + args = parser.parse_args() + + if not POSTIZ_API_KEY: + print("Error: POSTIZ_API_KEY not set in .env") + sys.exit(1) + + clips_dir = Path(args.clips_dir).expanduser().resolve() + metadata_path = clips_dir / "clips-metadata.json" + + if not metadata_path.exists(): + print(f"Error: No clips-metadata.json found in {clips_dir}") + print("Run make_clips.py first to generate clips and metadata.") + sys.exit(1) + + with open(metadata_path) as f: + clips = json.load(f) + + if args.clip: + if args.clip < 1 or args.clip > len(clips): + print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)") + sys.exit(1) + clips = [clips[args.clip - 1]] + + # Determine which platforms to post to + do_instagram = not args.youtube_only + do_youtube = not args.instagram_only + + # Fetch integrations from Postiz + print("Fetching connected accounts from Postiz...") + integrations = fetch_integrations() + + ig_integration = None + yt_integration = None + + if do_instagram: + ig_integration = find_integration(integrations, "instagram") + if not ig_integration: + print("Warning: No Instagram account connected in Postiz") + do_instagram = False + + if do_youtube: + yt_integration = find_integration(integrations, "youtube") + if not yt_integration: + print("Warning: No YouTube account connected in Postiz") + do_youtube = False + + if not do_instagram and not do_youtube: + print("Error: No platforms available to upload to") + sys.exit(1) + + # Show summary + platforms = [] + if do_instagram: + platforms.append(f"Instagram Reels ({ig_integration.get('name', 'connected')})") + if do_youtube: + platforms.append(f"YouTube Shorts ({yt_integration.get('name', 'connected')})") + + print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platforms)}") + if args.schedule: + print(f"Scheduled for: {args.schedule}") + print() + + for i, clip in enumerate(clips): + print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)") + print(f" {clip.get('description', '')[:80]}") + print(f" {' '.join(clip.get('hashtags', []))}") + print() + + if args.dry_run: + print("Dry run — nothing uploaded.") + return + + if not args.yes: + confirm = input("Proceed? [y/N] ").strip().lower() + if confirm != "y": + print("Cancelled.") + return + + # Upload each clip + 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']}\"") + + # Upload video to Postiz + print(f" Uploading {clip_file.name}...") + media = upload_file(clip_file) + if not media: + print(f" Failed to upload video, skipping") + continue + print(f" Uploaded: {media.get('path', 'ok')}") + + # Post to Instagram Reels + if do_instagram: + print(f" Posting to Instagram Reels...") + content = build_instagram_content(clip) + settings = { + "__type": "instagram", + "post_type": "reel", + } + result = create_post( + ig_integration["id"], content, media, settings, args.schedule + ) + if result: + print(f" Instagram: Posted!") + else: + print(f" Instagram: Failed") + + # Post to YouTube Shorts + if do_youtube: + print(f" Posting to YouTube Shorts...") + content = build_youtube_content(clip) + settings = { + "__type": "youtube", + "title": clip["title"], + "type": "short", + "selfDeclaredMadeForKids": False, + "tags": [h.lstrip("#") for h in clip.get("hashtags", [])], + } + result = create_post( + yt_integration["id"], content, media, settings, args.schedule + ) + if result: + print(f" YouTube: Posted!") + else: + print(f" YouTube: Failed") + + print(f"\nDone!") + + +if __name__ == "__main__": + main() +``` + +**Step 2: Add `POSTIZ_API_KEY` and `POSTIZ_URL` to `.env`** + +Add to `.env`: +``` +POSTIZ_API_KEY=your-postiz-api-key-here +POSTIZ_URL=https://social.lukeattheroost.com +``` + +Get your API key from Postiz Settings page. + +**Step 3: Test the script loads** + +Run: `python upload_clips.py --help` +Expected: Help text displays with all flags + +**Step 4: Commit** + +```bash +git add upload_clips.py +git commit -m "Add upload_clips.py for posting clips to Instagram/YouTube via Postiz" +``` + +--- + +### Task 4: Test with real Postiz instance + +**Step 1: Get Postiz API key** + +Go to `https://social.lukeattheroost.com` → Settings → API Keys → Generate key. Add to `.env` as `POSTIZ_API_KEY`. + +**Step 2: Verify integrations endpoint** + +Run: `python -c "from upload_clips import *; print(json.dumps(fetch_integrations(), indent=2))"` + +This confirms the API key works and shows connected Instagram/YouTube accounts. Note the integration IDs and provider identifiers — if `find_integration()` doesn't match correctly, adjust the provider string matching. + +**Step 3: Dry-run with existing clips** + +Run: `python upload_clips.py clips/episode-12/ --dry-run` +Expected: Shows clip summary, "Dry run — nothing uploaded." + +**Step 4: Upload a single test clip** + +Run: `python upload_clips.py clips/episode-12/ --clip 1 --instagram-only` + +Check Postiz dashboard and Instagram to verify it posted as a Reel. + +**Step 5: Commit .env update (do NOT commit the key itself)** + +The `.env` is gitignored so no action needed. Just ensure the key names are documented in CLAUDE.md if desired. diff --git a/frontend/css/style.css b/frontend/css/style.css index cbf9a2d..d27b3e2 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -349,6 +349,19 @@ section h2 { margin-bottom: 10px; } +.music-section select optgroup { + color: var(--accent); + font-weight: bold; + font-style: normal; + padding: 4px 0; +} + +.music-section select option { + color: var(--text); + font-weight: normal; + padding: 2px 8px; +} + .music-controls { display: flex; gap: 8px; @@ -725,3 +738,26 @@ section h2 { .message.real-caller { border-left: 3px solid var(--accent-red); padding-left: 0.5rem; } .message.ai-caller { border-left: 3px solid var(--accent); padding-left: 0.5rem; } .message.host { border-left: 3px solid var(--accent-green); padding-left: 0.5rem; } + +/* Voicemail */ +.voicemail-section { margin: 1rem 0; } +.voicemail-list { border: 1px solid rgba(232, 121, 29, 0.15); border-radius: var(--radius-sm); padding: 0.5rem; max-height: 200px; overflow-y: auto; } +.voicemail-badge { background: var(--accent-red); color: white; font-size: 0.7rem; font-weight: bold; padding: 0.1rem 0.45rem; border-radius: 10px; margin-left: 0.4rem; vertical-align: middle; } +.voicemail-badge.hidden { display: none; } +.vm-item { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); } +.vm-item:last-child { border-bottom: none; } +.vm-item.vm-unlistened { background: rgba(232, 121, 29, 0.06); } +.vm-info { display: flex; gap: 0.6rem; align-items: center; flex: 1; min-width: 0; } +.vm-phone { font-family: monospace; color: var(--accent); font-size: 0.85rem; } +.vm-time { color: var(--text-muted); font-size: 0.8rem; } +.vm-dur { color: var(--text-muted); font-size: 0.8rem; } +.vm-actions { display: flex; gap: 0.3rem; flex-shrink: 0; } +.vm-btn { border: none; padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.75rem; transition: background 0.2s; } +.vm-btn.listen { background: var(--accent); color: white; } +.vm-btn.listen:hover { background: var(--accent-hover); } +.vm-btn.on-air { background: var(--accent-green); color: white; } +.vm-btn.on-air:hover { background: #6a9a4c; } +.vm-btn.save { background: #3a7bd5; color: white; } +.vm-btn.save:hover { background: #2a5db0; } +.vm-btn.delete { background: var(--accent-red); color: white; } +.vm-btn.delete:hover { background: #e03030; } diff --git a/frontend/index.html b/frontend/index.html index d9cc064..4950c13 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -65,6 +65,14 @@ + +
+

Voicemail

+
+
No voicemails
+
+
+
@@ -224,6 +232,6 @@ - + diff --git a/frontend/js/app.js b/frontend/js/app.js index 9e43d49..6b3a77f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -16,6 +16,15 @@ let tracks = []; let sounds = []; +// --- Helpers --- +function _isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; +} + + // --- Safe JSON parsing --- async function safeFetch(url, options = {}, timeoutMs = 30000) { const controller = new AbortController(); @@ -51,6 +60,8 @@ document.addEventListener('DOMContentLoaded', async () => { await loadSounds(); await loadSettings(); initEventListeners(); + loadVoicemails(); + setInterval(loadVoicemails, 30000); log('Ready. Configure audio devices in Settings, then click a caller to start.'); console.log('AI Radio Show ready'); } catch (err) { @@ -137,6 +148,20 @@ function initEventListeners() { talkBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); }); } + // Spacebar push-to-talk — blur buttons so Space doesn't also trigger button click + document.addEventListener('keydown', e => { + if (e.code !== 'Space' || e.repeat || _isTyping()) return; + e.preventDefault(); + // Blur any focused button so browser doesn't fire its click + if (document.activeElement?.tagName === 'BUTTON') document.activeElement.blur(); + startRecording(); + }); + document.addEventListener('keyup', e => { + if (e.code !== 'Space' || _isTyping()) return; + e.preventDefault(); + stopRecording(); + }); + // Type button document.getElementById('type-btn')?.addEventListener('click', () => { document.getElementById('type-modal')?.classList.remove('hidden'); @@ -630,11 +655,31 @@ async function loadMusic() { const previousValue = select.value; select.innerHTML = ''; - tracks.forEach((track, i) => { - const option = document.createElement('option'); - option.value = track.file; - option.textContent = track.name; - select.appendChild(option); + // Group tracks by genre + const genres = {}; + tracks.forEach(track => { + const genre = track.genre || 'Other'; + if (!genres[genre]) genres[genre] = []; + genres[genre].push(track); + }); + + // Sort genre names, but put "Other" last + const genreOrder = Object.keys(genres).sort((a, b) => { + if (a === 'Other') return 1; + if (b === 'Other') return -1; + return a.localeCompare(b); + }); + + genreOrder.forEach(genre => { + const group = document.createElement('optgroup'); + group.label = genre; + genres[genre].forEach(track => { + const option = document.createElement('option'); + option.value = track.file; + option.textContent = track.name; + group.appendChild(option); + }); + select.appendChild(group); }); // Restore previous selection if it still exists @@ -1225,3 +1270,93 @@ async function stopServer() { log('Failed to stop server: ' + err.message); } } + + +// --- Voicemail --- +let _currentVmAudio = null; + +async function loadVoicemails() { + try { + const res = await fetch('/api/voicemails'); + const data = await res.json(); + renderVoicemails(data); + } catch (err) {} +} + +function renderVoicemails(voicemails) { + const list = document.getElementById('voicemail-list'); + const badge = document.getElementById('voicemail-badge'); + if (!list) return; + + const unlistened = voicemails.filter(v => !v.listened).length; + if (badge) { + badge.textContent = unlistened; + badge.classList.toggle('hidden', unlistened === 0); + } + + if (voicemails.length === 0) { + list.innerHTML = '
No voicemails
'; + return; + } + + list.innerHTML = voicemails.map(v => { + const date = new Date(v.timestamp * 1000); + const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const mins = Math.floor(v.duration / 60); + const secs = v.duration % 60; + const durStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + const unlistenedCls = v.listened ? '' : ' vm-unlistened'; + return `
+
+ ${v.phone} + ${timeStr} + ${durStr} +
+
+ + + + +
+
`; + }).join(''); +} + +function listenVoicemail(id) { + if (_currentVmAudio) { + _currentVmAudio.pause(); + _currentVmAudio = null; + } + _currentVmAudio = new Audio(`/api/voicemail/${id}/audio`); + _currentVmAudio.play(); + fetch(`/api/voicemail/${id}/mark-listened`, { method: 'POST' }).then(() => loadVoicemails()); +} + +async function playVoicemailOnAir(id) { + try { + await safeFetch(`/api/voicemail/${id}/play-on-air`, { method: 'POST' }); + log('Playing voicemail on air'); + loadVoicemails(); + } catch (err) { + log('Failed to play voicemail: ' + err.message); + } +} + +async function saveVoicemail(id) { + try { + await safeFetch(`/api/voicemail/${id}/save`, { method: 'POST' }); + log('Voicemail saved to archive'); + } catch (err) { + log('Failed to save voicemail: ' + err.message); + } +} + +async function deleteVoicemail(id) { + if (!confirm('Delete this voicemail?')) return; + try { + await safeFetch(`/api/voicemail/${id}`, { method: 'DELETE' }); + loadVoicemails(); + } catch (err) { + log('Failed to delete voicemail: ' + err.message); + } +} diff --git a/make_clips.py b/make_clips.py index 862d2cd..71576c8 100755 --- a/make_clips.py +++ b/make_clips.py @@ -20,6 +20,7 @@ import re import subprocess import sys import tempfile +import xml.etree.ElementTree as ET from pathlib import Path import requests @@ -28,6 +29,8 @@ from dotenv import load_dotenv load_dotenv(Path(__file__).parent / ".env") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +RSS_FEED_URL = "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" +EPISODE_CACHE_DIR = Path(__file__).parent / "clips" / ".episode-cache" WHISPER_MODEL_FAST = "base" WHISPER_MODEL_QUALITY = "large-v3" COVER_ART = Path(__file__).parent / "website" / "images" / "cover.png" @@ -273,7 +276,7 @@ Respond with ONLY a JSON array, no markdown or explanation: "Content-Type": "application/json", }, json={ - "model": "anthropic/claude-3.5-sonnet", + "model": "anthropic/claude-sonnet-4-5", "messages": [{"role": "user", "content": prompt}], "max_tokens": 2048, "temperature": 0.3, @@ -309,6 +312,70 @@ Respond with ONLY a JSON array, no markdown or explanation: return validated +def generate_social_metadata(clips: list[dict], labeled_transcript: str, + episode_number: int | None) -> list[dict]: + """Generate social media descriptions and hashtags for each clip.""" + if not OPENROUTER_API_KEY: + print("Error: OPENROUTER_API_KEY not set in .env") + sys.exit(1) + + clips_summary = "\n".join( + f'{i+1}. "{c["title"]}" — {c["caption_text"]}' + for i, c in enumerate(clips) + ) + + episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of " + + prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke. + +Here are {len(clips)} clips selected from this episode: + +{clips_summary} + +For each clip, generate: +1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description. +2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags. + +Respond with ONLY a JSON array matching the clip order: +[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]""" + + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": "anthropic/claude-sonnet-4-5", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 2048, + "temperature": 0.7, + }, + ) + + if response.status_code != 200: + print(f"Error from OpenRouter: {response.text}") + return clips + + content = response.json()["choices"][0]["message"]["content"].strip() + if content.startswith("```"): + content = re.sub(r"^```(?:json)?\n?", "", content) + content = re.sub(r"\n?```$", "", content) + + try: + metadata = json.loads(content) + except json.JSONDecodeError as e: + print(f"Error parsing social metadata: {e}") + return clips + + for i, clip in enumerate(clips): + if i < len(metadata): + clip["description"] = metadata[i].get("description", "") + clip["hashtags"] = metadata[i].get("hashtags", []) + + return clips + + def snap_to_sentences(clips: list[dict], segments: list[dict]) -> list[dict]: """Snap clip start/end times to sentence boundaries. @@ -398,11 +465,10 @@ def get_words_in_range(segments: list[dict], start: float, end: float) -> list[d return words -def _words_similar(a: str, b: str, max_dist: int = 2) -> bool: - """Check if two words are within edit distance max_dist (Levenshtein).""" - if abs(len(a) - len(b)) > max_dist: - return False - # Simple DP edit distance, bounded +def _edit_distance(a: str, b: str) -> int: + """Levenshtein edit distance between two strings.""" + if abs(len(a) - len(b)) > 5: + return max(len(a), len(b)) prev = list(range(len(b) + 1)) for i in range(1, len(a) + 1): curr = [i] + [0] * len(b) @@ -410,139 +476,204 @@ def _words_similar(a: str, b: str, max_dist: int = 2) -> bool: cost = 0 if a[i - 1] == b[j - 1] else 1 curr[j] = min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost) prev = curr - return prev[len(b)] <= max_dist + return prev[len(b)] -def _find_labeled_section(labeled_transcript: str, range_text: str) -> str | None: - """Find the section of labeled transcript matching a Whisper text range.""" - # Strip speaker labels and punctuation from labeled transcript for matching - labeled_stripped = re.sub(r'^[A-Z][A-Z\s\'-]+?:\s*', '', labeled_transcript, flags=re.MULTILINE) - labeled_clean = re.sub(r'[^\w\s]', '', labeled_stripped.lower()) - labeled_clean = re.sub(r'\s+', ' ', labeled_clean) - - whisper_clean = re.sub(r'[^\w\s]', '', range_text.lower()) - whisper_clean = re.sub(r'\s+', ' ', whisper_clean) - whisper_words_list = whisper_clean.split() - - # Try progressively shorter phrases from different positions - for phrase_len in [10, 7, 5, 3]: - for start_offset in [0, len(whisper_words_list) // 3, len(whisper_words_list) // 2]: - words_slice = whisper_words_list[start_offset:start_offset + phrase_len] - phrase = " ".join(words_slice) - if len(phrase) < 8: - continue - pos = labeled_clean.find(phrase) - if pos != -1: - # Map back to original transcript — find first word near this position - match_pos = labeled_transcript.lower().find( - words_slice[0], max(0, pos - 300)) - if match_pos == -1: - match_pos = max(0, pos) - else: - match_pos = max(0, match_pos - start_offset * 6) - - context_start = max(0, match_pos - 400) - context_end = min(len(labeled_transcript), match_pos + len(range_text) + 600) - return labeled_transcript[context_start:context_end] - - return None +def _word_score(a: str, b: str) -> int: + """Alignment score: +2 exact, +1 fuzzy (edit dist ≤2), -1 mismatch.""" + if a == b: + return 2 + if len(a) >= 3 and len(b) >= 3 and _edit_distance(a, b) <= 2: + return 1 + return -1 -def _parse_labeled_words(labeled_section: str) -> list[tuple[str, str, str]]: - """Parse speaker-labeled text into (original_word, clean_lower, speaker) tuples.""" +def _align_sequences(whisper_words: list[str], + labeled_words: list[str]) -> list[tuple[int | None, int | None]]: + """Needleman-Wunsch DP alignment between whisper and labeled word sequences. + + Returns list of (whisper_idx, labeled_idx) pairs where None = gap. + """ + n = len(whisper_words) + m = len(labeled_words) + GAP = -1 + + # Build score matrix + score = [[0] * (m + 1) for _ in range(n + 1)] + for i in range(1, n + 1): + score[i][0] = score[i - 1][0] + GAP + for j in range(1, m + 1): + score[0][j] = score[0][j - 1] + GAP + + for i in range(1, n + 1): + for j in range(1, m + 1): + match = score[i - 1][j - 1] + _word_score(whisper_words[i - 1], labeled_words[j - 1]) + delete = score[i - 1][j] + GAP + insert = score[i][j - 1] + GAP + score[i][j] = max(match, delete, insert) + + # Traceback + pairs = [] + i, j = n, m + while i > 0 or j > 0: + if i > 0 and j > 0 and score[i][j] == score[i - 1][j - 1] + _word_score(whisper_words[i - 1], labeled_words[j - 1]): + pairs.append((i - 1, j - 1)) + i -= 1 + j -= 1 + elif i > 0 and score[i][j] == score[i - 1][j] + GAP: + pairs.append((i - 1, None)) + i -= 1 + else: + pairs.append((None, j - 1)) + j -= 1 + + pairs.reverse() + return pairs + + +def _parse_full_transcript(labeled_transcript: str) -> list[dict]: + """Parse entire labeled transcript into flat word list with speaker metadata. + + Returns list of {word: str, clean: str, speaker: str} for every word. + """ result = [] for m in re.finditer(r'^([A-Z][A-Z\s\'-]+?):\s*(.+?)(?=\n[A-Z][A-Z\s\'-]+?:|\n\n|\Z)', - labeled_section, re.MULTILINE | re.DOTALL): + labeled_transcript, re.MULTILINE | re.DOTALL): speaker = m.group(1).strip() text = m.group(2) for w in text.split(): original = w.strip() clean = re.sub(r"[^\w']", '', original.lower()) if clean: - result.append((original, clean, speaker)) + result.append({"word": original, "clean": clean, "speaker": speaker}) return result +def _find_transcript_region(labeled_words: list[dict], whisper_words: list[str], + ) -> tuple[int, int] | None: + """Find the region of labeled_words that best matches the whisper words. + + Uses multi-anchor matching: tries phrases from start, middle, and end + of the whisper words to find a consensus region. + """ + if not whisper_words or not labeled_words: + return None + + labeled_clean = [w["clean"] for w in labeled_words] + n_labeled = len(labeled_clean) + + def find_phrase(phrase_words: list[str], search_start: int = 0, + search_end: int | None = None) -> int | None: + """Find a phrase in labeled_clean, return index of first word or None.""" + if search_end is None: + search_end = n_labeled + plen = len(phrase_words) + for i in range(search_start, min(search_end, n_labeled - plen + 1)): + match = True + for k in range(plen): + if _word_score(phrase_words[k], labeled_clean[i + k]) < 1: + match = False + break + if match: + return i + return None + + # Try anchors from different positions in the whisper words + anchors = [] + n_whisper = len(whisper_words) + anchor_positions = [0, n_whisper // 2, max(0, n_whisper - 5)] + # Deduplicate positions + anchor_positions = sorted(set(anchor_positions)) + + for pos in anchor_positions: + for phrase_len in [5, 4, 3]: + phrase = whisper_words[pos:pos + phrase_len] + if len(phrase) < 3: + continue + idx = find_phrase(phrase) + if idx is not None: + # Estimate region start based on anchor's position in whisper + region_start = max(0, idx - pos) + anchors.append(region_start) + break + + if not anchors: + return None + + # Use median anchor as region start for robustness + anchors.sort() + region_start = anchors[len(anchors) // 2] + + # Region extends to cover all whisper words plus margin + margin = max(20, n_whisper // 4) + region_start = max(0, region_start - margin) + region_end = min(n_labeled, region_start + n_whisper + 2 * margin) + + return (region_start, region_end) + + 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. - Uses Whisper only for timestamps. Takes text from the labeled transcript, - which has correct names and spelling. Aligns using greedy forward matching - with edit-distance fuzzy matching. + 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. """ if not labeled_transcript or not words: return words - # Get the raw Whisper text for this time range - range_text = "" - for seg in segments: - if seg["end"] < start_time or seg["start"] > end_time: - continue - range_text += " " + seg["text"] - range_text = range_text.strip() - - # Find matching section in labeled transcript - labeled_section = _find_labeled_section(labeled_transcript, range_text) - if not labeled_section: + # Parse full transcript into flat word list + all_labeled = _parse_full_transcript(labeled_transcript) + if not all_labeled: return words - labeled_words_flat = _parse_labeled_words(labeled_section) - if not labeled_words_flat: + # 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 - # Greedy forward alignment: for each Whisper word, find best match - # in labeled words within a lookahead window - labeled_idx = 0 - current_speaker = labeled_words_flat[0][2] + region_start, region_end = region + 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 = {} + 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) + + # Apply matches and interpolate speakers for gaps 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 word_entry in words: - whisper_clean = re.sub(r"[^\w']", '', word_entry["word"].lower()) - if not whisper_clean: - word_entry["speaker"] = current_speaker - continue - - # Search forward for best match - best_idx = None - best_score = 0 # 2 = exact, 1 = fuzzy - window = min(labeled_idx + 12, len(labeled_words_flat)) - - for j in range(labeled_idx, window): - labeled_clean = labeled_words_flat[j][1] - - if labeled_clean == whisper_clean: - best_idx = j - best_score = 2 - break - - if len(whisper_clean) >= 3 and len(labeled_clean) >= 3: - if _words_similar(whisper_clean, labeled_clean): - if best_score < 1: - best_idx = j - best_score = 1 - # Don't break — keep looking for exact match - - if best_idx is not None: - original_word, _, speaker = labeled_words_flat[best_idx] - current_speaker = speaker - - # Replace Whisper's word with correct version - corrected = re.sub(r'[^\w\s\'-]', '', original_word) - if corrected and corrected.lower() != whisper_clean: + # 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 - corrections += 1 - elif corrected: - word_entry["word"] = corrected - - labeled_idx = best_idx + 1 else: - # No match — advance labeled pointer by 1 to stay roughly in sync - if labeled_idx < len(labeled_words_flat): - labeled_idx += 1 - - word_entry["speaker"] = current_speaker + # Interpolate speaker from nearest matched neighbor + speaker = _interpolate_speaker(i, matched, len(words)) + if speaker: + word_entry["speaker"] = speaker if corrections: print(f" Corrected {corrections} words from labeled transcript") @@ -550,6 +681,19 @@ def add_speaker_labels(words: list[dict], labeled_transcript: str, return words +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 + if before >= 0 and before in matched: + return matched[before][0]["speaker"] + if after < n_words and after in matched: + return matched[after][0]["speaker"] + return None + + def group_words_into_lines(words: list[dict], clip_start: float, clip_duration: float) -> list[dict]: """Group words into timed caption lines for rendering. @@ -894,9 +1038,123 @@ def detect_episode_number(audio_path: str) -> int | None: return None +def fetch_episodes() -> list[dict]: + """Fetch episode list from Castopod RSS feed.""" + print("Fetching episodes from Castopod...") + try: + resp = requests.get(RSS_FEED_URL, timeout=15) + resp.raise_for_status() + except requests.RequestException as e: + print(f"Error fetching RSS feed: {e}") + sys.exit(1) + + root = ET.fromstring(resp.content) + ns = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"} + episodes = [] + + for item in root.findall(".//item"): + title = item.findtext("title", "") + enclosure = item.find("enclosure") + audio_url = enclosure.get("url", "") if enclosure is not None else "" + duration = item.findtext("itunes:duration", "", ns) + ep_num = item.findtext("itunes:episode", "", ns) + pub_date = item.findtext("pubDate", "") + + if not audio_url: + continue + + episodes.append({ + "title": title, + "audio_url": audio_url, + "duration": duration, + "episode_number": int(ep_num) if ep_num and ep_num.isdigit() else None, + "pub_date": pub_date, + }) + + return episodes + + +def pick_episode(episodes: list[dict]) -> dict: + """Display episode list and let user pick one.""" + if not episodes: + print("No episodes found.") + sys.exit(1) + + # Sort by episode number (episodes without numbers go to the end) + episodes.sort(key=lambda e: (e["episode_number"] is None, e["episode_number"] or 0)) + + print(f"\nFound {len(episodes)} episodes:\n") + for ep in episodes: + num = ep['episode_number'] + label = f"Ep{num}" if num else " " + dur = ep['duration'] or "?" + display_num = f"{num:>2}" if num else " ?" + print(f" {display_num}. [{label:>4}] {ep['title']} ({dur})") + + print() + while True: + try: + choice = input("Select episode number (or 'q' to quit): ").strip() + if choice.lower() == 'q': + sys.exit(0) + num = int(choice) + # Match by episode number first + match = next((ep for ep in episodes if ep["episode_number"] == num), None) + if match: + return match + print(f" No episode #{num} found. Episodes: {', '.join(str(e['episode_number']) for e in episodes if e['episode_number'])}") + except (ValueError, EOFError): + print(" Enter an episode number") + + +def download_episode(episode: dict) -> Path: + """Download episode audio, using a cache to avoid re-downloading.""" + EPISODE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + # Build a filename from episode number or title slug + if episode["episode_number"]: + filename = f"episode-{episode['episode_number']}.mp3" + else: + filename = slugify(episode["title"]) + ".mp3" + + cached = EPISODE_CACHE_DIR / filename + if cached.exists(): + size_mb = cached.stat().st_size / (1024 * 1024) + print(f"Using cached: {cached.name} ({size_mb:.1f} MB)") + return cached + + print(f"Downloading: {episode['title']}...") + try: + resp = requests.get(episode["audio_url"], stream=True, timeout=30) + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + with open(cached, "wb") as f: + for chunk in resp.iter_content(chunk_size=1024 * 1024): + f.write(chunk) + downloaded += len(chunk) + if total: + pct = downloaded / total * 100 + print(f"\r {downloaded / (1024*1024):.1f} / {total / (1024*1024):.1f} MB ({pct:.0f}%)", end="", flush=True) + else: + print(f"\r {downloaded / (1024*1024):.1f} MB", end="", flush=True) + print() + except requests.RequestException as e: + if cached.exists(): + cached.unlink() + print(f"\nError downloading episode: {e}") + sys.exit(1) + + size_mb = cached.stat().st_size / (1024 * 1024) + print(f"Saved: {cached.name} ({size_mb:.1f} MB)") + return cached + + def main(): parser = argparse.ArgumentParser(description="Extract short-form clips from podcast episodes") - parser.add_argument("audio_file", help="Path to episode MP3") + parser.add_argument("audio_file", nargs="?", help="Path to episode MP3 (optional if using --pick)") + parser.add_argument("--pick", "-p", action="store_true", + help="Pick an episode from Castopod to clip") parser.add_argument("--transcript", "-t", help="Path to labeled transcript (.txt)") parser.add_argument("--chapters", "-c", help="Path to chapters JSON") parser.add_argument("--count", "-n", type=int, default=3, help="Number of clips to extract (default: 3)") @@ -911,13 +1169,27 @@ def main(): help="Use quality model for everything (slower, no two-pass)") args = parser.parse_args() - audio_path = Path(args.audio_file).expanduser().resolve() - if not audio_path.exists(): - print(f"Error: Audio file not found: {audio_path}") - sys.exit(1) + # Default to --pick when no audio file provided + if not args.audio_file and not args.pick: + args.pick = True + + if args.pick: + episodes = fetch_episodes() + selected = pick_episode(episodes) + audio_path = download_episode(selected) + episode_number = selected["episode_number"] or args.episode_number + else: + audio_path = Path(args.audio_file).expanduser().resolve() + if not audio_path.exists(): + print(f"Error: Audio file not found: {audio_path}") + sys.exit(1) + episode_number = None # Detect episode number - episode_number = args.episode_number or detect_episode_number(str(audio_path)) + if not args.pick: + episode_number = args.episode_number or detect_episode_number(str(audio_path)) + if args.episode_number: + episode_number = args.episode_number # Resolve output directory if args.output_dir: @@ -959,9 +1231,9 @@ def main(): # Step 2: Fast transcription for clip identification two_pass = not args.single_pass and args.fast_model != args.quality_model if two_pass: - print(f"\n[2/6] Fast transcription for clip identification ({args.fast_model})...") + print(f"\n[2/7] Fast transcription for clip identification ({args.fast_model})...") else: - print(f"\n[2/5] Transcribing with word-level timestamps ({args.quality_model})...") + print(f"\n[2/6] Transcribing with word-level timestamps ({args.quality_model})...") identify_model = args.fast_model if two_pass else args.quality_model segments = transcribe_with_timestamps( str(audio_path), identify_model, labeled_transcript @@ -980,7 +1252,7 @@ def main(): print(f" Chapters loaded: {chapters_path.name}") # Step 3: LLM selects best moments - step_total = 6 if two_pass else 5 + step_total = 7 if two_pass else 6 print(f"\n[3/{step_total}] Selecting {args.count} best moments with LLM...") clips = select_clips_with_llm(transcript_text, labeled_transcript, chapters_json, args.count) @@ -994,10 +1266,19 @@ def main(): f"({clip['start_time']:.1f}s - {clip['end_time']:.1f}s, {duration:.0f}s)") print(f" \"{clip['caption_text']}\"") - # Step 4: Refine clip timestamps with quality model (two-pass only) + # Generate social media metadata + meta_step = 4 + print(f"\n[{meta_step}/{step_total}] Generating social media descriptions...") + clips = generate_social_metadata(clips, labeled_transcript, episode_number) + for i, clip in enumerate(clips): + if "description" in clip: + print(f" Clip {i+1}: {clip['description'][:80]}...") + print(f" {' '.join(clip.get('hashtags', []))}") + + # Step 5: Refine clip timestamps with quality model (two-pass only) refined = {} if two_pass: - print(f"\n[4/{step_total}] Refining clips with {args.quality_model}...") + print(f"\n[5/{step_total}] Refining clips with {args.quality_model}...") refined = refine_clip_timestamps( str(audio_path), clips, args.quality_model, labeled_transcript ) @@ -1008,7 +1289,7 @@ def main(): clips[i:i+1] = snap_to_sentences([clip], clip_segments) # Step N: Extract audio clips - extract_step = 5 if two_pass else 4 + extract_step = 6 if two_pass else 5 print(f"\n[{extract_step}/{step_total}] Extracting audio clips...") for i, clip in enumerate(clips): slug = slugify(clip["title"]) @@ -1020,7 +1301,7 @@ def main(): else: print(f" Error extracting clip {i+1} audio") - video_step = 6 if two_pass else 5 + video_step = 7 if two_pass else 6 if args.audio_only: print(f"\n[{video_step}/{step_total}] Skipped video generation (--audio-only)") print(f"\nDone! {len(clips)} audio clips saved to {output_dir}") @@ -1071,6 +1352,27 @@ def main(): else: print(f" Error generating clip {i+1} video") + # Save clips metadata for social upload + metadata_path = output_dir / "clips-metadata.json" + metadata = [] + for i, clip in enumerate(clips): + slug = slugify(clip["title"]) + metadata.append({ + "title": clip["title"], + "clip_file": f"clip-{i+1}-{slug}.mp4", + "audio_file": f"clip-{i+1}-{slug}.mp3", + "caption_text": clip.get("caption_text", ""), + "description": clip.get("description", ""), + "hashtags": clip.get("hashtags", []), + "start_time": clip["start_time"], + "end_time": clip["end_time"], + "duration": round(clip["end_time"] - clip["start_time"], 1), + "episode_number": episode_number, + }) + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + print(f"\nSocial metadata: {metadata_path}") + # Summary print(f"\nDone! {len(clips)} clips saved to {output_dir}") for i, clip in enumerate(clips): diff --git a/publish_episode.py b/publish_episode.py index 8109037..3b30de3 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -60,7 +60,7 @@ PODCAST_ID = 1 PODCAST_HANDLE = "LukeAtTheRoost" OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") -WHISPER_MODEL = "large-v3" +WHISPER_MODEL = "distil-large-v3" # Postiz (social media posting) POSTIZ_URL = "https://social.lukeattheroost.com" @@ -189,35 +189,41 @@ TRANSCRIPT: def transcribe_audio(audio_path: str) -> dict: - """Transcribe audio using faster-whisper with timestamps.""" - print(f"[1/5] Transcribing {audio_path}...") + """Transcribe audio using Lightning Whisper MLX (Apple Silicon GPU).""" + print(f"[1/5] Transcribing {audio_path} (MLX GPU)...") try: - from faster_whisper import WhisperModel + from lightning_whisper_mlx import LightningWhisperMLX except ImportError: - print("Error: faster-whisper not installed. Run: pip install faster-whisper") + print("Error: lightning-whisper-mlx not installed. Run: pip install lightning-whisper-mlx") sys.exit(1) - model = WhisperModel(WHISPER_MODEL, compute_type="int8") - segments, info = model.transcribe(audio_path, word_timestamps=True) + probe = subprocess.run( + ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", audio_path], + capture_output=True, text=True + ) + duration = int(float(probe.stdout.strip())) if probe.returncode == 0 else 0 + + whisper = LightningWhisperMLX(model=WHISPER_MODEL, batch_size=12, quant=None) + result = whisper.transcribe(audio_path=audio_path, language="en") transcript_segments = [] full_text = [] - for segment in segments: + for segment in result.get("segments", []): + start_ms, end_ms, text = segment[0], segment[1], segment[2] transcript_segments.append({ - "start": segment.start, - "end": segment.end, - "text": segment.text.strip() + "start": start_ms / 1000.0, + "end": end_ms / 1000.0, + "text": text.strip() }) - full_text.append(segment.text.strip()) - - print(f" Transcribed {info.duration:.1f} seconds of audio") + full_text.append(text.strip()) + print(f" Transcribed {duration} seconds of audio ({len(transcript_segments)} segments)") return { "segments": transcript_segments, "full_text": " ".join(full_text), - "duration": int(info.duration) + "duration": duration } diff --git a/website/_worker.js b/website/_worker.js new file mode 100644 index 0000000..841b937 --- /dev/null +++ b/website/_worker.js @@ -0,0 +1,64 @@ +const VOICEMAIL_XML = ` + + Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show! + + Thank you for calling. Goodbye! + +`; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/api/signalwire/voice") { + try { + const body = await request.text(); + const resp = await fetch("https://radioshow.macneilmediagroup.com/api/signalwire/voice", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body, + signal: AbortSignal.timeout(5000), + }); + + if (resp.ok) { + return new Response(await resp.text(), { + status: 200, + headers: { "Content-Type": "application/xml" }, + }); + } + } catch (e) { + // Server unreachable or timed out + } + + return new Response(VOICEMAIL_XML, { + status: 200, + headers: { "Content-Type": "application/xml" }, + }); + } + + // RSS feed proxy + if (url.pathname === "/feed") { + try { + const resp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", { + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + return new Response(await resp.text(), { + status: 200, + headers: { + "Content-Type": "application/xml", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300", + }, + }); + } + } catch (e) { + // Castopod unreachable + } + return new Response("Feed unavailable", { status: 502 }); + } + + // All other requests — serve static assets + return env.ASSETS.fetch(request); + }, +}; diff --git a/website/css/style.css b/website/css/style.css index fb179f3..639fe27 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -88,46 +88,39 @@ a:hover { } .phone { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; margin-top: 0.5rem; } -.phone-number { - font-size: 2.2rem; - font-weight: 800; - color: var(--accent); - letter-spacing: 0.02em; - display: block; -} - -.phone-digits { +.phone-inline { font-size: 0.95rem; color: var(--text-muted); } -.phone-label { - font-size: 0.85rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.15em; - margin-bottom: 0.25rem; +.phone-inline strong { + color: var(--text); + font-weight: 700; + letter-spacing: 0.02em; } /* On-Air Badge */ .on-air-badge { display: none; align-items: center; - justify-content: center; - gap: 0.5rem; + gap: 0.4rem; background: var(--accent-red); color: #fff; - padding: 0.4rem 1.2rem; + padding: 0.25rem 0.75rem; border-radius: 50px; - font-size: 0.85rem; + font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; animation: on-air-glow 2s ease-in-out infinite; - margin-bottom: 0.5rem; + flex-shrink: 0; } .on-air-badge.visible { @@ -156,99 +149,98 @@ a:hover { .off-air-badge { display: inline-flex; align-items: center; - justify-content: center; background: #444; color: var(--text-muted); - padding: 0.35rem 1.1rem; + padding: 0.25rem 0.75rem; border-radius: 50px; - font-size: 0.8rem; + font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; - margin-bottom: 0.5rem; + flex-shrink: 0; } .off-air-badge.hidden { display: none; } -.phone.live .phone-number { +.phone.live .phone-inline strong { color: var(--accent-red); text-shadow: 0 0 16px rgba(204, 34, 34, 0.35); } -.phone.live .phone-label { - color: var(--text); -} - /* Subscribe buttons — primary listen platforms */ .subscribe-row { display: flex; - flex-wrap: wrap; - justify-content: center; + flex-direction: column; + align-items: center; gap: 0.6rem; margin-top: 1.5rem; } +.subscribe-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.15em; +} + +.subscribe-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + .subscribe-btn { display: inline-flex; align-items: center; - gap: 0.45rem; - padding: 0.55rem 1.1rem; + gap: 0.4rem; + padding: 0.45rem 1rem; border-radius: 50px; - font-size: 0.85rem; + font-size: 0.8rem; font-weight: 600; - color: #fff; - transition: opacity 0.2s, transform 0.2s; + color: var(--text); + background: transparent; + border: 1px solid var(--text-muted); + transition: border-color 0.2s, color 0.2s; } .subscribe-btn:hover { - opacity: 0.85; - transform: translateY(-1px); - color: #fff; + border-color: var(--accent); + color: var(--accent); } .subscribe-btn svg { - width: 16px; - height: 16px; + width: 14px; + height: 14px; flex-shrink: 0; } -.btn-spotify { background: #1DB954; } -.btn-youtube { background: #FF0000; } -.btn-apple { background: #A033FF; } - -/* Secondary links — How It Works, Discord, RSS */ +/* Secondary links — How It Works, Discord, Support */ .secondary-links { display: flex; flex-wrap: wrap; justify-content: center; - gap: 1.25rem; + align-items: center; + gap: 0.5rem; margin-top: 0.75rem; } .secondary-link { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.85rem; - font-weight: 600; - color: var(--accent); - border: 1px solid var(--accent); - border-radius: 50px; - padding: 0.3rem 0.85rem; - transition: background 0.2s, color 0.2s; + font-size: 0.8rem; + color: var(--text-muted); + transition: color 0.2s; } .secondary-link:hover { - background: var(--accent); - color: #fff; + color: var(--accent); } -.secondary-link svg { - width: 14px; - height: 14px; - flex-shrink: 0; +.secondary-sep { + color: var(--text-muted); + opacity: 0.4; + font-size: 0.8rem; } /* Episodes */ @@ -729,6 +721,26 @@ a:hover { height: 100%; } +.diagram-row-compact { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; + width: 100%; +} + +.diagram-row-compact .diagram-box { + min-width: unset; + padding: 0.5rem 0.75rem; + font-size: 0.7rem; + gap: 0.25rem; +} + +.diagram-row-compact .diagram-icon { + width: 20px; + height: 20px; +} + .diagram-arrow { font-size: 1.5rem; color: var(--text-muted); @@ -916,6 +928,21 @@ a:hover { color: var(--accent); } +.hiw-cta-support { + display: inline-block; + margin-top: 1.25rem; + color: var(--text-muted); + font-size: 0.85rem; + text-decoration: none; + border-bottom: 1px solid var(--text-muted); + transition: color 0.2s, border-color 0.2s; +} + +.hiw-cta-support:hover { + color: var(--accent); + border-color: var(--accent); +} + /* Episode Page */ .ep-header { max-width: 900px; diff --git a/website/episode.html b/website/episode.html index d6d9be3..441ee7b 100644 --- a/website/episode.html +++ b/website/episode.html @@ -96,6 +96,7 @@ X Bluesky Mastodon + Nostr Spotify YouTube RSS @@ -108,6 +109,7 @@ YouTube +

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/how-it-works.html b/website/how-it-works.html index 805ae31..20ddf90 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -94,6 +94,12 @@ Real Callers +
+
+ +
+ Voicemails +
@@ -132,6 +138,18 @@ Audio Router +
+
+ +
+ Phone System +
+
+
+ +
+ Ad Engine +
@@ -197,7 +215,7 @@
Distribution
-
+
@@ -228,12 +246,30 @@
Instagram
+
+
+ +
+ Facebook +
Bluesky
+
+
+ +
+ Mastodon +
+
+
+ +
+ Nostr +
@@ -540,6 +576,7 @@
Or call in live: 208-439-LUKE
+ Support the show on Ko-fi
@@ -552,6 +589,7 @@ X Bluesky Mastodon + Nostr Spotify YouTube RSS @@ -564,6 +602,7 @@ YouTube +

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/index.html b/website/index.html index a993d96..e67fc75 100644 --- a/website/index.html +++ b/website/index.html @@ -89,37 +89,35 @@
OFF AIR
- Call in live - 208-439-LUKE - (208-439-5853) + Call in: 208-439-LUKE
- - - Spotify - - - - Apple - - - - YouTube - + Listen On +
+ + + + +
@@ -217,6 +215,7 @@ X Bluesky Mastodon + Nostr Spotify YouTube RSS @@ -229,6 +228,7 @@ YouTube +

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/privacy.html b/website/privacy.html index 14791cc..4373b8a 100644 --- a/website/privacy.html +++ b/website/privacy.html @@ -93,6 +93,7 @@ X Bluesky Mastodon + Nostr Spotify YouTube RSS @@ -105,6 +106,7 @@ YouTube +

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/sitemap.xml b/website/sitemap.xml index 44bab6a..81fb4a6 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -96,4 +96,10 @@ never 0.7 + + https://lukeattheroost.com/episode.html?slug=episode-13-navigating-life-s-unexpected-turns + 2026-02-16 + never + 0.7 + diff --git a/website/stats.html b/website/stats.html index f7d129c..4a92f99 100644 --- a/website/stats.html +++ b/website/stats.html @@ -65,6 +65,7 @@ X Bluesky Mastodon + Nostr Spotify YouTube RSS @@ -77,6 +78,7 @@ YouTube +

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/transcripts/episode-13-navigating-life-s-unexpected-turns.txt b/website/transcripts/episode-13-navigating-life-s-unexpected-turns.txt new file mode 100644 index 0000000..d0652e1 --- /dev/null +++ b/website/transcripts/episode-13-navigating-life-s-unexpected-turns.txt @@ -0,0 +1,205 @@ +LUKE: All right, welcome back. It's Luke at the Roost. This is the radio show where I take your calls and give you real-world advice. If you'd like to call in, the numbers 208-439-58-3-3. That's 208-439 Luke. And we have a new feature to the show. Now, if you call in and we're not recording, you can leave a voicemail. And if your voicemail is funny enough, I will play it on the next episode. If we all, we all, we are, you all. We are, you all, you can leave a voicemail. If we are, you call in, we are, if you can leave a voicemail. If we are, if you are, if you can leave you can recording and you call in now, you'll be placed into a queue, and I can take your call, and we'll talk to you live on the air. Today is Sunday, February 15th. It's about 10.30, and our phones are already lighting up, as they usually are around now. And first up on the show, we have Phil. Phil, welcome to the show. What would you like to talk about tonight? + +PHIL: Oh, man, Luke, thanks for taking my call. So I'm sitting in my truck right now, because Three hours ago, my brother sent me a screenshot of my partner's Tinder profile, and I can't go home yet. + +LUKE: Oh, what was the screenshot? Was it a picture you're familiar with? + +PHIL: Yeah, it's this photo from last month. We went up to Sedona. I paid for the whole trip, and Marcus bought this ridiculous cowboy hat at some tourist shop. We were laughing about it, you know, like it was this inside joke between us. Tinder photo. + +LUKE: So what are you going to do? Do you have a Tinder profile as well? Are you going to match with them? + +PHIL: Oh, Jesus. I haven't even thought about that. No, I don't have one. I mean, I thought we were past all that. But that's kind of genius in a horrible way, right? Just swipe right. See what happens? + +LUKE: Yeah, maybe you've got something in common and it's worth getting together and having a little date or some type thing. + +PHIL: Yeah. Hey, you look familiar. God, that's... I don't know if I'm more pissed off or if I just want to understand what the hell he's thinking. Like we've been together two years. I lost my first husband to cancer six years back, and I really thought Marcus got that... got how big a deal it was for me to do this again. + +LUKE: Yeah, that sounds unfortunate. You should probably maybe confront Marcus about that and be like, hey, saw your Tinder profile. What's this about? How long you've been stepping? out, um, then make sure that whatever it is he's doing, he's doing in a protected manner, you know? + +PHIL: Yeah, you're right. I mean, that's the thing that's actually making me sick right now. Like, I haven't even let myself go there yet. The health stuff. + +LUKE: Well, that's the important stuff. And you might want to get yourself checked out and all that, uh, before anything becomes an issue. But talk to, talk to, talk to your partner. And, uh, maybe you've found, um, you've found, out the hard way that he's not the right partner for you. + +PHIL: Yeah. Yeah. I think I already know he's not, you know? Like the second I saw that screenshot, it wasn't even shock. It was just this feeling of, oh, there it is. Like I've been waiting for the other shoe to drop this whole time and now it has. The thing is, I keep thinking about my first husband, Tom. + +LUKE: And what happened with your first husband? answer? Did I get that right? + +PHIL: Yeah, pancreatic. It was eight months from diagnosis to the end. And the whole time, even when he couldn't get out of bed, even when he was so sick, there was never a question, you know, never a doubt about us. And I guess I thought that's what love was supposed to look like. That's the bar. + +LUKE: Hey, man, I'm sorry to hear that. And, uh, you know, some people get that once in their life. Some people get it more than once. Uh, the thing is you've got to be, You got to be all right by yourself and worry about love second. So how are you feeling individually as yourself? Do you need your partner to feel whole or would you be all right on your own? + +PHIL: Man, that's the question, isn't it? I don't know. I thought I was good on my own after Tom died. I did the work, you know. Went to the grief group, got back into my photography, managed the bar, saw my friends. + +LUKE: sound like it's a good idea to hang out with a dude that is seeing other people on the side without telling you. So it might be time to break up that relationship. I don't know. You should have that conversation with him. And if you've got a strike out on your own again, then do that. Get back to the photography and your friends and move on with your life. + +PHIL: You're right. You're absolutely right. I just... I keep thinking about how I'm going to tell my brother + +LUKE: Why is your brother in this picture? It sounds like that's none of his business. + +PHIL: No, you're right, it's just he's the one who sent me the screenshot. He found Marcus on Tinder and took the screenshot and sent it to me. So now he's going to want to know what I did about it. And Eddie's got this way of making everything feel like a test I'm failing, you know? Like he's keeping score of whether I'm handling my life right. + +LUKE: whatever, let him keep score. You don't have to care about what his score is. Just sit down with your partner and talk it out. If you got to break up, break up and move on. And I wish you only happiness going forward. Okay, sir. Thank you for the call. + +PHIL: Yeah, yeah, okay. Thanks, Luke. + +LUKE: All right. Good luck to you and your brother and your husband or whatever. Hope everything works out there. Looks like next up we've got Lori. Welcome to the show. How are you doing tonight? + +LORI: Oh, man, I'm not great, Luke. I've been sitting in my truck at a flying J for 20 minutes trying to figure out if I'm about to blow up my whole life here. + +LUKE: Why are you thinking about blowing up your life? + +LORI: So my 14-year-old daughter's been playing me and her dad against each other for God knows how long. Telling him, she's at my place on weekends. Telling me she's with him. And actually, she's been out in the desert somewhere with friends doing who the hell knows what. He just called me back about it 20 minutes ago. And here's the thing. I'm hauling refrigerated goods. I'm six days into this route. I've got a load that needs to be in Tucson by tomorrow morning. But I'm four hours from home right now. And I know, I know I need to turn this rig around and deal with this. + +LUKE: Well, think this one through. What happens if you do turn the rig around? What are you going to do when you get there? How are you going to deal with it? + +LORI: That's the problem, Luke. I don't know. I mean, what am I going to do? Ground her. She's already been sneaking out for weeks, maybe months, show up and yell at her. That's just going to make her better at lying. Her dad and I, we can barely have a conversation without it turning into whose fault everything is. + +LUKE: All right? Well, it sounds like that makes your decision, right? If there's no action for you to take if you were to go back, then don't go back. You've got work to do. You've got to take care of your responsibilities to your job too. And then you can deal with this when you're get back. Kids do do this. Kids go out and party in the woods and play their parents off each other. It's a very normal thing for a teenage kid to do. It's not that, not that life-shattering. + +LORI: Yeah, but okay, you're right. It's normal, but Luke, she's 14. And I don't even know where she's going or who she's with. Her dad won't tell me the names of these friends because apparently she made him promised not to. + +LUKE: Well, it doesn't sound like it really matters what their names are, right? She's out doing her thing. I don't see a problem here. Do your job. Talk to your family when you get home. And if you don't want her out partying at 14 out in the desert, then maybe think about a different profession where you can be around to be involved in her life more than being on the road for six days at a time, you know? + +LORI: Wow. Okay. Yeah, no. You're that's exactly what my ex said when I took this job. that I was choosing the road over her. + +LUKE: And here's the thing that pisses me off about that. I took this job because of the divorce. + +LORI: Hey, I'm not trying to ideologically say that it's right or wrong for you to have taken the job. I don't care. I'm just saying if you're a long-haul trucker and you're gone for weeks at a time, then you're not particularly invested in your teenage daughter's life. And you're only going to show up once in a while to scold her for doing something wrong and acting out direction or guidance from our parent in her life. And that's not unusual. I'm not saying it's even wrong. I mean, you want her to be safe and you want to do this job and both those things can happen, but you need to sit down with her and talk to her about safety, make sure that she's letting you know what's going on because you care. If you do care, if not, then you just want to be controlling and punish her for doing, you know, stuff behind your back, which is, I don't think that's fair. + +LUKE: No, you're right. I, God, I do care. I care so much it's making me sick right now sitting here. And you know what the worst part is? My dispatcher, Karen, has been so good to me since the divorce. She worked out this schedule where I could do shorter halls, be home more often, and I still manage to screw it up. + +LORI: I don't know if you screwed it up or not, but the fact remains, you're away from home a lot. your daughter's home, and she's out doing stuff. You know, if you're not going to be around, she's going to live her life. And that might not be in a way that you appreciate. But you're making that choice, and it could be the right choice or the wrong choice for you. But you've got to decide. You can't have it both ways. + +LUKE: You're absolutely right. I can't. And sitting here trying to figure out if I blow this contract and turn around or keep going, I'm just avoiding the real decision. aren't I? The thing is, Luke, when I lost the house and the divorce, when everything fell apart, this job felt like the one thing I could actually control. + +LORI: Well, maybe it is. Maybe the job's a great thing for you and your family and it's providing for you, but you have to then give your daughter the grace to be her own person early because you're not there to give her that traditional upbringing that she otherwise would have got. It doesn't mean she's going to turn into a mom. her in adulthood. She's just going to have a different road. And that can be okay, but you should talk about it. + +LUKE: Yeah. Yeah, you're right. I need to actually talk to her, not at her. + +LORI: That's right. And it sounds like you've got a job to do. So get back on the road, finish out your obligation. And when you get back, sit her down and have a conversation that's not accusatory and you're not mad at her or punishing her, but talk to her. And let her, know what's going on in your life, why you've made the decisions that you've made, and what you expect out of her, and come to a compromise, an agreement. Talk to her like she's an adult, because at this point, she kind of is. + +LUKE: She kind of is. God, that's terrifying. All right. All right. I'm going to finish the route. I'm going to get home Tuesday. And I'm going to sit down with her. Not with her dad there. Not making it this big intervention thing. Just me and her. + +LORI: it sounds like a good plan. + +LUKE: I hope it all works out. But, you know, I was in a similar situation as a teenager. I was your daughter in this scenario. And I was out with people I shouldn't have been with doing things I shouldn't have done. And there's lots of reasons for that. But it's not because my parents didn't love me. It's not because I was a piece of shit. It's, we're not going to get into it all right now. But over the course of the show, we'll probably learn a little bit more about that. For now, though, we have to go to our sponsor. So please stay tuned for a word from our sponsors. Life is hard. You're listening to a man in an RV talk to strangers at 2 in the morning, so you already know that. That's why we partnered with Better Maybe. Online therapy that's honest about the whole situation. With Better Maybe, you get matched with a licensed therapist within 48 hours. Will they fix your problems? Maybe. That's the whole brand. They're not going to lie to you. Your first session might change your life. It also might just be you staring at the webcam while someone in another time zone nods politely. That's still more than your friends are doing. Better maybe. It's better than nothing. And that's not nothing. Okay. Better maybe. Give them a call. Maybe they can help some of our callers. That's why I'm not. they reached out to us so they could get their product to you. Okay, Roland, Roland, welcome to the show. What's going on tonight, sir? + +ROLAND: Oh, man. So I've been working from home for UPS for four years now, routing, logistics, all that. And Friday, they dropped the email, back to the Albuquerque office by March 1st, or I'm out. Three hours of driving every day, Luke, six hours round trip. And the thing is, I finally got my life set up exactly how I wanted it. + +LUKE: Well, it sounds like maybe you've got to let that job go then. I mean, a six-hour round-trip commute to work for UPS doesn't sound reasonable to me. + +ROLAND: Yeah, but it's 16 years total with the company, you know? The benefits, the retirement, are not starting over at 43, and the pay's solid. But, man, I finally set up my grandfather's old workbench in the garage, been restoring furniture at night, and I'm actually at it. My neighbor Gary caught me sanding at midnight Friday with every light on. asked what was eating me, and I couldn't even explain it to him. + +LUKE: Yeah, well, the pay may be solid, but now subtract six hours of commuting expenses and mileage on your vehicle. Retirement doesn't just start over if you go to another company. Like, I assume you've got an IRA or a 401K type situation with them. That's going to roll over into your next position. It's not like you forfeit it. You might forfeit whatever pension it is you got, but you got, what, 45 years more to work? Come on, man. You can't just deal with that for another 40 years because you're holding onto a pension. That's silly. If you've got to let it go, you've got to let it go. + +ROLAND: No, you're right. You're right. I know you're right. It's just, okay. So here's the real thing. I spent the first 12 years on the road, delivering packages, breaking my back in the heat. When they finally moved me to remote work during COVID, it felt like I'd made it, you know? + +LUKE: I do know. I remember back when I demanded to work remote, I would not go into an office anymore. And everybody told me that was a mistake and that you can't do that. This was pre-COVID. And I said, I can do whatever I want. You know, I'm going to do what I'm going to do. And I don't have to do anything I don't want to do. And I don't want to go into an office, so I'm not going to do it. And then I never went back into an office again. You can absolutely do that. There's plenty of ways to make money. You don't have to work for UPS. You can start your own company. You can start your own company. find another job that offers you remote work. You can, I don't know, play the stock market. There's plenty of ways that people make money without going in an office. And if you really don't want to go into an office, don't. + +ROLAND: You know what? You're making it sound simpler than it feels, but okay, I've been reading about these CEOs running remote first companies. No plans to bring people back. And I keep thinking, why can't UPS figure this out? I've been more productive at home, six new hires over Zoom last year, never missed a deadline. But I think what's really getting me is, + +LUKE: Well, before you get to that, I think a lot of companies and a lot of research has proven that people are generally very productive at home, and in a lot of cases more so than an office. + +TERRY: Oh, man, Luke. So my best friend since third grade just told me she's been sleeping with my ex-husband since literally the week our divorce was finalized. Like, Michelle was at my wedding. She helped me move my stuff out of the house six months ago. she's been with David this whole time. And when I got upset about it tonight, because, yeah, I'm upset, she hit me with, it's been six months, you should be over it. Like I'm being dramatic for caring that she's been lying to my face for half a year while I'm crying to her about the divorce. + +LUKE: Yeah, that's messed up. I think she's not your friend, and it's time to let go of that relationship. + +TERRY: I mean, yeah, you're right. I know you're right. But Luke, 30 years. 30 years. We learned to drive together. We got matching tattoos when we turned 20. One, stupid little stars on our ankles. Her mom was like my second mom growing up. And it's not even just about David, you know? + +LUKE: No, I don't know, but the whole David thing tells me that this chick needs to go. She doesn't care about you. Whether you've known her for 30 years or 300 years, Fuck this chick. + +TERRY: You're not wrong. God, you're not wrong. It's just... Okay, so here's the thing that's really messing with me. I'm sitting here in my uncle's laundromat, at 11 o'clock on a Sunday night, with a warm beer, and I'm realizing I'm more hurt about Michelle than I ever was about David. Like the divorce sucked, but I saw that coming for a while, you know? We've been going through the motions for like two years. + +LUKE: Yeah, that's a hard thing. I understand. And, you know, you've got that much history with somebody. It sucks to lose it, but it's, uh, it sounds like it's not you that has made this decision. And this person isn't somebody that's got your back that you can trust. So if you thought you had a great friend, maybe you did it once, but people change. And it sounds like she changed. And maybe some day she'll change again. But for now, let that chick go. She sucks. + +TERRY: Yeah. Yeah. Yeah. She does suck. You know what the worst part is? I called her first when I found out David was seeing someone. Like two weeks ago, I saw his truck at the Applebee's with some woman, and I was all worked up about it, called Michelle literally crying in the parking lot. And she was like, oh, honey, you got to let him go. He's moved on. The whole time knowing it was her. That's that sociopath behavior, right? + +LUKE: It sure is. And it's not what you would expect about a friend of 30 years. So my advice to you is just stop talking to that chick. Let her go. Let her have the dude. And, uh, move on with your life. + +TERRY: You're right. I know you're right. It's just, God. It's pathetic, but I'm sitting here thinking about who I'm going to call now when stuff goes wrong. You know, like she was that person. + +LUKE: Well, it sounds like you're going to call Luke at the Roost, the, uh, call in radio show where we help you out with your real world problems. + +TERRY: Ha. Yeah. Well, you're doing better than she did tonight. That's for sure. I just... Okay. Real talk, though. Am I crazy for still being hurt about David? + +LUKE: No, you're not crazy. + +LUKE: You're hurt when you're hurt, and you've got good reason to be hurt here, and you've got some grief going on, and it's big life changes, and there's lots of reasons to be hurt. But you don't have to continue to stay that way. If you don't want to, you can decide to not be hurt and move on. And I think that's what you need to. to do. It's easier said than done, but it can be done. So just every day, wake up and be grateful to be here and say, hey, you know, I don't have the life that I had yesterday, but I've got the life that I have today, and it can still be pretty fucking cool. + +TERRY: You know what? You're right. And honestly, and this is going to sound terrible. But I think I've been more hung up on the idea of David than actual David for a while now. Like we got married at 23, Luke. 23. + +LUKE: Yep, that happens. That is young, and it explains a lot. So, um, you know, I wish you the best of luck. We're going to move on on the show now, unless you have some other point to bring up. I'll give you one more chance to respond. But otherwise, I'm going to say, uh, let go with the chick, let go to the dude, move on with your life, and everything's going to be fine. + +TERRY: No, you're good. I appreciate you letting me vent. And hey, I heard Roland, earlier? The UPS guy? + +LUKE: Yeah, what about him? + +TERRY: You got a job for Roland? No, but his wife definitely sucks too. That whole thing about her wanting him out of the house? Come on. That man needs to check the credit card statements. + +LUKE: That is absolutely true. I didn't want to say that to him at the time, but he absolutely should check the credit card statements. And maybe the little internal camera that he's got in the fucking teddy bear And now, now folks, you know what? Actually, she's already gone, but Terry, that was the smartest thing you sent that whole call. All right. Now it's time for a word from our sponsors. I'm not saying that for sympathy. I'm saying it because Pillow Forever asked me to establish a before state, and that's mine. Pillow Forever is a memory foam pillow that remembers your head shapes so you don't have to. It's got cooling gel, bamboo fiber, and a 30-night risk-free trial, which means you can sleep on it for a month and then send it back, and someone in a warehouse has to deal with that. Every pillow forever comes to. in a box that's too small, which is part of the experience. You open it and it slowly expands like a major documentary. My dog tried to fight it. Pillow Forever. You deserve better than a horse blanket. Okay, thanks to Pillow Forever. They are a proud sponsor of the Luke at the Roo Show, and we appreciate them. Now let's get to, uh, let's get back to the phones. Chester, Chester, welcome to the show. What's on your mind, sir? + +CHESTER: Luke, hey, so I almost killed my kid this morning. Not on purpose, I mean. I didn't. I showed up to get her from her ex-wife's place, custody Sunday, and the second I rolled down the window, I could smell the whiskey coming off me. Hands still shaking. I'd been drinking until like four in the morning, and it was eight o'clock. And I'm sitting there with the engine running, thinking, well, I'm sitting there with the engine running, thinking, well, What the hell am I doing? She's seven. Her name's Daisy. + +LUKE: Oh man, yeah. You can't, you can't do that, man. If you've got a drinking problem, then you're going to have to take, you're going to have to take care of that because you could kill your kid and or you could kill someone else's kid and it's really not okay. It's, it's irresponsible and it's not good for anybody. + +CHESTER: No, you're right. You're absolutely right. I didn't drive. I mean, I sat there for maybe 30 seconds. And then I shut the truck off and I called her mom and told her I was sick, food poisoning or something, which is bullshit. She probably knew. + +LUKE: How long have you known you've had a problem with the drinking? + +CHESTER: I mean, that's the thing, Luke. I don't drink every day. I work at Desert Star Pond, been there six years. I show up on time. I do my job. It's just when I get home and Daisy's not there. When it's those empty nights, I'll start with one beer. And then it's three I'm watching Civil War documentaries with a bottle of Jim Beam. And I can tell you the exact date of Antietam, but I can't tell you when it got like this. + +LUKE: Well, are you, uh, are you tired of it yet? Or it sounds like maybe this is a moment of clarity where you're recognizing the situation and, and thinking about taking accountability for it. Is that true? + +CHESTER: Yeah. Yeah. Yeah. I think. I mean, my dad, Big Jim, he ran cattle for years. Never missed a day. Never touched the stuff. + +LUKE: Well, you know, they have support group meetings. It can help you out in these situations. You can go to after work. Lots of cool people there that are trying to live sober, not pick up the drink and end up in situations like you're finding yourself in now. It is possible. You don't have to get drunk by yourself at night watching Civil War documentaries. You can just watch the Civil War documentary without they're getting drunk bit. then when you get up in the morning and pick up your kid, you're not going to be drunk. + +CHESTER: I know. You're right. It's just, the house is so damn quiet, you know? And I keep thinking about what kind of example I'm setting. + +LUKE: Well, it's good that you figure it out now when you're at a spot where you can still do something about it. And what I recommend is when you get home, next time you get home, do something else. Instead of cracking that beer, just do anything else. You can play a video game or you could, uh, go for a walk or you could read or play on the internet, whatever it is, just break that habit of having that first beer. Because if you don't pick up the first one, you're not going to end up drunk. Isn't it funny how that works? If you don't pick up the first one, you're not going to end up drunk. + +CHESTER: Yeah, yeah, that makes sense. I heard Lori earlier, the trucker lady with the daughter sneaking out, and I kept thinking at least she's got a reason she's not around, you know? + +LUKE: No, what do you mean by that? + +CHESTER: I mean, she's working. She's on the road providing for her kid. Me, I'm just, I'm home and Daisy's at her moms. And I'm choosing to sit there with a bottle instead of, I don't know, calling her reading her a bedtime story over the phone. Something. + +LUKE: Well, yeah. And, uh, you know, sometimes you just have to say that to a radio host at midnight to, uh, to hear yourself say it. So hopefully you can snap out of your, uh, your, your situation. Don't pick up the beer next time. And instead, call your daughter and read her a story. + +CHESTER: You're right. I actually. I got this book about Gettysburg I was going to show her. She's been asking about it because we drove through Pennsylvania last summer. And she saw the signs. Smart kid. Smarter than me, that's for sure. + +LUKE: Well, you sound pretty smart today. You know you get a problem and you're going to do stuff to take care of it. So if you need help, then I recommend that you find your your local neighborhood at AA or NA meetings, depending on what type of situation you're dealing with. And just go check it out. And have an open mind and maybe you find a new way of life. Okay, Dolores. Delores, welcome to the radio show. How can we help? + +DELORES: Hey, Luke. Yeah. So, okay. I got recognized today at an open house and I kind of panicked. And now sitting in a circle K parking lot feeling like a complete idiot. + +LUKE: Well, all of us get recognized several times a day. So why is it, what is it about this particular recognition that's got you up in arms? + +DELORES: Because I've been going to open houses every weekend for like eight months and I'm not actually buying anything. This realtor, Patricia, she remembered me from a showing last spring, and she just asked me you actually planning to buy a house and I froze. I told her my financing fell through, but that's not even true because I've never applied for a loan in the first place. + +LUKE: Okay. Next up on the radio show, we have Marvin. Marvin, thanks for calling in. What's happening tonight? + +MARVIN: Hey, Luke. Yeah, thanks for taking the call. So I got home for my shift tonight and there's this cardboard box sitting on my porch. No note, nothing. And it's full of stuff from my dad's house. Stuff I haven't seen since he died 11 years ago. His Bolo tie. This pocket knife I thought I lost senior year. Photos of my uncle's old place. + +LUKE: What's a Bolo tie? + +MARVIN: Oh, it's that Western Thai thing. You know, the braided leather cord with the silver clasp that slides up and down. My dad wore one to every wedding, every funeral, every time he needed to look respectable. His had this. This turquoise stone in the middle, real heavy. + +LUKE: Okay, well, that's cool. Somebody dropped off some of your dead dad's belongings, and what are they bringing up inside? They bring it up memories? Is this got you upset? Are you concerned? Like, what's, what feeling is this bringing up inside of you? + +MARVIN: I mean, yeah, memories. But more than that, I'm sitting here like, who the hell had this stuff? my dad's been gone 11 years. Where has this box been? And why now? Why tonight? What's in the box? Why no explanation? Like, someone just decided, oh, time for Marvin to deal with this and left it there like a package from Amazon. And honestly, I opened it and just... + +LUKE: All right. Well, congratulations for, uh, the... safe return of your dad's belongings. I hope that you wear them in good health. Next on the radio show, oh, before, no, oh, Luke, you almost, come on, come on, I'm fucking amateur here. It is time for a word from our sponsors. Look, I'm not a financial advisor. I'm a guy with a microphone and a dog. But the folks at Crypto know asked me to tell you about their new decentralized investment platform, and I to read this part. Past performance does not guarantee future results. This is not financial advice. And if you invest your rent money, you deserve exactly what happens next. Crypto No lets you trade over 400 digital currencies, including three that were invented this morning, and one that's just a picture of my dog. The app features a real-time portfolio tracker with a built-in panic button that just plays ocean sounds when your balance drops. Crypto No! Fortune favors the bold, but it does not return their calls. Okay, thanks to Crypto No for sponsoring the show today. I think we're probably running a little bit late. These have been some decent calls. They're going a little long. So I'm just going to take one more for tonight's show. And next up to the line, we have Francine. Francine, you're going to be our last caller of the night. + +LUKE: What's on your mind? + +CALLER: Luke, I just had a deer on the 11, and it's still alive, and I don't know what the hell I'm playing. supposed to do about it. I've been sitting here with my hazards on for like 20 minutes. Just, it's looking at me. When if its legs is completely shattered, it's on the shoulder. And I can't tell if I'm supposed to call someone, or if I'm supposed to, I don't know, put it out of its misery myself? Which I don't even know how I do that. I'm a nurse. I work on people, not my ex would have known. He grew up out here, hunted with his dad. He would have just handled it. + +LUKE: Yeah, that sounds like a. less than ideal situation. I would call the police first, call 911, and let them know that there's been an accident and there's an animal, I assume, still in the road. If you have a gun, then I think you know what you have to do. If you don't have a gun, I would probably refrain from doing anything hasty with like a tire iron or a rock. + +CALLER: No, I don't have a gun. It's off the road. It's on the shoulder. So nobody's going to hit it. I just, I'll call. I didn't know if this was even a 911 thing or if there was like animal control or something, but out here at midnight on a Sunday, I guess there's not exactly a whole directory of options. The stupid thing is, I've been driving the same loop between Deming and Los Cruces for six months now. Three different clinics, and this is the first time I've hit anything. + +LUKE: Well, you know what I always say? 911 was an inside job. So I don't actually know who you're supposed to call in this situation. I would call 911 and ask the them and they'll let you know or send out animal control or whoever comes out of the sheriff's office. Who knows out here especially? But, you know, it happens. There's wild animals out here. They run in the road. Sometimes we hit them. It's probably not your fault unless you were playing with your phone, and in which case it was your fault. And just do the best you can do. It's unfortunate that the animal's suffering right now, but if you don't have a way to humanely end it, then you got to just get somebody out there as soon. as you can. + +CALLER: I wasn't on my phone. I was just tired. Just came off a double. Wasn't paying attention like I should have been. You're right, though. I'll call. + +LUKE: All right. Well, get off the line with me and get on the line with 911. And that is the conclusion of our show for tonight. Thanks everybody for tuning in, and we'll talk to you again tomorrow. Thank you. \ No newline at end of file diff --git a/website/voicemail.xml b/website/voicemail.xml new file mode 100644 index 0000000..364257e --- /dev/null +++ b/website/voicemail.xml @@ -0,0 +1,7 @@ + + + Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show! + + Thank you for calling. Goodbye! + +