diff --git a/lib/signs/bus.ex b/lib/signs/bus.ex index a87a75e77..d7a09e6fe 100644 --- a/lib/signs/bus.ex +++ b/lib/signs/bus.ex @@ -121,6 +121,10 @@ defmodule Signs.Bus do {:ok, state} end + @type content_values :: + {messages :: [Content.Message.value()], audios :: [Content.Audio.value()], + tts_audios :: [Content.Audio.tts_value()]} + @impl true def handle_info(:run_loop, state) do Process.send_after(self(), :run_loop, 1000) @@ -165,10 +169,10 @@ defmodule Signs.Bus do |> Enum.group_by(&{&1.stop_id, &1.route_id, &1.direction_id}) # Compute new sign text and audio - {[top, bottom], audios} = + {[top, bottom], audios, tts_audios} = cond do config == :off -> - {["", ""], []} + {_messages = ["", ""], _audios = [], _tts_audios = []} match?({:static_text, _}, config) -> static_text_content( @@ -225,12 +229,16 @@ defmodule Signs.Bus do end) |> then(fn state -> if should_read?(current_time, state) do - send_audio(audios, state) + send_audio(audios, tts_audios, state) %{state | last_read_time: current_time} else if should_announce_drawbridge?(bridge_status, bridge_enabled?, current_time, state) do - bridge_audio(bridge_status, bridge_enabled?, current_time, state) - |> send_audio(state) + bridge_audios = bridge_audio(bridge_status, bridge_enabled?, current_time, state) + + bridge_tts_audios = + bridge_tts_audio(bridge_status, bridge_enabled?, current_time, state) + + send_audio(bridge_audios, bridge_tts_audios, state) end state @@ -292,6 +300,15 @@ defmodule Signs.Bus do end # Static text mode. Just display the configured text, and possibly the bridge message. + @spec static_text_content( + Engine.Config.sign_config(), + term(), + boolean(), + DateTime.t(), + map(), + map(), + t() + ) :: content_values() defp static_text_content( config, bridge_status, @@ -321,11 +338,15 @@ defmodule Signs.Bus do [{:ad_hoc, {"#{line1} #{line2}", :audio}}] |> Enum.concat(bridge_audio(bridge_status, bridge_enabled?, current_time, state)) - {messages, audios} + tts_audios = [{"#{line1} #{line2}", nil}] + + {messages, audios, tts_audios} end # Platform mode. Display one prediction per route, but if all the predictions are for the # same route, then show a single page of two. + @spec platform_mode_content(map(), map(), map(), DateTime.t(), term(), boolean(), t()) :: + content_values() defp platform_mode_content( predictions_lookup, route_alerts_lookup, @@ -390,11 +411,30 @@ defmodule Signs.Bus do |> paginate_audio() |> Enum.concat(bridge_audio(bridge_status, bridge_enabled?, current_time, state)) - {messages, audios} + tts_audios = + case audio_content do + [] -> + [] + + [single] -> + [long_message_tts_audio(single, current_time, state)] + + list -> + Enum.map(list, &message_tts_audio(&1, current_time, state)) + |> Enum.join(" ") + |> add_tts_preamble() + |> List.wrap() + end + |> Enum.map(&{&1, nil}) + |> Enum.concat(bridge_tts_audio(bridge_status, bridge_enabled?, current_time, state)) + + {messages, audios, tts_audios} end end # Mezzanine mode. Display and paginate each line separately. + @spec mezzanine_mode_content(map(), map(), map(), DateTime.t(), term(), boolean(), t()) :: + content_values() defp mezzanine_mode_content( predictions_lookup, route_alerts_lookup, @@ -429,20 +469,38 @@ defmodule Signs.Bus do |> paginate_audio() |> Enum.concat(bridge_audio(bridge_status, bridge_enabled?, current_time, state)) - {messages, audios} + tts_audios = + case top_content ++ bottom_content do + [] -> + [] + + list -> + Enum.map(list, &message_tts_audio(&1, current_time, state)) + |> Enum.join(" ") + |> add_tts_preamble() + |> List.wrap() + end + |> Enum.map(&{&1, nil}) + |> Enum.concat(bridge_tts_audio(bridge_status, bridge_enabled?, current_time, state)) + + {messages, audios, tts_audios} end end + @spec special_harvard_content() :: content_values() defp special_harvard_content() do messages = ["Board routes 71", "and 73 on upper level"] audios = paginate_audio([:board_routes_71_and_73_on_upper_level]) - {messages, audios} + tts_audios = [{"Board routes 71 and 73 on upper level", nil}] + {messages, audios, tts_audios} end + @spec no_service_content() :: content_values() defp no_service_content do messages = paginate_pairs([["No bus service", ""]]) audios = paginate_audio([:no_bus_service]) - {messages, audios} + tts_audios = [{"No bus service", nil}] + {messages, audios, tts_audios} end defp configs_content(nil, _, _), do: [] @@ -612,6 +670,36 @@ defmodule Signs.Bus do end end + defp bridge_tts_audio(bridge_status, bridge_enabled?, current_time, state) do + %{chelsea_bridge: chelsea_bridge} = state + + if bridge_enabled? && chelsea_bridge && + bridge_status_raised?(bridge_status, current_time) do + {duration, duration_spanish} = + case bridge_status_minutes(bridge_status, current_time) do + minutes when minutes < 2 -> + {"We expect it to be lowered soon.", "Esperamos que se baje pronto."} + + minutes -> + {"We expect this to last for at least #{minutes} more minutes.", + "Esperamos que esto dure al menos #{minutes} minutos más."} + end + + english_text = + "The Chelsea Street bridge is raised. #{duration} SL3 buses may be delayed, detoured, or turned back." + + spanish_text = + "El puente de Chelsea Street está levantado. #{duration_spanish} Los autobuses SL3 pueden sufrir retrasos, desvíos o dar marcha atrás." + + [ + {english_text, PaEss.Utilities.paginate_text(english_text)}, + {spanish_text, PaEss.Utilities.paginate_text(spanish_text)} + ] + else + [] + end + end + defp format_message(nil, _, _, _state), do: "" defp format_message({:predictions, [first | _]}, other, current_time, _state) do @@ -742,6 +830,8 @@ defmodule Signs.Bus do defp add_preamble([]), do: [] defp add_preamble(items), do: [[:upcoming_departures] | items] + defp add_tts_preamble(str), do: "Upcoming departures: " <> str + # Returns a list of audio tokens describing the given message. defp message_audio({:predictions, [prediction | _]}, current_time, _state) do route = @@ -774,6 +864,34 @@ defmodule Signs.Bus do route ++ [{:headsign, headsign}, :no_service] end + defp message_tts_audio({:predictions, [prediction | _]}, current_time, _state) do + route = + case PaEss.Utilities.prediction_route_name(prediction) do + nil -> "" + name -> "Route #{name}, " + end + + time = + case prediction_minutes(prediction, current_time) do + 0 -> "arriving" + 1 -> "1 minute" + m -> "#{m} minutes" + end + + "#{route}#{prediction.headsign}, #{time}." + end + + defp message_tts_audio({:alert, config}, _, state) do + route = + case config_route_name(config) do + nil -> "" + name -> "Route #{name}, " + end + + headsign = config_headsign(config, state) + "#{route}#{headsign}, no service." + end + # Returns a list of audio tokens representing the special "long form" description of # the given prediction. defp long_message_audio({:predictions, predictions}, current_time, _state) do @@ -812,6 +930,38 @@ defmodule Signs.Bus do [Enum.concat([[:there_is_no], route, [:bus_service_to, {:headsign, headsign}]])] end + defp long_message_tts_audio({:predictions, predictions}, current_time, _state) do + Stream.take(predictions, 2) + |> Enum.zip_with(["next", "following"], fn prediction, next_or_following -> + route = + case PaEss.Utilities.prediction_route_name(prediction) do + nil -> "" + name -> "route #{name} " + end + + time = + case prediction_minutes(prediction, current_time) do + 0 -> "is now arriving" + 1 -> "arrives in 1 minute" + m -> "arrives in #{m} minutes" + end + + "The #{next_or_following} #{route}bus to #{prediction.headsign} #{time}." + end) + |> Enum.join(" ") + end + + defp long_message_tts_audio({:alert, config}, _, state) do + route = + case config_route_name(config) do + nil -> "" + name -> "route #{name} " + end + + headsign = config_headsign(config, state) + "There is no #{route}bus service to #{headsign}." + end + # Turns a list of audio tokens into a list of audio messages, chunking as needed to stay under # the max var limit. defp paginate_audio(items) do @@ -828,15 +978,14 @@ defmodule Signs.Bus do end) end - defp send_audio(audios, state) do + defp send_audio(audios, tts_audios, state) do %{audio_zones: audio_zones, sign_updater: sign_updater} = state if audios != [] && audio_zones != [] do sign_updater.play_message( state, audios, - # TODO: Implement TTS for bus audio - [], + tts_audios, Enum.map(audios, fn _ -> [message_type: "Bus"] end) ) end diff --git a/test/signs/bus_test.exs b/test/signs/bus_test.exs index 5507f8a02..404a1a68f 100644 --- a/test/signs/bus_test.exs +++ b/test/signs/bus_test.exs @@ -127,29 +127,35 @@ defmodule Signs.BusTest do test "platform mode, top two" do expect_messages(["14 WakfldAv 2 min", "14 WakfldAv 11 min"]) - expect_audios([ - {:canned, - {"119", - [ - "501", - "575", - "859", - "621", - "503", - "504", - "5502", - "505", - "21012", - "667", - "575", - "859", - "621", - "503", - "504", - "5511", - "505" - ], :audio}} - ]) + expect_audios( + [ + {:canned, + {"119", + [ + "501", + "575", + "859", + "621", + "503", + "504", + "5502", + "505", + "21012", + "667", + "575", + "859", + "621", + "503", + "504", + "5511", + "505" + ], :audio}} + ], + [ + {"The next route 14 bus to Wakefield Ave arrives in 2 minutes. The following route 14 bus to Wakefield Ave arrives in 11 minutes.", + nil} + ] + ) state = Map.merge(@sign_state, %{ @@ -165,27 +171,33 @@ defmodule Signs.BusTest do [{"Chelsea 4 min", 6}, {"", 6}] ]) - expect_audios([ - {:canned, - {"117", - [ - "548", - "21012", - "575", - "621", - "5502", - "505", - "21012", - "860", - "5504", - "505", - "21012", - "678", - "605", - "5507", - "505" - ], :audio}} - ]) + expect_audios( + [ + {:canned, + {"117", + [ + "548", + "21012", + "575", + "621", + "5502", + "505", + "21012", + "860", + "5504", + "505", + "21012", + "678", + "605", + "5507", + "505" + ], :audio}} + ], + [ + {"Upcoming departures: Route 14, Wakefield Ave, 2 minutes. Chelsea, 4 minutes. Route 34, Clarendon Hill, 7 minutes.", + nil} + ] + ) state = Map.merge(@sign_state, %{ @@ -202,12 +214,18 @@ defmodule Signs.BusTest do test "mezzanine mode" do expect_messages(["SL5 Nubian 8 min", "14 WakefldAv 2 min"]) - expect_audios([ - {:canned, - {"113", - ["548", "21012", "587", "812", "5508", "505", "21012", "575", "621", "5502", "505"], - :audio}} - ]) + expect_audios( + [ + {:canned, + {"113", + ["548", "21012", "587", "812", "5508", "505", "21012", "575", "621", "5502", "505"], + :audio}} + ], + [ + {"Upcoming departures: Route SL5, Nubian, 8 minutes. Route 14, Wakefield Ave, 2 minutes.", + nil} + ] + ) state = Map.merge(@sign_state, %{ @@ -220,7 +238,7 @@ defmodule Signs.BusTest do test "static mode" do expect_messages(["custom", "message"]) - expect_audios([{:ad_hoc, {"custom message", :audio}}]) + expect_audios([{:ad_hoc, {"custom message", :audio}}], [{"custom message", nil}]) state = Map.merge(@sign_state, %{id: "static_sign"}) @@ -245,31 +263,51 @@ defmodule Signs.BusTest do [{"14 WakfldAv 11 min", 6}, {"SL3 delays 4 more min", 6}] ]) - expect_audios([ - {:canned, - {"119", - [ - "501", - "575", - "859", - "621", - "503", - "504", - "5502", - "505", - "21012", - "667", - "575", - "859", - "621", - "503", - "504", - "5511", - "505" - ], :audio}}, - {:canned, {"135", ["5504"], :audio_visual}}, - {:canned, {"152", ["37004"], :audio_visual}} - ]) + expect_audios( + [ + {:canned, + {"119", + [ + "501", + "575", + "859", + "621", + "503", + "504", + "5502", + "505", + "21012", + "667", + "575", + "859", + "621", + "503", + "504", + "5511", + "505" + ], :audio}}, + {:canned, {"135", ["5504"], :audio_visual}}, + {:canned, {"152", ["37004"], :audio_visual}} + ], + [ + {"The next route 14 bus to Wakefield Ave arrives in 2 minutes. The following route 14 bus to Wakefield Ave arrives in 11 minutes.", + nil}, + {"The Chelsea Street bridge is raised. We expect this to last for at least 4 more minutes. SL3 buses may be delayed, detoured, or turned back.", + [ + {"The Chelsea Street", "bridge is raised. We", 3}, + {"expect this to last for", "at least 4 more minutes.", 3}, + {"SL3 buses may be", "delayed, detoured, or", 3}, + {"turned back.", "", 3} + ]}, + {"El puente de Chelsea Street está levantado. Esperamos que esto dure al menos 4 minutos más. Los autobuses SL3 pueden sufrir retrasos, desvíos o dar marcha atrás.", + [ + {"El puente de Chelsea", "Street está levantado.", 3}, + {"Esperamos que esto dure", "al menos 4 minutos más.", 3}, + {"Los autobuses SL3 pueden", "sufrir retrasos, desvíos", 3}, + {"o dar marcha atrás.", "", 3} + ]} + ] + ) state = Map.merge(@sign_state, %{ @@ -282,10 +320,28 @@ defmodule Signs.BusTest do end test "standalone bridge announcement" do - expect_audios([ - {:canned, {"135", ["5504"], :audio_visual}}, - {:canned, {"152", ["37004"], :audio_visual}} - ]) + expect_audios( + [ + {:canned, {"135", ["5504"], :audio_visual}}, + {:canned, {"152", ["37004"], :audio_visual}} + ], + [ + {"The Chelsea Street bridge is raised. We expect this to last for at least 4 more minutes. SL3 buses may be delayed, detoured, or turned back.", + [ + {"The Chelsea Street", "bridge is raised. We", 3}, + {"expect this to last for", "at least 4 more minutes.", 3}, + {"SL3 buses may be", "delayed, detoured, or", 3}, + {"turned back.", "", 3} + ]}, + {"El puente de Chelsea Street está levantado. Esperamos que esto dure al menos 4 minutos más. Los autobuses SL3 pueden sufrir retrasos, desvíos o dar marcha atrás.", + [ + {"El puente de Chelsea", "Street está levantado.", 3}, + {"Esperamos que esto dure", "al menos 4 minutos más.", 3}, + {"Los autobuses SL3 pueden", "sufrir retrasos, desvíos", 3}, + {"o dar marcha atrás.", "", 3} + ]} + ] + ) state = Map.merge(@sign_state, %{ @@ -303,7 +359,7 @@ defmodule Signs.BusTest do test "no service alert" do expect_messages(["No bus service", ""]) - expect_audios([{:canned, {"103", ["878"], :audio}}]) + expect_audios([{:canned, {"103", ["878"], :audio}}], [{"No bus service", nil}]) state = Map.merge(@sign_state, %{ @@ -316,11 +372,17 @@ defmodule Signs.BusTest do test "route alert on multi-route sign" do expect_messages(["14 WakefldAv 2 min", "51 Resrvoir no svc"]) - expect_audios([ - {:canned, - {"112", ["548", "21012", "575", "621", "5502", "505", "21012", "687", "4076", "879"], - :audio}} - ]) + expect_audios( + [ + {:canned, + {"112", ["548", "21012", "575", "621", "5502", "505", "21012", "687", "4076", "879"], + :audio}} + ], + [ + {"Upcoming departures: Route 14, Wakefield Ave, 2 minutes. Route 51, Reservoir Station, no service.", + nil} + ] + ) state = %{ @sign_state @@ -336,7 +398,9 @@ defmodule Signs.BusTest do test "route alert on single-route sign" do expect_messages(["51 Resrvoir no svc", ""]) - expect_audios([{:canned, {"106", ["880", "687", "877", "4076"], :audio}}]) + expect_audios([{:canned, {"106", ["880", "687", "877", "4076"], :audio}}], [ + {"There is no route 51 bus service to Reservoir Station.", nil} + ]) state = %{ @sign_state @@ -354,9 +418,10 @@ defmodule Signs.BusTest do end) end - defp expect_audios(audios) do - expect(PaEss.Updater.Mock, :play_message, fn _, list, _, _ -> + defp expect_audios(audios, tts_audios) do + expect(PaEss.Updater.Mock, :play_message, fn _, list, tts_list, _ -> assert list == audios + assert tts_list == tts_audios :ok end) end