diff --git a/lib/podcast_buddy.rb b/lib/podcast_buddy.rb index 0a4c199..ccf6f8e 100644 --- a/lib/podcast_buddy.rb +++ b/lib/podcast_buddy.rb @@ -17,10 +17,14 @@ require_relative "podcast_buddy/transcriber" require_relative "podcast_buddy/listener" require_relative "podcast_buddy/audio_service" +require_relative "podcast_buddy/show_assistant" +require_relative "podcast_buddy/co_host" module PodcastBuddy class Error < StandardError; end + NamedTask = Struct.new(:name, :task, keyword_init: true) + class << self def config @config ||= Configuration.new @@ -42,6 +46,21 @@ def setup SystemDependency.auto_install!(:bat) SystemDependency.resolve_whisper_model(whisper_model) end + + def to_human(text, label = :info) + case label.to_sym + when :info + Rainbow(text).blue + when :wait + Rainbow(text).yellow + when :input + Rainbow(text).black.bg(:yellow) + when :success + Rainbow(text).green + else + text + end + end end configure diff --git a/lib/podcast_buddy/cli.rb b/lib/podcast_buddy/cli.rb index 7c04597..def0192 100644 --- a/lib/podcast_buddy/cli.rb +++ b/lib/podcast_buddy/cli.rb @@ -17,12 +17,10 @@ module PodcastBuddy # cli = PodcastBuddy::CLI.new(["--debug", "-n", "my-podcast"]) # cli.run class CLI - NamedTask = Struct.new(:name, :task, keyword_init: true) - def initialize(argv) @options = parse_options(argv) @tasks = [] - @listener = nil + @show_assistant = PodcastBuddy::ShowAssistant.new end def run @@ -59,7 +57,7 @@ def parse_options(argv) def configure_logger if @options[:debug] - PodcastBuddy.logger.info to_human("Turning on debug mode...", :info) + PodcastBuddy.logger.info PodcastBuddy.to_human("Turning on debug mode...", :info) PodcastBuddy.logger.level = Logger::DEBUG else PodcastBuddy.logger.level = Logger::INFO @@ -73,15 +71,15 @@ def configure_logger def configure_session if @options[:whisper_model] PodcastBuddy.config.whisper_model = @options[:whisper_model] - PodcastBuddy.logger.info to_human("Using whisper model: #{@options[:whisper_model]}", :info) + PodcastBuddy.logger.info PodcastBuddy.to_human("Using whisper model: #{@options[:whisper_model]}", :info) end if @options[:name] base_path = "#{PodcastBuddy.root}/tmp/#{@options[:name]}" FileUtils.mkdir_p base_path PodcastBuddy.session = @options[:name] - PodcastBuddy.logger.info to_human("Using custom session name: #{@options[:name]}", :info) - PodcastBuddy.logger.info to_human(" Saving files to: #{PodcastBuddy.session}", :info) + PodcastBuddy.logger.info PodcastBuddy.to_human("Using custom session name: #{@options[:name]}", :info) + PodcastBuddy.logger.info PodcastBuddy.to_human(" Saving files to: #{PodcastBuddy.session}", :info) end end @@ -94,14 +92,12 @@ def setup_dependencies def start_recording Sync do |task| - @listener = PodcastBuddy::Listener.new(transcriber: PodcastBuddy::Transcriber.new) - listener_task = task.async { @listener.start } - periodic_summarization_task = task.async { periodic_summarization(@listener) } - question_listener_task = task.async { wait_for_question_start(@listener) } + @co_host = PodcastBuddy::CoHost.new(listener: @show_assistant.listener) + show_assistant_task = task.async { @show_assistant.start } + co_host_task = task.async { @co_host.start } @tasks = [ - NamedTask.new(name: "Listener", task: listener_task), - NamedTask.new(name: "Periodic Summarizer", task: periodic_summarization_task), - NamedTask.new(name: "question listener", task: question_listener_task) + PodcastBuddy::NamedTask.new(name: "Show Assistant", task: show_assistant_task), + PodcastBuddy::NamedTask.new(name: "Co-Host", task: co_host_task) ] task.with_timeout(60 * 60 * 2) do @@ -111,224 +107,20 @@ def start_recording end def handle_shutdown - PodcastBuddy.logger.info to_human("\nShutting down streams...", :wait) + PodcastBuddy.logger.info PodcastBuddy.to_human("\nShutting down streams...", :wait) @shutdown = true end def shutdown_tasks! - PodcastBuddy.logger.info to_human("Waiting for Listener to shutdown...", :wait) - @listener&.stop + @show_assistant.stop + @co_host&.stop @tasks.each do |task| - PodcastBuddy.logger.info to_human("Waiting for #{task.name} to shutdown...", :wait) - task.task.wait - end - - PodcastBuddy.logger.info to_human("Generating show notes...", :wait) - generate_show_notes - end - - def periodic_summarization(listener, interval = 15) - Async do - loop do - PodcastBuddy.logger.debug("Shutdown: periodic_summarization...") and break if @shutdown - - sleep interval - summarize_latest(listener) - rescue => e - PodcastBuddy.logger.warn "[summarization] periodic summarization failed: #{e.message}" - end - end - end - - def summarize_latest(listener) - current_discussion = listener.current_discussion - return if current_discussion.empty? - - PodcastBuddy.logger.debug "[periodic summarization] Latest transcript: #{current_discussion}" - extract_topics_and_summarize(current_discussion) - end - - def extract_topics_and_summarize(text) - Async do |parent| - parent.async { update_topics(text) } - parent.async { think_about(text) } - end - end - - def update_topics(text) - Async do - PodcastBuddy.logger.debug "Looking for topics related to: #{text}" - response = PodcastBuddy.openai_client.chat(parameters: { - model: "gpt-4o-mini", - messages: topic_extraction_messages(text), - max_tokens: 500 - }) - new_topics = response.dig("choices", 0, "message", "content").gsub("NONE", "").strip - - PodcastBuddy.session.announce_topics(new_topics) - PodcastBuddy.session.add_to_topics(new_topics) - rescue => e - PodcastBuddy.logger.error "Failed to update topics: #{e.message}" - end - end - - def think_about(text) - Async do - PodcastBuddy.logger.debug "Summarizing current discussion..." - response = PodcastBuddy.openai_client.chat(parameters: { - model: "gpt-4o", - messages: discussion_messages(text), - max_tokens: 250 - }) - new_summary = response.dig("choices", 0, "message", "content").strip - PodcastBuddy.logger.info to_human("Thoughts: #{new_summary}", :info) - PodcastBuddy.session.update_summary(new_summary) - rescue => e - PodcastBuddy.logger.error "Failed to summarize discussion: #{e.message}" + PodcastBuddy.logger.info PodcastBuddy.to_human("Waiting for #{task.name} to shutdown...", :wait) + task&.task&.wait end - end - - def wait_for_question_start(listener) - Async do |parent| - PodcastBuddy.logger.info Rainbow("Press ").blue + Rainbow("Enter").black.bg(:yellow) + Rainbow(" to signal a question start...").blue - loop do - PodcastBuddy.logger.debug("Shutdown: wait_for_question...") and break if @shutdown - - input = "" - Timeout.timeout(5) do - input = gets - PodcastBuddy.logger.debug("Input received...") if input.include?("\n") - listener.listen_for_question! if input.include?("\n") - rescue Timeout::Error - next - end - - next unless listener.listening_for_question - - PodcastBuddy.logger.info to_human("🎙️ Listening for quesiton. Press ", :wait) + to_human("Enter", :input) + to_human(" to signal the end of the question...", :wait) - wait_for_question_end(listener) - end - end - end - - def wait_for_question_end(listener) - Async do - loop do - PodcastBuddy.logger.debug("Shutdown: wait_for_question_end...") and break if @shutdown - - sleep 0.1 and next if !listener.listening_for_question - - input = "" - Timeout.timeout(5) do - input = gets - PodcastBuddy.logger.debug("Input received...") if input.include?("\n") - next unless input.to_s.include?("\n") - rescue Timeout::Error - PodcastBuddy.logger.debug("Input timeout...") - next - end - - if input.empty? - next - else - PodcastBuddy.logger.info "End of question signal. Generating answer..." - question = listener.stop_listening_for_question! - answer_question(question, listener).wait - PodcastBuddy.logger.info Rainbow("Press ").blue + Rainbow("Enter").black.bg(:yellow) + Rainbow(" to signal a question start...").blue - break - end - end - end - end - - def answer_question(question, listener) - Async do - summarize_latest(listener) if PodcastBuddy.session.current_summary.to_s.empty? - latest_context = "#{PodcastBuddy.session.current_summary}\nTopics discussed recently:\n---\n#{PodcastBuddy.session.current_topics.split("\n").last(10)}\n---\n" - previous_discussion = listener.transcriber.latest(1_000) - PodcastBuddy.logger.info "Answering question:\n#{question}" - PodcastBuddy.logger.debug "Context:\n---#{latest_context}\n---\nPrevious discussion:\n---#{previous_discussion}\n---\nAnswering question:\n---#{question}\n---" - response = PodcastBuddy.openai_client.chat(parameters: { - model: "gpt-4o-mini", - messages: [ - {role: "system", content: format(PodcastBuddy.config.discussion_system_prompt, {summary: PodcastBuddy.current_summary})}, - {role: "user", content: question} - ], - max_tokens: 150 - }) - answer = response.dig("choices", 0, "message", "content").strip - PodcastBuddy.logger.debug "Answer: #{answer}" - text_to_speech(answer) - PodcastBuddy.logger.debug("Answer converted to speech: #{PodcastBuddy.answer_audio_file_path}") - play_answer - end - end - - def text_to_speech(text) - audio_service.text_to_speech(text, PodcastBuddy.answer_audio_file_path) - end - - def play_answer - PodcastBuddy.logger.debug("Playing answer...") - audio_service.play_audio(PodcastBuddy.answer_audio_file_path) - end - - private - - def audio_service - @audio_service ||= AudioService.new - end - - def generate_show_notes - return if PodcastBuddy.current_transcript.strip.empty? - - response = PodcastBuddy.openai_client.chat(parameters: { - model: "gpt-4o", - messages: show_notes_messages, - max_tokens: 500 - }) - show_notes = response.dig("choices", 0, "message", "content").strip - File.open(PodcastBuddy.session.show_notes_path, "w") do |file| - file.puts show_notes - end - - PodcastBuddy.logger.info to_human("Show notes saved to: #{PodcastBuddy.session.show_notes_path}", :success) - end - - def to_human(text, label = :info) - case label.to_sym - when :info - Rainbow(text).blue - when :wait - Rainbow(text).yellow - when :input - Rainbow(text).black.bg(:yellow) - when :success - Rainbow(text).green - else - text - end - end - - def topic_extraction_messages(text) - [ - {role: "system", content: PodcastBuddy.config.topic_extraction_system_prompt}, - {role: "user", content: format(PodcastBuddy.config.topic_extraction_user_prompt, {discussion: text})} - ] - end - - def discussion_messages(text) - [ - {role: "system", content: format(PodcastBuddy.config.discussion_system_prompt, {summary: PodcastBuddy.current_summary})}, - {role: "user", content: format(PodcastBuddy.config.discussion_user_prompt, {discussion: text})} - ] - end - def show_notes_messages - [ - {role: "system", content: "You are a kind and helpful podcast assistant helping to take notes for the show, and extract useful information being discussed for listeners."}, - {role: "user", content: "Transcript:\n---\n#{PodcastBuddy.current_transcript}\n---\n\nTopics:\n---\n#{PodcastBuddy.current_topics}\n---\n\nUse the above transcript and topics to create Show Notes in markdown that outline the discussion. Extract a breif summary that describes the overall conversation, the people involved and their roles, and sentiment of the topics discussed. Follow the summary with a list of helpful links to any libraries, products, or other resources related to the discussion. Cite sources."} - ] + PodcastBuddy.logger.info PodcastBuddy.to_human("Generating show notes...", :wait) + @show_assistant.generate_show_notes end end end diff --git a/lib/podcast_buddy/co_host.rb b/lib/podcast_buddy/co_host.rb new file mode 100644 index 0000000..d15e006 --- /dev/null +++ b/lib/podcast_buddy/co_host.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: false + +module PodcastBuddy + # Handles active podcast participation including: + # - Question detection + # - Answer generation + # - Text-to-speech response + # - Audio playback + class CoHost + attr_reader :listener + + def initialize(listener:, audio_service: AudioService.new) + @listener = listener + @audio_service = audio_service + @shutdown = false + @question_buffer = "" + @listening_for_question_at = nil + @notified_input_toggle = false + @listener.subscribe { |data| handle_transcription(data) } + end + + def start + Async do |parent| + loop do + PodcastBuddy.logger.debug("Shutdown: wait_for_question...") and break if @shutdown + + PodcastBuddy.logger.info Rainbow("Press ").blue + Rainbow("Enter").black.bg(:yellow) + Rainbow(" to signal a question start...").blue + wait_for_question_start + next unless @listening_for_question_at + + PodcastBuddy.logger.info PodcastBuddy.to_human("🎙️ Listening for question. Press ", :wait) + + PodcastBuddy.to_human("Enter", :input) + + PodcastBuddy.to_human(" to signal the end of the question...", :wait) + wait_for_question_end + end + end + end + + def stop + @shutdown = true + end + + private + + def handle_transcription(data) + if question_listening_started_before?(data[:started_at]) + PodcastBuddy.logger.info PodcastBuddy.to_human("Heard Question: #{data[:text]}", :wait) + @question_buffer << data[:text] + end + end + + def question_listening_started_before?(start) + @listening_for_question_at && start <= @listening_for_question_at + end + + def wait_for_question_start + input = "" + Timeout.timeout(5) do + input = gets + PodcastBuddy.logger.debug("Input received...") if input.include?("\n") + if input.include?("\n") + @listening_for_question_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @listening_for_question_at.nil? + @listener.suppress_what_you_hear! + @question_buffer = "" + end + rescue Timeout::Error + return + end + end + + def wait_for_question_end + loop do + PodcastBuddy.logger.debug("Shutdown: wait_for_question_end...") and break if @shutdown + + sleep 0.1 and next if @listening_for_question_at.nil? + + input = "" + Timeout.timeout(5) do + input = gets + PodcastBuddy.logger.debug("Input received...") if input.include?("\n") + next unless input.to_s.include?("\n") + rescue Timeout::Error + PodcastBuddy.logger.debug("Input timeout...") + next + end + + if input.empty? + next + else + PodcastBuddy.logger.info "End of question signal. Generating answer..." + @listening_for_question_at = nil + @listener.announce_what_you_hear! + answer_question(@question_buffer).wait + break + end + end + end + + def answer_question(question) + Async do + latest_context = "#{PodcastBuddy.session.current_summary}\nTopics discussed recently:\n---\n#{PodcastBuddy.session.current_topics.split("\n").last(10)}\n---\n" + previous_discussion = @listener.transcriber.latest(1_000) + PodcastBuddy.logger.info "Answering question:\n#{question}" + PodcastBuddy.logger.debug "Context:\n---#{latest_context}\n---\nPrevious discussion:\n---#{previous_discussion}\n---\nAnswering question:\n---#{question}\n---" + response = PodcastBuddy.openai_client.chat(parameters: { + model: "gpt-4o-mini", + messages: [ + {role: "system", content: format(PodcastBuddy.config.discussion_system_prompt, {summary: PodcastBuddy.current_summary})}, + {role: "user", content: question} + ], + max_tokens: 150 + }) + answer = response.dig("choices", 0, "message", "content").strip + PodcastBuddy.logger.debug "Answer: #{answer}" + @audio_service.text_to_speech(answer, PodcastBuddy.answer_audio_file_path) + PodcastBuddy.logger.debug("Answer converted to speech: #{PodcastBuddy.answer_audio_file_path}") + PodcastBuddy.logger.debug("Playing answer...") + @audio_service.play_audio(PodcastBuddy.answer_audio_file_path) + end + end + end +end diff --git a/lib/podcast_buddy/knowledge.md b/lib/podcast_buddy/knowledge.md new file mode 100644 index 0000000..4febfe1 --- /dev/null +++ b/lib/podcast_buddy/knowledge.md @@ -0,0 +1,23 @@ +# PodcastBuddy Service Architecture + +## Core Services + +### ShowAssistant +Handles passive podcast assistance: +- Continuous transcription +- Periodic summarization +- Topic extraction +- Show notes generation + +### CoHost +Handles active podcast participation: +- Question detection via user input (Enter key) +- Answer generation using GPT-4 +- Text-to-speech response using OpenAI TTS +- Audio playback via system audio + +## Design Goals +- Each service should be independently runnable +- Services share common resources (Session, Configuration) +- Communication between services via PodSignal +- Clear separation between passive monitoring and active participation diff --git a/lib/podcast_buddy/listener.rb b/lib/podcast_buddy/listener.rb index 0b7fcb7..21e83e2 100644 --- a/lib/podcast_buddy/listener.rb +++ b/lib/podcast_buddy/listener.rb @@ -4,12 +4,6 @@ class Listener # @return [Queue] queue for storing transcriptions attr_reader :transcription_queue, :transcriber - # @return [Queue] queue for storing questions - attr_reader :question_queue - - # @return [Boolean] indicates if currently listening for a question - attr_reader :listening_for_question - # Initialize a new Listener # @param transcriber [PodcastBuddy::Transcriber] the transcriber to use # @param whisper_command [String] the system command to use for streaming whisper @@ -17,12 +11,12 @@ class Listener def initialize(transcriber:, whisper_command: PodcastBuddy.whisper_command, whisper_logger: PodcastBuddy.whisper_logger) @transcriber = transcriber @transcription_queue = Queue.new - @question_queue = Queue.new - @current_discussion = "" - @listening_for_question = false @shutdown = false @whisper_command = whisper_command || PodcastBuddy.whisper_command @whisper_logger = whisper_logger || PodcastBuddy.whisper_logger + @transcription_signal = PodSignal.new + @listening_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @announce_hearing = true end # Start the listening process @@ -54,27 +48,16 @@ def stop @shutdown = true end - # Enter question listening mode - def listen_for_question! - @listening_for_question = true - @question_queue.clear + def subscribe(&block) + @transcription_signal.subscribe(&block) end - # Exit question listening mode and return running question - def stop_listening_for_question! - @listening_for_question = false - question = "" - question << @question_queue.pop until @question_queue.empty? - question + def announce_what_you_hear! + @announce_hearing = true end - # @return [String] current discussion still in the @transcription_queue - def current_discussion - return @current_discussion if @transcription_queue.empty? - - latest_transcriptions = [] - latest_transcriptions << transcription_queue.pop until transcription_queue.empty? - @current_discussion = latest_transcriptions.join.strip + def suppress_what_you_hear! + @announce_hearing = false end private @@ -85,14 +68,11 @@ def process_transcription(line) text = @transcriber.process(line) return if text.empty? - if @listening_for_question - PodcastBuddy.logger.info "Heard Question: #{text}" - @question_queue << text - else - PodcastBuddy.logger.info "Heard: #{text}" - PodcastBuddy.update_transcript(text) - @transcription_queue << text - end + PodcastBuddy.logger.info "Heard: #{text}" if @announce_hearing + PodcastBuddy.update_transcript(text) + @transcription_queue << text + @transcription_signal.trigger({text: text, started_at: @listening_start}) + text end end end diff --git a/lib/podcast_buddy/show_assistant.rb b/lib/podcast_buddy/show_assistant.rb new file mode 100644 index 0000000..32ec054 --- /dev/null +++ b/lib/podcast_buddy/show_assistant.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: false + +module PodcastBuddy + # Handles passive podcast assistance including transcription, summarization, + # topic extraction, and show notes generation + class ShowAssistant + attr_reader :listener + + def initialize(session: PodcastBuddy.session) + @session = session + @listener = PodcastBuddy::Listener.new(transcriber: PodcastBuddy::Transcriber.new) + @shutdown = false + @listener.subscribe { |data| handle_transcription(data) } + @current_discussion = "" + end + + def start + Sync do |task| + listener_task = task.async { @listener.start } + summarization_task = task.async { periodic_summarization } + + @tasks = [ + PodcastBuddy::NamedTask.new(name: "Listener", task: listener_task), + PodcastBuddy::NamedTask.new(name: "Periodic Summarizer", task: summarization_task) + ] + + task.yield until @shutdown + end + end + + def stop + @shutdown = true + @listener&.stop + @tasks&.each do |task| + PodcastBuddy.logger.info "Waiting for #{task.name} to shutdown..." + task.task.wait + end + end + + def summarize_latest + return if @current_discussion.empty? + + PodcastBuddy.logger.debug "[periodic summarization] Latest transcript: #{@current_discussion}" + extract_topics_and_summarize(@current_discussion) + @current_discussion = "" + end + + def generate_show_notes + return if PodcastBuddy.current_transcript.strip.empty? + + response = PodcastBuddy.openai_client.chat(parameters: { + model: "gpt-4o", + messages: show_notes_messages, + max_tokens: 500 + }) + show_notes = response.dig("choices", 0, "message", "content").strip + File.open(@session.show_notes_path, "w") do |file| + file.puts show_notes + end + + PodcastBuddy.logger.info "Show notes saved to: #{@session.show_notes_path}" + end + + private + + def handle_transcription(data) + @current_discussion << data[:text] + end + + def periodic_summarization(interval = 15) + Async do + loop do + PodcastBuddy.logger.debug("Shutdown: periodic_summarization...") and break if @shutdown + + sleep interval + summarize_latest + rescue => e + PodcastBuddy.logger.warn "[summarization] periodic summarization failed: #{e.message}" + end + end + end + + def extract_topics_and_summarize(text) + Async do |parent| + parent.async { update_topics(text) } + parent.async { think_about(text) } + end + end + + def update_topics(text) + Async do + PodcastBuddy.logger.debug "Looking for topics related to: #{text}" + response = PodcastBuddy.openai_client.chat(parameters: { + model: "gpt-4o-mini", + messages: topic_extraction_messages(text), + max_tokens: 500 + }) + new_topics = response.dig("choices", 0, "message", "content").gsub("NONE", "").strip + + @session.announce_topics(new_topics) + @session.add_to_topics(new_topics) + rescue => e + PodcastBuddy.logger.error "Failed to update topics: #{e.message}" + end + end + + def think_about(text) + Async do + PodcastBuddy.logger.debug "Summarizing current discussion..." + response = PodcastBuddy.openai_client.chat(parameters: { + model: "gpt-4o", + messages: discussion_messages(text), + max_tokens: 250 + }) + new_summary = response.dig("choices", 0, "message", "content").strip + PodcastBuddy.logger.info "Thoughts: #{new_summary}" + @session.update_summary(new_summary) + rescue => e + PodcastBuddy.logger.error "Failed to summarize discussion: #{e.message}" + end + end + + def topic_extraction_messages(text) + [ + {role: "system", content: PodcastBuddy.config.topic_extraction_system_prompt}, + {role: "user", content: format(PodcastBuddy.config.topic_extraction_user_prompt, {discussion: text})} + ] + end + + def discussion_messages(text) + [ + {role: "system", content: format(PodcastBuddy.config.discussion_system_prompt, {summary: PodcastBuddy.current_summary})}, + {role: "user", content: format(PodcastBuddy.config.discussion_user_prompt, {discussion: text})} + ] + end + + def show_notes_messages + [ + {role: "system", content: "You are a kind and helpful podcast assistant helping to take notes for the show, and extract useful information being discussed for listeners."}, + {role: "user", content: "Transcript:\n---\n#{PodcastBuddy.current_transcript}\n---\n\nTopics:\n---\n#{PodcastBuddy.current_topics}\n---\n\nUse the above transcript and topics to create Show Notes in markdown that outline the discussion. Extract a breif summary that describes the overall conversation, the people involved and their roles, and sentiment of the topics discussed. Follow the summary with a list of helpful links to any libraries, products, or other resources related to the discussion. Cite sources."} + ] + end + end +end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 1ade600..2eae472 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -23,10 +23,16 @@ end describe "#run" do + let(:show_assistant) { instance_double(PodcastBuddy::ShowAssistant) } + before do allow(PodcastBuddy).to receive(:logger).and_return(Logger.new(nil)) allow(PodcastBuddy).to receive(:setup) allow(PodcastBuddy).to receive(:openai_client).and_return(double(chat: {"choices" => [{"message" => {"content" => "test"}}]})) + allow(PodcastBuddy::ShowAssistant).to receive(:new).and_return(show_assistant) + allow(show_assistant).to receive(:start) + allow(show_assistant).to receive(:stop) + allow(show_assistant).to receive(:generate_show_notes) end it "configures logger and session" do @@ -40,5 +46,11 @@ expect(cli).to receive(:handle_shutdown) cli.run end + + it "starts and stops show assistant" do + allow(cli).to receive(:start_recording).and_raise(Interrupt) + expect(show_assistant).to receive(:stop) + cli.run + end end end diff --git a/spec/co_host_spec.rb b/spec/co_host_spec.rb new file mode 100644 index 0000000..d811679 --- /dev/null +++ b/spec/co_host_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe PodcastBuddy::CoHost do + let(:listener) { instance_double(PodcastBuddy::Listener, subscribe: nil) } + let(:audio_service) { instance_double(PodcastBuddy::AudioService) } + let(:co_host) { described_class.new(listener: listener, audio_service: audio_service) } + + describe "#initialize" do + it "sets up listener and audio service" do + expect(co_host.listener).to eq(listener) + end + end + + describe "#start" do + before do + allow(listener).to receive(:start).and_return(instance_double(Async::Task)) + co_host.instance_variable_set(:@listener, listener) + allow(PodcastBuddy.logger).to receive(:info) + allow(PodcastBuddy.logger).to receive(:debug) + end + + it "starts listening for questions" + end + + describe "#stop" do + it "sets shutdown flag" do + co_host.stop + expect(co_host.instance_variable_get(:@shutdown)).to be true + end + end +end diff --git a/spec/fixtures/tmp/2024-12-10_08-32-19/transcript.log b/spec/fixtures/tmp/2024-12-10_08-32-19/transcript.log new file mode 100644 index 0000000..204dced --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_08-32-19/transcript.log @@ -0,0 +1 @@ +test transcription diff --git a/spec/fixtures/tmp/2024-12-10_08-32-19/whisper.log b/spec/fixtures/tmp/2024-12-10_08-32-19/whisper.log new file mode 100644 index 0000000..56654a8 --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_08-32-19/whisper.log @@ -0,0 +1 @@ +# Logfile created on 2024-12-10 08:32:19 -0500 by logger.rb/v1.6.1 diff --git a/spec/fixtures/tmp/2024-12-10_08-32-53/transcript.log b/spec/fixtures/tmp/2024-12-10_08-32-53/transcript.log new file mode 100644 index 0000000..204dced --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_08-32-53/transcript.log @@ -0,0 +1 @@ +test transcription diff --git a/spec/fixtures/tmp/2024-12-10_08-32-53/whisper.log b/spec/fixtures/tmp/2024-12-10_08-32-53/whisper.log new file mode 100644 index 0000000..ec02a4a --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_08-32-53/whisper.log @@ -0,0 +1 @@ +# Logfile created on 2024-12-10 08:32:53 -0500 by logger.rb/v1.6.1 diff --git a/spec/fixtures/tmp/2024-12-10_09-26-07/transcript.log b/spec/fixtures/tmp/2024-12-10_09-26-07/transcript.log new file mode 100644 index 0000000..22439c3 --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_09-26-07/transcript.log @@ -0,0 +1,2 @@ +test transcription +test transcription diff --git a/spec/fixtures/tmp/2024-12-10_09-26-07/whisper.log b/spec/fixtures/tmp/2024-12-10_09-26-07/whisper.log new file mode 100644 index 0000000..30054f7 --- /dev/null +++ b/spec/fixtures/tmp/2024-12-10_09-26-07/whisper.log @@ -0,0 +1 @@ +# Logfile created on 2024-12-10 09:26:07 -0500 by logger.rb/v1.6.1 diff --git a/spec/listener_spec.rb b/spec/listener_spec.rb index 8cdaa46..4e4e632 100644 --- a/spec/listener_spec.rb +++ b/spec/listener_spec.rb @@ -8,66 +8,23 @@ let(:listener) { described_class.new(transcriber: transcriber) } describe "#initialize" do - it "initializes with empty queues and not listening for questions" do + it "initializes with empty transcription queue" do expect(listener.transcription_queue).to be_empty - expect(listener.question_queue).to be_empty - expect(listener.listening_for_question).to be false end end - describe "#current_discussion" do - it "returns the current discussion from the transcription queue" do - listener.transcription_queue << "First part. " - listener.transcription_queue << "Second part. " - expect(listener.current_discussion).to eq("First part. Second part.") - end - - it "returns an empty string when the transcription queue is empty" do - expect(listener.current_discussion).to eq("") - end - - it "resets the current discussion on subsequent calls" do - listener.transcription_queue << "First part. " - expect(listener.current_discussion).to eq("First part.") - - listener.transcription_queue << "Second part. " - expect(listener.current_discussion).to eq("Second part.") - end - - it "retains the current discussion on subsequent calls without any new queue additions" do - listener.transcription_queue << "First part. " - expect(listener.current_discussion).to eq("First part.") - expect(listener.current_discussion).to eq("First part.") - end - end - - describe "#listen_for_question!" do - it "marks listening_for_question as true" do - expect { listener.listen_for_question! }.to change { listener.listening_for_question }.from(false).to(true) - expect { listener.listen_for_question! }.not_to change { listener.listening_for_question } - end - - it "clears question_queue when toggled on" do - listener.question_queue << "test question" - expect { listener.listen_for_question! }.to change { listener.question_queue.empty? }.from(false).to(true) - end - end - - describe "#stop_listening_for_question!" do - it "marks listening_for_question as false" do - expect { listener.listen_for_question! }.to change { listener.listening_for_question }.from(false).to(true) - expect { listener.stop_listening_for_question! }.to change { listener.listening_for_question }.from(true).to(false) - expect { listener.stop_listening_for_question! }.not_to change { listener.listening_for_question } - end + describe "#subscribe" do + it "allows subscribing to transcriptions" do + received_text = nil + listener.subscribe { |data| received_text = data[:text] } - it "returns the question" do - listener.question_queue << "test question" - expect(listener.stop_listening_for_question!).to eq("test question") - end + allow(transcriber).to receive(:process).and_return("test transcription") + result = listener.send(:process_transcription, "input") + # Wait for PodSignal queue + sleep 0.01 - it "clears the question_queue" do - listener.question_queue << "test question" - expect { listener.stop_listening_for_question! }.to change { listener.question_queue.empty? }.from(false).to(true) + expect(result).to eq("test transcription") + expect(received_text).to eq("test transcription") end end @@ -81,16 +38,9 @@ expect(listener.transcription_queue.pop).to eq("test transcription") end - it "adds transcription to question_queue when listening for questions" do - listener.listen_for_question! - expect { listener.send(:process_transcription, "input") }.to change { listener.question_queue.size }.by(1) - expect(listener.question_queue.pop).to eq("test transcription") - end - it "does not add empty transcriptions to any queue" do allow(transcriber).to receive(:process).and_return("") expect { listener.send(:process_transcription, "input") }.not_to change { listener.transcription_queue.size } - expect { listener.send(:process_transcription, "input") }.not_to change { listener.question_queue.size } end end end diff --git a/spec/show_assistant_spec.rb b/spec/show_assistant_spec.rb new file mode 100644 index 0000000..070b0f3 --- /dev/null +++ b/spec/show_assistant_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe PodcastBuddy::ShowAssistant do + let(:session) { instance_double(PodcastBuddy::Session) } + let(:show_assistant) { described_class.new(session: session) } + + describe "#initialize" do + it "creates a new listener with transcriber" do + expect(show_assistant.instance_variable_get(:@listener)).to be_a(PodcastBuddy::Listener) + end + end + + describe "#start" do + let(:listener) { instance_double(PodcastBuddy::Listener) } + let(:listener_task) { instance_double(Async::Task) } + let(:summarization_task) { instance_double(Async::Task) } + + before do + allow(listener).to receive(:start).and_return(instance_double(Async::Task)) + show_assistant.instance_variable_set(:@listener, listener) + end + + it "starts listener and summarization tasks" do + allow(show_assistant).to receive(:periodic_summarization).and_return(instance_double(Async::Task)) + + # Simulate Async behavior + # Mark shutdown flag so the task doesn't yield and hang + show_assistant.instance_variable_set(:@shutdown, true) + Sync do |task| + show_assistant.start + end + + expect(listener).to have_received(:start) + end + end + + describe "#stop" do + let(:listener) { instance_double(PodcastBuddy::Listener) } + + before do + allow(session).to receive(:show_notes_path).and_return(File.join("tmp", "show-notes.md")) + allow(PodcastBuddy).to receive(:current_transcript).and_return("") + allow(PodcastBuddy.logger).to receive(:info) + allow(listener).to receive(:stop) + show_assistant.instance_variable_set(:@listener, listener) + end + + it "sets shutdown flag" do + show_assistant.stop + expect(show_assistant.instance_variable_get(:@shutdown)).to be true + end + + it "stops the listener" do + expect(listener).to receive(:stop) + show_assistant.stop + end + end + + describe "#summarize_latest" do + let(:listener) { instance_double(PodcastBuddy::Listener) } + + before do + show_assistant.instance_variable_set(:@current_discussion, "test discussion") + show_assistant.instance_variable_set(:@listener, listener) + end + + it "extracts topics and summarizes when discussion exists" do + expect(show_assistant).to receive(:extract_topics_and_summarize).with("test discussion") + show_assistant.summarize_latest + end + + it "does nothing when discussion is empty" do + show_assistant.instance_variable_set(:@current_discussion, "") + expect(show_assistant).not_to receive(:extract_topics_and_summarize) + show_assistant.summarize_latest + end + end +end