From 831cda541d7f490b77302b9dd3235d49979ec46c Mon Sep 17 00:00:00 2001 From: Christian Maddox Date: Mon, 1 Jul 2024 12:59:38 -0400 Subject: [PATCH] feat: Preview Audio (#372) * Added module for requesting audio from Watts. * Added preview_audio endpoint. * Added SSML tags that Watts expects. * Added required filename. * Added API fetch for audio previews. * Updated labels when audio is playing. * Fixed position of Review audio buttons. * Improved styling on review audio button. * Added a reviewed state. * Added an error toast when audio preview can't play. * Cleaned up ReviewAudioButton. * Reset audioReviewed when user edits either text box. * fetch_env instead of get_env. * Added doc and typespec. * Added function to help build post requests. * Added function to help build post requests. * Changed audio to stop playing when user changes text. * Added an enum to better track audio states. * Added a text limit to textboxes. * Renamed enum values. * Added a better default for Watts ENV. * Noted VPN requirement. --- .envrc.template | 7 + .../new-pa-message-page.scss | 87 ++++++++- .../NewPaMessagePage/MessageTextBox.tsx | 11 +- .../NewPaMessagePage/NewPaMessagePage.tsx | 176 ++++++++++++++++-- assets/js/utils/api.ts | 40 ++-- config/runtime.exs | 4 +- lib/screenplay/watts.ex | 43 +++++ .../controllers/pa_messages_api_controller.ex | 10 + lib/screenplay_web/router.ex | 1 + 9 files changed, 329 insertions(+), 50 deletions(-) create mode 100644 lib/screenplay/watts.ex diff --git a/.envrc.template b/.envrc.template index e802ad29..7765285c 100644 --- a/.envrc.template +++ b/.envrc.template @@ -26,3 +26,10 @@ export DATABASE_HOSTNAME="localhost" # Access key for allowing PA Message API calls. Required for endpoints in the /api/pa-messages scope. Generate a unique random string for local use. export SCREENPLAY_API_KEY="local_api_key" + +# Host and access key needed to communicate with Watts. Required when previewing PA message audio. +# For local use, set to the same value as your local instance of Watts. +# VPN is required for requests to watts-staging. +#export WATTS_URL="https://watts-staging.mbtace.com" +# API Key is in the Screens 1Password vault under `Watts Staging API Key` +#export WATTS_API_KEY="" diff --git a/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss b/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss index 6e96a9f1..77634483 100644 --- a/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss +++ b/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss @@ -147,13 +147,17 @@ } .message-card { - .copy-text-button { - background-color: $bg-border-disabled; - width: 38px; - height: 38px; + .copy-button-col { + margin-top: 141px; + + .copy-text-button { + background-color: $bg-border-disabled; + width: 38px; + height: 38px; - .bi-arrow-right-short { - fill: $text-disabled; + .bi-arrow-right-short { + fill: $text-disabled; + } } } @@ -162,15 +166,78 @@ resize: none; } + .audio-reviewed-text { + color: $text-primary; + margin-top: auto; + margin-bottom: auto; + align-self: center; + + span { + vertical-align: middle; + } + } + + .review-audio-button { + color: $button-primary; + margin-top: auto; + margin-bottom: auto; + margin-left: 16px; + padding: 0; + + .bi { + margin-right: 8px; + } + + &--audio-playing { + color: $text-primary; + text-decoration: none; + } + + &:disabled { + color: $text-secondary; + text-decoration: none; + } + } + .review-audio-card { height: 250px; background-color: $cool-gray-15; + } + } + + .error-alert-container { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 40px; + z-index: 1001; + } - .review-audio-button { - color: $button-primary; - margin-top: auto; - margin-bottom: auto; + .error-alert { + width: 400px; + background-color: #ffdd00; + font-size: 16px; + display: flex; + align-items: center; + + .btn-close { + &:hover { + background-color: transparent; } } + + &__icon { + height: 24px; + width: 24px; + opacity: 60%; + flex-shrink: 0; + } + + &__text { + padding-left: 8px; + } } } diff --git a/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/MessageTextBox.tsx b/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/MessageTextBox.tsx index 6a31fd51..2e138c8b 100644 --- a/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/MessageTextBox.tsx +++ b/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/MessageTextBox.tsx @@ -7,15 +7,24 @@ interface Props { disabled?: boolean; label: string; id: string; + maxLength: number; } -const MessageTextBox = ({ text, onChangeText, disabled, label, id }: Props) => { +const MessageTextBox = ({ + text, + onChangeText, + disabled, + label, + id, + maxLength, +}: Props) => { return ( {label} onChangeText(textbox.target.value)} diff --git a/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/NewPaMessagePage.tsx b/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/NewPaMessagePage.tsx index 7516c59c..fc908dc8 100644 --- a/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/NewPaMessagePage.tsx +++ b/assets/js/components/Dashboard/PaMessaging/NewPaMessagePage/NewPaMessagePage.tsx @@ -1,5 +1,14 @@ +/* eslint-disable jsx-a11y/media-has-caption */ import React, { useState } from "react"; -import { Button, Card, Col, Container, Form, Row } from "react-bootstrap"; +import { + Alert, + Button, + Card, + Col, + Container, + Form, + Row, +} from "react-bootstrap"; import DaysPicker from "./DaysPicker"; import PriorityPicker from "./PriorityPicker"; import IntervalPicker from "./IntervalPicker"; @@ -8,7 +17,23 @@ import { useNavigate } from "react-router-dom"; import moment from "moment"; import DatePicker from "../../DatePicker"; import TimePicker from "../../TimePicker"; -import { ArrowRightShort, PlusLg, VolumeUpFill } from "react-bootstrap-icons"; +import { + ArrowRightShort, + CheckCircleFill, + ExclamationTriangleFill, + PlusLg, + VolumeUpFill, +} from "react-bootstrap-icons"; +import cx from "classnames"; + +const MAX_TEXT_LENGTH = 2000; + +enum AudioPreview { + Unreviewed, + Playing, + Reviewed, + Outdated, +} const NewPaMessagePage = () => { const now = moment(); @@ -22,9 +47,35 @@ const NewPaMessagePage = () => { const [interval, setInterval] = useState("4"); const [visualText, setVisualText] = useState(""); const [phoneticText, setPhoneticText] = useState(""); + const [audioState, setAudioState] = useState( + AudioPreview.Unreviewed, + ); + const [errorMessage, setErrorMessage] = useState(""); const navigate = useNavigate(); + const previewAudio = () => { + if (audioState === AudioPreview.Playing) return; + + if (phoneticText.length === 0) { + setPhoneticText(visualText); + } + + setAudioState(AudioPreview.Playing); + }; + + // Called after the audio preview plays in full + const onAudioEnded = () => { + setAudioState(AudioPreview.Reviewed); + }; + + const onAudioError = () => { + setAudioState(AudioPreview.Outdated); + setErrorMessage( + "Error occurred while fetching audio preview. Please try again.", + ); + }; + return (
{
Message
- + { + setVisualText(text); + if (audioState !== AudioPreview.Unreviewed) { + setAudioState(AudioPreview.Outdated); + } + }} label="Text" + maxLength={MAX_TEXT_LENGTH} /> - + -
+ <> +
Phonetic Audio
+ + + + + )} + {audioState === AudioPreview.Playing && ( +
); }; +interface ReviewAudioButtonProps { + audioState: AudioPreview; + disabled?: boolean; + onClick: () => void; +} + +const ReviewAudioButton = ({ + audioState, + disabled, + onClick, +}: ReviewAudioButtonProps) => { + const audioPlaying = audioState === AudioPreview.Playing; + return audioState === AudioPreview.Reviewed ? ( +
+ + Audio reviewed + + +
+ ) : ( + + ); +}; + export default NewPaMessagePage; diff --git a/assets/js/utils/api.ts b/assets/js/utils/api.ts index 26f9a370..e57131c6 100644 --- a/assets/js/utils/api.ts +++ b/assets/js/utils/api.ts @@ -84,17 +84,12 @@ export const putPendingScreens = async ( version_id: string, ) => { return await fetch("/config/put", { - method: "POST", - headers: { - "content-type": "application/json", - "x-csrf-token": getCsrfToken(), - }, - credentials: "include", - body: JSON.stringify({ + ...getPostBodyAndHeaders({ places_and_screens: placesAndScreens, screen_type: screenType, version_id: version_id, }), + credentials: "include", }); }; @@ -104,19 +99,11 @@ export const publishScreensForPlace = async ( hiddenFromScreenplayIds: string[], etag: string, ) => { + const bodyData = { + hidden_from_screenplay_ids: hiddenFromScreenplayIds, + }; const response = await fetch(`/config/publish/${placeId}/${appId}`, { - method: "POST", - body: JSON.stringify({ - hidden_from_screenplay_ids: hiddenFromScreenplayIds, - }), - headers: { - "content-type": "application/json", - "if-match": etag, - "x-csrf-token": - document?.head?.querySelector( - "[name~=csrf-token][content]", - )?.content ?? "", - }, + ...getPostBodyAndHeaders(bodyData, { "if-match": etag }), credentials: "include", }); @@ -129,3 +116,18 @@ export const publishScreensForPlace = async ( return { status: response.status, message }; }; + +const getPostBodyAndHeaders = ( + bodyData: { [key: string]: any }, + extraHeaders: { [key: string]: string } = {}, +) => { + return { + method: "POST", + body: JSON.stringify(bodyData), + headers: { + ...extraHeaders, + "content-type": "application/json", + "x-csrf-token": getCsrfToken(), + }, + }; +}; diff --git a/config/runtime.exs b/config/runtime.exs index bc36a94f..5a9a2ca4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -51,7 +51,9 @@ config :screenplay, signs_ui_url: System.get_env("SIGNS_UI_URL"), alerts_ui_url: System.get_env("ALERTS_UI_URL"), fullstory_org_id: System.get_env("FULLSTORY_ORG_ID"), - api_key: System.fetch_env!("SCREENPLAY_API_KEY") + api_key: System.fetch_env!("SCREENPLAY_API_KEY"), + watts_url: System.get_env("WATTS_URL"), + watts_api_key: System.get_env("WATTS_API_KEY") if sentry_dsn not in [nil, ""] do config :sentry, diff --git a/lib/screenplay/watts.ex b/lib/screenplay/watts.ex new file mode 100644 index 00000000..4af0495b --- /dev/null +++ b/lib/screenplay/watts.ex @@ -0,0 +1,43 @@ +defmodule Screenplay.Watts do + @moduledoc """ + Module used to fetch TTS audio files from the Watts app. + """ + + require Logger + + @doc """ + Fetches an audio file from Watts given a string. + """ + @spec fetch_tts(String.t()) :: :error | {:ok, binary()} + def fetch_tts(text) do + watts_url = Application.fetch_env!(:screenplay, :watts_url) + watts_api_key = Application.fetch_env!(:screenplay, :watts_api_key) + request_data = Jason.encode!(%{text: "#{text}", voice_id: "Matthew"}) + + case HTTPoison.post( + "#{watts_url}/tts", + request_data, + [ + {"Content-type", "application/json"}, + {"x-api-key", watts_api_key} + ] + ) do + {:ok, %{status_code: 200, body: body}} -> + {:ok, body} + + {:ok, response} -> + log_error({:bad_response_code, response}) + + {:error, httpoison_error} -> + log_error({:http_post_error, httpoison_error}) + end + end + + defp log_error({error_type, error_data}) do + Logger.error( + "[watts_request_error] error_type=#{error_type} error_data=#{inspect(error_data)}" + ) + + :error + end +end diff --git a/lib/screenplay_web/controllers/pa_messages_api_controller.ex b/lib/screenplay_web/controllers/pa_messages_api_controller.ex index cdc97bae..b296026e 100644 --- a/lib/screenplay_web/controllers/pa_messages_api_controller.ex +++ b/lib/screenplay_web/controllers/pa_messages_api_controller.ex @@ -10,4 +10,14 @@ defmodule ScreenplayWeb.PaMessagesApiController do def active(conn, _params) do json(conn, PaMessage.get_active_messages()) end + + def preview_audio(conn, %{"text" => text}) do + case Screenplay.Watts.fetch_tts(text) do + {:ok, audio_data} -> + send_download(conn, {:binary, audio_data}, filename: "preview.mp3") + + :error -> + send_resp(conn, 500, "Could not fetch audio preview") + end + end end diff --git a/lib/screenplay_web/router.ex b/lib/screenplay_web/router.ex index a95a6eba..5ba3f5d7 100644 --- a/lib/screenplay_web/router.ex +++ b/lib/screenplay_web/router.ex @@ -88,6 +88,7 @@ defmodule ScreenplayWeb.Router do get("/pa-messages", PaMessagesController, :index) get("/pa-messages/new", PaMessagesController, :index) get("/api/pa-messages", PaMessagesApiController, :index) + get("/api/pa-messages/preview_audio", PaMessagesApiController, :preview_audio) end scope "/", ScreenplayWeb do