diff --git a/.github/workflows/switch.yml b/.github/workflows/switch.yml index ad9767528..f60251717 100644 --- a/.github/workflows/switch.yml +++ b/.github/workflows/switch.yml @@ -89,6 +89,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + - name: Configure AWS credentials id: aws-login uses: aws-actions/configure-aws-credentials@v4 @@ -116,7 +119,7 @@ jobs: - name: Export Polly Voices run: | - components/freeswitch/bin/export_aws_polly_voices components/freeswitch/conf/autoload_configs/polly_voices.xml + components/freeswitch/bin/export_tts_voices > components/freeswitch/conf/autoload_configs/tts_voices.xml - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.rubocop.yml b/.rubocop.yml index ef9dc662c..94c727901 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,6 +2,9 @@ require: - rubocop-performance - rubocop-rspec +AllCops: + NewCops: enable + Style/FrozenStringLiteralComment: Enabled: false diff --git a/.tool-versions b/.tool-versions index ae1afb27d..2f09535ff 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ terraform 1.5.6 +ruby 3.2.2 diff --git a/components/app/Gemfile b/components/app/Gemfile index 772cf6237..c7ba36dee 100644 --- a/components/app/Gemfile +++ b/components/app/Gemfile @@ -2,6 +2,8 @@ source "https://rubygems.org" gem "adhearsion", github: "somleng/adhearsion" gem "aws-sdk-lambda" +gem "aws-sdk-polly" +gem "blather", github: "adhearsion/blather", branch: "develop" gem "faraday" gem "http" gem "okcomputer" @@ -11,6 +13,8 @@ gem "sentry-ruby" gem "sinatra" gem "sinatra-contrib", require: false gem "skylight" +gem "sucker_punch" +gem "tts_voices", github: "somleng/tts_voices" group :development, :test do gem "rubocop" diff --git a/components/app/Gemfile.lock b/components/app/Gemfile.lock index 69c47092e..5907f0ff3 100644 --- a/components/app/Gemfile.lock +++ b/components/app/Gemfile.lock @@ -1,3 +1,14 @@ +GIT + remote: https://github.com/adhearsion/blather.git + revision: 03f9cda14ed3f93a80504c2538cd514d6b025d04 + branch: develop + specs: + blather (2.0.0) + activesupport (>= 2.3.11) + eventmachine (~> 1.2, >= 1.2.6) + niceogiri (~> 1.0) + nokogiri (~> 1.8, >= 1.8.3) + GIT remote: https://github.com/samnang/reel-rack.git revision: 615a9c9219aa00675a4290c4f7245684c701ea0e @@ -36,20 +47,32 @@ GIT thor virtus (~> 1.0) +GIT + remote: https://github.com/somleng/tts_voices.git + revision: afb3b77d74a0bea12b1c8eaa1303e20076a43912 + specs: + tts_voices (0.1.0) + aws-sdk-polly + GEM remote: https://rubygems.org/ specs: - activesupport (7.0.8) + activesupport (7.1.1) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) adhearsion-loquacious (1.9.3) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.835.0) + aws-partitions (1.838.0) aws-sdk-core (3.185.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) @@ -58,6 +81,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) @@ -65,12 +91,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.1.1) - blather (2.0.0) - activesupport (>= 2.3.11) - eventmachine (~> 1.2, >= 1.2.6) - niceogiri (~> 1.0) - nokogiri (~> 1.8, >= 1.8.3) - sucker_punch (~> 2.0) + bigdecimal (3.1.4) celluloid (0.16.0) timers (~> 4.0.0) celluloid-io (0.16.2) @@ -80,6 +101,7 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.2.2) + connection_pool (2.4.1) countdownlatch (1.0.0) crack (0.4.5) rexml @@ -90,6 +112,8 @@ GEM docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) + drb (2.1.1) + ruby2_keywords equalizer (0.0.11) eventmachine (1.2.7) faraday (2.7.11) @@ -134,6 +158,7 @@ GEM multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) + mutex_m (0.1.2) niceogiri (1.1.2) nokogiri (~> 1.5) nio4r (2.5.9) @@ -237,7 +262,7 @@ GEM skylight (6.0.1) activesupport (>= 5.2.0) state_machine (1.2.0) - sucker_punch (2.1.2) + sucker_punch (3.1.0) concurrent-ruby (~> 1.0) thor (1.2.2) thread_safe (0.3.6) @@ -274,6 +299,8 @@ PLATFORMS DEPENDENCIES adhearsion! aws-sdk-lambda + aws-sdk-polly + blather! faraday http okcomputer @@ -290,6 +317,8 @@ DEPENDENCIES sinatra sinatra-contrib skylight + sucker_punch + tts_voices! twilio-ruby vcr webmock diff --git a/components/app/app/call_controllers/call_controller.rb b/components/app/app/call_controllers/call_controller.rb index f3165921e..de199180b 100644 --- a/components/app/app/call_controllers/call_controller.rb +++ b/components/app/app/call_controllers/call_controller.rb @@ -64,6 +64,7 @@ def build_call_properties api_version: response.api_version, to: response.to, from: response.from, + default_tts_voice: response.default_tts_voice, sip_headers: SIPHeaders.new(call_sid: response.call_sid, account_sid: response.account_sid) ) end diff --git a/components/app/app/jobs/notify_tts_event_job.rb b/components/app/app/jobs/notify_tts_event_job.rb new file mode 100644 index 000000000..4ba3ed58c --- /dev/null +++ b/components/app/app/jobs/notify_tts_event_job.rb @@ -0,0 +1,7 @@ +class NotifyTTSEventJob + include SuckerPunch::Job + + def perform(client, data) + client.notify_tts_event(data) + end +end diff --git a/components/app/app/models/call_properties.rb b/components/app/app/models/call_properties.rb index e205c6480..5181e521f 100644 --- a/components/app/app/models/call_properties.rb +++ b/components/app/app/models/call_properties.rb @@ -10,6 +10,7 @@ :to, :from, :sip_headers, + :default_tts_voice, keyword_init: true ) do def inbound? diff --git a/components/app/app/models/execute_twiml.rb b/components/app/app/models/execute_twiml.rb index d3556a3e4..886430193 100644 --- a/components/app/app/models/execute_twiml.rb +++ b/components/app/app/models/execute_twiml.rb @@ -13,6 +13,11 @@ class ExecuteTwiML 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", @@ -105,9 +110,17 @@ def execute_say(verb) answer unless answered? attributes = twiml_attributes(verb) + tts_voice = resolve_tts_voice(attributes) + + NotifyTTSEventJob.perform_async( + call_platform_client, + phone_call: call_properties.call_sid, + tts_voice: tts_voice.identifier, + num_chars: verb.content.length + ) twiml_loop(attributes).each do - say(say_options(verb.content, attributes)) + say(say_options(verb.content, tts_voice)) end end @@ -133,7 +146,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 @@ -247,14 +265,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 + ".") @@ -302,4 +315,28 @@ def sip_headers def normalize_recording_url(raw_recording_url) URL_PATTERN.match(raw_recording_url)[0] end + + def resolve_tts_voice(attributes) + voice_attribute = attributes["voice"] + language_attribute = attributes["language"] + + default_tts_voice = TTSVoices::Voice.find(call_properties.default_tts_voice) + voice_attribute = BASIC_TTS_MAPPING.fetch(voice_attribute) if BASIC_TTS_MAPPING.key?(voice_attribute) + + if voice_attribute.blank? + tts_voice = resolve_tts_voice_by_language(default_tts_voice, language_attribute) + voice_attribute = tts_voice&.identifier + end + + TTSVoices::Voice.find(voice_attribute) || default_tts_voice + end + + def resolve_tts_voice_by_language(default_tts_voice, language_attribute) + return default_tts_voice if language_attribute.blank? + return default_tts_voice if default_tts_voice.language.casecmp(language_attribute).zero? + + TTSVoices::Voice.all.find do |voice| + voice.provider == default_tts_voice.provider && voice.language.casecmp(language_attribute).zero? + end + end end diff --git a/components/app/app/models/outbound_call.rb b/components/app/app/models/outbound_call.rb index 7c5310096..f378c003e 100644 --- a/components/app/app/models/outbound_call.rb +++ b/components/app/app/models/outbound_call.rb @@ -29,6 +29,7 @@ def initiate api_version: call_params.fetch("api_version"), from: call_params.fetch("from"), to: call_params.fetch("to"), + default_tts_voice: call_params.fetch("default_tts_voice"), sip_headers: ) }, diff --git a/components/app/config/initializers/aws_stubs.rb b/components/app/config/initializers/aws_stubs.rb index c60b033f3..4770fb77f 100644 --- a/components/app/config/initializers/aws_stubs.rb +++ b/components/app/config/initializers/aws_stubs.rb @@ -28,4 +28,16 @@ def to_h } } } + + Aws.config[:polly] ||= { + stub_responses: { + describe_voices: { + voices: [ + { gender: "Female", id: "Joanna", language_code: "en-US", supported_engines: ["standard"] }, + { gender: "Female", id: "Lotte", language_code: "nl-NL", supported_engines: ["neural"] }, + { gender: "Female", id: "Vitoria", language_code: "pt-BR", supported_engines: %w[neural standard] } + ] + } + } + } end diff --git a/components/app/lib/call_platform/client.rb b/components/app/lib/call_platform/client.rb index ecf4ac1f8..9f9ddfec1 100644 --- a/components/app/lib/call_platform/client.rb +++ b/components/app/lib/call_platform/client.rb @@ -15,6 +15,7 @@ class InvalidRequestError < StandardError; end :api_version, :to, :from, + :default_tts_voice, keyword_init: true ) @@ -32,6 +33,14 @@ def notify_call_event(params) end end + def notify_tts_event(params) + response = http_client.post("/services/tts_events", params.to_json) + + unless response.success? + Sentry.capture_message("Invalid TTS event", extra: { response_body: response.body }) + end + end + def build_routing_parameters(params) make_request("/services/routing_parameters", params: params) end @@ -48,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: json_response.fetch("default_tts_voice") ) end diff --git a/components/app/lib/call_platform/fake_client.rb b/components/app/lib/call_platform/fake_client.rb index ed5f43084..d29b159d8 100644 --- a/components/app/lib/call_platform/fake_client.rb +++ b/components/app/lib/call_platform/fake_client.rb @@ -1,6 +1,7 @@ module CallPlatform class FakeClient < Client - def notify_call_event(_params); end + def notify_call_event(params); end + def notify_tts_event(params); end def create_call(params) validate_gateway_headers(params) @@ -21,7 +22,8 @@ def create_call(params) direction: "inbound", to: params.fetch(:to), from: params.fetch(:from), - api_version: "2010-04-01" + api_version: "2010-04-01", + default_tts_voice: "Basic.Kal" ) end diff --git a/components/app/spec/call_controllers/gather_spec.rb b/components/app/spec/call_controllers/gather_spec.rb index e38f35b2a..92b7ba461 100644 --- a/components/app/spec/call_controllers/gather_spec.rb +++ b/components/app/spec/call_controllers/gather_spec.rb @@ -442,7 +442,9 @@ it "handles nested " 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) @@ -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 diff --git a/components/app/spec/call_controllers/run_with_twiml_payload_spec.rb b/components/app/spec/call_controllers/run_with_twiml_payload_spec.rb index 05480101d..0c1ced503 100644 --- a/components/app/spec/call_controllers/run_with_twiml_payload_spec.rb +++ b/components/app/spec/call_controllers/run_with_twiml_payload_spec.rb @@ -3,13 +3,13 @@ RSpec.describe CallController, type: :call_controller do it "executes with the provided twiml payload" do controller = build_controller( - stub_voice_commands: :say, + stub_voice_commands: :play_audio, call_properties: { voice_url: nil, twiml: <<~TWIML - Hello World + https://demo.twilio.com/docs/classic.mp3 TWIML } @@ -17,6 +17,6 @@ controller.run - expect(controller).to have_received(:say) + expect(controller).to have_received(:play_audio) end end diff --git a/components/app/spec/call_controllers/say_spec.rb b/components/app/spec/call_controllers/say_spec.rb index 97ff01515..833d2a916 100644 --- a/components/app/spec/call_controllers/say_spec.rb +++ b/components/app/spec/call_controllers/say_spec.rb @@ -2,6 +2,9 @@ RSpec.describe CallController, type: :call_controller do describe "" do + before do + stub_request(:post, "http://api.lvh.me:3000/services/tts_events") + end # https://www.twilio.com/docs/api/twiml/say # The verb converts text to speech that is read back to the caller. @@ -19,7 +22,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: "Basic.Slt" + } + ) stub_twiml_request(controller, response: <<~TWIML) @@ -35,7 +43,18 @@ 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 + + expect(WebMock).to(have_requested(:post, "http://api.lvh.me:3000/services/tts_events").with { |request| + request_payload = JSON.parse(request.body) + expect(request_payload).to eq( + "phone_call" => controller.call_properties.call_sid, + "tts_voice" => "Basic.Slt", + "num_chars" => 14 + ) + }) end end @@ -58,35 +77,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) - Hello World + Hello World 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) - Hello World + Hello World 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-Neural") + expect(fetch_ssml_attribute(ssml, :lang)).to eq("nl-NL") end end end @@ -106,7 +126,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: "Basic.Kal" + } + ) stub_twiml_request(controller, response: <<~TWIML) @@ -117,12 +142,39 @@ 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: "Polly.Joanna" + } + ) + stub_twiml_request(controller, response: <<~TWIML) + + + Hello World + + TWIML + + controller.run + + expect(controller).to have_received(:say) do |ssml| + expect(fetch_ssml_attribute(ssml, :name)).to eq("Polly.Lotte-Neural") + expect(fetch_ssml_attribute(ssml, :lang)).to eq("nl-NL") + end + end + + it "uses the default voice if the language is the same" do + controller = build_controller( + stub_voice_commands: :say, + call_properties: { + default_tts_voice: "Polly.Vitoria" + } + ) stub_twiml_request(controller, response: <<~TWIML) @@ -133,7 +185,7 @@ 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.Vitoria") end end end diff --git a/components/app/spec/fixtures/vcr_cassettes/inbound_call.yml b/components/app/spec/fixtures/vcr_cassettes/inbound_call.yml index 858c9e61b..1428e7832 100644 --- a/components/app/spec/fixtures/vcr_cassettes/inbound_call.yml +++ b/components/app/spec/fixtures/vcr_cassettes/inbound_call.yml @@ -5,16 +5,16 @@ http_interactions: uri: http://api.lvh.me:3000/services/inbound_phone_calls body: encoding: UTF-8 - string: '{"to":"1234","from":"1000","external_id":"30393425-1c1d-4c3b-8910-57b54e68133b","source_ip":"192.168.3.1","variables":{"sip_from_host":"192.168.1.1","sip_to_host":"192.168.2.1","sip_network_ip":"192.168.3.1"}}' + string: '{"to":"1294","from":"0715100960","external_id":"06c0c60a-47a2-426a-87e9-95ea85d22bf2","source_ip":"27.109.112.141","client_identifier":null,"variables":{"sip_from_host":"192.168.1.1","sip_to_host":"192.168.2.1","sip_network_ip":"10.0.0.1","sip_via_host":null}}' headers: Accept: - application/json Content-Type: - application/json + User-Agent: + - Faraday v2.7.11 Authorization: - Basic c2VydmljZXM6cGFzc3dvcmQ= - User-Agent: - - Faraday v1.4.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 response: @@ -25,36 +25,38 @@ http_interactions: X-Frame-Options: - SAMEORIGIN X-Xss-Protection: - - 1; mode=block + - '0' X-Content-Type-Options: - nosniff - X-Download-Options: - - noopen X-Permitted-Cross-Domain-Policies: - none Referrer-Policy: - strict-origin-when-cross-origin Content-Type: - - application/json; charset=utf-8 + - application/vnd.api+json; charset=utf-8 Etag: - - W/"9e2400d79dcbc837e5921ad5072bf034" + - W/"8aa42d1e00281587f058e37db761f7c9" Cache-Control: - max-age=0, private, must-revalidate X-Request-Id: - - 750e8f9b-adc3-4836-8786-03631db705d2 + - daa15cf3-8d31-4958-ad86-2dced50b69d7 X-Runtime: - - '0.043391' - Transfer-Encoding: - - chunked + - '0.052932' + Server-Timing: + - start_processing.action_controller;dur=0.00, sql.active_record;dur=16.44, + instantiation.active_record;dur=0.24, transaction.active_record;dur=26.56, + process_action.action_controller;dur=43.11 + Content-Length: + - '498' body: encoding: UTF-8 - string: '{"api_version":"2010-04-01","created_at":"2021-06-23T09:02:30Z","updated_at":"2021-06-23T09:02:30Z","voice_url":"https://demo.twilio.com/docs/voice.xml","voice_method":"GET","status_callback_url":null,"status_callback_method":null,"to":"1234","from":"1000","sid":"574f0314-8d52-4a79-a7aa-2ff9898f7b79","account_sid":"0f82ec1a-c776-4669-a0b6-c2b11bef156a","account_auth_token":"pd-eZBeCozOHqtdcwYOpGcPaZNByMFQjryK73K3Pm14","direction":"inbound","twiml":null}' - recorded_at: Wed, 23 Jun 2021 09:02:30 GMT + string: '{"created_at":"2023-10-20T05:06:28Z","updated_at":"2023-10-20T05:06:28Z","voice_url":"https://demo.twilio.com/docs/voice.xml","voice_method":"GET","status_callback_url":null,"status_callback_method":null,"twiml":null,"to":"1294","from":"+855715100960","sid":"23233573-25be-486d-9631-c33d6f5eb80c","account_sid":"10304d06-de4a-40b5-9453-2ab15ebf5c52","account_auth_token":"LaCY_bLXWZUp9CpFMvBwQiLUn5Kry20lRxOUeERqFPI","direction":"inbound","api_version":"2010-04-01","default_tts_voice":"Basic.Kal"}' + recorded_at: Fri, 20 Oct 2023 05:06:28 GMT - request: method: get - uri: https://demo.twilio.com/docs/voice.xml?AccountSid=0f82ec1a-c776-4669-a0b6-c2b11bef156a&ApiVersion=2010-04-01&CallSid=574f0314-8d52-4a79-a7aa-2ff9898f7b79&CallStatus=ringing&Called=1234&Caller=1000&Direction=inbound&From=1000&To=1234 + uri: https://demo.twilio.com/docs/voice.xml?AccountSid=10304d06-de4a-40b5-9453-2ab15ebf5c52&ApiVersion=2010-04-01&CallSid=23233573-25be-486d-9631-c33d6f5eb80c&CallStatus=ringing&Called=1294&Caller=%2B855715100960&Direction=inbound&From=%2B855715100960&To=1294 body: - encoding: UTF-8 + encoding: ASCII-8BIT string: '' headers: Content-Type: @@ -66,7 +68,7 @@ http_interactions: Cache-Control: - max-age=259200 X-Twilio-Signature: - - YXDSpMZlefngpbgS+wTLaIZ1Ik4= + - 1TPqVlvI+EzgGUzcXsHjIF4lAlU= Connection: - close Host: @@ -77,7 +79,7 @@ http_interactions: message: OK headers: Date: - - Wed, 23 Jun 2021 09:02:32 GMT + - Fri, 20 Oct 2023 05:06:29 GMT Content-Type: - text/xml Content-Length: @@ -87,14 +89,13 @@ http_interactions: Server: - nginx Last-Modified: - - Mon, 07 Oct 2019 18:46:57 GMT + - Tue, 13 Jun 2023 19:18:49 GMT Vary: - Accept-Encoding Etag: - - '"5d9b8821-c0"' + - '"6488c119-c0"' X-Shenanigans: - none - - none Accept-Ranges: - bytes body: @@ -102,8 +103,57 @@ http_interactions: string: | - Thanks for trying our documentation. Enjoy! + Thanks for trying our documentation. Enjoy! http://demo.twilio.com/docs/classic.mp3 - recorded_at: Wed, 23 Jun 2021 09:02:32 GMT -recorded_with: VCR 6.0.0 + recorded_at: Fri, 20 Oct 2023 05:06:29 GMT +- request: + method: post + uri: http://api.lvh.me:3000/services/tts_events + body: + encoding: UTF-8 + string: '{"phone_call":"23233573-25be-486d-9631-c33d6f5eb80c","tts_voice":"Basic.Slt","num_chars":43}' + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - Faraday v2.7.11 + Authorization: + - Basic c2VydmljZXM6cGFzc3dvcmQ= + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 201 + message: Created + headers: + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - '0' + X-Content-Type-Options: + - nosniff + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Content-Type: + - application/vnd.api+json + Cache-Control: + - no-cache + X-Request-Id: + - 5ecc33fd-b6fb-4c51-a946-288f2bb3567b + X-Runtime: + - '0.034691' + Server-Timing: + - sql.active_record;dur=8.08, start_processing.action_controller;dur=0.00, instantiation.active_record;dur=0.23, + transaction.active_record;dur=8.13, process_action.action_controller;dur=18.49 + Content-Length: + - '0' + body: + encoding: UTF-8 + string: '' + recorded_at: Fri, 20 Oct 2023 05:06:29 GMT +recorded_with: VCR 6.2.0 diff --git a/components/app/spec/models/outbound_call_spec.rb b/components/app/spec/models/outbound_call_spec.rb index 537ec2897..dc437bc0f 100644 --- a/components/app/spec/models/outbound_call_spec.rb +++ b/components/app/spec/models/outbound_call_spec.rb @@ -15,6 +15,7 @@ "account_auth_token" => "sample-auth-token", "direction" => "outbound-api", "api_version" => "2010-04-01", + "default_tts_voice" => "Basic.Kal", "routing_parameters" => { "destination" => "85516701721", "dial_string_prefix" => nil, @@ -46,6 +47,7 @@ call_sid: "sample-call-sid", direction: "outbound-api", api_version: "2010-04-01", + default_tts_voice: "Basic.Kal", to: "+85516701721", from: "2442", sip_headers: SIPHeaders.new( @@ -139,6 +141,7 @@ def build_call_params(params) "account_auth_token" => "sample-auth-token", "direction" => "outbound-api", "api_version" => "2010-04-01", + "default_tts_voice" => "Basic.Kal", "routing_parameters" => { "destination" => "85516701721", "dial_string_prefix" => nil, diff --git a/components/app/spec/support/call_controller_helpers.rb b/components/app/spec/support/call_controller_helpers.rb index c7ba20634..a7032ede5 100644 --- a/components/app/spec/support/call_controller_helpers.rb +++ b/components/app/spec/support/call_controller_helpers.rb @@ -8,6 +8,7 @@ def build_controller(call: build_fake_call, call_properties: {}, **options) account_sid: SecureRandom.uuid, api_version: "2010-04-01", auth_token: SecureRandom.alphanumeric, + default_tts_voice: "Basic.Kal", from: "1000", to: "85512456869" ) diff --git a/components/app/spec/support/sucker_punch.rb b/components/app/spec/support/sucker_punch.rb new file mode 100644 index 000000000..2660c13c3 --- /dev/null +++ b/components/app/spec/support/sucker_punch.rb @@ -0,0 +1 @@ +require "sucker_punch/testing/inline" diff --git a/components/app/spec/web/api_spec.rb b/components/app/spec/web/api_spec.rb index 19a641607..0c82e578c 100644 --- a/components/app/spec/web/api_spec.rb +++ b/components/app/spec/web/api_spec.rb @@ -33,6 +33,7 @@ module Web "account_auth_token" => "sample-auth-token", "direction" => "outbound-api", "api_version" => "2010-04-01", + "default_tts_voice" => "Basic.Kal", "routing_parameters" => { "destination" => "85516701721", "dial_string_prefix" => nil, diff --git a/components/freeswitch/bin/aws_polly b/components/freeswitch/bin/aws_polly index d859f2d6a..d4e40fe18 100755 --- a/components/freeswitch/bin/aws_polly +++ b/components/freeswitch/bin/aws_polly @@ -3,12 +3,13 @@ text="$1" file="$2" voice_id="$3" -cache_file="$4" +engine="$4" +cache_file="$5" # Output the text to a file before executing polly # https://github.com/somleng/somleng-switch/pull/238 echo "$text" > "$file.txt" -aws polly synthesize-speech --sample-rate "8000" --output-format mp3 --voice-id "$voice_id" --text "file://$file.txt" "$file.mp3" +aws polly synthesize-speech --sample-rate "8000" --output-format mp3 --voice-id "$voice_id" --engine "$engine" --text "file://$file.txt" "$file.mp3" ffmpeg -i "$file.mp3" "$cache_file" ln -s "$cache_file" "$file" diff --git a/components/freeswitch/bin/cloud_tts b/components/freeswitch/bin/cloud_tts index a4fb77b74..f4e270df3 100755 --- a/components/freeswitch/bin/cloud_tts +++ b/components/freeswitch/bin/cloud_tts @@ -6,6 +6,10 @@ voice="$3" cache_file="$4" if [[ "$voice" =~ ^Polly\..+ ]]; then - # extract Polly.from voice and execute aws_polly - aws_polly "$text" "$file" "${voice#"Polly."}" "$cache_file" + # extract 'Polly.' from voice + voice_id_with_engine="${voice#"Polly."}" + # extract '-Neural' from voice + voice_id="${voice_id_with_engine%"-Neural"}" + [[ $voice_id_with_engine = $voice_id ]] && engine="standard" || engine="neural" + aws_polly "$text" "$file" "$voice_id" "$engine" "$cache_file" fi diff --git a/components/freeswitch/bin/export_aws_polly_voices b/components/freeswitch/bin/export_aws_polly_voices deleted file mode 100755 index 523055201..000000000 --- a/components/freeswitch/bin/export_aws_polly_voices +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -# Modified from: -# https://starkandwayne.com/blog/bash-for-loop-over-json-array-using-jq/ - -output_file="$1" - -export_polly_voices () -{ - local voices_content=() - voices_content+=("") - - for row in $(aws polly describe-voices --query 'Voices' | jq -r '.[] | @base64'); do - _jq() { - echo ${row} | base64 --decode | jq -r ${1} - } - - local name="Polly.$(_jq '.Id')" - local language_code="$(_jq '.LanguageCode')" - local gender="$(_jq '.Gender')" - - voices_content+=("") - done - - printf "%s\n" "${voices_content[@]}" > $output_file -} - -export_polly_voices diff --git a/components/freeswitch/bin/export_tts_voices b/components/freeswitch/bin/export_tts_voices new file mode 100755 index 000000000..1dfc126c9 --- /dev/null +++ b/components/freeswitch/bin/export_tts_voices @@ -0,0 +1,48 @@ +#!/usr/bin/env ruby + +require "bundler/inline" + +gemfile do + source "https://rubygems.org" + + gem "tts_voices", github: "somleng/tts_voices" + gem "ox" + gem "gyoku" +end + +class Exporter + attr_reader :tts_voices + + def initialize(tts_voices) + @tts_voices = tts_voices + end + + def result + Gyoku.xml("voice/" => voice_tags) + end + + private + + def voice_tags + tts_voices.each_with_object([]) do |tts_voice, result| + result << { + "@name": tts_voice.identifier, + "@language": tts_voice.language, + "@gender": tts_voice.gender, + "@prefix": prefix(tts_voice) + } + end + end + + def prefix(tts_voice) + if tts_voice.provider == "Basic" + "tts://flite|#{tts_voice.name.downcase}|" + else + "tts://tts_commandline|#{tts_voice.identifier}|" + end + end +end + +tts_voices = ENV["TTS_VOICES"] == "basic" ? TTSVoices::Voice.basic : TTSVoices::Voice.all + +puts Exporter.new(tts_voices).result diff --git a/components/freeswitch/conf/autoload_configs/polly_voices.xml b/components/freeswitch/conf/autoload_configs/polly_voices.xml deleted file mode 100644 index 4402ebe40..000000000 --- a/components/freeswitch/conf/autoload_configs/polly_voices.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/components/freeswitch/conf/autoload_configs/ssml.conf.xml b/components/freeswitch/conf/autoload_configs/ssml.conf.xml index ffe1f1fb9..30948584f 100644 --- a/components/freeswitch/conf/autoload_configs/ssml.conf.xml +++ b/components/freeswitch/conf/autoload_configs/ssml.conf.xml @@ -3,9 +3,7 @@ - - - + diff --git a/components/freeswitch/conf/autoload_configs/tts_voices.xml b/components/freeswitch/conf/autoload_configs/tts_voices.xml new file mode 100644 index 000000000..d91412720 --- /dev/null +++ b/components/freeswitch/conf/autoload_configs/tts_voices.xml @@ -0,0 +1,3 @@ + + + diff --git a/components/freeswitch/tests/bin/export_tts_voices_test.rb b/components/freeswitch/tests/bin/export_tts_voices_test.rb new file mode 100755 index 000000000..0d9a61d54 --- /dev/null +++ b/components/freeswitch/tests/bin/export_tts_voices_test.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby + +require "test/unit" +require "pathname" +require "open3" + +class ExporterTest < Test::Unit::TestCase + def test_export + script = File.expand_path(File.join(__dir__, "../../bin/export_tts_voices")) + stdout, status = Open3.capture2({ "TTS_VOICES" => "basic" }, script) + + assert(status.success?) + assert(stdout.include?('')) + assert(stdout.include?('')) + end +end diff --git a/components/freeswitch/tests/tests.sh b/components/freeswitch/tests/tests.sh index 584c363f8..3ca487f48 100755 --- a/components/freeswitch/tests/tests.sh +++ b/components/freeswitch/tests/tests.sh @@ -9,6 +9,6 @@ echo "Running tests..." current_dir=$(dirname "$(readlink -f "$0")") -for f in $current_dir/bin/*.sh; do - sh "$f" +for f in $current_dir/bin/*; do + $f done diff --git a/components/gateway/Dockerfile b/components/gateway/Dockerfile index 4567fb8c7..4e01de624 100644 --- a/components/gateway/Dockerfile +++ b/components/gateway/Dockerfile @@ -1,4 +1,6 @@ -FROM debian:bullseye-slim AS bootstrap +# https://apt.opensips.org/packages.php?os=bookworm + +FROM public.ecr.aws/debian/debian:bookworm-slim AS bootstrap USER root @@ -7,13 +9,13 @@ ENV DEBIAN_FRONTEND noninteractive ARG OPENSIPS_VERSION=3.3 ARG OPENSIPS_BUILD=releases -RUN apt-get -y update -qq && apt-get -y install gnupg2 ca-certificates -RUN apt-key adv --fetch-keys https://apt.opensips.org/pubkey.gpg -RUN echo "deb https://apt.opensips.org bullseye ${OPENSIPS_VERSION}-${OPENSIPS_BUILD}" > /etc/apt/sources.list.d/opensips.list -RUN echo "deb https://apt.opensips.org bullseye cli-nightly" > /etc/apt/sources.list.d/opensips-cli.list -RUN apt-get -y update -qq && apt-get -y install opensips opensips-postgres-module opensips-cli python3-psycopg2 m4 - -RUN rm -rf /var/lib/apt/lists/* +RUN apt-get -y update -qq && apt-get -y install gnupg2 ca-certificates curl && \ + curl https://apt.opensips.org/opensips-org.gpg -o /usr/share/keyrings/opensips-org.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/opensips-org.gpg] https://apt.opensips.org bookworm ${OPENSIPS_VERSION}-${OPENSIPS_BUILD}" >/etc/apt/sources.list.d/opensips.list && \ + echo "deb [signed-by=/usr/share/keyrings/opensips-org.gpg] https://apt.opensips.org bookworm cli-nightly" >/etc/apt/sources.list.d/opensips-cli.list && \ + apt-get -y update -qq && apt-get -y install opensips opensips-postgres-module opensips-cli python3-psycopg2 python3-sqlalchemy python3-sqlalchemy-utils && \ + apt-get purge -y --auto-remove curl && \ + rm -rf /var/lib/apt/lists/* COPY bootstrap.sh /docker-entrypoint.sh @@ -28,7 +30,7 @@ CMD ["create_db"] # Build -FROM debian:bullseye-slim AS build +FROM public.ecr.aws/debian/debian:bookworm-slim AS build USER root @@ -37,12 +39,11 @@ ENV DEBIAN_FRONTEND noninteractive ARG OPENSIPS_VERSION=3.3 ARG OPENSIPS_BUILD=releases -RUN apt-get -y update -qq && apt-get -y install gnupg2 ca-certificates iproute2 -RUN apt-key adv --fetch-keys https://apt.opensips.org/pubkey.gpg -RUN echo "deb https://apt.opensips.org bullseye ${OPENSIPS_VERSION}-${OPENSIPS_BUILD}" >/etc/apt/sources.list.d/opensips.list -RUN apt-get -y update -qq && apt-get -y install opensips opensips-postgres-module opensips-auth-modules netcat jq curl - -RUN rm -rf /var/lib/apt/lists/* +RUN apt-get -y update -qq && apt-get -y install gnupg2 ca-certificates iproute2 curl netcat-traditional jq && \ + curl https://apt.opensips.org/opensips-org.gpg -o /usr/share/keyrings/opensips-org.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/opensips-org.gpg] https://apt.opensips.org bookworm ${OPENSIPS_VERSION}-${OPENSIPS_BUILD}" >/etc/apt/sources.list.d/opensips.list && \ + apt-get -y update -qq && apt-get -y install opensips opensips-postgres-module opensips-auth-modules && \ + rm -rf /var/lib/apt/lists/* ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["opensips"] @@ -60,4 +61,3 @@ FROM build AS client_gateway COPY client_gateway/opensips.cfg /etc/opensips/opensips.cfg COPY client_gateway/docker-entrypoint.sh /docker-entrypoint.sh - diff --git a/components/gateway/bootstrap.sh b/components/gateway/bootstrap.sh index 50330507d..be515ffcc 100755 --- a/components/gateway/bootstrap.sh +++ b/components/gateway/bootstrap.sh @@ -6,8 +6,8 @@ set -e -ADMIN_DATABASE_URL="postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT/postgres" -DATABASE_URL="postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" +ADMIN_DATABASE_URL="postgresql://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT/postgres" +DATABASE_URL="postgresql://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" if [ "$1" = 'create_db' ]; then if [ "$2" = 'public_gateway' ]; then diff --git a/components/testing/Dockerfile b/components/testing/Dockerfile index 031264c77..b81c07645 100644 --- a/components/testing/Dockerfile +++ b/components/testing/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest +FROM public.ecr.aws/docker/library/alpine:latest RUN apk update && apk upgrade && apk add --update --no-cache sipp curl postgresql-client bind-tools diff --git a/components/testing/tests/client_gateway/outbound_proxy_test.sh b/components/testing/tests/client_gateway/outbound_proxy_test.sh index 1cf65d614..85a71409b 100755 --- a/components/testing/tests/client_gateway/outbound_proxy_test.sh +++ b/components/testing/tests/client_gateway/outbound_proxy_test.sh @@ -31,6 +31,7 @@ curl -s -o /dev/null -XPOST -u "adhearsion:password" http://switch-app:8080/call "account_auth_token": "sample-auth-token", "direction": "outbound-api", "api_version": "2010-04-01", + "default_tts_voice": "Basic.Kal", "routing_parameters": { "destination": "85512334667", "dial_string_prefix": null, diff --git a/components/testing/tests/client_gateway/outbound_uas_behind_nat_test.sh b/components/testing/tests/client_gateway/outbound_uas_behind_nat_test.sh index 40cc6e8e5..1bf33e789 100755 --- a/components/testing/tests/client_gateway/outbound_uas_behind_nat_test.sh +++ b/components/testing/tests/client_gateway/outbound_uas_behind_nat_test.sh @@ -30,6 +30,7 @@ curl -s -o /dev/null -XPOST -u "adhearsion:password" http://switch-app:8080/call "account_auth_token": "sample-auth-token", "direction": "outbound-api", "api_version": "2010-04-01", + "default_tts_voice": "Basic.Kal", "routing_parameters": { "destination": "85512334667", "dial_string_prefix": null, diff --git a/components/testing/tests/public_gateway/alternative_outbound_test.sh b/components/testing/tests/public_gateway/alternative_outbound_test.sh index 0422c4c6a..8c0a2763a 100755 --- a/components/testing/tests/public_gateway/alternative_outbound_test.sh +++ b/components/testing/tests/public_gateway/alternative_outbound_test.sh @@ -22,6 +22,7 @@ curl -s -o /dev/null -XPOST -u "adhearsion:password" http://switch-app:8080/call "account_auth_token": "sample-auth-token", "direction": "outbound-api", "api_version": "2010-04-01", + "default_tts_voice": "Basic.Kal", "routing_parameters": { "destination": "85512334667", "dial_string_prefix": null, diff --git a/components/testing/tests/public_gateway/outbound_test.sh b/components/testing/tests/public_gateway/outbound_test.sh index a3434d55c..7a81a338c 100755 --- a/components/testing/tests/public_gateway/outbound_test.sh +++ b/components/testing/tests/public_gateway/outbound_test.sh @@ -24,6 +24,7 @@ curl -s -o /dev/null -XPOST -u "adhearsion:password" http://switch-app:8080/call "account_auth_token": "sample-auth-token", "direction": "outbound-api", "api_version": "2010-04-01", + "default_tts_voice": "Basic.Kal", "routing_parameters": { "destination": "85512334667", "dial_string_prefix": null, diff --git a/docker-compose.yml b/docker-compose.yml index 6d07140bf..ebfcc19a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: context: components/gateway target: bootstrap image: gateway:bootstrap + platform: linux/amd64 environment: DATABASE_USERNAME: "postgres" DATABASE_PASSWORD: @@ -30,6 +31,7 @@ services: context: components/gateway target: public_gateway image: public_gateway:latest + platform: linux/amd64 environment: DATABASE_URL: "postgres://postgres:@host.docker.internal:5432/opensips_public_gateway_test" FIFO_NAME: /opensips/fifo/public_gateway @@ -51,6 +53,7 @@ services: context: components/gateway target: client_gateway image: client_gateway:latest + platform: linux/amd64 environment: DATABASE_URL: "postgres://postgres:@host.docker.internal:5432/opensips_client_gateway_test" FIFO_NAME: /opensips/fifo/client_gateway @@ -70,6 +73,7 @@ services: build: context: components/media_proxy image: media_proxy:latest + platform: linux/amd64 extra_hosts: - "host.docker.internal:host-gateway" healthcheck: @@ -84,6 +88,7 @@ services: build: context: components/freeswitch image: freeswitch:latest + platform: linux/amd64 environment: FS_EXTERNAL_RTP_IP: "13.250.230.15" FS_ALTERNATIVE_RTP_IP: "18.141.245.230" @@ -103,6 +108,7 @@ services: build: context: components/app image: switch-app:latest + platform: linux/amd64 environment: AHN_CORE_HOST: freeswitch CALL_PLATFORM_STUB_RESPONSES: "true" diff --git a/infrastructure/modules/somleng_switch/switch.tf b/infrastructure/modules/somleng_switch/switch.tf index 019b91870..754422ffb 100644 --- a/infrastructure/modules/somleng_switch/switch.tf +++ b/infrastructure/modules/somleng_switch/switch.tf @@ -279,6 +279,7 @@ resource "aws_iam_policy" "ecs_task_policy" { { "Effect": "Allow", "Action": [ + "polly:DescribeVoices", "polly:SynthesizeSpeech" ], "Resource": "*"