podcast-buddy 0.1.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9821fea9e037f8483457b4e8fc90e453a81f7ae90505b67b537683705eb57733
4
- data.tar.gz: e0ef8feff9e801347a99beda8432ab46d92498aa9795408afaaf9167317cce83
3
+ metadata.gz: 6ebed9ffec7e1ce399ed7b64d332087e433b8348e84b1af2fc8a13a959163802
4
+ data.tar.gz: b3d9fe281bf8259bcc9b65ef466fe517885e3855fd95da0672ab93f7a08f80f0
5
5
  SHA512:
6
- metadata.gz: 8b80d6502926859d82ed679ddcddf6dad8bdbfd6507e8936652861dac44f03b6a92d7fc06c06a93cc7886b0e2a9d5c126b72cd618c32015c3c61f77eee01819d
7
- data.tar.gz: aa35a927a14fd5e48df5ec6a1d09bb5a262bfc06f60b3c75f9dfe3dcf7e672f4074d4d7bf77c96da09bcdfebd1dd608cf1245cd59cc7e605462a8a32ea716004
6
+ metadata.gz: b53e802df3a99224404789e1c9acb061b2d4dcc2ef57927ba0b2a0cf3a0aaf62fa8e115d148c27a62407c44851853b39fc2955d2e4748a2ecf869fa4cb926c43
7
+ data.tar.gz: 230641523d5daf007ac70ea44d41d2dad49a12c28741c26b78e348b1e573e5ffe24c707fd76c505ba64dee25e783c9c51722cb8bf4272410aaab41cef4c54fae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1] - 2024-08-13
4
+
5
+ * Fixes typo in command-line help banner to reference proper podcast_buddy command
6
+ * Updates README with new options
7
+
8
+ ## [0.2.0] - 2024-08-13
9
+
10
+ * 9edc409 - Refactor to make use of Async
11
+ * Now properly generates show notes on exit
12
+ * 7d8db13 - Update show notes prompt to cite sources
13
+ * c3ef97d - Allow configuring the whisper model to use
14
+ * e7cb278 - Adds session naming to store files in specific directories
15
+ * 39e9e78 - Overwrite summary on every periodic update
16
+ * 0518b56 - Update topic extraction prompt to fix 'Empty response' topics
17
+ * b72ba8f - Use brew list -1 to /dev/null to quiet initial outputs when already setup
18
+ * 98a2484 - Remove no longer needed commented code in Signal.trap now that we're Ansync
19
+ * 62cf6ea - Update periodic summarize/topic calls to use new PodcastBuddy.current_transcript method, and update other current_* methods to re-use the memoized versions
20
+
21
+
3
22
  ## [0.1.1] - 2024-08-09
4
23
 
