Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
dwilkie committed Oct 18, 2023
1 parent 01987a4 commit 2c02178
Show file tree
Hide file tree
Showing 16 changed files with 113 additions and 43 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ require:
- rubocop-performance
- rubocop-rspec

AllCops:
NewCops: enable

Style/FrozenStringLiteralComment:
Enabled: false

Expand Down
2 changes: 2 additions & 0 deletions components/app/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ source "https://rubygems.org"

gem "adhearsion", github: "somleng/adhearsion"
gem "aws-sdk-lambda"
gem "aws-sdk-polly"
gem "faraday"
gem "http"
gem "okcomputer"
Expand All @@ -11,6 +12,7 @@ gem "sentry-ruby"
gem "sinatra"
gem "sinatra-contrib", require: false
gem "skylight"
gem "tts_voices", github: "somleng/tts_voices"

group :development, :test do
gem "rubocop"
Expand Down
12 changes: 12 additions & 0 deletions components/app/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ GIT
thor
virtus (~> 1.0)

GIT
remote: https://github.com/somleng/tts_voices.git
revision: e719a88769a77d08a85dc3aa99ef04ba4630ad18
specs:
tts_voices (0.1.0)
aws-sdk-polly

GEM
remote: https://rubygems.org/
specs:
Expand All @@ -58,6 +65,9 @@ GEM
aws-sdk-lambda (1.106.0)
aws-sdk-core (~> 3, >= 3.184.0)
aws-sigv4 (~> 1.1)
aws-sdk-polly (1.76.0)
aws-sdk-core (~> 3, >= 3.184.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
axiom-types (0.1.1)
Expand Down Expand Up @@ -274,6 +284,7 @@ PLATFORMS
DEPENDENCIES
adhearsion!
aws-sdk-lambda
aws-sdk-polly
faraday
http
okcomputer
Expand All @@ -290,6 +301,7 @@ DEPENDENCIES
sinatra
sinatra-contrib
skylight
tts_voices!
twilio-ruby
vcr
webmock
Expand Down
1 change: 1 addition & 0 deletions components/app/app/call_controllers/call_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def build_call_properties
api_version: response.api_version,
to: response.to,
from: response.from,
default_tts_voice_identifier: response.default_tts_voice_identifier,
sip_headers: SIPHeaders.new(call_sid: response.call_sid, account_sid: response.account_sid)
)
end
Expand Down
2 changes: 1 addition & 1 deletion components/app/app/models/call_properties.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
:to,
:from,
:sip_headers,
:default_tts_provider,
:default_tts_voice_identifier,
keyword_init: true
) do
def inbound?
Expand Down
48 changes: 33 additions & 15 deletions components/app/app/models/execute_twiml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ class ExecuteTwiML
NESTED_GATHER_VERBS = %w[Say Play].freeze
MAX_LOOP = 100
SLEEP_BETWEEN_REDIRECTS = 1
DEFAULT_VOICE_PROVIDER = "polly".freeze
DEFAULT_TWILIO_VOICE = "man".freeze
DEFAULT_TWILIO_LANGUAGE = "en".freeze
FINISH_ON_KEY_PATTERN = /\A(?:\d|\*|\#)\z/.freeze
BASIC_TTS_MAPPING = {
"man" => "Basic.Kal",
"woman" => "Basic.Slt"
}.freeze

DIAL_CALL_STATUSES = {
no_answer: "no-answer",
answer: "completed",
Expand Down Expand Up @@ -106,10 +110,10 @@ def execute_say(verb)
answer unless answered?

attributes = twiml_attributes(verb)
provider = resolve_voice_provider(attributes["voice"])
tts_voice = resolve_tts_voice(attributes)

twiml_loop(attributes).each do
say(say_options(verb.content, attributes))
say(say_options(verb.content, tts_voice))
end

# call_platform_client.notify_tts_event(
Expand Down Expand Up @@ -141,7 +145,12 @@ def execute_gather(verb)
end

nested_verb_attributes = twiml_attributes(nested_verb)
content = nested_verb.name == "Say" ? say_options(nested_verb.content, nested_verb_attributes) : nested_verb.content
content = if nested_verb.name == "Say"
tts_voice = resolve_tts_voice(nested_verb_attributes)
say_options(nested_verb.content, tts_voice)
else
nested_verb.content
end
result.concat(Array.new(twiml_loop(nested_verb_attributes).count, content))
end

Expand Down Expand Up @@ -255,14 +264,9 @@ def execute_record(verb)
)
end

def say_options(content, attributes)
voice_params = {
name: attributes.fetch("voice", DEFAULT_TWILIO_VOICE),
language: attributes.fetch("language", DEFAULT_TWILIO_LANGUAGE)
}

def say_options(content, tts_voice)
ssml = RubySpeech::SSML.draw do
voice(voice_params) do
voice(name: tts_voice.identifier, language: tts_voice.language) do
# mod ssml doesn't support non-ascii characters
# https://github.com/signalwire/freeswitch/issues/1348
string(content + ".")
Expand Down Expand Up @@ -311,10 +315,24 @@ def normalize_recording_url(raw_recording_url)
URL_PATTERN.match(raw_recording_url)[0]
end

def resolve_voice_provider(voice)
return DEFAULT_VOICE_PROVIDER if voice.in?(["man", "woman"])
return DEFAULT_VOICE_PROVIDER if voice.start_with?("Polly.")
def resolve_tts_voice(attributes)
voice_attribute = attributes["voice"]
language_attribute = attributes["language"]

default_tts_voice = TTSVoices::Voice.find(call_properties.default_tts_voice_identifier)
voice_attribute = BASIC_TTS_MAPPING.fetch(voice_attribute) if BASIC_TTS_MAPPING.key?(voice_attribute)

raise Errors::TwiMLError, "Unsupported voice"
if voice_attribute.blank? && language_attribute.present?
tts_voice = resolve_tts_voice_by_language(default_tts_voice.provider, language_attribute)
voice_attribute = tts_voice&.identifier
end

TTSVoices::Voice.find(voice_attribute) || default_tts_voice
end

def resolve_tts_voice_by_language(provider, language_attribute)
TTSVoices::Voice.all.find do |voice|
voice.provider == provider && voice.language.casecmp(language_attribute).zero?
end
end
end
2 changes: 1 addition & 1 deletion components/app/app/models/outbound_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def initiate
api_version: call_params.fetch("api_version"),
from: call_params.fetch("from"),
to: call_params.fetch("to"),
default_tts_provider: call_params.fetch("default_tts_provider"),
default_tts_voice_identifier: call_params.fetch("default_tts_voice_identifier"),
sip_headers:
)
},
Expand Down
11 changes: 11 additions & 0 deletions components/app/config/initializers/aws_stubs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,15 @@ def to_h
}
}
}

