actual featured page
This commit is contained in:
405
discover/featured.json
Normal file
405
discover/featured.json
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"podcasts": [
|
||||||
|
{
|
||||||
|
"id": "discover-daily",
|
||||||
|
"title": "The Daily",
|
||||||
|
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
||||||
|
"feedUrl": "http://rss.art19.com/the-daily",
|
||||||
|
"author": "The New York Times",
|
||||||
|
"categories": ["News", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-up-first",
|
||||||
|
"title": "Up First",
|
||||||
|
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-npr-politics",
|
||||||
|
"title": "The NPR Politics Podcast",
|
||||||
|
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-code-switch",
|
||||||
|
"title": "Code Switch",
|
||||||
|
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510352/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News", "Culture", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-rough-translation",
|
||||||
|
"title": "Rough Translation",
|
||||||
|
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News", "Culture"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-crime-junkie",
|
||||||
|
"title": "Crime Junkie",
|
||||||
|
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
||||||
|
"author": "audiochuck",
|
||||||
|
"categories": ["True Crime"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-serial",
|
||||||
|
"title": "Serial",
|
||||||
|
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
||||||
|
"author": "Serial Productions & The New York Times",
|
||||||
|
"categories": ["True Crime", "Storytelling"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-intelligence-matters",
|
||||||
|
"title": "Intelligence Matters",
|
||||||
|
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
||||||
|
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
||||||
|
"author": "CBS News",
|
||||||
|
"categories": ["True Crime", "Politics", "News"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-smartless",
|
||||||
|
"title": "SmartLess",
|
||||||
|
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
||||||
|
"feedUrl": "https://rss.art19.com/smartless",
|
||||||
|
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
||||||
|
"categories": ["Comedy", "Entertainment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-past-weekend",
|
||||||
|
"title": "This Past Weekend w/ Theo Von",
|
||||||
|
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
||||||
|
"author": "Theo Von",
|
||||||
|
"categories": ["Comedy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-joe-rogan",
|
||||||
|
"title": "The Joe Rogan Experience",
|
||||||
|
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
||||||
|
"author": "Joe Rogan",
|
||||||
|
"categories": ["Comedy", "Entertainment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-comedy-bang-bang",
|
||||||
|
"title": "Comedy Bang Bang: The Podcast",
|
||||||
|
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
||||||
|
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": ["Comedy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-office-ladies",
|
||||||
|
"title": "Office Ladies",
|
||||||
|
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
||||||
|
"feedUrl": "https://rss.art19.com/office-ladies",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": ["Comedy", "Entertainment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-did-this-get-made",
|
||||||
|
"title": "How Did This Get Made?",
|
||||||
|
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
||||||
|
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": ["Comedy", "Film"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-wait-wait",
|
||||||
|
"title": "Wait Wait... Don't Tell Me!",
|
||||||
|
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["Comedy", "News"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-new-heights",
|
||||||
|
"title": "New Heights with Jason & Travis Kelce",
|
||||||
|
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
||||||
|
"feedUrl": "https://rss.art19.com/new-heights",
|
||||||
|
"author": "Jason & Travis Kelce",
|
||||||
|
"categories": ["Sports", "Comedy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-bill-simmons",
|
||||||
|
"title": "The Bill Simmons Podcast",
|
||||||
|
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": ["Sports", "Entertainment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-acquired",
|
||||||
|
"title": "Acquired",
|
||||||
|
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
||||||
|
"feedUrl": "https://feeds.transistor.fm/acquired",
|
||||||
|
"author": "Ben Gilbert & David Rosenthal",
|
||||||
|
"categories": ["Business", "Technology"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-in",
|
||||||
|
"title": "All-In Podcast",
|
||||||
|
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||||
|
"feedUrl": "https://feeds.transistor.fm/all-in",
|
||||||
|
"author": "Chamath, Jason, Sacks & Friedberg",
|
||||||
|
"categories": ["Business", "Technology", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-planet-money",
|
||||||
|
"title": "Planet Money",
|
||||||
|
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["Business", "Economics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-i-built-this",
|
||||||
|
"title": "How I Built This with Guy Raz",
|
||||||
|
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
||||||
|
"author": "NPR / Wondery",
|
||||||
|
"categories": ["Business", "Technology"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-freakonomics",
|
||||||
|
"title": "Freakonomics Radio",
|
||||||
|
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
||||||
|
"author": "Stephen J. Dubner",
|
||||||
|
"categories": ["Business", "Economics", "Society"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-darknet-diaries",
|
||||||
|
"title": "Darknet Diaries",
|
||||||
|
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
||||||
|
"feedUrl": "https://podcast.darknetdiaries.com/",
|
||||||
|
"author": "Jack Rhysider",
|
||||||
|
"categories": ["Technology", "True Crime"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-changelog",
|
||||||
|
"title": "The Changelog",
|
||||||
|
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
||||||
|
"feedUrl": "https://changelog.fm/rss",
|
||||||
|
"author": "Changelog Media",
|
||||||
|
"categories": ["Technology", "Software Engineering"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-twit",
|
||||||
|
"title": "This Week in Tech (TWiT)",
|
||||||
|
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
||||||
|
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
||||||
|
"author": "TWiT",
|
||||||
|
"categories": ["Technology"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-radiolab",
|
||||||
|
"title": "Radiolab",
|
||||||
|
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
||||||
|
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
||||||
|
"author": "WNYC Studios",
|
||||||
|
"categories": ["Science", "Storytelling"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-huberman-lab",
|
||||||
|
"title": "Huberman Lab",
|
||||||
|
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
||||||
|
"author": "Scicomm Media",
|
||||||
|
"categories": ["Health", "Science"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-skeptics-guide",
|
||||||
|
"title": "The Skeptics' Guide to the Universe",
|
||||||
|
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
||||||
|
"author": "Steven Novella",
|
||||||
|
"categories": ["Science", "Philosophy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-throughline",
|
||||||
|
"title": "Throughline",
|
||||||
|
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["History", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hardcore-history",
|
||||||
|
"title": "Dan Carlin's Hardcore History",
|
||||||
|
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
||||||
|
"author": "Dan Carlin",
|
||||||
|
"categories": ["History"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-history-of-rome",
|
||||||
|
"title": "The History of Rome",
|
||||||
|
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
||||||
|
"author": "Mike Duncan",
|
||||||
|
"categories": ["History"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-philosophize-this",
|
||||||
|
"title": "Philosophize This!",
|
||||||
|
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||||
|
"feedUrl": "https://feed.podbean.com/philosophizethis/feed.xml",
|
||||||
|
"author": "Stephen West",
|
||||||
|
"categories": ["Philosophy", "Education"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-very-bad-wizards",
|
||||||
|
"title": "Very Bad Wizards",
|
||||||
|
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||||
|
"feedUrl": "https://feed.podbean.com/verybadwizards/feed.xml",
|
||||||
|
"author": "Tamler Sommers & David Pizarro",
|
||||||
|
"categories": ["Philosophy", "Science"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-big-picture",
|
||||||
|
"title": "The Big Picture",
|
||||||
|
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-big-picture",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": ["Film", "Entertainment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-songs-considered",
|
||||||
|
"title": "All Songs Considered",
|
||||||
|
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
||||||
|
"author": "NPR Music",
|
||||||
|
"categories": ["Music"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-switched-on-pop",
|
||||||
|
"title": "Switched on Pop",
|
||||||
|
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
||||||
|
"author": "Vox Media / Panoply",
|
||||||
|
"categories": ["Music"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hit-parade",
|
||||||
|
"title": "Hit Parade",
|
||||||
|
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
||||||
|
"author": "Slate",
|
||||||
|
"categories": ["Music", "History"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-song-exploder",
|
||||||
|
"title": "Song Exploder",
|
||||||
|
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
||||||
|
"feedUrl": "https://songexploder.net/rss",
|
||||||
|
"author": "Hrishikesh Hirway",
|
||||||
|
"categories": ["Music", "Arts"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-blank-check",
|
||||||
|
"title": "Blank Check with Griffin & David",
|
||||||
|
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
||||||
|
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
||||||
|
"author": "Griffin Newman & David Sims",
|
||||||
|
"categories": ["Film", "Comedy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-99-invisible",
|
||||||
|
"title": "99% Invisible",
|
||||||
|
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
||||||
|
"author": "Roman Mars",
|
||||||
|
"categories": ["Design", "Arts", "Culture"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-gastropod",
|
||||||
|
"title": "Gastropod",
|
||||||
|
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
||||||
|
"feedUrl": "https://gastropod.com/feed",
|
||||||
|
"author": "Cynthia Graber & Nicola Twilley",
|
||||||
|
"categories": ["Food", "Science", "History"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-american-life",
|
||||||
|
"title": "This American Life",
|
||||||
|
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
||||||
|
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
||||||
|
"author": "This American Life",
|
||||||
|
"categories": ["Storytelling", "Culture"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-ted-talks-daily",
|
||||||
|
"title": "TED Talks Daily",
|
||||||
|
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
||||||
|
"author": "TED",
|
||||||
|
"categories": ["Education", "Storytelling"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-tim-ferriss",
|
||||||
|
"title": "The Tim Ferriss Show",
|
||||||
|
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
||||||
|
"author": "Tim Ferriss",
|
||||||
|
"categories": ["Self-Improvement", "Business"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-jordan-harbinger",
|
||||||
|
"title": "The Jordan Harbinger Show",
|
||||||
|
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
||||||
|
"author": "Jordan Harbinger",
|
||||||
|
"categories": ["Self-Improvement"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-on-purpose",
|
||||||
|
"title": "On Purpose with Jay Shetty",
|
||||||
|
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
||||||
|
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
||||||
|
"author": "Jay Shetty",
|
||||||
|
"categories": ["Self-Improvement", "Health"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-10-percent-happier",
|
||||||
|
"title": "10% Happier with Dan Harris",
|
||||||
|
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
||||||
|
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
||||||
|
"author": "Dan Harris",
|
||||||
|
"categories": ["Self-Improvement", "Health", "Philosophy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-school-of-greatness",
|
||||||
|
"title": "The School of Greatness",
|
||||||
|
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
||||||
|
"author": "Lewis Howes",
|
||||||
|
"categories": ["Self-Improvement", "Business"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-sysk",
|
||||||
|
"title": "Stuff You Should Know",
|
||||||
|
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||||
|
"feedUrl": "https://rss.art19.com/stuff-you-should-know",
|
||||||
|
"author": "iHeartRadio",
|
||||||
|
"categories": ["Education", "Comedy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-in-our-time",
|
||||||
|
"title": "In Our Time",
|
||||||
|
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
||||||
|
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
||||||
|
"author": "BBC Radio 4",
|
||||||
|
"categories": ["History", "Education", "Philosophy"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -65,6 +65,12 @@ function DiscoverPage() {
|
|||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
|
// Auto-fetch the featured-shows manifest on first mount (network failure is
|
||||||
|
// non-fatal — the list stays empty until the user hits refresh).
|
||||||
|
onMount(() => {
|
||||||
|
discoverStore.refresh().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
@@ -243,7 +249,9 @@ function DiscoverPage() {
|
|||||||
{podcast.title}
|
{podcast.title}
|
||||||
</text>
|
</text>
|
||||||
<Show when={podcast.isSubscribed}>
|
<Show when={podcast.isSubscribed}>
|
||||||
<text fg={index() === lf() ? theme.surface : theme.success}>
|
<text
|
||||||
|
fg={index() === lf() ? theme.surface : theme.success}
|
||||||
|
>
|
||||||
[+]
|
[+]
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||||
import {
|
import {
|
||||||
loadCavaCore,
|
loadCavaCore,
|
||||||
type CavaCore,
|
type CavaCore,
|
||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
@@ -20,20 +20,20 @@ import { useTheme } from "@/context/ThemeContext";
|
|||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
export type RealtimeWaveformProps = {
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||||
const BARS = [
|
const BARS = [
|
||||||
" ",
|
" ",
|
||||||
"\u2581",
|
"\u2581",
|
||||||
"\u2582",
|
"\u2582",
|
||||||
"\u2583",
|
"\u2583",
|
||||||
"\u2584",
|
"\u2584",
|
||||||
"\u2585",
|
"\u2585",
|
||||||
"\u2586",
|
"\u2586",
|
||||||
"\u2587",
|
"\u2587",
|
||||||
"\u2588",
|
"\u2588",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Target frame interval in ms (~30 fps) */
|
/** Target frame interval in ms (~30 fps) */
|
||||||
@@ -45,212 +45,221 @@ const SAMPLES_PER_FRAME = 512;
|
|||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
// Track whether cavacore is available
|
// Track whether cavacore is available
|
||||||
const [available, setAvailable] = createSignal(false);
|
const [available, setAvailable] = createSignal(false);
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// Bar count comes from the visualizer config (set in Settings); default 64.
|
||||||
|
// Single source of truth used for cavacore init, rendering, and seek clicks.
|
||||||
|
const numBars = () => props.visualizerConfig?.bars ?? 64;
|
||||||
|
|
||||||
const initCava = () => {
|
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||||
if (cava) return true;
|
|
||||||
|
|
||||||
cava = loadCavaCore();
|
const initCava = () => {
|
||||||
if (!cava) {
|
if (cava) return true;
|
||||||
setAvailable(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailable(true);
|
cava = loadCavaCore();
|
||||||
return true;
|
if (!cava) {
|
||||||
};
|
setAvailable(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
setAvailable(true);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
stopVisualization();
|
|
||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
const startVisualization = (url: string, position: number, speed: number) => {
|
||||||
|
stopVisualization();
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
if (!url || !initCava() || !cava) return;
|
||||||
const config: CavaCoreConfig = {
|
|
||||||
bars: 32,
|
|
||||||
sampleRate: 44100,
|
|
||||||
channels: 1,
|
|
||||||
...props.visualizerConfig,
|
|
||||||
};
|
|
||||||
cava.init(config);
|
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
// Initialize cavacore with current resolution + any overrides
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
const config: CavaCoreConfig = {
|
||||||
|
bars: numBars(),
|
||||||
|
sampleRate: 44100,
|
||||||
|
channels: 1,
|
||||||
|
...props.visualizerConfig,
|
||||||
|
};
|
||||||
|
cava.init(config);
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
// Pre-allocate sample read buffer
|
||||||
if (!reader || reader.url !== url) {
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
if (reader) reader.stop();
|
|
||||||
reader = new AudioStreamReader({ url });
|
|
||||||
}
|
|
||||||
reader.start(position, speed);
|
|
||||||
|
|
||||||
// Start render loop
|
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
if (!reader || reader.url !== url) {
|
||||||
};
|
if (reader) reader.stop();
|
||||||
|
reader = new AudioStreamReader({ url });
|
||||||
|
}
|
||||||
|
reader.start(position, speed);
|
||||||
|
|
||||||
const stopVisualization = () => {
|
// Start render loop
|
||||||
if (frameTimer) {
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
clearInterval(frameTimer);
|
};
|
||||||
frameTimer = null;
|
|
||||||
}
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
|
||||||
}
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
sampleBuffer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
const stopVisualization = () => {
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
if (reader) {
|
||||||
|
reader.stop();
|
||||||
|
// Don't null reader — we reuse it across start/stop cycles
|
||||||
|
}
|
||||||
|
if (cava?.isReady) {
|
||||||
|
cava.destroy();
|
||||||
|
}
|
||||||
|
sampleBuffer = null;
|
||||||
|
};
|
||||||
|
|
||||||
const renderFrame = () => {
|
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
|
||||||
|
|
||||||
// Read available PCM samples from the stream
|
const renderFrame = () => {
|
||||||
const count = reader.read(sampleBuffer);
|
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||||
if (count === 0) return;
|
|
||||||
|
|
||||||
// Feed samples to cavacore → get frequency bars
|
// Read available PCM samples from the stream
|
||||||
const input =
|
const count = reader.read(sampleBuffer);
|
||||||
count < sampleBuffer.length
|
if (count === 0) return;
|
||||||
? sampleBuffer.subarray(0, count)
|
|
||||||
: sampleBuffer;
|
|
||||||
const output = cava.execute(input);
|
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
// Feed samples to cavacore → get frequency bars
|
||||||
setBarData(Array.from(output));
|
const input =
|
||||||
};
|
count < sampleBuffer.length
|
||||||
|
? sampleBuffer.subarray(0, count)
|
||||||
|
: sampleBuffer;
|
||||||
|
const output = cava.execute(input);
|
||||||
|
|
||||||
createEffect(
|
// Copy bar values to a new array for the signal
|
||||||
on(
|
setBarData(Array.from(output));
|
||||||
[
|
};
|
||||||
audio.isPlaying,
|
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
|
|
||||||
audio.speed,
|
|
||||||
() => 32,
|
|
||||||
],
|
|
||||||
([playing, url, speed]) => {
|
|
||||||
if (playing && url) {
|
|
||||||
const pos = untrack(audio.position);
|
|
||||||
startVisualization(url, pos, speed);
|
|
||||||
} else {
|
|
||||||
stopVisualization();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
createEffect(
|
||||||
//
|
on(
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
[
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
audio.isPlaying,
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
() => audio.currentEpisode()?.audioUrl ?? "",
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
audio.speed,
|
||||||
|
numBars,
|
||||||
|
],
|
||||||
|
([playing, url, speed]) => {
|
||||||
|
if (playing && url) {
|
||||||
|
const pos = untrack(audio.position);
|
||||||
|
startVisualization(url, pos, speed);
|
||||||
|
} else {
|
||||||
|
stopVisualization();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
// ── Seek detection: lightweight effect for position jumps ──────────
|
||||||
createEffect(
|
//
|
||||||
on(audio.position, (pos) => {
|
// Watches position and restarts the reader (not the whole pipeline)
|
||||||
if (!audio.isPlaying || !reader?.running) {
|
// only on significant jumps (>2s), which indicate a user seek.
|
||||||
lastSyncPosition = pos;
|
// This is intentionally a separate effect — it should NOT trigger a
|
||||||
return;
|
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
let lastSyncPosition = 0;
|
||||||
lastSyncPosition = pos;
|
createEffect(
|
||||||
|
on(audio.position, (pos) => {
|
||||||
|
if (!audio.isPlaying || !reader?.running) {
|
||||||
|
lastSyncPosition = pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (delta > 2) {
|
const delta = Math.abs(pos - lastSyncPosition);
|
||||||
reader.restart(pos, audio.speed() ?? 1);
|
lastSyncPosition = pos;
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
if (delta > 2) {
|
||||||
onCleanup(() => {
|
reader.restart(pos, audio.speed() ?? 1);
|
||||||
stopVisualization();
|
}
|
||||||
if (reader) {
|
}),
|
||||||
reader.stop();
|
);
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// Cleanup on unmount
|
||||||
|
onCleanup(() => {
|
||||||
|
stopVisualization();
|
||||||
|
if (reader) {
|
||||||
|
reader.stop();
|
||||||
|
reader = null;
|
||||||
|
}
|
||||||
|
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||||
|
if (cava?.isReady) {
|
||||||
|
cava.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const playedRatio = () =>
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
const playedRatio = () =>
|
||||||
const bars = barData();
|
audio.duration() <= 0
|
||||||
const numBars = 32;
|
? 0
|
||||||
|
: Math.min(1, audio.position() / audio.duration());
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
const renderLine = () => {
|
||||||
if (bars.length === 0) {
|
const bars = barData();
|
||||||
const placeholder = ".".repeat(numBars);
|
const count = numBars();
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={0}>
|
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const played = Math.floor(numBars * playedRatio());
|
// If no data yet, show empty placeholder
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
if (bars.length === 0) {
|
||||||
const futureColor = "#3b4252";
|
const placeholder = ".".repeat(count);
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={0}>
|
||||||
|
<text fg="#3b4252">{placeholder}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const playedChars = bars
|
const played = Math.floor(count * playedRatio());
|
||||||
.slice(0, played)
|
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
||||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
const futureColor = "#3b4252";
|
||||||
.join("");
|
|
||||||
|
|
||||||
const futureChars = bars
|
const playedChars = bars
|
||||||
.slice(played)
|
.slice(0, played)
|
||||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
return (
|
const futureChars = bars
|
||||||
<box flexDirection="row" gap={0}>
|
.slice(played)
|
||||||
<text fg={playedColor}>{playedChars || " "}</text>
|
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||||
<text fg={futureColor}>{futureChars || " "}</text>
|
.join("");
|
||||||
</box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
return (
|
||||||
const numBars = 32;
|
<box flexDirection="row" gap={0}>
|
||||||
const ratio = event.x / numBars;
|
<text fg={playedColor}>{playedChars || " "}</text>
|
||||||
const next = Math.max(
|
<text fg={futureColor}>{futureChars || " "}</text>
|
||||||
0,
|
</box>
|
||||||
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
);
|
||||||
);
|
};
|
||||||
audio.seek(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const handleClick = (event: { x: number }) => {
|
||||||
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
const count = numBars();
|
||||||
{renderLine()}
|
const ratio = event.x / count;
|
||||||
</box>
|
const next = Math.max(
|
||||||
);
|
0,
|
||||||
|
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
||||||
|
);
|
||||||
|
audio.seek(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={1}
|
||||||
|
onMouseDown={handleClick}
|
||||||
|
>
|
||||||
|
{renderLine()}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,130 +1,130 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
|
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppState,
|
AppState,
|
||||||
ThemeColors,
|
ThemeColors,
|
||||||
ThemeName,
|
ThemeName,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
VisualizerSettings,
|
VisualizerSettings,
|
||||||
} from "../types/settings";
|
} from "../types/settings";
|
||||||
import { resolveTheme } from "../utils/theme-resolver";
|
import { resolveTheme } from "../utils/theme-resolver";
|
||||||
import type { ThemeJson } from "../types/theme-schema";
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
import {
|
import {
|
||||||
loadAppStateFromFile,
|
loadAppStateFromFile,
|
||||||
saveAppStateToFile,
|
saveAppStateToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
bars: 32,
|
bars: 64,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
preferences: defaultPreferences,
|
preferences: defaultPreferences,
|
||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAppStore() {
|
export function createAppStore() {
|
||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState);
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
// Fire-and-forget async initialisation
|
// Fire-and-forget async initialisation
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const loaded = await loadAppStateFromFile();
|
const loaded = await loadAppStateFromFile();
|
||||||
setState(loaded);
|
setState(loaded);
|
||||||
};
|
};
|
||||||
init();
|
init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next).catch(() => {});
|
saveAppStateToFile(next).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
setState(next);
|
setState(next);
|
||||||
saveState(next);
|
saveState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSettings = (updates: Partial<AppSettings>) => {
|
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
settings: { ...state().settings, ...updates },
|
settings: { ...state().settings, ...updates },
|
||||||
};
|
};
|
||||||
updateState(next);
|
updateState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
preferences: { ...state().preferences, ...updates },
|
preferences: { ...state().preferences, ...updates },
|
||||||
};
|
};
|
||||||
updateState(next);
|
updateState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
customTheme: { ...state().customTheme, ...updates },
|
customTheme: { ...state().customTheme, ...updates },
|
||||||
};
|
};
|
||||||
updateState(next);
|
updateState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
visualizer: { ...state().settings.visualizer, ...updates },
|
visualizer: { ...state().settings.visualizer, ...updates },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTheme = (theme: ThemeName) => {
|
const setTheme = (theme: ThemeName) => {
|
||||||
updateSettings({ theme });
|
updateSettings({ theme });
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveThemeColors = (): ThemeColors => {
|
const resolveThemeColors = (): ThemeColors => {
|
||||||
const theme = state().settings.theme;
|
const theme = state().settings.theme;
|
||||||
if (theme === "custom") return state().customTheme;
|
if (theme === "custom") return state().customTheme;
|
||||||
if (theme === "system") return DEFAULT_THEME;
|
if (theme === "system") return DEFAULT_THEME;
|
||||||
const json = THEME_JSON[theme];
|
const json = THEME_JSON[theme];
|
||||||
if (!json) return DEFAULT_THEME;
|
if (!json) return DEFAULT_THEME;
|
||||||
return resolveTheme(
|
return resolveTheme(
|
||||||
json as ThemeJson,
|
json as ThemeJson,
|
||||||
"dark" as ThemeMode,
|
"dark" as ThemeMode,
|
||||||
) as unknown as ThemeColors;
|
) as unknown as ThemeColors;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateCustomTheme,
|
updateCustomTheme,
|
||||||
updateVisualizer,
|
updateVisualizer,
|
||||||
setTheme,
|
setTheme,
|
||||||
resolveTheme: resolveThemeColors,
|
resolveTheme: resolveThemeColors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
|
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
|
||||||
|
|
||||||
export function useAppStore() {
|
export function useAppStore() {
|
||||||
if (!appStoreInstance) {
|
if (!appStoreInstance) {
|
||||||
appStoreInstance = createAppStore();
|
appStoreInstance = createAppStore();
|
||||||
}
|
}
|
||||||
return appStoreInstance;
|
return appStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Discover store for PodTUI
|
* Discover store for PodTUI
|
||||||
* Manages trending/popular podcasts and category filtering
|
* Manages trending/popular podcasts and category filtering.
|
||||||
|
*
|
||||||
|
* The featured-shows list is fetched at runtime from a JSON file hosted in the
|
||||||
|
* GitHub repo (discover/featured.json on the `master` branch), so the list
|
||||||
|
* can be updated without shipping a new release. The feed URL, de-duped set,
|
||||||
|
* and version field act as the cache key — a fresh fetch only happens when the
|
||||||
|
* version bumps or the cache window (24h) expires.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
@@ -27,125 +33,113 @@ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
|||||||
{ id: "arts", name: "Arts", icon: "@" },
|
{ id: "arts", name: "Arts", icon: "@" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Mock trending podcasts */
|
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||||
const TRENDING_PODCASTS: Podcast[] = [
|
// The raw GitHub URL serving discover/featured.json from the master branch.
|
||||||
{
|
// Update this file in the repo (no release needed) to refresh the list.
|
||||||
id: "trend-1",
|
const FEATURED_JSON_URL =
|
||||||
title: "AI Today",
|
"https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
|
||||||
description:
|
|
||||||
"The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
/** Cache window for the remote featured list (24 hours) */
|
||||||
feedUrl: "https://example.com/aitoday.rss",
|
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
author: "Tech Futures",
|
|
||||||
categories: ["Technology", "Science"],
|
/** Shape of a single entry in the remote JSON */
|
||||||
|
interface FeaturedEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
feedUrl: string;
|
||||||
|
author?: string;
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the remote JSON manifest */
|
||||||
|
interface FeaturedManifest {
|
||||||
|
version: number;
|
||||||
|
podcasts: FeaturedEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
|
||||||
|
function entryToPodcast(entry: FeaturedEntry): Podcast {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
description: entry.description,
|
||||||
|
feedUrl: entry.feedUrl,
|
||||||
|
author: entry.author,
|
||||||
|
categories: entry.categories ?? [],
|
||||||
coverUrl: undefined,
|
coverUrl: undefined,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
isSubscribed: false,
|
isSubscribed: false,
|
||||||
},
|
};
|
||||||
{
|
}
|
||||||
id: "trend-2",
|
|
||||||
title: "The History Hour",
|
/** Reconcile isSubscribed state across the discover list against the feed store */
|
||||||
description:
|
function syncSubscriptionState(
|
||||||
"Fascinating stories from history that shaped our world today.",
|
podcasts: Podcast[],
|
||||||
feedUrl: "https://example.com/historyhour.rss",
|
subscribedUrls: Set<string>,
|
||||||
author: "History Channel",
|
subscribedIds: Set<string>,
|
||||||
categories: ["Education", "History"],
|
): Podcast[] {
|
||||||
lastUpdated: new Date(),
|
return podcasts.map((p) => ({
|
||||||
isSubscribed: false,
|
...p,
|
||||||
},
|
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
|
||||||
{
|
}));
|
||||||
id: "trend-3",
|
}
|
||||||
title: "Comedy Gold",
|
|
||||||
description:
|
|
||||||
"Weekly stand-up comedy, sketches, and hilarious conversations.",
|
|
||||||
feedUrl: "https://example.com/comedygold.rss",
|
|
||||||
author: "Laugh Factory",
|
|
||||||
categories: ["Comedy", "Entertainment"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-4",
|
|
||||||
title: "Market Watch",
|
|
||||||
description: "Daily financial news, stock analysis, and investing tips.",
|
|
||||||
feedUrl: "https://example.com/marketwatch.rss",
|
|
||||||
author: "Finance Daily",
|
|
||||||
categories: ["Business", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-5",
|
|
||||||
title: "Science Weekly",
|
|
||||||
description:
|
|
||||||
"Breaking science news and in-depth analysis of the latest research.",
|
|
||||||
feedUrl: "https://example.com/scienceweekly.rss",
|
|
||||||
author: "Science Network",
|
|
||||||
categories: ["Science", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-6",
|
|
||||||
title: "True Crime Files",
|
|
||||||
description:
|
|
||||||
"Investigative journalism into real criminal cases and unsolved mysteries.",
|
|
||||||
feedUrl: "https://example.com/truecrime.rss",
|
|
||||||
author: "Crime Network",
|
|
||||||
categories: ["True Crime", "Documentary"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-7",
|
|
||||||
title: "Wellness Journey",
|
|
||||||
description:
|
|
||||||
"Tips for mental and physical health, meditation, and mindful living.",
|
|
||||||
feedUrl: "https://example.com/wellness.rss",
|
|
||||||
author: "Health Media",
|
|
||||||
categories: ["Health", "Self-Help"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-8",
|
|
||||||
title: "Sports Talk Live",
|
|
||||||
description:
|
|
||||||
"Live commentary, analysis, and interviews from the world of sports.",
|
|
||||||
feedUrl: "https://example.com/sportstalk.rss",
|
|
||||||
author: "Sports Network",
|
|
||||||
categories: ["Sports", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-9",
|
|
||||||
title: "Creative Minds",
|
|
||||||
description:
|
|
||||||
"Interviews with artists, designers, and creative professionals.",
|
|
||||||
feedUrl: "https://example.com/creativeminds.rss",
|
|
||||||
author: "Arts Weekly",
|
|
||||||
categories: ["Arts", "Culture"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-10",
|
|
||||||
title: "Dev Talk",
|
|
||||||
description:
|
|
||||||
"Software development, programming tutorials, and tech career advice.",
|
|
||||||
feedUrl: "https://example.com/devtalk.rss",
|
|
||||||
author: "Code Academy",
|
|
||||||
categories: ["Technology", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Create discover store */
|
/** Create discover store */
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS);
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||||
|
|
||||||
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
/** Reconcile local isSubscribed flags with the feed store */
|
||||||
|
const syncSubscriptions = () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feeds = feedStore.feeds();
|
||||||
|
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
|
||||||
|
const ids = new Set(feeds.map((f) => f.podcast.id));
|
||||||
|
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch the featured-shows manifest from GitHub if stale */
|
||||||
|
const refresh = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Skip if cache is still fresh
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(FEATURED_JSON_URL, {
|
||||||
|
headers: { "User-Agent": "PodTUI/1.0" },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const manifest = (await resp.json()) as FeaturedManifest;
|
||||||
|
if (!manifest?.podcasts?.length) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the podcast list from the manifest entries
|
||||||
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
|
cachedAt = now;
|
||||||
|
setPodcasts(fetched);
|
||||||
|
|
||||||
|
// Reflect current feed-store subscriptions
|
||||||
|
syncSubscriptions();
|
||||||
|
} catch {
|
||||||
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
/** Get filtered podcasts by category */
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
@@ -198,15 +192,6 @@ export function createDiscoverStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh trending podcasts (mock) */
|
|
||||||
const refresh = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
// In real app, would fetch from API
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
|
|||||||
@@ -2,95 +2,95 @@ import type { RGBA } from "@opentui/core";
|
|||||||
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
|
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
|
||||||
|
|
||||||
export type ThemeName =
|
export type ThemeName =
|
||||||
| "system"
|
| "system"
|
||||||
| "catppuccin"
|
| "catppuccin"
|
||||||
| "gruvbox"
|
| "gruvbox"
|
||||||
| "tokyo"
|
| "tokyo"
|
||||||
| "nord"
|
| "nord"
|
||||||
| "custom";
|
| "custom";
|
||||||
|
|
||||||
export type LayerBackgrounds = {
|
export type LayerBackgrounds = {
|
||||||
layer0: ColorValue;
|
layer0: ColorValue;
|
||||||
layer1: ColorValue;
|
layer1: ColorValue;
|
||||||
layer2: ColorValue;
|
layer2: ColorValue;
|
||||||
layer3: ColorValue;
|
layer3: ColorValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeColors = {
|
export type ThemeColors = {
|
||||||
background: ColorValue;
|
background: ColorValue;
|
||||||
surface: ColorValue;
|
surface: ColorValue;
|
||||||
primary: ColorValue;
|
primary: ColorValue;
|
||||||
secondary: ColorValue;
|
secondary: ColorValue;
|
||||||
accent: ColorValue;
|
accent: ColorValue;
|
||||||
text: ColorValue;
|
text: ColorValue;
|
||||||
textPrimary?: ColorValue;
|
textPrimary?: ColorValue;
|
||||||
textSecondary?: ColorValue;
|
textSecondary?: ColorValue;
|
||||||
textTertiary?: ColorValue;
|
textTertiary?: ColorValue;
|
||||||
textSelectedPrimary?: ColorValue;
|
textSelectedPrimary?: ColorValue;
|
||||||
textSelectedSecondary?: ColorValue;
|
textSelectedSecondary?: ColorValue;
|
||||||
textSelectedTertiary?: ColorValue;
|
textSelectedTertiary?: ColorValue;
|
||||||
muted: ColorValue;
|
muted: ColorValue;
|
||||||
warning: ColorValue;
|
warning: ColorValue;
|
||||||
error: ColorValue;
|
error: ColorValue;
|
||||||
success: ColorValue;
|
success: ColorValue;
|
||||||
layerBackgrounds?: LayerBackgrounds;
|
layerBackgrounds?: LayerBackgrounds;
|
||||||
_hasSelectedListItemText?: boolean;
|
_hasSelectedListItemText?: boolean;
|
||||||
thinkingOpacity?: number;
|
thinkingOpacity?: number;
|
||||||
selectedListItemText?: ColorValue;
|
selectedListItemText?: ColorValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeVariant = {
|
export type ThemeVariant = {
|
||||||
name: string;
|
name: string;
|
||||||
colors: ThemeColors;
|
colors: ThemeColors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeToken = {
|
export type ThemeToken = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedTheme = Record<string, RGBA> & {
|
export type ResolvedTheme = Record<string, RGBA> & {
|
||||||
layerBackgrounds: Record<string, RGBA>;
|
layerBackgrounds: Record<string, RGBA>;
|
||||||
_hasSelectedListItemText: boolean;
|
_hasSelectedListItemText: boolean;
|
||||||
thinkingOpacity: number;
|
thinkingOpacity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DesktopTheme = {
|
export type DesktopTheme = {
|
||||||
name: string;
|
name: string;
|
||||||
variants: ThemeVariant[];
|
variants: ThemeVariant[];
|
||||||
defaultVariant: string;
|
defaultVariant: string;
|
||||||
tokens: ThemeToken;
|
tokens: ThemeToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VisualizerSettings = {
|
export type VisualizerSettings = {
|
||||||
/** Number of frequency bars (8–128, default: 32) */
|
/** Number of frequency bars (8–128, default: 64) */
|
||||||
bars: number;
|
bars: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
sensitivity: number;
|
sensitivity: number;
|
||||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||||
noiseReduction: number;
|
noiseReduction: number;
|
||||||
/** Low frequency cutoff in Hz (default: 50) */
|
/** Low frequency cutoff in Hz (default: 50) */
|
||||||
lowCutOff: number;
|
lowCutOff: number;
|
||||||
/** High frequency cutoff in Hz (default: 10000) */
|
/** High frequency cutoff in Hz (default: 10000) */
|
||||||
highCutOff: number;
|
highCutOff: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppSettings = {
|
export type AppSettings = {
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
visualizer: VisualizerSettings;
|
visualizer: VisualizerSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
customTheme: ThemeColors;
|
customTheme: ThemeColors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeMode = "dark" | "light";
|
export type ThemeMode = "dark" | "light";
|
||||||
|
|||||||
@@ -11,18 +11,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** PCM output format constants */
|
/** PCM output format constants */
|
||||||
const SAMPLE_RATE = 44100
|
const SAMPLE_RATE = 44100;
|
||||||
const CHANNELS = 1
|
const CHANNELS = 1;
|
||||||
const BYTES_PER_SAMPLE = 2 // s16le
|
const BYTES_PER_SAMPLE = 2; // s16le
|
||||||
|
|
||||||
/** How many samples to buffer (~1 second) */
|
/** How many samples to buffer (~1 second) */
|
||||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE
|
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
|
||||||
|
|
||||||
export interface AudioStreamReaderOptions {
|
export interface AudioStreamReaderOptions {
|
||||||
/** Audio URL or file path to decode */
|
/** Audio URL or file path to decode */
|
||||||
url: string
|
url: string;
|
||||||
/** Sample rate (default: 44100) */
|
/** Sample rate (default: 44100) */
|
||||||
sampleRate?: number
|
sampleRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,202 +30,232 @@ export interface AudioStreamReaderOptions {
|
|||||||
* Each start() increments this; the read loop checks it to know
|
* Each start() increments this; the read loop checks it to know
|
||||||
* if it's been superseded and should bail out.
|
* if it's been superseded and should bail out.
|
||||||
*/
|
*/
|
||||||
let globalGeneration = 0
|
let globalGeneration = 0;
|
||||||
|
|
||||||
export class AudioStreamReader {
|
export class AudioStreamReader {
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null
|
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||||
private ringBuffer: Float64Array
|
private ringBuffer: Float64Array;
|
||||||
private writePos = 0
|
private writePos = 0;
|
||||||
private totalSamplesWritten = 0
|
private totalSamplesWritten = 0;
|
||||||
private _running = false
|
private _running = false;
|
||||||
private generation = 0
|
private generation = 0;
|
||||||
readonly url: string
|
readonly url: string;
|
||||||
private sampleRate: number
|
private sampleRate: number;
|
||||||
|
|
||||||
constructor(options: AudioStreamReaderOptions) {
|
constructor(options: AudioStreamReaderOptions) {
|
||||||
this.url = options.url
|
this.url = options.url;
|
||||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
|
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
||||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
|
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the reader is actively reading samples. */
|
/** Whether the reader is actively reading samples. */
|
||||||
get running(): boolean {
|
get running(): boolean {
|
||||||
return this._running
|
return this._running;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total number of samples written since start(). */
|
/** Total number of samples written since start(). */
|
||||||
get samplesWritten(): number {
|
get samplesWritten(): number {
|
||||||
return this.totalSamplesWritten
|
return this.totalSamplesWritten;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the ffmpeg decode process and begin reading PCM data.
|
* Start the ffmpeg decode process and begin reading PCM data.
|
||||||
*
|
*
|
||||||
* If already running, the previous process is killed first.
|
* If already running, the previous process is killed first.
|
||||||
* Uses a generation counter to guarantee that only one read loop
|
* Uses a generation counter to guarantee that only one read loop
|
||||||
* is ever active — stale loops from killed processes bail out
|
* is ever active — stale loops from killed processes bail out
|
||||||
* immediately.
|
* immediately.
|
||||||
*
|
*
|
||||||
* @param startPosition Seek position in seconds (default: 0).
|
* @param startPosition Seek position in seconds (default: 0).
|
||||||
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
|
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
|
||||||
* atempo filter so visualization stays in sync with audio.
|
* atempo filter so visualization stays in sync with audio.
|
||||||
*/
|
*/
|
||||||
start(startPosition = 0, speed = 1): void {
|
start(startPosition = 0, speed = 1): void {
|
||||||
// Always kill the previous process first — no early return on _running
|
// Always kill the previous process first — no early return on _running
|
||||||
this.killProcess()
|
this.killProcess();
|
||||||
|
|
||||||
if (!Bun.which("ffmpeg")) {
|
if (!Bun.which("ffmpeg")) {
|
||||||
throw new Error("ffmpeg not found — required for audio visualization")
|
throw new Error("ffmpeg not found — required for audio visualization");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment generation so any lingering read loop from a previous
|
// Increment generation so any lingering read loop from a previous
|
||||||
// start() will see a mismatch and exit.
|
// start() will see a mismatch and exit.
|
||||||
this.generation = ++globalGeneration
|
this.generation = ++globalGeneration;
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-loglevel", "quiet",
|
"-loglevel",
|
||||||
"-reconnect", "1",
|
"quiet",
|
||||||
"-reconnect_streamed", "1",
|
// Read input at native frame rate so decoded PCM stays in sync with
|
||||||
"-reconnect_delay_max", "5",
|
// real-time playback. Without -re, ffmpeg greedily decodes the whole
|
||||||
]
|
// file as fast as possible: the ring buffer fills with audio seconds
|
||||||
|
// ahead of the player (laggy bars), then the process exits when it
|
||||||
|
// hits EOF (bars freeze ~10s in).
|
||||||
|
"-re",
|
||||||
|
"-reconnect",
|
||||||
|
"1",
|
||||||
|
"-reconnect_streamed",
|
||||||
|
"1",
|
||||||
|
"-reconnect_delay_max",
|
||||||
|
"5",
|
||||||
|
];
|
||||||
|
|
||||||
// Seek before input for network efficiency
|
// Seek before input for network efficiency
|
||||||
if (startPosition > 0) {
|
if (startPosition > 0) {
|
||||||
args.push("-ss", String(startPosition))
|
args.push("-ss", String(startPosition));
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push("-i", this.url)
|
args.push("-i", this.url);
|
||||||
|
|
||||||
// Apply speed via atempo filter if not 1x.
|
// Apply speed via atempo filter if not 1x.
|
||||||
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
||||||
if (speed !== 1 && speed > 0) {
|
if (speed !== 1 && speed > 0) {
|
||||||
args.push("-af", buildAtempoChain(speed))
|
args.push("-af", buildAtempoChain(speed));
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(
|
args.push(
|
||||||
"-ac", String(CHANNELS),
|
"-ac",
|
||||||
"-ar", String(this.sampleRate),
|
String(CHANNELS),
|
||||||
"-f", "s16le",
|
"-ar",
|
||||||
"-acodec", "pcm_s16le",
|
String(this.sampleRate),
|
||||||
"-",
|
"-f",
|
||||||
)
|
"s16le",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-",
|
||||||
|
);
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
this.proc = Bun.spawn(args, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
})
|
});
|
||||||
|
|
||||||
this._running = true
|
this._running = true;
|
||||||
this.writePos = 0
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0
|
this.totalSamplesWritten = 0;
|
||||||
|
|
||||||
// Capture generation for this run
|
// Capture generation for this run
|
||||||
const myGeneration = this.generation
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
// Start async reading loop
|
// Start async reading loop
|
||||||
this.readLoop(myGeneration)
|
this.readLoop(myGeneration);
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
this.proc.exited.then(() => {
|
this.proc.exited
|
||||||
// Only clear _running if this is still the current generation
|
.then(() => {
|
||||||
if (this.generation === myGeneration) {
|
// Only clear _running if this is still the current generation
|
||||||
this._running = false
|
if (this.generation === myGeneration) {
|
||||||
}
|
this._running = false;
|
||||||
}).catch(() => {
|
}
|
||||||
if (this.generation === myGeneration) {
|
})
|
||||||
this._running = false
|
.catch(() => {
|
||||||
}
|
if (this.generation === myGeneration) {
|
||||||
})
|
this._running = false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read available samples into the provided buffer.
|
* Read available samples into the provided buffer.
|
||||||
* Returns the number of samples actually copied.
|
* Returns the number of samples actually copied.
|
||||||
*
|
*
|
||||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
||||||
* @returns Number of samples written to `out`.
|
* @returns Number of samples written to `out`.
|
||||||
*/
|
*/
|
||||||
read(out: Float64Array): number {
|
read(out: Float64Array): number {
|
||||||
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
|
const available = Math.min(
|
||||||
if (available <= 0) return 0
|
out.length,
|
||||||
|
this.totalSamplesWritten,
|
||||||
|
this.ringBuffer.length,
|
||||||
|
);
|
||||||
|
if (available <= 0) return 0;
|
||||||
|
|
||||||
// Read the most recent `available` samples from the ring buffer
|
// Read the most recent `available` samples from the ring buffer
|
||||||
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
|
const readStart =
|
||||||
|
(this.writePos - available + this.ringBuffer.length) %
|
||||||
|
this.ringBuffer.length;
|
||||||
|
|
||||||
if (readStart + available <= this.ringBuffer.length) {
|
if (readStart + available <= this.ringBuffer.length) {
|
||||||
out.set(this.ringBuffer.subarray(readStart, readStart + available))
|
out.set(this.ringBuffer.subarray(readStart, readStart + available));
|
||||||
} else {
|
} else {
|
||||||
const firstChunk = this.ringBuffer.length - readStart
|
const firstChunk = this.ringBuffer.length - readStart;
|
||||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
|
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
|
||||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
|
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
return available
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the ffmpeg process and clean up.
|
* Stop the ffmpeg process and clean up.
|
||||||
* Safe to call multiple times. Guarantees the read loop exits.
|
* Safe to call multiple times. Guarantees the read loop exits.
|
||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
// Bump generation to invalidate any running read loop
|
// Bump generation to invalidate any running read loop
|
||||||
this.generation = ++globalGeneration
|
this.generation = ++globalGeneration;
|
||||||
this._running = false
|
this._running = false;
|
||||||
this.killProcess()
|
this.killProcess();
|
||||||
this.writePos = 0
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0
|
this.totalSamplesWritten = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart the reader at a new position and/or speed.
|
* Restart the reader at a new position and/or speed.
|
||||||
*/
|
*/
|
||||||
restart(startPosition = 0, speed = 1): void {
|
restart(startPosition = 0, speed = 1): void {
|
||||||
this.start(startPosition, speed)
|
this.start(startPosition, speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill the ffmpeg process without touching generation/state. */
|
/** Kill the ffmpeg process without touching generation/state. */
|
||||||
private killProcess(): void {
|
private killProcess(): void {
|
||||||
if (this.proc) {
|
if (this.proc) {
|
||||||
try { this.proc.kill() } catch { /* ignore */ }
|
try {
|
||||||
this.proc = null
|
this.proc.kill();
|
||||||
}
|
} catch {
|
||||||
}
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||||
private async readLoop(myGeneration: number): Promise<void> {
|
private async readLoop(myGeneration: number): Promise<void> {
|
||||||
const stdout = this.proc?.stdout
|
const stdout = this.proc?.stdout;
|
||||||
if (!stdout || typeof stdout === "number") return
|
if (!stdout || typeof stdout === "number") return;
|
||||||
|
|
||||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
try {
|
try {
|
||||||
while (this.generation === myGeneration) {
|
while (this.generation === myGeneration) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read();
|
||||||
if (done || this.generation !== myGeneration) break
|
if (done || this.generation !== myGeneration) break;
|
||||||
if (!value || value.byteLength === 0) continue
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
|
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
||||||
if (sampleCount === 0) continue
|
if (sampleCount === 0) continue;
|
||||||
|
|
||||||
const int16View = new Int16Array(
|
const int16View = new Int16Array(
|
||||||
value.buffer,
|
value.buffer,
|
||||||
value.byteOffset,
|
value.byteOffset,
|
||||||
sampleCount,
|
sampleCount,
|
||||||
)
|
);
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
this.ringBuffer[this.writePos] = int16View[i]
|
this.ringBuffer[this.writePos] = int16View[i];
|
||||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length
|
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
||||||
this.totalSamplesWritten++
|
this.totalSamplesWritten++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Stream ended or process killed — expected during stop()
|
// Stream ended or process killed — expected during stop()
|
||||||
} finally {
|
} finally {
|
||||||
try { reader.releaseLock() } catch { /* ignore */ }
|
try {
|
||||||
}
|
reader.releaseLock();
|
||||||
}
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,18 +264,18 @@ export class AudioStreamReader {
|
|||||||
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
|
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
|
||||||
*/
|
*/
|
||||||
function buildAtempoChain(speed: number): string {
|
function buildAtempoChain(speed: number): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = [];
|
||||||
let remaining = Math.max(0.25, Math.min(4, speed))
|
let remaining = Math.max(0.25, Math.min(4, speed));
|
||||||
|
|
||||||
while (remaining > 100) {
|
while (remaining > 100) {
|
||||||
parts.push("atempo=100.0")
|
parts.push("atempo=100.0");
|
||||||
remaining /= 100
|
remaining /= 100;
|
||||||
}
|
}
|
||||||
while (remaining < 0.5) {
|
while (remaining < 0.5) {
|
||||||
parts.push("atempo=0.5")
|
parts.push("atempo=0.5");
|
||||||
remaining /= 0.5
|
remaining /= 0.5;
|
||||||
}
|
}
|
||||||
parts.push(`atempo=${remaining}`)
|
parts.push(`atempo=${remaining}`);
|
||||||
|
|
||||||
return parts.join(",")
|
return parts.join(",");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,170 +16,178 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs";
|
||||||
import { join, dirname } from "path"
|
import { join, dirname } from "path";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CavaCoreConfig {
|
export interface CavaCoreConfig {
|
||||||
/** Number of frequency bars (default: 32) */
|
/** Number of frequency bars (default: 32) */
|
||||||
bars?: number
|
bars?: number;
|
||||||
/** Audio sample rate in Hz (default: 44100) */
|
/** Audio sample rate in Hz (default: 44100) */
|
||||||
sampleRate?: number
|
sampleRate?: number;
|
||||||
/** Number of audio channels (default: 1 = mono) */
|
/** Number of audio channels (default: 1 = mono) */
|
||||||
channels?: number
|
channels?: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
autosens?: number
|
autosens?: number;
|
||||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||||
noiseReduction?: number
|
noiseReduction?: number;
|
||||||
/** Low frequency cutoff in Hz (default: 50) */
|
/** Low frequency cutoff in Hz (default: 50) */
|
||||||
lowCutOff?: number
|
lowCutOff?: number;
|
||||||
/** High frequency cutoff in Hz (default: 10000) */
|
/** High frequency cutoff in Hz (default: 10000) */
|
||||||
highCutOff?: number
|
highCutOff?: number;
|
||||||
|
/** Output scaling mode: 0 = linear (default), 1 = decibel */
|
||||||
|
scalingMode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: Required<CavaCoreConfig> = {
|
const DEFAULTS: Required<CavaCoreConfig> = {
|
||||||
bars: 32,
|
bars: 32,
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
autosens: 1,
|
autosens: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
}
|
scalingMode: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
|
type CavaLib = {
|
||||||
|
symbols: Record<string, (...args: any[]) => any>;
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Library resolution ───────────────────────────────────────────────
|
// ── Library resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
function findLibrary(): string | null {
|
function findLibrary(): string | null {
|
||||||
const platform = process.platform
|
const platform = process.platform;
|
||||||
const libName = platform === "darwin"
|
const libName =
|
||||||
? "libcavacore.dylib"
|
platform === "darwin"
|
||||||
: platform === "win32"
|
? "libcavacore.dylib"
|
||||||
? "cavacore.dll"
|
: platform === "win32"
|
||||||
: "libcavacore.so"
|
? "cavacore.dll"
|
||||||
|
: "libcavacore.so";
|
||||||
|
|
||||||
// Candidate paths, in priority order:
|
// Candidate paths, in priority order:
|
||||||
// 1. src/native/ (development)
|
// 1. src/native/ (development)
|
||||||
// 2. Same directory as the running executable (dist bundle)
|
// 2. Same directory as the running executable (dist bundle)
|
||||||
// 3. dist/ relative to cwd
|
// 3. dist/ relative to cwd
|
||||||
const candidates = [
|
const candidates = [
|
||||||
join(import.meta.dir, "..", "native", libName),
|
join(import.meta.dir, "..", "native", libName),
|
||||||
join(dirname(process.execPath), libName),
|
join(dirname(process.execPath), libName),
|
||||||
join(process.cwd(), "dist", libName),
|
join(process.cwd(), "dist", libName),
|
||||||
]
|
];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (existsSync(candidate)) return candidate
|
if (existsSync(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CavaCore class ───────────────────────────────────────────────────
|
// ── CavaCore class ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export class CavaCore {
|
export class CavaCore {
|
||||||
private lib: CavaLib
|
private lib: CavaLib;
|
||||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
|
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
|
||||||
private inputBuffer: Float64Array | null = null
|
private inputBuffer: Float64Array | null = null;
|
||||||
private outputBuffer: Float64Array | null = null
|
private outputBuffer: Float64Array | null = null;
|
||||||
private _bars = 0
|
private _bars = 0;
|
||||||
private _channels = 1
|
private _channels = 1;
|
||||||
private _destroyed = false
|
private _destroyed = false;
|
||||||
|
|
||||||
/** Use loadCavaCore() instead of constructing directly. */
|
/** Use loadCavaCore() instead of constructing directly. */
|
||||||
constructor(lib: CavaLib) {
|
constructor(lib: CavaLib) {
|
||||||
this.lib = lib
|
this.lib = lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of frequency bars configured. */
|
/** Number of frequency bars configured. */
|
||||||
get bars(): number {
|
get bars(): number {
|
||||||
return this._bars
|
return this._bars;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||||
get isReady(): boolean {
|
get isReady(): boolean {
|
||||||
return this.plan !== null && !this._destroyed
|
return this.plan !== null && !this._destroyed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the cavacore engine with the given configuration.
|
* Initialize the cavacore engine with the given configuration.
|
||||||
* Must be called before execute(). Can be called again after destroy()
|
* Must be called before execute(). Can be called again after destroy()
|
||||||
* to reinitialize with different parameters.
|
* to reinitialize with different parameters.
|
||||||
*/
|
*/
|
||||||
init(config: CavaCoreConfig = {}): void {
|
init(config: CavaCoreConfig = {}): void {
|
||||||
if (this.plan) {
|
if (this.plan) {
|
||||||
this.destroy()
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = { ...DEFAULTS, ...config }
|
const cfg = { ...DEFAULTS, ...config };
|
||||||
this._bars = cfg.bars
|
this._bars = cfg.bars;
|
||||||
this._channels = cfg.channels
|
this._channels = cfg.channels;
|
||||||
|
|
||||||
this.plan = this.lib.symbols.cava_init(
|
this.plan = this.lib.symbols.cava_init(
|
||||||
cfg.bars,
|
cfg.bars,
|
||||||
cfg.sampleRate,
|
cfg.sampleRate,
|
||||||
cfg.channels,
|
cfg.channels,
|
||||||
cfg.autosens,
|
cfg.autosens,
|
||||||
cfg.noiseReduction,
|
cfg.noiseReduction,
|
||||||
cfg.lowCutOff,
|
cfg.lowCutOff,
|
||||||
cfg.highCutOff,
|
cfg.highCutOff,
|
||||||
)
|
cfg.scalingMode,
|
||||||
|
);
|
||||||
|
|
||||||
if (!this.plan) {
|
if (!this.plan) {
|
||||||
throw new Error("cava_init returned null — initialization failed")
|
throw new Error("cava_init returned null — initialization failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-allocate output buffer (bars * channels)
|
// Pre-allocate output buffer (bars * channels)
|
||||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
|
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
|
||||||
this._destroyed = false
|
this._destroyed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feed PCM samples into cavacore and get frequency bar values back.
|
* Feed PCM samples into cavacore and get frequency bar values back.
|
||||||
*
|
*
|
||||||
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
||||||
* The array length determines the number of samples processed.
|
* The array length determines the number of samples processed.
|
||||||
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
||||||
* Returns the same buffer reference each call (overwritten in place).
|
* Returns the same buffer reference each call (overwritten in place).
|
||||||
*/
|
*/
|
||||||
execute(samples: Float64Array): Float64Array {
|
execute(samples: Float64Array): Float64Array {
|
||||||
if (!this.plan || !this.outputBuffer) {
|
if (!this.plan || !this.outputBuffer) {
|
||||||
throw new Error("CavaCore not initialized — call init() first")
|
throw new Error("CavaCore not initialized — call init() first");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse input buffer if same size, otherwise allocate new
|
// Reuse input buffer if same size, otherwise allocate new
|
||||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||||
this.inputBuffer = new Float64Array(samples.length)
|
this.inputBuffer = new Float64Array(samples.length);
|
||||||
}
|
}
|
||||||
this.inputBuffer.set(samples)
|
this.inputBuffer.set(samples);
|
||||||
|
|
||||||
this.lib.symbols.cava_execute(
|
this.lib.symbols.cava_execute(
|
||||||
ptr(this.inputBuffer),
|
ptr(this.inputBuffer),
|
||||||
samples.length,
|
samples.length,
|
||||||
ptr(this.outputBuffer),
|
ptr(this.outputBuffer),
|
||||||
this.plan,
|
this.plan,
|
||||||
)
|
);
|
||||||
|
|
||||||
return this.outputBuffer
|
return this.outputBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release all native resources. Safe to call multiple times.
|
* Release all native resources. Safe to call multiple times.
|
||||||
* After calling destroy(), init() can be called again to reuse the instance.
|
* After calling destroy(), init() can be called again to reuse the instance.
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.plan && !this._destroyed) {
|
if (this.plan && !this._destroyed) {
|
||||||
this.lib.symbols.cava_destroy(this.plan)
|
this.lib.symbols.cava_destroy(this.plan);
|
||||||
this.plan = null
|
this.plan = null;
|
||||||
this._destroyed = true
|
this._destroyed = true;
|
||||||
}
|
}
|
||||||
this.inputBuffer = null
|
this.inputBuffer = null;
|
||||||
this.outputBuffer = null
|
this.outputBuffer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Factory ──────────────────────────────────────────────────────────
|
// ── Factory ──────────────────────────────────────────────────────────
|
||||||
@@ -190,41 +198,42 @@ export class CavaCore {
|
|||||||
* to the static waveform display.
|
* to the static waveform display.
|
||||||
*/
|
*/
|
||||||
export function loadCavaCore(): CavaCore | null {
|
export function loadCavaCore(): CavaCore | null {
|
||||||
try {
|
try {
|
||||||
const libPath = findLibrary()
|
const libPath = findLibrary();
|
||||||
if (!libPath) return null
|
if (!libPath) return null;
|
||||||
|
|
||||||
const lib = dlopen(libPath, {
|
const lib = dlopen(libPath, {
|
||||||
cava_init: {
|
cava_init: {
|
||||||
args: [
|
args: [
|
||||||
FFIType.i32, // bars
|
FFIType.i32, // bars
|
||||||
FFIType.u32, // rate
|
FFIType.u32, // rate
|
||||||
FFIType.i32, // channels
|
FFIType.i32, // channels
|
||||||
FFIType.i32, // autosens
|
FFIType.i32, // autosens
|
||||||
FFIType.double, // noise_reduction
|
FFIType.double, // noise_reduction
|
||||||
FFIType.i32, // low_cut_off
|
FFIType.i32, // low_cut_off
|
||||||
FFIType.i32, // high_cut_off
|
FFIType.i32, // high_cut_off
|
||||||
],
|
FFIType.i32, // scaling_mode
|
||||||
returns: FFIType.ptr,
|
],
|
||||||
},
|
returns: FFIType.ptr,
|
||||||
cava_execute: {
|
},
|
||||||
args: [
|
cava_execute: {
|
||||||
FFIType.ptr, // cava_in (double*)
|
args: [
|
||||||
FFIType.i32, // samples
|
FFIType.ptr, // cava_in (double*)
|
||||||
FFIType.ptr, // cava_out (double*)
|
FFIType.i32, // samples
|
||||||
FFIType.ptr, // plan
|
FFIType.ptr, // cava_out (double*)
|
||||||
],
|
FFIType.ptr, // plan
|
||||||
returns: FFIType.void,
|
],
|
||||||
},
|
returns: FFIType.void,
|
||||||
cava_destroy: {
|
},
|
||||||
args: [FFIType.ptr], // plan
|
cava_destroy: {
|
||||||
returns: FFIType.void,
|
args: [FFIType.ptr], // plan
|
||||||
},
|
returns: FFIType.void,
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return new CavaCore(lib as CavaLib)
|
return new CavaCore(lib as CavaLib);
|
||||||
} catch {
|
} catch {
|
||||||
// Library load failed — missing dylib, wrong arch, etc.
|
// Library load failed — missing dylib, wrong arch, etc.
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,57 +2,85 @@
|
|||||||
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
||||||
* Run: bun tests/cavacore-smoke.ts
|
* Run: bun tests/cavacore-smoke.ts
|
||||||
*/
|
*/
|
||||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||||
import { join } from "path"
|
import { join } from "path";
|
||||||
|
|
||||||
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib")
|
const libPath = join(
|
||||||
|
import.meta.dir,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"native",
|
||||||
|
"libcavacore.dylib",
|
||||||
|
);
|
||||||
|
|
||||||
const lib = dlopen(libPath, {
|
const lib = dlopen(libPath, {
|
||||||
cava_init: {
|
cava_init: {
|
||||||
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32],
|
args: [
|
||||||
returns: FFIType.ptr,
|
FFIType.i32,
|
||||||
},
|
FFIType.u32,
|
||||||
cava_execute: {
|
FFIType.i32,
|
||||||
args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
FFIType.i32,
|
||||||
returns: FFIType.void,
|
FFIType.double,
|
||||||
},
|
FFIType.i32,
|
||||||
cava_destroy: {
|
FFIType.i32,
|
||||||
args: [FFIType.ptr],
|
FFIType.i32,
|
||||||
returns: FFIType.void,
|
],
|
||||||
},
|
returns: FFIType.ptr,
|
||||||
})
|
},
|
||||||
|
cava_execute: {
|
||||||
|
args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
||||||
|
returns: FFIType.void,
|
||||||
|
},
|
||||||
|
cava_destroy: {
|
||||||
|
args: [FFIType.ptr],
|
||||||
|
returns: FFIType.void,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const bars = 10
|
const bars = 10;
|
||||||
const rate = 44100
|
const rate = 44100;
|
||||||
const channels = 1
|
const channels = 1;
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000)
|
const plan = lib.symbols.cava_init(
|
||||||
|
bars,
|
||||||
|
rate,
|
||||||
|
channels,
|
||||||
|
1,
|
||||||
|
0.77,
|
||||||
|
50,
|
||||||
|
10000,
|
||||||
|
0 /* CAVA_SCALING_LINEAR */,
|
||||||
|
);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
console.error("FAIL: cava_init returned null")
|
console.error("FAIL: cava_init returned null");
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("cava_init OK, plan pointer:", plan)
|
console.log("cava_init OK, plan pointer:", plan);
|
||||||
|
|
||||||
// Generate a 200Hz sine wave test signal
|
// Generate a 200Hz sine wave test signal
|
||||||
const bufferSize = 512
|
const bufferSize = 512;
|
||||||
const cavaIn = new Float64Array(bufferSize)
|
const cavaIn = new Float64Array(bufferSize);
|
||||||
const cavaOut = new Float64Array(bars * channels)
|
const cavaOut = new Float64Array(bars * channels);
|
||||||
|
|
||||||
for (let k = 0; k < 100; k++) {
|
for (let k = 0; k < 100; k++) {
|
||||||
for (let n = 0; n < bufferSize; n++) {
|
for (let n = 0; n < bufferSize; n++) {
|
||||||
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000
|
cavaIn[n] =
|
||||||
}
|
Math.sin(((2 * Math.PI * 200) / rate) * (n + k * bufferSize)) * 20000;
|
||||||
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan)
|
}
|
||||||
|
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3)))
|
console.log(
|
||||||
|
"cava_execute OK, output:",
|
||||||
|
Array.from(cavaOut).map((v) => v.toFixed(3)),
|
||||||
|
);
|
||||||
|
|
||||||
// Check that bar 2 (200Hz) has the peak
|
// Check that bar 2 (200Hz) has the peak
|
||||||
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut))
|
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut));
|
||||||
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`)
|
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`);
|
||||||
|
|
||||||
// Destroy
|
// Destroy
|
||||||
lib.symbols.cava_destroy(plan)
|
lib.symbols.cava_destroy(plan);
|
||||||
console.log("cava_destroy OK")
|
console.log("cava_destroy OK");
|
||||||
console.log("\nSMOKE TEST PASSED")
|
console.log("\nSMOKE TEST PASSED");
|
||||||
|
|||||||
Reference in New Issue
Block a user