module AbcTutorial exposing (..) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (on, targetValue, onClick, onInput) import Task exposing (Task, andThen, succeed, sequence, onError) import Process exposing (sleep) import List exposing (reverse) import Maybe exposing (Maybe, withDefault) import String exposing (toInt) import Result exposing (Result) import Array exposing (Array, get) import Abc exposing (..) import AbcPerformance exposing (melodyFromAbcResult) import Melody exposing (..) import Notable exposing (..) import MidiNotes exposing (..) import Lessons exposing (..) import Json.Encode as Json import SoundFont.Ports exposing (..) import SoundFont.Types exposing (..) import Debug exposing (..) main = Html.program { init = ( init, requestLoadPianoFonts "assets/soundfonts" ), update = update, view = view, subscriptions = subscriptions } -- MODEL type alias Model = { fontsLoaded : Bool , abc : String , playing : Bool , lessonIndex : Int , duration : Float -- the tune duration in seconds , error : Maybe ParseError } init : Model init = { fontsLoaded = False , abc = example 0 , playing = False , lessonIndex = 0 , duration = 0.0 , error = Nothing } -- UPDATE type Msg = NoOp | FontsLoaded Bool | Abc String | Play | PlayStarted Bool -- response from the player that it's started | PlayCompleted -- the play has completed (we compute the time ourselves) | ShowButtons -- immediately after play has ended | Move Bool | MoveToEnd Bool | Error ParseError update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of NoOp -> ( model, Cmd.none ) ShowButtons -> ( { model | playing = False }, Cmd.none ) FontsLoaded loaded -> ( { model | fontsLoaded = loaded } , Cmd.none ) Abc s -> ( { model | abc = s }, Cmd.none ) Play -> playAbc model PlayStarted _ -> ( model, (suspend model.duration) ) PlayCompleted -> ( { model | playing = False }, Cmd.none ) Move b -> let next = case b of True -> Basics.min (model.lessonIndex + 1) (Array.length lessons - 1) False -> Basics.max (model.lessonIndex - 1) 0 in ( { model | lessonIndex = next , abc = (example next) , error = Nothing } , Cmd.none ) MoveToEnd b -> let next = case b of True -> (Array.length lessons - 1) False -> 0 in ( { model | lessonIndex = next , abc = (example next) , error = Nothing } , Cmd.none ) Error pe -> ( { model | error = Just pe }, showButtonsAction ) -- SUBSCRIPTIONS fontsLoadedSub : Sub Msg fontsLoadedSub = fontsLoaded FontsLoaded playSequenceStartedSub : Sub Msg playSequenceStartedSub = playSequenceStarted PlayStarted subscriptions : Model -> Sub Msg subscriptions m = Sub.batch [ fontsLoadedSub, playSequenceStartedSub ] -- COMMANDS {- sleep for a number of seconds -} suspend : Float -> Cmd Msg suspend secs = let _ = log "suspend time" secs time = secs * 1000 in Process.sleep time |> Task.perform (\_ -> PlayCompleted) -- |> Task.perform (\_ -> NoOp) (\_ -> PlayCompleted) {- just the ShowButton action wrapped in a Task -} showButtons : Task Never Msg showButtons = succeed (ShowButtons) {- and as an effect -} showButtonsAction : Cmd Msg showButtonsAction = Task.perform (\_ -> NoOp) showButtons {- calculate the performance duration in seconds -} performanceDuration : MidiNotes -> Float performanceDuration notes = let maybeLastNote = List.head (List.reverse notes) in case maybeLastNote of Nothing -> 0.0 Just n -> n.timeOffset -- the accumulated time returnError : ParseError -> Cmd Msg returnError e = Task.succeed (Error e) |> Task.perform (\_ -> NoOp) terminateLine : String -> String terminateLine s = s ++ "|\x0D\n" {- cast a String to an Int -} toInt : String -> Int toInt = String.toInt >> Result.toMaybe >> Maybe.withDefault 0 {- play the ABC and return the duration in the amended model -} playAbc : Model -> ( Model, Cmd Msg ) playAbc m = let abcTuneResult = m.abc |> terminateLine |> parse in case abcTuneResult of Ok _ -> let notesReversed = abcTuneResult |> melodyFromAbcResult |> toPerformance |> makeMIDINotes -- _ = log "notes reversed" notesReversed duration = reversedPhraseDuration notesReversed in ( { m | playing = True , duration = duration } , requestPlayNoteSequence (List.reverse notesReversed) ) Err error -> ( { m | error = Just error }, returnError error ) -- VIEW viewError : Maybe ParseError -> String viewError me = case me of Nothing -> "" Just pe -> "parse error: " ++ pe.input ++ " at position " ++ toString (pe.position) view : Model -> Html Msg view model = if (model.fontsLoaded) then div [] [ h2 [ centreStyle ] [ text (title model.lessonIndex) ] , textarea [ centreStyle , value (instruction model.lessonIndex) , instructionStyle , readonly True , cols 96 , rows 6 ] [] , div [] [ fieldset [ fieldsetStyle ] [ legend [ legendStyle ] [ text "you can edit the text inside the box and then hit play" ] , textarea ([ placeholder "abc" , value model.abc , onInput Abc , taStyle , cols 70 , rows 15 , autocomplete False , spellcheck False , autofocus True ] ++ highlights model ) [] ] , img [ src (scoreUrl model.lessonIndex) , rightImageStyle ] [] ] , div [ leftPaneCentreStyle ] [ button (buttonAttributes (not model.playing) (MoveToEnd False)) [ text "first" ] , button (buttonAttributes (not model.playing) (Move False)) [ text "previous" ] , button (buttonAttributes (not model.playing) Play) [ text "play" ] , button (buttonAttributes (not model.playing) (Move True)) [ text "next" ] , button (buttonAttributes (not model.playing) (MoveToEnd True)) [ text "last" ] ] , div [ leftPaneCentreStyle ] [ p [] [ text (hint model.lessonIndex) ] , p [] [ text (viewError model.error) ] ] ] else div [ centreStyle ] [ p [] [ text "It seems as if your browser does not support web-audio. Perhaps try Chrome" ] ] title : Int -> String title i = let mlesson = Array.get i lessons in case mlesson of Nothing -> "error" Just l -> "ABC Tutorial: lesson " ++ (toString (i + 1) ++ " - " ++ l.title) instruction : Int -> String instruction i = let mlesson = Array.get i lessons in case mlesson of Nothing -> "error" Just l -> l.instruction example : Int -> String example i = let mlesson = Array.get i lessons in case mlesson of Nothing -> "error" Just l -> l.example hint : Int -> String hint i = let mlesson = Array.get i lessons in case mlesson of Nothing -> "" Just l -> l.hint scoreUrl : Int -> String scoreUrl i = let mlesson = Array.get i lessons in case mlesson of Nothing -> "" Just l -> "assets/images/tutorial/" ++ l.id ++ ".png" {- style a textarea -} taStyle : Attribute Msg taStyle = style [ ( "padding", "10px 0" ) , ( "font-size", "1.5em" ) , ( "text-align", "left" ) , ( "align", "center" ) , ( "display", "block" ) , ( "margin-left", "auto" ) , ( "margin-right", "auto" ) , ( "background-color", "#f3f6c6" ) , ( "font-family", "monospace" ) ] {- style the instructions section -} instructionStyle : Attribute Msg instructionStyle = style [ ( "padding", "10px 0" ) , ( "border", "none" ) , ( "text-align", "left" ) , ( "align", "center" ) , ( "display", "block" ) , ( "margin-left", "auto" ) , ( "margin-right", "auto" ) , ( "font", "100% \"Trebuchet MS\", Verdana, sans-serif" ) ] {- style a centered component -} centreStyle : Attribute Msg centreStyle = style [ ( "text-align", "center" ) , ( "margin", "auto" ) ] leftPaneStyle : Attribute msg leftPaneStyle = style [ ( "float", "left" ) , ( "width", "800px" ) ] leftPaneCentreStyle : Attribute msg leftPaneCentreStyle = style [ ( "float", "left" ) , ( "margin-left", "200px" ) ] rightImageStyle : Attribute msg rightImageStyle = style [ ( "position", "absolute" ) ] {- gather together all the button attributes -} buttonAttributes : Bool -> Msg -> List (Attribute Msg) buttonAttributes isEnabled msg = [ class "hoverable" , bStyle isEnabled , onClick msg , disabled (not isEnabled) ] {- style a button Note: all button styling is deferred to the external css (which implements hover) except for when the button is greyed out when it is disabled -} bStyle : Bool -> Attribute msg bStyle enabled = let colour = if enabled then [] else [ ( "background-color", "lightgray" ) , ( "color", "darkgrey" ) ] in style (colour) {- style a fieldset -} fieldsetStyle : Attribute Msg fieldsetStyle = style [ ( "background-color", "#f1f1f1" ) , ( "border", "none" ) , ( "border-radius", "2px" ) , ( "margin-bottom", "12px" ) , ( "margin-left", "12px" ) , ( "margin-right", "12px" ) , ( "padding", "10px 10px 20px 10px" ) , ( "display", "inline-block" ) ] {- style a fieldset legend -} legendStyle : Attribute Msg legendStyle = style [ ( "background-color", "#67d665" ) , ( "border-top", "1px solid #d4d4d4" ) , ( "border-bottom", "1px solid #d4d4d4" ) , ( "-moz-box-shadow", "3px 3px 3px #ccc" ) , ( "-webkit-box-shadow", "3px 3px 3px #ccc" ) , ( "box-shadow", "3px 3px 3px #ccc" ) , ( "font-size", "1em" ) , ( "padding", "0.3em 1em" ) ] highlights : Model -> List (Attribute Msg) highlights model = let mpe = model.error in case mpe of Nothing -> [] Just pe -> if (String.length model.abc > pe.position) then [ property "selectionStart" (Json.string (toString pe.position)) , property "selectionEnd" (Json.string (toString (pe.position + 1))) , property "focus" (Json.null) ] else []