Aws.config[:polly] ||= {
stub_responses: {
describe_voices: {
voices: [
{ gender: "Female", id: "Lotte", language_code: "nl-NL" },
{ gender: "Female", id: "Joanna", language_code: "en-US" }
]
}
}
}
end
4 changes: 3 additions & 1 deletion components/app/lib/call_platform/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class InvalidRequestError < StandardError; end
:api_version,
:to,
:from,
:default_tts_voice_identifier,
keyword_init: true
)

Expand Down Expand Up @@ -56,7 +57,8 @@ def create_call(params)
direction: json_response.fetch("direction"),
to: json_response.fetch("to"),
from: json_response.fetch("from"),
api_version: json_response.fetch("api_version")
api_version: json_response.fetch("api_version"),
default_tts_voice_identifier: json_response.fetch("default_tts_voice_identifier")
)
end

Expand Down
12 changes: 7 additions & 5 deletions components/app/spec/call_controllers/gather_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,9 @@
it "handles nested <Say>" do
controller = build_controller(
stub_voice_commands: { ask: build_input_result(nil) },
call_properties: { voice_url: "https://www.example.com/gather.xml" }
call_properties: {
voice_url: "https://www.example.com/gather.xml"
}
)

stub_twiml_request(controller, response: <<~TWIML)
Expand All @@ -464,15 +466,15 @@
outputs.first(3).each do |ssml|
node = ssml.voice.children.first
expect(node.content).to eq("Hello World.")
expect(node.attributes.fetch("name").value).to eq("woman")
expect(node.attributes.fetch("lang").value).to eq("de")
expect(node.attributes.fetch("name").value).to eq("Basic.Slt")
expect(node.attributes.fetch("lang").value).to eq("en-US")
end

outputs.last(5).each do |ssml|
node = ssml.voice.children.first
expect(node.content).to eq("Foobar.")
expect(node.attributes.fetch("name").value).to eq("man")
expect(node.attributes.fetch("lang").value).to eq("en")
expect(node.attributes.fetch("name").value).to eq("Basic.Kal")
expect(node.attributes.fetch("lang").value).to eq("en-US")
end
end
end
Expand Down
43 changes: 31 additions & 12 deletions components/app/spec/call_controllers/say_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
# | | Limited to 4KB (4,000 ASCII characters) |

