module AbcEditor 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, isEmpty)
import Maybe exposing (Maybe, withDefault)
import String exposing (toInt, slice)
import Result exposing (Result, mapError)
import Array exposing (Array, get)
import Tuple exposing (first, second)
import SoundFont.Ports exposing (..)
import SoundFont.Types exposing (..)
import Abc exposing (..)
import AbcPerformance exposing (melodyFromAbcResult)
import Abc.ParseTree exposing (AbcTune, PitchClass(..), Mode(..), Accidental(..), ModifiedKeySignature, KeySignature)
import Abc.Canonical exposing (fromResult, fromTune)
import Music.Notation exposing (getKeySig)
import Music.Transposition exposing (transposeTo)
import Music.Octave exposing (up, down)
import Melody exposing (..)
import Notable exposing (..)
import MidiNotes exposing (..)
import Json.Decode as Json exposing (succeed)
import Debug exposing (..)
{-| An ABC editor. It continually parses the ABC as it is entered and flags up errors.
If the (checked) tune contains a key signature, then transposition options will be shown.
-}
main =
Html.program
{ init = ( init, requestLoadPianoFonts "assets/soundfonts" ), update = update, view = view, subscriptions = subscriptions }
-- MODEL
type alias Model =
{ fontsLoaded : Bool
, playing : Bool
, abc : String
, tuneResult : Result ParseError AbcTune
, duration :
Float
-- the tune duration in seconds
}
dummyError : ParseError
dummyError =
{ msgs = []
, input = ""
, position = 0
}
emptyTune : AbcTune
emptyTune =
( [], [] )
init : Model
init =
{ fontsLoaded = False
, playing = False
, abc = ""
, tuneResult = Ok emptyTune
, duration = 0.0
}
-- UPDATE
type Msg
= NoOp
| FontsLoaded Bool
| Abc String
| Play
-- request that a tune plays
| PlayStarted Bool
-- response from the player that it's started
| PlayCompleted
-- the play has completed (we compute the time ourselves)
| Transpose String
| MoveOctave Bool
| TuneResult (Result ParseError AbcTune)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NoOp ->
( model, Cmd.none )
FontsLoaded loaded ->
( { model | fontsLoaded = loaded }
, Cmd.none
)
Abc s ->
( { model | abc = s }, checkAbc s )
Play ->
playAbc model
PlayStarted _ ->
( model, (suspend model.duration) )
PlayCompleted ->
( { model | playing = False }, Cmd.none )
Transpose s ->
( transpose s model, Cmd.none )
MoveOctave isUp ->
if isUp then
( moveOctave up model, Cmd.none )
else
( moveOctave down model, Cmd.none )
TuneResult tr ->
( { model | tuneResult = tr }, Cmd.none )
{- a different attempt at checking if buttons are enabled -}
areButtonsEnabled : Model -> Bool
areButtonsEnabled m =
case m.tuneResult of
Ok _ ->
not (m.playing)
Err _ ->
False
{- 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)
returnTuneResult : Result ParseError AbcTune -> Cmd Msg
returnTuneResult r =
-- Task.perform (\_ -> NoOp) TuneResult (Task.succeed r)
Task.perform TuneResult (Task.succeed r)
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
{- continually parse the ABC after every key stroke -}
checkAbc : String -> Cmd Msg
checkAbc abc =
let
terminatedAbc =
terminateLine abc
-- _ = "checking" terminatedAbc
pr =
parse terminatedAbc
in
returnTuneResult (pr)
{- play the ABC and return the duration in the amended model -}
playAbc : Model -> ( Model, Cmd Msg )
playAbc m =
let
notesReversed =
m.abc
|> terminateLine
|> parse
|> melodyFromAbcResult
|> toPerformance
|> makeMIDINotes
-- _ = log "notes reversed" notesReversed
duration =
reversedPhraseDuration notesReversed
in
( { m
| playing = True
, duration = duration
}
, requestPlayNoteSequence (List.reverse notesReversed)
)
{- transpose the tune to a new key -}
transpose : String -> Model -> Model
transpose kstr model =
let
mksr =
parseKeySignature kstr
in
case ( mksr, model.tuneResult ) of
( Ok mks, Ok tune ) ->
let
newTuneResult =
transposeTo mks tune
-- this is awkward in elm's Result - in this instance we're guaranteed not to have errors
-- in transposition because our modes always match. Just convert the notional String error to a notional empty parser error
newTRCorrectedErr =
newTuneResult
|> mapError (\_ -> dummyError)
-- and collect the new ABC wrapped in a Result
newAbcResult =
fromResult newTuneResult
in
-- if we're OK, we have both a new ABC Tune and a new ABC source of that tune
case newAbcResult of
Ok newAbc ->
{ model | abc = newAbc, tuneResult = newTRCorrectedErr }
_ ->
model
_ ->
model
{- move the tune up or down an octave -}
moveOctave : (AbcTune -> AbcTune) -> Model -> Model
moveOctave movefn model =
case model.tuneResult of
Ok tune ->
let
newTune =
movefn tune
newAbc =
fromTune newTune
in
{ model | abc = newAbc, tuneResult = (Ok newTune) }
_ ->
model
-- SUBSCRIPTIONS
fontsLoadedSub : Sub Msg
fontsLoadedSub =
fontsLoaded FontsLoaded
playSequenceStartedSub : Sub Msg
playSequenceStartedSub =
playSequenceStarted PlayStarted
subscriptions : Model -> Sub Msg
subscriptions m =
Sub.batch [ fontsLoadedSub, playSequenceStartedSub ]
-- VIEW
viewError : Model -> Html Msg
viewError m =
let
tuneResult =
m.tuneResult
in
case tuneResult of
Err e ->
-- we start off with a dummy error message which is empty
if (isEmpty e.msgs) then
text ""
else
let
-- display a prefix of 5 characters before the error (if they're there) and a suffix of 5 after
startPhrase =
Basics.max (e.position - 5) 0
errorPrefix =
"error: " ++ slice startPhrase e.position m.abc
startSuffix =
Basics.min (e.position + 1) (String.length m.abc)
endSuffix =
Basics.min (e.position + 6) (String.length m.abc)
errorSuffix =
slice startSuffix endSuffix m.abc
errorChar =
slice e.position (e.position + 1) m.abc
in
p []
[ text errorPrefix
, span [ errorHighlightStyle ]
[ text errorChar ]
, text errorSuffix
]
_ ->
text ""
view : Model -> Html Msg
view model =
if (model.fontsLoaded) then
div []
[ h1 [ centreStyle ] [ text "ABC Editor" ]
, div [ leftPaneStyle ]
[ span [ leftPanelWidgetStyle ] [ text "Transpose to:" ]
, transpositionMenu model
, span [ leftPanelWidgetStyle ] [ text "Move octave:" ]
, button (buttonAttributes (areButtonsEnabled model) (MoveOctave True))
[ text "up" ]
, button (buttonAttributes (areButtonsEnabled model) (MoveOctave False))
[ text "down" ]
]
, div [ rightPaneStyle ]
[ fieldset [ fieldsetStyle ]
[ textarea
[ placeholder "abc"
, value model.abc
, onInput Abc
, taStyle
, cols 70
, rows 16
, autocomplete False
, spellcheck False
, autofocus True
]
[]
]
, div
[]
[ button (buttonAttributes (areButtonsEnabled model) Play)
[ text "play" ]
]
, div
[]
[ p [] [ viewError model ]
]
]
]
else
div [ centreStyle ]
[ p [] [ text "It seems as if your browser does not support web-audio. Perhaps try Chrome." ]
]
{- an active menu of transposition options -}
transpositionMenu : Model -> Html Msg
transpositionMenu m =
let
mKeySig =
case
m.tuneResult
of
Ok tune ->
defaultToC (getKeySig tune)
_ ->
Nothing
in
case mKeySig of
Just mks ->
select
[ leftPanelWidgetStyle
, (disabled m.playing)
, on "change" (Json.map Transpose targetValue)
]
(transpositionOptions mks)
Nothing ->
select
[ leftPanelWidgetStyle
, (disabled True)
]
[ option [] [ text "not available" ]
]
{- offer a menu of transposition options, appropriate to the
current key (if such a key has been entered in the ABC).
The mode of each option always matches the current mode and
the selected option matches the current key
-}
transpositionOptions : ModifiedKeySignature -> List (Html Msg)
transpositionOptions mks =
let
ks =
first mks
mode =
ks.mode
allModes =
[ option [ selectedKey ks (key C mode) ]
[ displayKeySig (key C mode) ]
, option [ selectedKey ks (key D mode) ]
[ displayKeySig (key D mode) ]
, option [ selectedKey ks (key E mode) ]
[ displayKeySig (key E mode) ]
, option [ selectedKey ks (key F mode) ]
[ displayKeySig (key F mode) ]
, option [ selectedKey ks (key G mode) ]
[ displayKeySig (key G mode) ]
, option [ selectedKey ks (key A mode) ]
[ displayKeySig (key A mode) ]
, option [ selectedKey ks (key B mode) ]
[ displayKeySig (key B mode) ]
]
majorMode =
[ option [ selectedKey ks (flatKey B Major) ]
[ displayKeySig (flatKey B Major) ]
, option [ selectedKey ks (flatKey A Major) ]
[ displayKeySig (flatKey A Major) ]
, option [ selectedKey ks (flatKey E Major) ]
[ displayKeySig (flatKey E Major) ]
]
minorMode =
[ option [ selectedKey ks (sharpKey F Minor) ]
[ displayKeySig (sharpKey F Minor) ]
, option [ selectedKey ks (sharpKey C Minor) ]
[ displayKeySig (sharpKey C Minor) ]
, option [ selectedKey ks (sharpKey G Minor) ]
[ displayKeySig (sharpKey G Minor) ]
]
in
case mode of
Major ->
allModes ++ majorMode
Minor ->
allModes ++ minorMode
_ ->
allModes
{- return a (selected true) attribute if the pattern key signature matches the target -}
selectedKey : KeySignature -> KeySignature -> Attribute Msg
selectedKey target pattern =
let
isMatched =
(target.pitchClass == pattern.pitchClass) && (target.accidental == pattern.accidental)
in
selected isMatched
{- display a key signature as text -}
displayKeySig : KeySignature -> Html Msg
displayKeySig ks =
let
accidental =
case ks.accidental of
Just Sharp ->
"#"
Just Flat ->
"b"
_ ->
""
in
text (toString ks.pitchClass ++ accidental ++ " " ++ toString ks.mode)
{- 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" )
]
leftPanelWidgetStyle : Attribute msg
leftPanelWidgetStyle =
style
[ ( "margin-left", "40px" )
, ( "margin-top", "40px" )
, ( "font-size", "1.2em" )
]
{- style a centered component -}
centreStyle : Attribute msg
centreStyle =
style
[ ( "text-align", "center" )
, ( "margin", "auto" )
]
leftPaneStyle : Attribute msg
leftPaneStyle =
style
[ ( "float", "left" )
, ( "width", "350px" )
]
rightPaneStyle : Attribute msg
rightPaneStyle =
style
[ ( "float", "left" )
]
{- 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" )
, ( "padding", "10px 10px 20px 10px" )
, ( "display", "inline-block" )
]
errorHighlightStyle : Attribute msg
errorHighlightStyle =
style
[ ( "color", "red" )
]
-- key signatures
key : PitchClass -> Mode -> KeySignature
key pc m =
{ pitchClass = pc, accidental = Nothing, mode = m }
sharpKey : PitchClass -> Mode -> KeySignature
sharpKey pc m =
{ pitchClass = pc, accidental = Just Sharp, mode = m }
flatKey : PitchClass -> Mode -> KeySignature
flatKey pc m =
{ pitchClass = pc, accidental = Just Flat, mode = m }
cMajor : ModifiedKeySignature
cMajor =
( { pitchClass = C, accidental = Nothing, mode = Major }, [] )
{- if there's no key signature in a properly parsed tune then default to C -}
defaultToC : Maybe ModifiedKeySignature -> Maybe ModifiedKeySignature
defaultToC mks =
case mks of
Just ks ->
mks
_ ->
Just cMajor