Skip to content

Commit

Permalink
Voice improvements: auto-dismiss, better reinvoking, better speaking …
Browse files Browse the repository at this point in the history
…of markdown (AllYourBot#413)
krschacht authored Jun 17, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 52caa20 commit b4d36f8
Showing 12 changed files with 346 additions and 246 deletions.
12 changes: 10 additions & 2 deletions app/helpers/messages_helper.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
require "./lib/markdown_renderer"

module MessagesHelper
def format(text, append_inside_tag:)
def format_for_copying(text)
text
end

def format_for_speaking(text)
::MarkdownRenderer.render_for_speaking(text)
end

def format_for_display(text, append_inside_tag:)
escaped_text = html_escape(text)

html = ::MarkdownRenderer.render(
html = ::MarkdownRenderer.render_for_display(
escaped_text,
block_code: block_code
)
18 changes: 13 additions & 5 deletions app/javascript/blocks/interfaces/listener_interface.js
Original file line number Diff line number Diff line change
@@ -3,20 +3,21 @@ import Interface from "../interface.js"
// To clarify the verbs:
// Invoke a Listener and it starts listening
// Dismiss a Listener and you can re-invoke it with wake words, so it's listening just not paying attention
// Mute a Listener and it completely ignores everything it hears, but the mic stays on
// Unmute a Listener and it clears any buffered audio before it starts listening again
// Disable a Listener and it completely stops working and will be fully re-initialized if you Invoke again

export default class extends Interface {
logLevel_info

log_Tell
async Tell(words) { if (_intendedDismiss(words)) {
async Tell(words) { if (this.engaged && _intendedDismiss(words)) {
await Dismiss.Listener()
return
}
if (_intendedInvoke(words)) Invoke.Listener()
if (!$.processing) return // gave Invoke() a chance
if (_intendedInvoke(words)) {
await Invoke.Listener()
words = _removeSpeechBeforeName(words)
}
if (!$.processing) return // Invoke() did not succeed

if (_referencingTheScreen(words))
$.attachment = await $.screenService.takeScreenshot()
@@ -82,4 +83,11 @@ export default class extends Interface {
Loop.Speaker.every(4, 'thinking')
})
}

_removeSpeechBeforeName(words) {
if (words.downcase().includes("samantha"))
return words.slice(words.downcase().indexOf("samantha"))
else
return words
}
}
90 changes: 52 additions & 38 deletions app/javascript/blocks/interfaces/transcriber_interface.js
Original file line number Diff line number Diff line change
@@ -14,53 +14,47 @@ import Interface from "../interface.js"
export default class extends Interface {
logLevel_info

async Flip(turnOn) { if (turnOn && !$.active) {
$.active = true
await $.transcriberService.start()
await Flip.Microphone.on()
await Invoke.Listener()

} else if (!turnOn && $.active) {
$.active = false
$.transcriberService.end()
async Flip(turnOn) { if (turnOn && !$.active) {
$.active = true
await $.transcriberService.start()
await Flip.Microphone.on()
await Invoke.Listener()

} else if (!turnOn && $.active) {
$.active = false
$.transcriberService.end()

await Flip.Microphone.off()
await Disable.Listener()
await Flip.Microphone.off()
await Disable.Listener()
}
}
}

async Approve() { let approved = await $.transcriberService.start()
$.transcriberService.end()
return approved
}
async Approve() { let approved = await $.transcriberService.start()
$.transcriberService.end()
return approved
}

log_SpeakTo
SpeakTo(text) { $.words += text+' '
if (!$.poller?.handler) $.poller = runEvery(0.2, () => {
log('enough silence...')
if (Microphone.msOfSilence <= 1800) return // what if there is background noise?

void Tell.Listener.to.consider($.words)
$.words = ''
$.poller.end()
})
}
SpeakTo(text) { $.words += text+' '
_shortWaitThenTell()
}

Cover() { $.covered = true }
Uncover() { $.transcriberService.restart()
$.covered = false
Play.Speaker.sound('pop', () => {
Loop.Speaker.every(8, 'typing1')
})
}
Cover() { $.covered = true }
Uncover() { $.transcriberService.restart()
$.covered = false
Play.Speaker.sound('pop', () => {
Loop.Speaker.every(8, 'typing1')
_longWaitThenDismis()
})
}

attr_words = ''
attr_active = false
attr_words = ''
attr_active = false

get on() { return $.active }
get off() { return !$.active }
get on() { return $.active }
get off() { return !$.active }

get supported() { return Transcriber.$.transcriberService.$.recognizer != null }
get supported() { return Transcriber.$.transcriberService.$.recognizer != null }

new() {
$.covered = false
@@ -70,4 +64,24 @@ export default class extends Interface {
SpeakTo.Transcriber.with.words(text)
}
}

_shortWaitThenTell() { if (!$.tellPoller?.handler) $.tellPoller = runEvery(0.2, () => {
if (Microphone.msOfSilence <= 1800) return // what if there is background noise?
log('enough silence to start processing...')

Tell.Listener.to.consider($.words)

$.words = ''
$.tellPoller.end()
})
}

_longWaitThenDismis() { if (!$.dismissPoller?.handler) $.dismissPoller = runEvery(0.2, () => {
if (Microphone.msOfSilence <= 30000) return // what if there is background noise?
log('enough silence to dismiss...')

Dismiss.Listener()
$.dismissPoller.end()
})
}
}
2 changes: 1 addition & 1 deletion app/javascript/blocks/services/transcriber_service.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Service from "../service.js"

export default class extends Service {
logLevel_info
logLevel_debug
attrAccessor_onTextReceived
attrReader_listening

19 changes: 11 additions & 8 deletions app/views/messages/_message.html.erb
Original file line number Diff line number Diff line change
@@ -56,13 +56,16 @@ end

<turbo-frame id="message-<%= message.id %>">

<div
id="message-text-<%= message.id %>"
class="hidden"
data-clipboard-target="text"
data-speaker-target="text <%= message.assistant? && 'assistantText' %>"
data-thinking="<%= thinking ? 'true' : 'false' %>"
><%= message.content_text %></div>
<div
class="hidden"
data-clipboard-target="text"
><%= format_for_copying message.content_text %></div>
<div
class="hidden"
data-speaker-target="text <%= message.assistant? && "assistantText" %>"
data-thinking="<%= thinking ? "true" : "false" %>"
><%= format_for_speaking message.content_text %></div>

<div
data-role="content-text"
class="prose break-words leading-normal dark:[&_*]:text-gray-100 dark:marker:text-gray-100"
@@ -101,7 +104,7 @@ end
</div>
<% end %>
<% end %>
<%= formatted_text = format message.content_text,
<%= formatted_text = format_for_display message.content_text,
append_inside_tag: span_tag("", class: %|
animate-breathe
w-3 h-3
67 changes: 36 additions & 31 deletions lib/markdown_renderer.rb
Original file line number Diff line number Diff line change
@@ -1,43 +1,48 @@
require "./lib/redcarpet/custom_html_renderer"
Dir[File.join(File.dirname(__FILE__), "redcarpet", "*.rb")].each { |file| require file }

class MarkdownRenderer
class << self
def render_for_speaking(markdown)
Redcarpet::CustomSpeakingRenderer.new.render(markdown.to_s)
end

def self.render(markdown, options = {})
markdown ||= ""

render_class = Redcarpet::CustomHtmlRenderer
def render_for_display(markdown, options = {})
renderer = create_renderer_for_display(options)
formatter = Redcarpet::Markdown.new(renderer,
autolink: true,
tables: true,
space_after_headers: true,
strikethrough: true,
underline: true,
no_intra_emphasis: true,
fenced_code_blocks: true,
disable_indented_code_blocks: true
)

markdown = ensure_blank_line_before_code_block_start(markdown)
formatter.render(markdown)
end

block_code_proc = options.delete(:block_code)
private

if block_code_proc
def create_renderer_for_display(options)
render_class = Redcarpet::CustomDisplayRenderer

render_class = Class.new(Redcarpet::CustomHtmlRenderer)
render_class.instance_eval do
define_method(:block_code) do |code, language|
block_code_proc.call(code.html_safe, language)
block_code_proc = options.delete(:block_code)
if block_code_proc
render_class = Class.new(Redcarpet::CustomDisplayRenderer)
render_class.instance_eval do
define_method(:block_code) do |code, language|
block_code_proc.call(code.html_safe, language)
end
end
end
end

renderer = render_class.new(safe_links_only: true)

formatter = Redcarpet::Markdown.new(renderer,
autolink: true,
tables: true,
space_after_headers: true,
strikethrough: true,
underline: true,
no_intra_emphasis: true,
fenced_code_blocks: true,
disable_indented_code_blocks: true
)

markdown = ensure_blank_line_before_code_block_start(markdown)

formatter.render(markdown)
end
render_class.new(safe_links_only: true)
end

def self.ensure_blank_line_before_code_block_start(markdown)
markdown.gsub(/(\n*)( *)(```.*?```)/m, "\n\n\\2\\3")
def ensure_blank_line_before_code_block_start(markdown)
markdown.to_s.gsub(/(\n*)( *)(```.*?```)/m, "\n\n\\2\\3")
end
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class Redcarpet::CustomHtmlRenderer < Redcarpet::Render::HTML
class Redcarpet::CustomDisplayRenderer < Redcarpet::Render::HTML
def paragraph(text)
text.gsub!("\n", "<br>\n")
"\n<p>#{text}</p>\n"
15 changes: 15 additions & 0 deletions lib/redcarpet/custom_speaking_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class Redcarpet::CustomSpeakingRenderer # can't get redcarpet working, just using regex
def render(markdown)
not_needing_suffix = %w[javascript jsx typescript css html json markdown yaml]

markdown.gsub(/```(\w+)?\s*(.*?)```/m) do |match| # paired ````
if $1.to_s.strip.in? not_needing_suffix
"Here is some #{$1}."
else
"Here is some #{$1.to_s + ($1.present? ? " " : "")}code."
end
end.gsub(/```.*$/m, '') # opening ``` without closing`
.gsub(/\[([^\]]+)\]\(([^)]+)\)/, "Here is a link to \\1") # markdown links
.gsub(/http[s]?:\/\/[^\s]+/, "this link") # naked links
end
end
124 changes: 1 addition & 123 deletions test/lib/markdown_renderer_test.rb
Original file line number Diff line number Diff line change
@@ -1,127 +1,5 @@
require 'test_helper'

class MarkdownRendererTest < ActiveSupport::TestCase
setup do
@renderer = MarkdownRenderer
end

test "ensure_blank_line_before_code_block_start adds blank line before code block when zero newlines" do
markdown = "Text before code block```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "Text before code block```ruby\ncode block\n```Text before second```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)
end

test "ensure_blank_line_before_code_block_start adds blank line before code block when one newline" do
markdown = "Text before code block\n```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "\n```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "Text before code block\n```ruby\ncode block\n```Text before second\n```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)
end

test "ensure_blank_line_before_code_block_start does not add blank line when one is already present" do
markdown = "Text before code block\n\n```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "\n\n```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)

markdown = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)
end

test "ensure_blank_line_before_code_block_start keeps a block indented if it is indented" do
# TODO: Notably, if a block is indented right after a bullet such that it's intended to be included within the bullet,
# redcarpet is not including the block within the bullet: https://github.com/allyourbot/hostedgpt/issues/323

markdown = "Text before code block\n\n ``` ruby\n code block\n ```"
expected = "Text before code block\n\n ``` ruby\n code block\n ```"
assert_equal expected, MarkdownRenderer.ensure_blank_line_before_code_block_start(markdown)
end

test "block_code nicely formatted gets converted" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<pre><code class="sql">SELECT * FROM users;
</code></pre>
<p>Line after.</p>
HTML

assert_equal formatted.strip, @renderer.render(markdown).strip
end

test "block_code missing a blank line before and after gets gets nicely - ensure_blank_line_before_code_block_start" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<pre><code class="sql">SELECT * FROM users;
</code></pre>
<p>Line after.</p>
HTML

assert_equal formatted.strip, @renderer.render(markdown).strip
end

test "block_code can be provided with a proc" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<CODE lang="sql">SELECT * FROM users;
</CODE>
<p>Line after.</p>
HTML

block_code = -> (code, language) do
%(<CODE lang="#{language}">#{code}</CODE>)
end

assert_equal formatted.strip, @renderer.render(markdown, block_code: block_code).strip
end
# See lib/redcarpet/* tests
end
157 changes: 157 additions & 0 deletions test/lib/redcarpet/custom_display_renderer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
require "test_helper"

class CustomDisplayRendererTest < ActiveSupport::TestCase
setup do
@renderer = MarkdownRenderer
end

test "code_span" do
markdown = "This is `code` inline."
formatted = "<p>This is <code>code</code> inline.</p>\n"

assert_equal formatted.strip, @renderer.render_for_display(markdown).strip
end

test "newlines within paragraphs get converted into BRs" do
markdown = <<~MD
This is the first paragraph
This is a second paragraph
with a line break.
This is a third paragraph
MD

formatted = <<~HTML
<p>This is the first paragraph</p>
<p>This is a second paragraph<br>
with a line break.</p>
<p>This is a third paragraph</p>
HTML

assert_equal formatted.strip, @renderer.render_for_display(markdown).strip
end

test "ensure_blank_line_before_code_block_start adds blank line before code block when zero newlines" do
markdown = "Text before code block```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "Text before code block```ruby\ncode block\n```Text before second```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)
end

test "ensure_blank_line_before_code_block_start adds blank line before code block when one newline" do
markdown = "Text before code block\n```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "\n```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "Text before code block\n```ruby\ncode block\n```Text before second\n```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)
end

test "ensure_blank_line_before_code_block_start does not add blank line when one is already present" do
markdown = "Text before code block\n\n```ruby\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "\n\n```ruby\ncode block\n```"
expected = "\n\n```ruby\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)

markdown = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
expected = "Text before code block\n\n```ruby\ncode block\n```Text before second\n\n```\ncode block\n```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)
end

test "ensure_blank_line_before_code_block_start keeps a block indented if it is indented" do
# TODO: Notably, if a block is indented right after a bullet such that it's intended to be included within the bullet,
# redcarpet is not including the block within the bullet: https://github.com/allyourbot/hostedgpt/issues/323

markdown = "Text before code block\n\n ``` ruby\n code block\n ```"
expected = "Text before code block\n\n ``` ruby\n code block\n ```"
assert_equal expected, MarkdownRenderer.send(:ensure_blank_line_before_code_block_start, markdown)
end

test "block_code nicely formatted gets converted" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<pre><code class="sql">SELECT * FROM users;
</code></pre>
<p>Line after.</p>
HTML

assert_equal formatted.strip, @renderer.render_for_display(markdown).strip
end

test "block_code missing a blank line before and after gets gets nicely - ensure_blank_line_before_code_block_start" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<pre><code class="sql">SELECT * FROM users;
</code></pre>
<p>Line after.</p>
HTML

assert_equal formatted.strip, @renderer.render_for_display(markdown).strip
end

test "block_code can be provided with a proc" do
markdown = <<~MD
This is sql:
```sql
SELECT * FROM users;
```
Line after.
MD

formatted = <<~HTML
<p>This is sql:</p>
<CODE lang="sql">SELECT * FROM users;
</CODE>
<p>Line after.</p>
HTML

block_code = -> (code, language) do
%(<CODE lang="#{language}">#{code}</CODE>)
end

assert_equal formatted.strip, @renderer.render_for_display(markdown, block_code: block_code).strip
end
end
37 changes: 0 additions & 37 deletions test/lib/redcarpet/custom_html_renderer_test.rb

This file was deleted.

49 changes: 49 additions & 0 deletions test/lib/redcarpet/custom_speaking_renderer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require "test_helper"

class CustomSpeakingRendererTest < ActiveSupport::TestCase
setup do
@renderer = MarkdownRenderer
end

test "code_span" do
markdown = "This is `code` inline. This is a bullet:\n\n* Bullet"
formatted = "This is `code` inline. This is a bullet:\n\n* Bullet"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "block_code with a language that gets the word 'code'" do
markdown = "This is an example:\n```ruby\ncode\n```\n"
formatted = "This is an example:\nHere is some ruby code.\n"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "block_code with a language that stands on its own'" do
markdown = "This is an example:\n```html\ncode\n```\n"
formatted = "This is an example:\nHere is some html.\n"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "block_code with no language rewrites properly" do
markdown = "This is an example:\n```\ncode\n```\n"
formatted = "This is an example:\nHere is some code.\n"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "block_code that is incomplete is hidden, because we are parsing a partially streamed response" do
markdown = "This is an example:\n```\nthis is a partially completed response"
formatted = "This is an example:\n"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "markdown links are caught" do
markdown = "Try visiting [Google](https://google.com)"
formatted = "Try visiting Here is a link to Google"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end

test "regular URLs are caught" do
markdown = "Try visiting https://google.com"
formatted = "Try visiting this link"
assert_equal formatted, @renderer.render_for_speaking(markdown)
end
end

0 comments on commit b4d36f8

Please sign in to comment.