it "outputs SSML" do
controller = build_controller(stub_voice_commands: :say)
controller = build_controller(
stub_voice_commands: :say,
call_properties: {
default_tts_voice_identifier: "Basic.Slt"
}
)
stub_twiml_request(controller, response: <<~TWIML)
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Handles whitespace -->
Expand All @@ -35,6 +40,8 @@
expect(ssml).to be_a(RubySpeech::SSML::Speak)
expect(ssml.text).to eq("Hola, buen día.")
expect(ssml.to_xml).to include("Hola, buen día.")
expect(fetch_ssml_attribute(ssml, :name)).to eq("Basic.Slt")
expect(fetch_ssml_attribute(ssml, :lang)).to eq("en-US")
end
end
end
Expand All @@ -58,35 +65,36 @@
# | Attribute Name | Allowed Values | Default Value |
# | voice | man, woman | man |

it "defaults to man" do
it "sets a custom voice" do
controller = build_controller(stub_voice_commands: :say)
stub_twiml_request(controller, response: <<~TWIML)
<?xml version="1.0" encoding="UTF-8" ?>
<Response>
<Say>Hello World</Say>
<Say voice="woman">Hello World</Say>
</Response>
TWIML

controller.run

expect(controller).to have_received(:say) do |ssml|
expect(fetch_ssml_attribute(ssml, :name)).to eq("man")
expect(fetch_ssml_attribute(ssml, :name)).to eq("Basic.Slt")
end
end

it "sets a custom voice" do
it "supports Polly" do
controller = build_controller(stub_voice_commands: :say)
stub_twiml_request(controller, response: <<~TWIML)
<?xml version="1.0" encoding="UTF-8" ?>
<Response>
<Say voice="woman">Hello World</Say>
<Say voice="Polly.Lotte">Hello World</Say>
</Response>
TWIML

controller.run

expect(controller).to have_received(:say) do |ssml|
expect(fetch_ssml_attribute(ssml, :name)).to eq("woman")
expect(fetch_ssml_attribute(ssml, :name)).to eq("Polly.Lotte")
expect(fetch_ssml_attribute(ssml, :lang)).to eq("nl-NL")
end
end
end
Expand All @@ -106,7 +114,12 @@
# so the option is ignored

it "sets the language to en by default" do
controller = build_controller(stub_voice_commands: :say)
controller = build_controller(
stub_voice_commands: :say,
call_properties: {
default_tts_voice_identifier: "Basic.Kal"
}
)
stub_twiml_request(controller, response: <<~TWIML)
<?xml version="1.0" encoding="UTF-8" ?>
<Response>
Expand All @@ -117,23 +130,29 @@
controller.run

expect(controller).to have_received(:say) do |ssml|
expect(fetch_ssml_attribute(ssml, :lang)).to eq("en")
expect(fetch_ssml_attribute(ssml, :lang)).to eq("en-US")
end
end

it "sets the language when specifying the language attribute" do
controller = build_controller(stub_voice_commands: :say)
controller = build_controller(
stub_voice_commands: :say,
call_properties: {
default_tts_voice_identifier: "Polly.Joanna"
}
)
stub_twiml_request(controller, response: <<~TWIML)
<?xml version="1.0" encoding="UTF-8" ?>
<Response>
<Say language="pt-BR">Hello World</Say>
<Say language="nl-NL">Hello World</Say>
</Response>
TWIML

controller.run

expect(controller).to have_received(:say) do |ssml|
expect(fetch_ssml_attribute(ssml, :lang)).to eq("pt-BR")
expect(fetch_ssml_attribute(ssml, :name)).to eq("Polly.Lotte")
expect(fetch_ssml_attribute(ssml, :lang)).to eq("nl-NL")
end
end
end
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions components/app/spec/models/outbound_call_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"account_auth_token" => "sample-auth-token",
"direction" => "outbound-api",
"api_version" => "2010-04-01",
"default_tts_provider" => "basic",
"default_tts_voice_identifier" => "Basic.Kal",
"routing_parameters" => {
"destination" => "85516701721",
"dial_string_prefix" => nil,
Expand Down Expand Up @@ -47,7 +47,7 @@
call_sid: "sample-call-sid",
direction: "outbound-api",
api_version: "2010-04-01",
default_tts_provider: "basic",
default_tts_voice_identifier: "Basic.Kal",
to: "+85516701721",
from: "2442",
sip_headers: SIPHeaders.new(
Expand Down Expand Up @@ -141,7 +141,7 @@ def build_call_params(params)
"account_auth_token" => "sample-auth-token",
"direction" => "outbound-api",
"api_version" => "2010-04-01",
"default_tts_provider" => "basic",
"default_tts_voice_identifier" => "Basic.Kal",
"routing_parameters" => {
"destination" => "85516701721",
"dial_string_prefix" => nil,
Expand Down
Loading

0 comments on commit 2c02178

Please sign in to comment.