Resolve #94: Reintroduced TTS support. Resolve #68: Added voice selection for TTS.

This commit is contained in:
Gareth Latty
2020-01-01 18:14:10 +00:00
parent 2ab4261bf7
commit d90541fe92
18 changed files with 467 additions and 63 deletions
-1
View File
@@ -7,7 +7,6 @@ WORKDIR /md
COPY ["./package.json", "./package-lock.json", "./"]
RUN ["apk", "add", "--no-cache", "--virtual", ".gyp", "python", "make", "g++"]
RUN ["npm", "ci"]
+3 -1
View File
@@ -7,6 +7,7 @@ import Html.Attributes as HtmlA
import Json.Decode as Json
import MassiveDecks.Cast.Client as Cast
import MassiveDecks.Cast.Model as Cast
import MassiveDecks.Error.Messages as Error
import MassiveDecks.Error.Model as Error
import MassiveDecks.Error.Overlay as Overlay
import MassiveDecks.Messages exposing (..)
@@ -82,6 +83,7 @@ subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Cast.subscriptions
, Settings.subscriptions (Error.Add >> ErrorMsg) SettingsMsg
, model.page |> Pages.subscriptions
]
@@ -205,7 +207,7 @@ update msg model =
LobbyMsg lobbyMsg ->
case model.page of
Pages.Lobby lobbyModel ->
Lobby.update model.shared.key lobbyMsg lobbyModel
Lobby.update model.shared lobbyMsg lobbyModel
|> Util.modelLift (\newLobbyModel -> { model | page = Pages.Lobby newLobbyModel })
_ ->
+37 -15
View File
@@ -8,7 +8,7 @@ module MassiveDecks.Card.Parts exposing
, unsafeFromList
, view
, viewFilled
, viewSingleLine
, viewFilledString
)
import Html exposing (Html)
@@ -94,56 +94,78 @@ view parts =
viewFilled [] parts
viewSingleLine : Parts -> List (Html msg)
viewSingleLine (Parts lines) =
lines |> List.concat |> viewParts []
{-| Render the `Parts` to a string.
-}
viewFilledString : String -> List String -> Parts -> String
viewFilledString blankString play (Parts lines) =
viewLinesString blankString play lines |> String.join "\n"
{-| Render the `Parts` with slots filled with the given values.
-}
viewFilled : List String -> Parts -> List (Html msg)
viewFilled play (Parts lines) =
viewLines play lines
viewLinesHtml play lines
{- Private -}
viewLines : List String -> List Line -> List (Html msg)
viewLines play lines =
viewLines : (List String -> List Part -> a) -> List String -> List Line -> List a
viewLines renderParts play lines =
case lines of
firstLine :: restLines ->
let
slots =
firstLine |> List.filter isSlot |> List.length
in
(viewParts (List.take slots play) firstLine |> Html.p []) :: viewLines (List.drop slots play) restLines
renderParts (List.take slots play) firstLine :: viewLines renderParts (List.drop slots play) restLines
[] ->
[]
viewParts : List String -> List Part -> List (Html msg)
viewParts play parts =
viewLinesHtml : List String -> List Line -> List (Html msg)
viewLinesHtml =
viewLines (\s -> \p -> viewPartsHtml s p |> Html.p [])
viewLinesString : String -> List String -> List Line -> List String
viewLinesString blankPhrase =
viewLines (\s -> \p -> viewPartsString blankPhrase s p |> String.join "")
viewParts : (Bool -> String -> List a) -> a -> List String -> List Part -> List a
viewParts viewText emptySlot play parts =
case parts of
firstPart :: restParts ->
case firstPart of
Text string ->
viewText False string ++ viewParts play restParts
viewText False string ++ viewParts viewText emptySlot play restParts
Slot transform ->
case play of
firstPlay :: restPlay ->
viewText True (applyTransform transform firstPlay) ++ viewParts restPlay restParts
viewText True (applyTransform transform firstPlay) ++ viewParts viewText emptySlot restPlay restParts
[] ->
Html.span [ HtmlA.class "slot" ] [] :: viewParts [] restParts
emptySlot :: viewParts viewText emptySlot [] restParts
[] ->
[]
viewPartsHtml : List String -> List Part -> List (Html msg)
viewPartsHtml =
viewParts viewTextHtml (Html.span [ HtmlA.class "slot" ] [])
viewPartsString : String -> List String -> List Part -> List String
viewPartsString blankPhrase =
viewParts (\_ -> \s -> [ s ]) blankPhrase
applyTransform : Transform -> String -> String
applyTransform transform value =
case transform of
@@ -157,8 +179,8 @@ applyTransform transform value =
value
viewText : Bool -> String -> List (Html msg)
viewText slot string =
viewTextHtml : Bool -> String -> List (Html msg)
viewTextHtml slot string =
let
words =
string |> splitWords |> List.map (\word -> Html.span [] [ Html.text word ])
+49 -18
View File
@@ -15,7 +15,8 @@ import Html exposing (Html)
import Html.Attributes as HtmlA
import Html.Events as HtmlE
import MassiveDecks.Card.Call as Call
import MassiveDecks.Card.Model as Card
import MassiveDecks.Card.Model as Card exposing (Call)
import MassiveDecks.Card.Parts as Parts
import MassiveDecks.Card.Play as Play exposing (Play)
import MassiveDecks.Components as Components
import MassiveDecks.Game.Action as Action
@@ -36,6 +37,8 @@ import MassiveDecks.Pages.Lobby.Configure.Model exposing (Config)
import MassiveDecks.Pages.Lobby.Events as Events
import MassiveDecks.Pages.Lobby.Messages as Lobby
import MassiveDecks.Pages.Lobby.Model as Lobby exposing (Lobby)
import MassiveDecks.Settings.Model exposing (Settings)
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings as Strings exposing (MdString)
import MassiveDecks.Strings.Languages as Lang
import MassiveDecks.User as User exposing (User)
@@ -64,8 +67,8 @@ init game hand pick =
( { model | hand = hand, game = { game | round = round } }, cmd )
update : Msg -> Model -> ( Model, Cmd Global.Msg )
update msg model =
update : Shared -> Msg -> Model -> ( Model, Cmd Global.Msg )
update shared msg model =
let
game =
model.game
@@ -189,8 +192,15 @@ update msg model =
AdvanceRound ->
case model.completeRound of
Just _ ->
let
round =
model.game.round
tts =
speak shared (round |> Round.data |> .call) Nothing
in
( { model | completeRound = Nothing }
, Cmd.none
, tts
)
Nothing ->
@@ -340,8 +350,8 @@ viewAction shared visible =
Html.div [] (Action.actions |> List.map (Action.view shared visible))
applyGameEvent : Lobby.Auth -> Events.GameEvent -> Model -> ( Model, Cmd Global.Msg )
applyGameEvent auth gameEvent model =
applyGameEvent : Lobby.Auth -> Shared -> Events.GameEvent -> Model -> ( Model, Cmd Global.Msg )
applyGameEvent auth shared gameEvent model =
case gameEvent of
Events.HandRedrawn { player, hand } ->
let
@@ -369,7 +379,7 @@ applyGameEvent auth gameEvent model =
game =
model.game
newRound =
( newRound, speech ) =
case model.game.round of
Round.R r ->
let
@@ -378,18 +388,24 @@ applyGameEvent auth gameEvent model =
known =
plays |> List.filterMap Play.asKnown
in
if List.length known == List.length plays then
Round.judging r.id r.czar r.players r.call known |> Round.J
else
{ r | plays = plays } |> Round.R
round =
if List.length known == List.length plays then
Round.judging r.id r.czar r.players r.call known |> Round.J
else
{ r | plays = plays } |> Round.R
tts =
speak shared r.call (Just play)
in
( round, tts )
_ ->
-- TODO: Error
model.game.round
( model.game.round, Cmd.none )
in
( { model | game = { game | round = newRound } }, Cmd.none )
( { model | game = { game | round = newRound } }, speech )
Events.PlaySubmitted { by } ->
case model.game.round of
@@ -429,7 +445,7 @@ applyGameEvent auth gameEvent model =
newPlayers =
game.players |> Dict.update winner (Maybe.map (\p -> { p | score = p.score + 1 }))
( newRound, history ) =
( newRound, history, speech ) =
case game.round of
Round.J r ->
let
@@ -438,14 +454,19 @@ applyGameEvent auth gameEvent model =
complete =
Round.complete r.id r.czar r.players r.call plays winner
tts =
Dict.get winner plays
|> Maybe.map (\p -> speak shared r.call (Just p))
|> Maybe.withDefault Cmd.none
in
( complete |> Round.C, complete :: game.history )
( complete |> Round.C, complete :: game.history, tts )
_ ->
-- TODO: Error
( game.round, game.history )
( game.round, game.history, Cmd.none )
in
( { model | game = { game | round = newRound, players = newPlayers, history = history } }, Cmd.none )
( { model | game = { game | round = newRound, players = newPlayers, history = history } }, speech )
Events.RoundStarted { id, czar, players, call, drawn } ->
let
@@ -529,6 +550,16 @@ applyGameStarted lobby round hand =
{- Private -}
speak : Shared -> Card.Call -> Maybe (List Card.Response) -> Cmd msg
speak shared call play =
Speech.speak shared.settings.settings.speech
(Parts.viewFilledString
(Strings.Blank |> Lang.string shared)
(play |> Maybe.map (List.map .body) |> Maybe.withDefault [])
call.body
)
reveal : Play.Id -> List Card.Response -> Play -> Play
reveal target responses play =
case play.responses of
@@ -39,6 +39,7 @@ import MassiveDecks.Pages.Lobby.GameCode as GameCode exposing (GameCode)
import MassiveDecks.Pages.Lobby.Model as Lobby exposing (Lobby)
import MassiveDecks.Pages.Start.LobbyBrowser.Model as LobbyBrowser
import MassiveDecks.Settings.Model as Settings exposing (Settings)
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings.Languages as Lang
import MassiveDecks.Strings.Languages.Model as Lang exposing (Language)
import MassiveDecks.User as User exposing (User)
@@ -91,13 +92,21 @@ flags =
settings : Json.Decoder Settings
settings =
Json.map6 Settings
Json.map7 Settings
(Json.field "tokens" (Json.dict lobbyToken))
(Json.field "openUserList" Json.bool)
(Json.maybe (Json.field "lastUsedName" Json.string))
(Json.field "recentDecks" (Json.list externalSource))
(Json.maybe (Json.field "chosenLanguage" language))
(Json.field "compactCards" cardSize)
(Json.maybe (Json.field "speech" speech) |> Json.map (Maybe.withDefault Speech.default))
speech : Json.Decoder Speech.Settings
speech =
Json.map2 Speech.Settings
(Json.field "enabled" Json.bool)
(Json.maybe (Json.field "selectedVoice" Json.string))
cardSize : Json.Decoder Settings.CardSize
@@ -19,6 +19,7 @@ import MassiveDecks.Game.Rules as Rules
import MassiveDecks.Pages.Lobby.Model as Lobby
import MassiveDecks.Pages.Start.Model as Start
import MassiveDecks.Settings.Model as Settings exposing (Settings)
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings.Languages as Lang
import MassiveDecks.Strings.Languages.Model as Lang exposing (Language)
import MassiveDecks.User as User
@@ -44,6 +45,7 @@ settings s =
, ( "openUserList", Json.bool s.openUserList )
, ( "recentDecks", Json.list source s.recentDecks )
, ( "compactCards", s.cardSize |> cardSize )
, ( "speech", s.speech |> speech )
]
, lun
, cl
@@ -52,6 +54,21 @@ settings s =
Json.object fields
speech : Speech.Settings -> Json.Value
speech speechSettings =
let
enabledField =
[ ( "enabled", speechSettings.enabled |> Json.bool ) ]
selectedVoiceField =
speechSettings.selectedVoice
|> Maybe.map (\sv -> [ ( "selectedVoice", Json.string sv ) ])
|> Maybe.withDefault []
in
Json.object
(List.concat [ enabledField, selectedVoiceField ])
cardSize : Settings.CardSize -> Json.Value
cardSize =
Settings.cardSizeToValue >> Json.int
+9 -5
View File
@@ -46,7 +46,7 @@ import MassiveDecks.Pages.Start.Route as Start
import MassiveDecks.ServerConnection as ServerConnection
import MassiveDecks.Settings as Settings
import MassiveDecks.Settings.Messages as Settings
import MassiveDecks.Settings.Model as Settings
import MassiveDecks.Settings.Model as Settings exposing (Settings)
import MassiveDecks.Strings as Strings exposing (MdString)
import MassiveDecks.Strings.Languages as Lang
import MassiveDecks.User as User exposing (User)
@@ -108,15 +108,19 @@ subscriptions model =
]
update : Navigation.Key -> Msg -> Model -> ( Model, Cmd Global.Msg )
update key msg model =
update : Shared -> Msg -> Model -> ( Model, Cmd Global.Msg )
update shared msg model =
let
key =
shared.key
in
case msg of
GameMsg gameMsg ->
case model.lobby of
Just l ->
l.game
|> Maybe.map
(Game.update gameMsg
(Game.update shared gameMsg
>> Util.modelLift (\newGame -> { model | lobby = Just { l | game = Just newGame } })
)
|> Maybe.withDefault ( model, Cmd.none )
@@ -149,7 +153,7 @@ update key msg model =
(\game ->
Util.modelLift
(\g -> { model | lobby = Just { lobby | game = Just g } })
(Game.applyGameEvent model.auth gameEvent game)
(Game.applyGameEvent model.auth shared gameEvent game)
)
|> Maybe.withDefault ( model, Cmd.none )
+8
View File
@@ -3,6 +3,8 @@ port module MassiveDecks.Ports exposing
, copyText
, serverRecv
, serverSend
, speechCommands
, speechVoices
, storeSettings
, tryCast
)
@@ -26,3 +28,9 @@ port serverRecv : (String -> msg) -> Sub msg
port copyText : String -> Cmd msg
port speechCommands : Json.Value -> Cmd msg
port speechVoices : (Json.Value -> msg) -> Sub msg
+112 -22
View File
@@ -4,6 +4,7 @@ module MassiveDecks.Settings exposing
, init
, onJoinLobby
, onTokenUpdate
, subscriptions
, update
, view
)
@@ -20,6 +21,7 @@ import Http
import MassiveDecks.Components as Components
import MassiveDecks.Components.Form as Form
import MassiveDecks.Components.Form.Message as Message
import MassiveDecks.Error.Model as Error exposing (Error)
import MassiveDecks.Icon as Icon
import MassiveDecks.LocalStorage as LocalStorage
import MassiveDecks.Messages as Global
@@ -31,6 +33,7 @@ import MassiveDecks.Requests.Api as Api
import MassiveDecks.Requests.Request as Request
import MassiveDecks.Settings.Messages exposing (..)
import MassiveDecks.Settings.Model exposing (..)
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings as Strings
import MassiveDecks.Strings.Languages as Lang
import MassiveDecks.Strings.Languages.Model as Lang exposing (Language)
@@ -51,11 +54,15 @@ init settings =
|> Dict.values
|> Api.checkAlive (Request.map ignore ignore (RemoveInvalid >> Global.SettingsMsg))
|> Http.request
( speech, speechCmd ) =
Speech.init
in
( { settings = settings
, open = False
, speech = speech
}
, cmd
, Cmd.batch [ cmd, speechCmd ]
)
@@ -67,9 +74,15 @@ defaults =
, recentDecks = []
, chosenLanguage = Nothing
, cardSize = Full
, speech = Speech.default
}
subscriptions : (Error -> msg) -> (Msg -> msg) -> Sub msg
subscriptions wrapError wrapMsg =
Speech.subscriptions (Error.Json >> wrapError) (SpeechMsg >> wrapMsg)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
@@ -93,6 +106,23 @@ update msg model =
in
changeSettings (\s -> { s | tokens = newTokens }) model
ToggleSpeech enabled ->
let
newSpeechSettings =
model.settings.speech |> Speech.toggle enabled
in
changeSettings (\s -> { s | speech = newSpeechSettings }) model
ChangeSpeech voice ->
let
newSpeechSettings =
model.settings.speech |> Speech.selectVoice voice
in
changeSettings (\s -> { s | speech = newSpeechSettings }) model
SpeechMsg speechMsg ->
( { model | speech = model.speech |> Speech.update speechMsg }, Cmd.none )
view : Shared -> Html Global.Msg
view shared =
@@ -121,7 +151,7 @@ view shared =
, Html.div [ HtmlA.class "body" ]
[ languageSelector shared
, cardSize shared
, speechSwitch shared
, speechVoiceSelector shared
, notificationsSwitch shared
]
]
@@ -193,11 +223,8 @@ changeSettings f model =
cardSize : Shared -> Html Global.Msg
cardSize shared =
let
model =
shared.settings
settings =
model.settings
shared.settings.settings
in
Form.section shared
"card-size"
@@ -217,10 +244,10 @@ cardSize shared =
>> ChangeCardSize
>> Global.SettingsMsg
|> HtmlE.onInput
, model.settings.cardSize |> cardSizeToValue |> String.fromInt |> WlA.value
, settings.cardSize |> cardSizeToValue |> String.fromInt |> WlA.value
]
[ Icon.viewStyled [ Slider.slot Slider.Before ] Icon.searchMinus
, [ model.settings.cardSize |> cardSizeThumb ] |> Html.span [ Slider.slot Slider.ThumbLabel ]
, [ settings.cardSize |> cardSizeThumb ] |> Html.span [ Slider.slot Slider.ThumbLabel ]
, Icon.viewStyled [ Slider.slot Slider.After ] Icon.searchPlus
]
]
@@ -241,22 +268,85 @@ cardSizeThumb size =
Icon.viewIcon Icon.callCard
speechSwitch : Shared -> Html Global.Msg
speechSwitch shared =
-- TODO: Impl
Form.section shared
"speech"
(Html.div
[ HtmlA.class "multipart" ]
[ Wl.switch [ WlA.disabled ]
, Html.label []
[ Icon.viewIcon Icon.volumeUp
, Html.text " "
, Strings.SpeechSetting |> Lang.html shared
speechVoiceSelector : Shared -> Html Global.Msg
speechVoiceSelector shared =
let
selectedVoice =
shared.settings.settings.speech.selectedVoice
voices =
shared.settings.speech.voices
enabled =
shared.settings.settings.speech.enabled
isDisabled =
List.isEmpty voices
notPossibleWarning =
if isDisabled then
Message.warning Strings.SpeechNotSupportedExplanation
else
Message.none
in
Html.div []
[ Form.section shared
"speech"
(Html.div []
[ Html.div
[ HtmlA.class "multipart" ]
[ Wl.switch
[ HtmlE.onCheck (ToggleSpeech >> Global.SettingsMsg)
, HtmlA.disabled isDisabled
, HtmlA.checked enabled
]
, Html.label []
[ Icon.viewIcon Icon.commentDots
, Html.text " "
, Strings.SpeechSetting |> Lang.html shared
]
]
, Wl.select
[ HtmlE.onInput (ChangeSpeech >> Global.SettingsMsg)
, Strings.VoiceSetting |> Lang.string shared |> WlA.label
, WlA.outlined
, HtmlA.disabled (not enabled || isDisabled)
]
(voices |> List.sortWith defaultFirst |> List.map (speechVoiceOption selectedVoice))
]
)
[ Message.info Strings.SpeechExplanation
, notPossibleWarning
]
)
[ Message.info Strings.SpeechExplanation ]
]
defaultFirst : Speech.Voice -> Speech.Voice -> Order
defaultFirst a b =
if a.default && b.default then
EQ
else if a.default then
LT
else
GT
speechVoiceOption : Maybe String -> Speech.Voice -> Html Global.Msg
speechVoiceOption selectedVoice voice =
let
selected =
if selectedVoice == Just voice.name then
[ WlA.selected ]
else
[]
in
Html.option
(HtmlA.value voice.name :: selected)
[ Html.text (voice.name ++ " (" ++ voice.lang ++ ")") ]
notificationsSwitch : Shared -> Html Global.Msg
@@ -3,6 +3,7 @@ module MassiveDecks.Settings.Messages exposing (Msg(..))
import Dict exposing (Dict)
import MassiveDecks.Pages.Lobby.Model as Lobby
import MassiveDecks.Settings.Model exposing (..)
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings.Languages.Model exposing (Language)
@@ -12,3 +13,6 @@ type Msg
| ChangeOpenUserList Bool
| ToggleOpen
| RemoveInvalid (Dict Lobby.Token Bool)
| ToggleSpeech Bool
| ChangeSpeech String
| SpeechMsg Speech.Msg
@@ -10,6 +10,7 @@ module MassiveDecks.Settings.Model exposing
import Dict exposing (Dict)
import MassiveDecks.Card.Source.Model as Source exposing (Source)
import MassiveDecks.Pages.Lobby.Model as Lobby
import MassiveDecks.Speech as Speech
import MassiveDecks.Strings.Languages.Model exposing (Language)
@@ -18,6 +19,7 @@ import MassiveDecks.Strings.Languages.Model exposing (Language)
type alias Model =
{ settings : Settings
, open : Bool
, speech : Speech.Model
}
@@ -31,6 +33,7 @@ type alias Settings =
, recentDecks : List Source.External
, chosenLanguage : Maybe Language
, cardSize : CardSize
, speech : Speech.Settings
}
+138
View File
@@ -0,0 +1,138 @@
module MassiveDecks.Speech exposing
( Model
, Msg(..)
, Settings
, Voice
, default
, init
, selectVoice
, speak
, subscriptions
, toggle
, update
)
import Json.Decode
import Json.Encode as Json
import MassiveDecks.Ports as Ports
{-| A voice that can be used to speak phrases.
-}
type alias Voice =
{ name : String
, lang : String
, default : Bool
}
{-| The configurable settings for speech.
-}
type alias Settings =
{ enabled : Bool
, selectedVoice : Maybe String
}
{-| Runtime data used for speech.
-}
type alias Model =
{ voices : List Voice
}
{-| Messages used for speech.
-}
type Msg
= UpdateVoices (List Voice)
{-| Initialize the model.
-}
init : ( Model, Cmd msg )
init =
( { voices = [] }, requestVoices )
{-| The default settings for speech.
-}
default : Settings
default =
{ enabled = False
, selectedVoice = Nothing
}
{-| Speak the given phrase.
-}
speak : Settings -> String -> Cmd msg
speak settings phrase =
if settings.enabled then
case settings.selectedVoice of
Just voice ->
Json.object [ ( "voice", Json.string voice ), ( "phrase", Json.string phrase ) ] |> Ports.speechCommands
Nothing ->
Cmd.none
else
Cmd.none
{-| Enable or disable speech.
-}
toggle : Bool -> Settings -> Settings
toggle enabled settings =
{ settings | enabled = enabled }
{-| Select a new voice for speech.
-}
selectVoice : String -> Settings -> Settings
selectVoice voice settings =
{ settings | selectedVoice = Just voice }
{-| Subscriptions for speech.
-}
subscriptions : (Json.Decode.Error -> msg) -> (Msg -> msg) -> Sub msg
subscriptions wrapError wrapMsg =
Ports.speechVoices (Json.Decode.decodeValue (Json.Decode.list decodeVoice) >> toMsg wrapError wrapMsg)
{-| Update the model based on messages.
-}
update : Msg -> Model -> Model
update msg model =
case msg of
UpdateVoices voices ->
{ model | voices = voices }
{- Internal -}
requestVoices : Cmd msg
requestVoices =
Json.object [ ( "name", Json.null ) ] |> Ports.speechCommands
decodeVoice : Json.Decode.Decoder Voice
decodeVoice =
Json.Decode.map3 Voice
(Json.Decode.field "name" Json.Decode.string)
(Json.Decode.field "lang" Json.Decode.string)
(Json.Decode.maybe (Json.Decode.field "default" Json.Decode.bool)
|> Json.Decode.map (Maybe.withDefault False)
)
toMsg : (Json.Decode.Error -> msg) -> (Msg -> msg) -> Result Json.Decode.Error (List Voice) -> msg
toMsg wrapError wrapMsg result =
case result of
Ok value ->
UpdateVoices value |> wrapMsg
Err error ->
error |> wrapError
+3
View File
@@ -73,6 +73,8 @@ type MdString
| CardSizeExplanation -- An explanation of what the card size does (changes the size of the card).
| SpeechSetting -- The label for the speech setting.
| SpeechExplanation -- An explanation of what the speech setting does (enables TTS on cards).
| SpeechNotSupportedExplanation -- An explanation that speech can't be enabled because the user's browser doesn't support it.
| VoiceSetting -- The label for the voice setting.
| NotificationsSetting -- The label for the notifications setting.
| NotificationsExplanation -- An explanation of what the notifications setting does (enables browser notifications).
| NotificationsBrowserPermissions -- An explanation that the user will need to give the game permission for notifications.
@@ -186,6 +188,7 @@ type MdString
| Judging -- A description of the stage of the round where the czar is picking a winner.
| Complete -- A description of the stage of the round where it is finished.
| ViewGameHistoryAction -- A description of the action of viewing the history of the game.
| Blank -- A term used in speech to denote a blank "slot" on a card the player will give a value to fill.
-- Instructions
| WhatToDo -- A description of the action of asking for help on what to do in the game at this time.
| PlayInstruction { numberOfCards : Int } -- Instruction to the player on how to play cards.
@@ -270,6 +270,12 @@ translate mdString =
SpeechExplanation ->
[ Text "Read out cards using text to speech." ]
SpeechNotSupportedExplanation ->
[ Text "Your browser does not support text to speech, or has no voices installed." ]
VoiceSetting ->
[ Text "Speech Voice" ]
NotificationsSetting ->
[ Text "Browser Notifications" ]
@@ -653,6 +659,9 @@ translate mdString =
ViewGameHistoryAction ->
[ Text "View previous rounds from this game." ]
Blank ->
[ Text "Blank" ]
-- Instructions
WhatToDo ->
[ Text "What should I be doing?" ]
+4
View File
@@ -1,3 +1,5 @@
import { Say, Voice } from "../../ts/speech";
type Token = string;
interface Settings {
@@ -46,6 +48,8 @@ export namespace Elm {
namespace MassiveDecks {
export interface App {
ports: {
speechCommands: InboundPort<Say>;
speechVoices: OutboundPort<Array<Voice>>;
storeSettings: InboundPort<Settings>;
tryCast: InboundPort<CastFlags>;
castStatus: OutboundPort<CastStatus>;
+5
View File
@@ -3,6 +3,7 @@ import "./weightless";
import { Client as CastClient } from "./chromecast";
import { OutboundPort, Settings, Token } from "../elm/MassiveDecks";
import { ServerConnection } from "./server-connection";
import { Speech } from "./speech";
/**
* Settings storage/retrieval in local storage.
@@ -53,6 +54,10 @@ import(/* webpackChunkName: "massive-decks" */ "../elm/MassiveDecks").then(
SettingsStorage.save(settings)
);
if ("speechSynthesis" in window) {
new Speech(app.ports.speechVoices, app.ports.speechCommands);
}
new ServerConnection(app.ports.serverRecv, app.ports.serverSend);
new CastClient(app.ports.tryCast, app.ports.castStatus);
+55
View File
@@ -0,0 +1,55 @@
import { InboundPort, OutboundPort } from "../elm/MassiveDecks";
export interface Voice {
name: string;
lang: string;
default?: boolean;
}
export interface Say {
voice: string;
phrase: string;
}
export class Speech {
speech: SpeechSynthesis;
voices: Map<string, SpeechSynthesisVoice>;
out: OutboundPort<Voice[]>;
constructor(out: OutboundPort<Array<Voice>>, inbound: InboundPort<Say>) {
this.speech = window.speechSynthesis;
this.voices = new Map<string, SpeechSynthesisVoice>();
this.out = out;
this.get_voices();
inbound.subscribe(command => {
this.say(command.voice, command.phrase);
});
this.speech.addEventListener("voiceschanged", () => this.get_voices());
}
get_voices(): void {
const voices = this.speech.getVoices();
this.voices.clear();
for (const voice of voices) {
this.voices.set(voice.name, voice);
}
this.out.send(
voices.map(voice => ({
name: voice.name,
lang: voice.lang,
...(voice.default ? { default: true } : {})
}))
);
}
say(voiceName: string, phrase: string): void {
const voice = this.voices.get(voiceName);
if (voice !== undefined) {
const utterance = new SpeechSynthesisUtterance(phrase);
utterance.voice = voice;
this.speech.speak(utterance);
}
}
}
+1
View File
@@ -37,4 +37,5 @@ COPY ["./config.json5", "./"]
COPY --from=0 ["/md/dist", "./"]
EXPOSE 8081
USER node
CMD ["node", "--es-module-specifier-resolution=node", "./index.js"]