5
24
  - Fixes typo in topic extraction prompt ([7e07e](https://github.com/codenamev/podcast-buddy/commit/7e07e307135c95cb4bb68dadc354f0b2519c7721))
data/README.md CHANGED
@@ -44,9 +44,17 @@ Once you're done, simply `ctrl-c` to wrap things up.
44
44
 
45
45
  ### Helpful files logged during your session with Buddy
46
46
 
47
- 1. Your full transcript is stored in `tmp/transcript.log`
48
- 2. A summarization of the discussion and topics are stored in `tmp/discussion.log`
49
- 3. The raw whisper logs are stored in `tmp/whisper.log`
47
+ 1. Your full transcript is stored in `tmp/transcripti-%Y-%m-%d.log`.
48
+ 2. A summarization of the discussion is stored in `tmp/summary-%Y-%m-%d.log`.
49
+ 3. A list of topics extracted from the discussion is stored in `tmp/topics-%Y-%m-%d.log`.
50
+ 4. The Show Notes are stored in `tmp/show-notes-%Y-%m-%d.log`.
51
+ 5. The raw whisper logs are stored in `tmp/whisper.log`.
52
+
53
+ ### Options
54
+
55
+ **debug mode**: `podcast_buddy --debug` – shows verbose logging
56
+ **custom whisper model**: `podcast_buddy --whisper base.en` – use any of [these available models](https://github.com/ggerganov/whisper.cpp/blob/master/models/download-ggml-model.sh#L28-L49).
57
+ **custom session**: `podcast_buddy --name "Ruby Rogues 08-15-2024"` – saves files to a new `tmp/Ruby Rogues 08-15-2024/` directory.
50
58
 
51
59
  ## Development
52
60
 
data/exe/podcast_buddy CHANGED
@@ -2,16 +2,42 @@
2
2
 
3
3
  require_relative "../lib/podcast_buddy"
4
4
  require "optparse"
5
+ require "rainbow"
6
+ require "timeout"
7
+ require "fileutils"
5
8
 
6
9
  options = {}
7
10
  OptionParser.new do |opts|
8
- opts.banner = "Usage: podcast-buddy [options]"
11
+ opts.banner = "Usage: podcast_buddy [options]"
9
12
 
10
13
  opts.on("--debug", "Run in debug mode") do |v|
11
14
  options[:debug] = v
12
15
  end
16
+
17
+ opts.on("-w", "--whisper MODEL", "Use specific whisper model (default: small.en)") do |v|
18
+ options[:whisper_model] = v
19
+ end
20
+
21
+ opts.on("-n", "--name NAME", "A name for the session to label all log files") do |v|
22
+ options[:name] = v
23
+ end
13
24
  end.parse!
14
25
 
26
+ def to_human(text, label = :info)
27
+ case label.to_sym
28
+ when :info
29
+ Rainbow(text).blue
30
+ when :wait
31
+ Rainbow(text).yellow
32
+ when :input
33
+ Rainbow(text).black.bg(:yellow)
34
+ when :success
35
+ Rainbow(text).green
36
+ else
37
+ text
38
+ end
39
+ end
40
+
15
41
  PodcastBuddy.logger.formatter = proc do |severity, datetime, progname, msg|
16
42
  if severity.to_s == "INFO"
17
43
  "#{msg}\n"
@@ -21,212 +47,300 @@ PodcastBuddy.logger.formatter = proc do |severity, datetime, progname, msg|
21
47
  end
22
48
 
23
49
  if options[:debug]
24
- PodcastBuddy.logger.info "Turning on debug mode..."
50
+ PodcastBuddy.logger.info to_human("Turning on debug mode...", :info)
25
51
  PodcastBuddy.logger.level = Logger::DEBUG
26
52
  else
27
53
  PodcastBuddy.logger.level = Logger::INFO
28
54
  end
29
55
 
56
+ if options[:whisper_model]
57
+ PodcastBuddy.whisper_model = options[:whisper_model]
58
+ PodcastBuddy.logger.info to_human("Using whisper model: #{options[:whisper_model]}", :info)
59
+ end
60
+
61
+ if options[:name]
62
+ base_path = "#{PodcastBuddy.root}/tmp/#{options[:name]}"
63
+ FileUtils.mkdir_p base_path
64
+ PodcastBuddy.session = options[:name]
65
+ PodcastBuddy.logger.info to_human("Using custom session name: #{options[:name]}", :info)
66
+ PodcastBuddy.logger.info to_human(" Saving files to: #{PodcastBuddy.session}", :info)
67
+ end
68
+
30
69
  PodcastBuddy.logger.info "Setting up dependencies..."
31
70
  PodcastBuddy.setup
32
71
  PodcastBuddy.logger.info "Setup complete."
33
72
 
34
73
  # OpenAI API client setup
35
- raise "Please set an OPENAI_ACCESS_TOKEN environment variable." if ENV["OPENAI_ACCESS_TOKEN"].to_s.strip.squeeze.empty?
36
-
37
- client = OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"], log_errors: true)
38
-
39
- # Signals for question detection
40
- question_signal = PodcastBuddy::PodSignal.new
41
- end_signal = PodcastBuddy::PodSignal.new
74
+ # Raises error if no OPENAI_ACCESS_TOKEN env is set
75
+ PodcastBuddy.openai_client
42
76
 
43
77
  # Initialize variables
44
78
  @latest_transcription = ""
45
- @question_transcription = ""
46
- @full_transcription = ""
79
+ @full_transcript = ""
47
80
  @listening_for_question = false
48
- @shutdown_flag = false
81
+ @shutdown = false
49
82
  @threads = []
50
83
  @transcription_queue = Queue.new
84
+ @question_transcript = Queue.new
51
85
 
52
86
  # Method to extract topics and summarize
53
- def extract_topics_and_summarize(client, text)
54
- current_discussion = File.exist?(PodcastBuddy.discussion_log_file) ? File.read(PodcastBuddy.discussion_log_file) : ""
55
- current_discussion.rstrip.squeeze!
56
- messages = []
57
- if current_discussion.length > 1
58
- messages << {role: "user", content: "Given the prior summary and topics:\n---\n#{current_discussion}\n---"}
59
- messages << {role: "user", content: "Continue the running summary and list of topics using the following discussion:\n---#{text}\n---"}
60
- else
61
- messages << {role: "user", content: "Extract topics and summarize the following discussion:\n---#{text}\n---"}
87
+ def extract_topics_and_summarize(text)
88
+ Sync do |parent|
89
+ parent.async { update_topics(text) }
90
+ parent.async { update_summary(text) }
62
91
  end
63
- response = client.chat(parameters: {
64
- model: "gpt-4o",
65
- messages: messages,
66
- max_tokens: 150
67
- })
68
- response.dig("choices", 0, "message", "content").strip
69
92
  end
70
93
 
71
- def answer_question(client, question)
72
- latest_context = `tail -n 25 #{PodcastBuddy.discussion_log_file}`
73
- previous_discussion = @full_transcription.split("\n").last(25)
74
- PodcastBuddy.logger.info "Answering question:\n#{question}"
75
- PodcastBuddy.logger.debug "Context:\n---#{latest_context}\n---\nPrevious discussion:\n---#{previous_discussion}\n---\nAnswering question:\n---#{question}\n---"
76
- response = client.chat(parameters: {
77
- model: "gpt-4o",
78
- messages: [
79
- {role: "system", content: "You are a kind and helpful podcast assistant helping to answer questions as them come up during a recording. Keep the answer succinct and without speculation."},
80
- {role: "user", content: "Context:\n#{latest_context}\n\nPrevious Discussion:\n#{previous_discussion}\n\nQuestion:\n#{question}\n\nAnswer:"}
81
- ],
82
- max_tokens: 150
83
- })
84
- response.dig("choices", 0, "message", "content").strip
94
+ def update_topics(text)
95
+ Sync do
96
+ PodcastBuddy.logger.debug "Looking for topics related to: #{text}"
97
+ response = PodcastBuddy.openai_client.chat(parameters: {
98
+ model: "gpt-4o",
99
+ messages: [
100
+ {role: "system", content: "You are a kind and helpful podcast assistant diligantly extracting topics related to the discussion on the show. Your goal is to prepare this information in an optimal way for participants to ask questions in the context of what is being discussed, and optimally for use in the closing Show Notes."},
101
+ {role: "user", content: "Extract topics for the following Discussion:\n---\n#{text}\n---\n\nExclude any of these topics that have been previously discussed:\n---\n#{PodcastBuddy.current_topics}\n---\n\nIf there are no new topics to add, return NONE."}
102
+ ],
103
+ max_tokens: 250
104
+ })
105
+ new_topics = response.dig("choices", 0, "message", "content").gsub("NONE", "").strip
106
+ PodcastBuddy.logger.debug "Updating topics: #{new_topics.split("\n").join(", ")}"
107
+ PodcastBuddy.add_to_topics(new_topics)
108
+ end
109
+ end
110
+
111
+ def update_summary(text)
112
+ Sync do
113
+ response = PodcastBuddy.openai_client.chat(parameters: {
114
+ model: "gpt-4o",
115
+ messages: [
116
+ {role: "system", content: "You are a kind and helpful podcast assistant diligantly keeping track of the overall discussion on the show. Your goal is to prepare a concise summary of the overall discussion."},
117
+ {role: "user", content: "Previous Summary:\n---\n#{PodcastBuddy.current_summary}\n---\n\nLatest Discussion:\n---\n#{text}\n---"},
118
+ {role: "user", content: "Use the Previous Summary, and Latest Discussion, to combine into a single cohesive summary.\nSummary: "}
119
+ ],
120
+ max_tokens: 250
121
+ })
122
+ new_summary = response.dig("choices", 0, "message", "content").strip
123
+ PodcastBuddy.logger.debug "Updating summary: #{new_summary}"
124
+ PodcastBuddy.update_summary(new_summary)
125
+ end
126
+ end
127
+
128
+ def answer_question
129
+ Async do
130
+ latest_context = "#{PodcastBuddy.current_summary}\nTopics discussed recently:\n---\n#{PodcastBuddy.current_topics.split("\n").last(10)}\n---\n"
131
+ previous_discussion = @full_transcript.split("\n").last(25)
132
+ question = ""
133
+ question << @question_transcript.pop until @question_transcript.empty?
134
+ PodcastBuddy.logger.info "Answering question:\n#{question}"
135
+ PodcastBuddy.logger.debug "Context:\n---#{latest_context}\n---\nPrevious discussion:\n---#{previous_discussion}\n---\nAnswering question:\n---#{question}\n---"
136
+ response = PodcastBuddy.openai_client.chat(parameters: {
137
+ model: "gpt-4o",
138
+ messages: [
139
+ {role: "system", content: "You are a kind and helpful podcast assistant helping to answer questions as they come up during a recording. Keep the answer succinct and without speculation. Be playful, witty, and kind. If a question is not asked try to add to the conversation as it best relates to the current discussion. Remember, this is a live recording, so there's no need to analyze and report on what is being asked."},
140
+ {role: "user", content: "Context:\n#{latest_context}\n\nPrevious Discussion:\n#{previous_discussion}\n\nQuestion:\n#{question}\n\nAnswer:"}
141
+ ],
142
+ max_tokens: 150
143
+ })
144
+ answer = response.dig("choices", 0, "message", "content").strip
145
+ PodcastBuddy.logger.debug "Answer: #{answer}"
146
+ text_to_speech(answer)
147
+ PodcastBuddy.logger.debug("Answer converted to speech: #{PodcastBuddy.answer_audio_file_path}")
148
+ play_answer
149
+ end
85
150
  end
86
151
 
87
152
  # Method to convert text to speech
88
- def text_to_speech(client, text)
89
- response = client.audio.speech(parameters: {
153
+ def text_to_speech(text)
154
+ response = PodcastBuddy.openai_client.audio.speech(parameters: {
90
155
  model: "tts-1",
91
156
  input: text,
92
157
  voice: "onyx",
93
158
  response_format: "mp3",
94
159
  speed: 1.0
95
160
  })
96
- File.binwrite("response.mp3", response)
161
+ File.binwrite(PodcastBuddy.answer_audio_file_path, response)
162
+ end
163
+
164
+ def play_answer
165
+ PodcastBuddy.logger.debug("Playing answer...")
166
+ system("afplay #{PodcastBuddy.answer_audio_file_path}")
97
167
  end
98
168
 
99
169
  # Method to handle audio stream processing
100
- def process_audio_stream(client)
101
- Thread.new do
102
- whisper_command = "./whisper.cpp/stream -m ./whisper.cpp/models/ggml-#{PodcastBuddy.whisper_model}.bin -t 4 --step 0 --length 5000 --keep 500 --vad-thold 0.60 --audio-ctx 0 --keep-context -c 1"
170
+ def process_audio_stream
171
+ Sync do |parent|
103
172
  PodcastBuddy.logger.info "Buffering audio..."
104
- Open3.popen3(whisper_command) do |stdin, stdout, stderr, thread|
105
- stderr_thread = Thread.new do
106
- stderr.each { |line| PodcastBuddy.whisper_logger.error line }
107
- end
108
- stdout_thread = Thread.new do
109
- stdout.each { |line| PodcastBuddy.whisper_logger.debug line }
110
- end
173
+ Open3.popen3(PodcastBuddy.whisper_command) do |stdin, stdout, stderr, _thread|
174
+ error_log_task = parent.async { stderr.each { |line| PodcastBuddy.whisper_logger.error line } }
175
+ output_log_task = parent.async { stdout.each { |line| PodcastBuddy.whisper_logger.debug line } }
111
176
 
112
177
  while (transcription = stdout.gets)
113
- # Cleanup captured text from whisper
114
- transcription_text = (transcription.scan(/\[[0-9]+.*[0-9]+\]\s?(.*)\n/) || [p])[0].to_s
115
- PodcastBuddy.logger.debug "Received transcription: #{transcription_text}"
116
- transcription_text = transcription_text.gsub("[BLANK_AUDIO]", "")
117
- transcription_text = transcription_text.gsub(/\A\["\s?/, "")
118
- transcription_text = transcription_text.gsub(/"\]\Z/, "")
119
- break if @shutdown_flag
120
-
121
- PodcastBuddy.logger.debug "Filtered: #{transcription_text}"
122
- next if transcription_text.to_s.strip.squeeze.empty?
123
-
124
- @full_transcription += transcription_text
125
-
126
- if @listening_for_question
127
- @question_transcription += transcription_text
128
- PodcastBuddy.logger.info "Heard Question: #{transcription_text}"
129
- else
130
- @transcription_queue << transcription_text
131
- PodcastBuddy.logger.info "Heard: #{transcription_text}"
132
- PodcastBuddy.update_transcript(transcription_text)
133
- end
178
+ PodcastBuddy.logger.debug("Shutdown: process_audio_stream...") and break if @shutdown
179
+ PodcastBuddy.logger.debug("Still listening...")
180
+
181
+ last_transcription_task = parent.async { process_transcription(transcription) }
134
182
  end
135
183
 
136
- # Close streams and join stderr thread on shutdown
137
- stderr_thread.join
138
- stdout_thread.join
139
- stdin.close
140
- stdout.close
141
- stderr.close
184
+ error_log_task.wait
185
+ output_log_task.wait
186
+ last_transcription_task.wait
142
187
  end
143
188
  end
144
189
  end
145
190
 
191
+ def process_transcription(line)
192
+ # Cleanup captured text from whisper
193
+ transcription_text = (line.scan(/\[[0-9]+.*[0-9]+\]\s?(.*)\n/) || [p])[0].to_s
194
+ PodcastBuddy.logger.debug "Received transcription: #{transcription_text}" if transcription_text
195
+ transcription_text = transcription_text.gsub("[BLANK_AUDIO]", "")
196
+ transcription_text = transcription_text.gsub(/\A\["\s?/, "")
197
+ transcription_text = transcription_text.gsub(/"\]\Z/, "")
198
+
199
+ return if transcription_text.to_s.strip.squeeze.empty?
200
+
201
+ @full_transcript += transcription_text
202
+
203
+ if @listening_for_question
204
+ PodcastBuddy.logger.info "Heard Question: #{transcription_text}"
205
+ @question_transcript << transcription_text
206
+ else
207
+ PodcastBuddy.logger.info "Heard: #{transcription_text}"
208
+ PodcastBuddy.update_transcript(transcription_text)
209
+ @transcription_queue << transcription_text
210
+ end
211
+ end
212
+
146
213
  # Method to generate show notes
147
- def generate_show_notes(client, transcription)
148
- summary = extract_topics_and_summarize(client, transcription)
149
- File.open("show_notes.md", "w") do |file|
150
- file.puts "# Show Notes"
151
- file.puts summary
214
+ def generate_show_notes
215
+ return if PodcastBuddy.current_transcript.strip.squeeze.empty?
216
+
217
+ PodcastBuddy.logger.info "Generting show notes..."
218
+ response = PodcastBuddy.openai_client.chat(parameters: {
219
+ model: "gpt-4o",
220
+ messages: [
221
+ {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."},
222
+ {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." }
223
+ ],
224
+ max_tokens: 150
225
+ })
226
+ show_notes = response.dig("choices", 0, "message", "content").strip
227
+ File.open(PodcastBuddy.show_notes_file, "w") do |file|
228
+ file.puts show_notes
152
229
  end
230
+
231
+ PodcastBuddy.logger.info to_human("Show notes saved to: #{PodcastBuddy.show_notes_file}", :success)
153
232
  end
154
233
 
155
234
  # Periodically summarize latest transcription
156
- def periodic_summarization(client, interval = 15)
157
- Thread.new do
235
+ def periodic_summarization(interval = 15)
236
+ Sync do
158
237
  loop do
159
- break if @shutdown_flag
238
+ PodcastBuddy.logger.debug("Shutdown: periodic_summarization...") and break if @shutdown
160
239
 
161
240
  sleep interval
162
- summarize_latest(client)
241
+ summarize_latest
163
242
  rescue => e
164
243
  PodcastBuddy.logger.warn "[summarization] periodic summarization failed: #{e.message}"
165
244
  end
166
245
  end
167
246
  end
168
247
 
169
- def summarize_latest(client = OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"], log_errors: true))
248
+ def summarize_latest
170
249
  latest_transcriptions = []
171
250
  latest_transcriptions << @transcription_queue.pop until @transcription_queue.empty?
172
251
  return if latest_transcriptions.join.strip.squeeze.empty?
173
252
 
174
253
  PodcastBuddy.logger.debug "[periodic summarization] Latest transcript: #{latest_transcriptions.join}"
175
- summary = extract_topics_and_summarize(client, latest_transcriptions.join)
176
- File.write(PodcastBuddy.discussion_log_file, summary)
254
+ extract_topics_and_summarize(latest_transcriptions.join)
177
255
  end
178
256
 
179
- # Handle Ctrl-C to generate show notes
180
- Signal.trap("INT") do
181
- puts "\nShutting down streams..."
182
- @shutdown_flag = true
183
- # Wait for all threads to finish
184
- @threads.compact.each(&:join)
257
+ def wait_for_question_end
258
+ Sync do |parent|
259
+ loop do
260
+ PodcastBuddy.logger.debug("Shutdown: wait_for_question_end...") and break if @shutdown
185
261
 
186
- # Will have to re-think this. Can't syncronize from a trap (Faraday)
187
- # Also cannot log from trap
188
- #puts "\nGenerating show notes..."
189
- #generate_show_notes(client, @full_transcription)
190
- exit
191
- end
262
+ # The wait_for_question_start will signal this
263
+ sleep 0.1 and next if !@listening_for_question
264
+
265
+ input = ""
266
+ Timeout.timeout(5) do
267
+ input = gets
268
+ PodcastBuddy.logger.debug("Input received...") if input.include?("\n")
269
+ next unless input.to_s.include?("\n")
270
+ rescue Timeout::Error
271
+ # Let the loop continue so that it can catch @shutdown periodically, or
272
+ # capture the signal
273
+ PodcastBuddy.logger.debug("Input timeout...")
274
+ next
275
+ end
192
276
 
193
- # Setup signal subscriptions
194
- question_signal.subscribe do
195
- PodcastBuddy.logger.debug "Question signal received"
196
- @listening_for_question = true
197
- @question_transcription.clear
198
- summarize_latest
277
+ if input.empty?
278
+ next
279
+ else
280
+ PodcastBuddy.logger.info "End of question signal. Generating answer..."
281
+ @listening_for_question = false
282
+ answer_question.wait
283
+ PodcastBuddy.logger.info Rainbow("Press ").blue + Rainbow("Enter").black.bg(:yellow) + Rainbow(" to signal a question start...").blue
284
+ break
285
+ end
286
+ end
287
+ end
199
288
  end
200
289
 
201
- end_signal.subscribe do
202
- PodcastBuddy.logger.info "End of question signal received"
203
- @listening_for_question = false
290
+ def wait_for_question_start
291
+ Sync do |parent|
292
+ PodcastBuddy.logger.info Rainbow("Press ").blue + Rainbow("Enter").black.bg(:yellow) + Rainbow(" to signal a question start...").blue
293
+ loop do
294
+ PodcastBuddy.logger.debug("Shutdown: wait_for_question...") and break if @shutdown
204
295
 
205
- PodcastBuddy.logger.info "Waiting for question to complete (enter return again)..."
206
- loop do
207
- PodcastBuddy.logger.debug "Checking for question..."
208
- sleep 0.3
209
- break if @question_transcription.to_s.length > 0 || @listening_for_question == true
210
- end
211
- # Guard against interruptions
212
- if !@question_transcription.empty?
213
- response_text = answer_question(client, @question_transcription)
214
- text_to_speech(client, response_text)
215
- system("afplay response.mp3")
296
+ PodcastBuddy.logger.debug("Waiting for input...")
297
+ input = ""
298
+ Timeout.timeout(5) do
299
+ input = gets
300
+ PodcastBuddy.logger.debug("Input received...") if input.include?("\n")
301
+ @question_transcript.clear
302
+ @listening_for_question = true if input.include?("\n")
303
+ rescue Timeout::Error
304
+ # Let the loop continue so that it can catch @shutdown periodically, or
305
+ # capture the signal
306
+ next
307
+ end
308
+
309
+ next unless @listening_for_question
310
+
311
+ PodcastBuddy.logger.info to_human("🎙️ Listening for quesiton. Press ", :wait) + to_human("Enter", :input) + to_human(" to signal the end of the question...", :wait)
312
+ wait_for_question_end
313
+ end
216
314
  end
217
315
  end
218
316
 
317
+
318
+ # Handle Ctrl-C to generate show notes
319
+ Signal.trap("INT") do
320
+ puts to_human("\nShutting down streams...", :wait)
321
+ @shutdown = true
322
+ end
323
+
219
324
  # Start audio stream processing
220
- @threads << process_audio_stream(client)
221
- # Start periodic summarization
222
- @threads << periodic_summarization(client, 60)
223
-
224
- # Main loop to wait for question and end signals
225
- loop do
226
- PodcastBuddy.logger.info "Press Enter to signal a question start..."
227
- gets
228
- question_signal.trigger
229
- PodcastBuddy.logger.info "Press Enter to signal the end of the question..."
230
- gets
231
- end_signal.trigger
325
+ Async do |task|
326
+ audio_input_task = task.async { process_audio_stream }
327
+ periodic_summarization_task = task.async { periodic_summarization(60) }
328
+ question_listener_task = task.async { wait_for_question_start }
329
+
330
+ # Allow recording up to 2 hours, but hard-timeout so that we don't become
331
+ # zombies.
332
+ task.with_timeout(60*60*2) do
333
+ # Wait for shutdown sub-tasks to complete
334
+ task.yield until @shutdown
335
+ end
336
+
337
+ PodcastBuddy.logger.info "Waiting for all tasks to complete before exiting..."
338
+ audio_input_task.wait
339
+ PodcastBuddy.logger.debug "Audio input task complete."
340
+ question_listener_task.wait
341
+ PodcastBuddy.logger.debug "Question listener task complete."
342
+ periodic_summarization_task.wait
343
+ PodcastBuddy.logger.debug "Periodic Summarization task complete."
344
+
345
+ generate_show_notes
232
346
  end
@@ -15,12 +15,30 @@ module PodcastBuddy
15
15
  system_dependency.install unless system_dependency.installed?
16
16
  end
17
17
 
18
+ def self.resolve_whisper_model(model)
19
+ return if model_downloaded?(model)
20
+ download_model(model)
21
+ end
22
+
23
+ def self.model_downloaded?(model)
24
+ File.exist?(File.join(PodcastBuddy.root, "whisper.cpp", "models", "ggml-#{model}.bin"))
25
+ end
26
+
27
+ def self.download_model(model)
28
+ originating_directory = Dir.pwd
29
+ Dir.chdir("whisper.cpp")
30
+ PodcastBuddy.logger.info "Downloading GGML model: #{PodcastBuddy.whisper_model}"
31
+ PodcastBuddy.logger.info `bash ./models/download-ggml-model.sh #{PodcastBuddy.whisper_model}`
32
+ ensure
33
+ Dir.chdir(originating_directory)
34
+ end
35
+
18
36
  def installed?
19
37
  PodcastBuddy.logger.info "Checking for system dependency: #{name}..."
20
38
  if name.to_s == "whisper"
21
39
  Dir.exist?("whisper.cpp")
22
40
  else
23
- system("brew list #{name}") || system("type -a #{name}")
41
+ system("brew list -1 #{name} > /dev/null") || system("type -a #{name}")
24
42
  end
25
43
  end
26
44
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PodcastBuddy
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/podcast_buddy.rb CHANGED
@@ -1,10 +1,11 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "net/http"
4
2
  require "uri"
5
3
  require "json"
6
4
  require "open3"
7
5
  require "logger"
6
+ require "async"
7
+ require "async/http/faraday"
8
+ require "rainbow"
8
9
 
9
10
  require "bundler/inline"
10
11
 
@@ -19,6 +20,14 @@ module PodcastBuddy
19
20
  Dir.pwd
20
21
  end
21
22
 
23
+ def self.session=(name)
24
+ @session = "#{root}/tmp/#{name}"
25
+ end
26
+
27
+ def self.session
28
+ @session ||= "#{root}/tmp"
29
+ end
30
+
22
31
  def self.logger
23
32
  @logger ||= Logger.new($stdout, level: Logger::DEBUG)
24
33
  end
@@ -27,24 +36,92 @@ module PodcastBuddy
27
36
  @logger = logger
28
37
  end
29
38
 
39
+ def self.whisper_model=(model)
40
+ @whisper_model = model
41
+ end
42
+
30
43
  def self.whisper_model
31
- "small.en"
44
+ @whisper_model ||= "small.en"
45
+ end
46
+
47
+ def self.whisper_command
48
+ "./whisper.cpp/stream -m ./whisper.cpp/models/ggml-#{PodcastBuddy.whisper_model}.bin -t 4 --step 0 --length 5000 --keep 500 --vad-thold 0.60 --audio-ctx 0 --keep-context -c 1"
49
+ end
50
+
51
+ def self.whisper_logger=(file_path)
52
+ @whisper_logger ||= Logger.new(file_path, "daily")
32
53
  end
33
54
 
34
55
  def self.whisper_logger
35
- @whisper_logger ||= Logger.new("tmp/whisper.log", "daily")
56
+ @whisper_logger ||= Logger.new("#{session}/whisper.log", "daily")
36
57
  end
37
58
 
38
- def self.discussion_log_file
39
- @discussion_log_file ||= "#{root}/tmp/discussion-#{Time.new.strftime("%Y-%m-%d")}.log"
59
+ def self.topics_log_file=(log_file_path)
60
+ @topics_log_file = log_file_path
61
+ end
62
+
63
+ def self.topics_log_file
64
+ @topics_log_file ||= "#{session}/topics-#{Time.new.strftime("%Y-%m-%d")}.log"
65
+ end
66
+
67
+ def self.summary_log_file=(log_file_path)
68
+ @summary_log_file = log_file_path
69
+ end
70
+
71
+ def self.summary_log_file
72
+ @summary_log_file ||= "#{session}/summary-#{Time.new.strftime("%Y-%m-%d")}.log"
73
+ end
74
+
75
+ def self.transcript_file=(file_path)
76
+ @transcript_file = file_path
40
77
  end
41
78
 
42
79
  def self.transcript_file
43
- @transcript_file ||= "#{root}/tmp/transcript-#{Time.new.strftime("%Y-%m-%d")}.log"
80
+ @transcript_file ||= "#{session}/transcript-#{Time.new.strftime("%Y-%m-%d")}.log"
81
+ end
82
+
83
+ def self.show_notes_file=(file_path)
84
+ @show_notes_file = file_path
85
+ end
86
+
87
+ def self.show_notes_file
88
+ @show_notes_file ||= "#{session}/show-notes-#{Time.new.strftime("%Y-%m-%d")}.md"
89
+ end
90
+
91
+ def self.current_transcript
92
+ @current_transcript ||= File.exist?(transcript_file) ? File.read(transcript_file) : ""
44
93
  end
45
94
 
46
95
  def self.update_transcript(text)
47
96
  File.open(transcript_file, "a") { |f| f.puts text }
97
+ current_transcript << text
98
+ end
99
+
100
+ def self.current_summary
101
+ @current_summary ||= File.exist?(summary_log_file) ? File.read(summary_log_file) : ""
102
+ end
103
+
104
+ def self.update_summary(text)
105
+ @current_summary = text
106
+ File.write(summary_log_file, text)
107
+ @current_summary
108
+ end
109
+
110
+ def self.current_topics
111
+ @current_topics ||= File.exist?(topics_log_file) ? File.read(topics_log_file) : ""
112
+ end
113
+
114
+ def self.add_to_topics(topics)
115
+ File.open(topics_log_file, "a") { |f| f.puts topics }
116
+ current_topics << topics
117
+ end
118
+
119
+ def self.answer_audio_file_path=(file_path)
120
+ @answer_audio_file_path = file_path
121
+ end
122
+
123
+ def self.answer_audio_file_path
124
+ @answer_audio_file_path ||= "response.mp3"
48
125
  end
49
126
 
50
127
  def self.setup
@@ -58,5 +135,16 @@ module PodcastBuddy
58
135
  SystemDependency.auto_install!(:git)
59
136
  SystemDependency.auto_install!(:sdl2)
60
137
  SystemDependency.auto_install!(:whisper)
138
+ SystemDependency.resolve_whisper_model(whisper_model)
139
+ end
140
+
141
+ def self.openai_client=(client)
142
+ @openai_client = client
143
+ end
144
+
145
+ def self.openai_client
146
+ raise Error, "Please set an OPENAI_ACCESS_TOKEN environment variable." if ENV["OPENAI_ACCESS_TOKEN"].to_s.strip.squeeze.empty?
147
+
148
+ @openai_client ||= OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"], log_errors: true)
61
149
  end
62
150
  end
metadata CHANGED
@@ -1,15 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: podcast-buddy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Valentino Stoll
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-09 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2024-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async-http-faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rainbow
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
13
55
  description: A simple Ruby command-line tool for dropping in an AI buddy to your podcast.
14
56
  email:
15
57
  - v@codenamev.com