"Chord Drill Sergeant" -> "Learn Piano Chords"

In the spirit of "keep it simple, stupid", I am naming this application as
closely to the functionality as I can imagine.
This commit is contained in:
William Carroll 2020-04-18 13:30:38 +01:00
parent 39d084e493
commit f0803547e4
20 changed files with 16 additions and 15 deletions

View file

@ -0,0 +1,3 @@
/elm-stuff
/elm.js
/output.css

View file

@ -0,0 +1,58 @@
# Learn Piano Chords (LPC)
Are you a musician looking for a more effective way to improve your craft? Maybe
you're a music teacher looking to create useful exercises to give your students.
Studying music theory can be a fruitful undertaking, but it can often overwhelm
or bore students. I think that if practicing is enjoyable, students will
practice more. Practice doesn't make perfect; *perfect* practice makes perfect.
Learn Piano Chords is a web app that lowers the barrier to practicing and
internalizing music theory.
## How does it work?
1. Grab a cell phone or a laptop and your instrument.
2. Open a web browser and visit the Learn Piano Chords app (URL and app
forthcoming).
3. Set the tempo at which you would like to practice.
4. Set the target duration of your session.
5. Select the key(s) and chord(s) you would like to practice.
6. Set the tempo (i.e. pace) at which you would like to practice.
7. LPC will display chords at various rhythmic intervals during your practice
session. It is your job to play these chords in time before the next chord
appears.
## Highlights
Here are some useful features of LPC:
- Tempo: Set the rate at which LPC displays chords.
- Predefined practice sessions: LPC offers users a few practice sessions to get
users started. The goal, however, is to teach users to create their own
bespoke practice sessions. LPC aims to foster a community of practitioners who
curate and share their practice sessions.
- Whitelist / blacklist: Construct the set of chords you would like to
practice. Let's say you only want to practice triads in the keys of F, C, and
G. Would you also like to avoid diminished chords? Or maybe you *only* want to
practice major-7th chords for *all* keys. LPC supports all of these scenarios
and many others. You can save these chord configurations to reuse them at any
time. You can also share chord configurations with other LPC users if you find
the practice useful.
- Inversions: Every chord has inversions. For instance, every triad (i.e. chord
composed of three notes) has three inversions: root, second, and third
positions. LPC acknowledges all of the positions in which chords may appear
and helps you study all, some, or none of these inversions.
- Harmony: LPC understands basic harmony and can sort the chords you would like
to train in various harmonious permutations.
- Chaos-mode: Feeling confident? Throw the classical notions of harmony to the
wayside and use LPC in "chaos-mode" where LPC samples randomly from the Circle
of Fifths.
## Developing
If you're interested in contributing, the following will create an environment
in which you can develop:
```shell
$ nix-shell
$ elm-live -- src/Main.elm --output=elm.js
```

View file

@ -0,0 +1,60 @@
{ pkgs ? <nixpkgs>, ... }:
with pkgs;
let
mkDerivation =
{ srcs ? ./elm-srcs.nix
, src
, name
, srcdir ? "./src"
, targets ? []
, registryDat ? ./registry.dat
, outputJavaScript ? false
}:
stdenv.mkDerivation {
inherit name src;
buildInputs = [ elmPackages.elm ]
++ lib.optional outputJavaScript nodePackages_10_x.uglify-js;
buildPhase = pkgs.elmPackages.fetchElmDeps {
elmPackages = import srcs;
elmVersion = "0.19.1";
inherit registryDat;
};
installPhase = let
elmfile = module: "${srcdir}/${builtins.replaceStrings ["."] ["/"] module}.elm";
extension = if outputJavaScript then "js" else "html";
in ''
mkdir -p $out/share/doc
${lib.concatStrings (map (module: ''
echo "compiling ${elmfile module}"
elm make ${elmfile module} --output $out/${module}.${extension} --docs $out/share/doc/${module}.json
${lib.optionalString outputJavaScript ''
echo "minifying ${elmfile module}"
uglifyjs $out/${module}.${extension} --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' \
| uglifyjs --mangle --output=$out/${module}.min.${extension}
''}
'') targets)}
'';
};
mainDotElm = mkDerivation {
name = "elm-app-0.1.0";
srcs = ./elm-srcs.nix;
src = ./.;
targets = ["Main"];
srcdir = "./src";
outputJavaScript = true;
};
in stdenv.mkDerivation {
name = "learn-piano-chords";
buildInputs = [];
src = ./.;
buildPhase = ''
mkdir -p $out
cp index.html output.css ${mainDotElm}/Main.min.js $out
'';
dontInstall = true;
}

View file

@ -0,0 +1,3 @@
let
briefcase = import <briefcase> {};
in briefcase.utils.nixBufferFromShell ./shell.nix

View file

@ -0,0 +1,67 @@
{
"elm-community/maybe-extra" = {
sha256 = "0qslmgswa625d218djd3p62pnqcrz38f5p558mbjl6kc1ss0kzv3";
version = "5.2.0";
};
"elm/html" = {
sha256 = "1n3gpzmpqqdsldys4ipgyl1zacn0kbpc3g4v3hdpiyfjlgh8bf3k";
version = "1.0.0";
};
"elm-community/random-extra" = {
sha256 = "1dg2nz77w2cvp16xazbdsxkkw0xc9ycqpkd032faqdyky6gmz9g6";
version = "3.1.0";
};
"elm/svg" = {
sha256 = "1cwcj73p61q45wqwgqvrvz3aypjyy3fw732xyxdyj6s256hwkn0k";
version = "1.0.1";
};
"elm/browser" = {
sha256 = "0nagb9ajacxbbg985r4k9h0jadqpp0gp84nm94kcgbr5sf8i9x13";
version = "1.0.2";
};
"elm/core" = {
sha256 = "19w0iisdd66ywjayyga4kv2p1v9rxzqjaxhckp8ni6n8i0fb2dvf";
version = "1.0.5";
};
"elm-community/list-extra" = {
sha256 = "1ayv3148drynqnxdfwpjxal8vwzgsjqanjg7yxp6lhdcbkxgd3vd";
version = "8.2.3";
};
"elm/random" = {
sha256 = "138n2455wdjwa657w6sjq18wx2r0k60ibpc4frhbqr50sncxrfdl";
version = "1.0.0";
};
"elm/time" = {
sha256 = "0vch7i86vn0x8b850w1p69vplll1bnbkp8s383z7pinyg94cm2z1";
version = "1.0.0";
};
"elm/json" = {
sha256 = "0kjwrz195z84kwywaxhhlnpl3p251qlbm5iz6byd6jky2crmyqyh";
version = "1.1.3";
};
"owanturist/elm-union-find" = {
sha256 = "13gm7msnp0gr1lqia5m7m4lhy3m6kvjg37d304whb3psn88wqhj5";
version = "1.0.0";
};
"elm/url" = {
sha256 = "0av8x5syid40sgpl5vd7pry2rq0q4pga28b4yykn9gd9v12rs3l4";
version = "1.0.0";
};
"elm/virtual-dom" = {
sha256 = "0q1v5gi4g336bzz1lgwpn5b1639lrn63d8y6k6pimcyismp2i1yg";
version = "1.0.2";
};
}

View file

@ -0,0 +1,30 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/random": "1.0.0",
"elm/svg": "1.0.1",
"elm/time": "1.0.0",
"elm-community/list-extra": "8.2.3",
"elm-community/maybe-extra": "5.2.0",
"elm-community/random-extra": "3.1.0"
},
"indirect": {
"elm/json": "1.1.3",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2",
"owanturist/elm-union-find": "1.0.0"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View file

@ -0,0 +1,3 @@
* Support a frequency table of all of the chords
* Support using spaced-repetition to help populate the frequency table of chords
* If doing a frequency table, support left and right hands

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Learn Piano Chords</title>
<link rel="stylesheet" href="./output.css" />
<script src="./Main.min.js"></script>
</head>
<body class="font-serif">
<div id="mount"></div>
<script>
Elm.Main.init({node: document.getElementById("mount")});
</script>
</body>
</html>

Binary file not shown.

View file

@ -0,0 +1,9 @@
let
pkgs = import <nixpkgs> {};
in pkgs.mkShell {
buildInputs = with pkgs; [
elmPackages.elm
elmPackages.elm-format
elmPackages.elm-live
];
}

View file

@ -0,0 +1,15 @@
module ChordInspector exposing (render)
import Html exposing (..)
import NoteInspector
import Theory
render : Theory.Chord -> Html a
render chord =
case Theory.notesForChord chord of
Nothing ->
p [] [ text "Cannot retrieve the notes for the chord." ]
Just notes ->
NoteInspector.render notes

View file

@ -0,0 +1,44 @@
module Icon exposing (..)
import Svg exposing (node, svg)
import Svg.Attributes exposing (..)
import UI
svgColor color =
let
classes =
case color of
UI.Primary ->
[ "text-gray-500", "fill-current" ]
UI.Secondary ->
[ "text-gray-300", "fill-current" ]
in
class <| String.join " " classes
cog =
svg [ class "icon-cog", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
[ Svg.path
[ svgColor UI.Primary
, d "M6.8 3.45c.87-.52 1.82-.92 2.83-1.17a2.5 2.5 0 0 0 4.74 0c1.01.25 1.96.65 2.82 1.17a2.5 2.5 0 0 0 3.36 3.36c.52.86.92 1.8 1.17 2.82a2.5 2.5 0 0 0 0 4.74c-.25 1.01-.65 1.96-1.17 2.82a2.5 2.5 0 0 0-3.36 3.36c-.86.52-1.8.92-2.82 1.17a2.5 2.5 0 0 0-4.74 0c-1.01-.25-1.96-.65-2.82-1.17a2.5 2.5 0 0 0-3.36-3.36 9.94 9.94 0 0 1-1.17-2.82 2.5 2.5 0 0 0 0-4.74c.25-1.01.65-1.96 1.17-2.82a2.5 2.5 0 0 0 3.36-3.36zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"
, fill "red"
]
[]
, node "circle"
[ svgColor UI.Secondary, cx "12", cy "12", r "2" ]
[]
]
close =
svg [ class "icon-close", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
[ Svg.path
[ svgColor UI.Primary
, d "M15.78 14.36a1 1 0 0 1-1.42 1.42l-2.82-2.83-2.83 2.83a1 1 0 1 1-1.42-1.42l2.83-2.82L7.3 8.7a1 1 0 0 1 1.42-1.42l2.83 2.83 2.82-2.83a1 1 0 0 1 1.42 1.42l-2.83 2.83 2.83 2.82z"
, fill "red"
, fillRule "evenodd"
]
[]
]

View file

@ -0,0 +1,555 @@
module Main exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Icon
import Piano
import Random
import Random.List
import Tempo
import Theory
import Time exposing (..)
import UI
type alias Model =
{ whitelistedChords : List Theory.Chord
, whitelistedChordTypes : List Theory.ChordType
, whitelistedInversions : List Theory.ChordInversion
, whitelistedPitchClasses : List Theory.PitchClass
, whitelistedKeys : List Theory.Key
, selectedChord : Maybe Theory.Chord
, isPaused : Bool
, tempo : Int
, firstNote : Theory.Note
, lastNote : Theory.Note
, practiceMode : PracticeMode
, view : View
}
type View
= Preferences
| Practice
{-| Control the type of practice you'd like.
-}
type PracticeMode
= KeyMode
| FineTuneMode
type Msg
= NextChord
| NewChord Theory.Chord
| Play
| Pause
| IncreaseTempo
| DecreaseTempo
| SetTempo String
| ToggleInversion Theory.ChordInversion
| ToggleChordType Theory.ChordType
| TogglePitchClass Theory.PitchClass
| ToggleKey Theory.Key
| DoNothing
| SetPracticeMode PracticeMode
| SelectAllKeys
| DeselectAllKeys
| SetView View
{-| The amount by which we increase or decrease tempo.
-}
tempoStep : Int
tempoStep =
5
{-| Return the number of milliseconds that elapse during an interval in a
`target` bpm.
-}
bpmToMilliseconds : Int -> Int
bpmToMilliseconds target =
let
msPerMinute =
1000 * 60
in
round (toFloat msPerMinute / toFloat target)
{-| The initial state for the application.
-}
init : Model
init =
let
( firstNote, lastNote ) =
( Theory.C3, Theory.C6 )
inversions =
Theory.allInversions
chordTypes =
Theory.allChordTypes
pitchClasses =
Theory.allPitchClasses
keys =
[]
practiceMode =
KeyMode
in
{ practiceMode = practiceMode
, whitelistedChords =
case practiceMode of
KeyMode ->
keys |> List.concatMap Theory.chordsForKey
FineTuneMode ->
Theory.allChords
{ start = firstNote
, end = lastNote
, inversions = inversions
, chordTypes = chordTypes
, pitchClasses = pitchClasses
}
, whitelistedChordTypes = chordTypes
, whitelistedInversions = inversions
, whitelistedPitchClasses = pitchClasses
, whitelistedKeys = keys
, selectedChord = Nothing
, isPaused = True
, tempo = 20
, firstNote = firstNote
, lastNote = lastNote
, view = Preferences
}
subscriptions : Model -> Sub Msg
subscriptions { isPaused, tempo } =
if isPaused then
Sub.none
else
Time.every (tempo |> bpmToMilliseconds |> toFloat) (\_ -> NextChord)
{-| Now that we have state, we need a function to change the state.
-}
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
DoNothing ->
( model, Cmd.none )
SetPracticeMode practiceMode ->
( { model
| practiceMode = practiceMode
, isPaused = True
}
, Cmd.none
)
SetView x ->
( { model
| view = x
, isPaused = True
}
, Cmd.none
)
SelectAllKeys ->
( { model
| whitelistedKeys = Theory.allKeys
, whitelistedChords =
Theory.allKeys |> List.concatMap Theory.chordsForKey
}
, Cmd.none
)
DeselectAllKeys ->
( { model
| whitelistedKeys = []
, whitelistedChords = []
}
, Cmd.none
)
NewChord chord ->
( { model | selectedChord = Just chord }
, Cmd.none
)
NextChord ->
( model
, Random.generate
(\x ->
case x of
( Just chord, _ ) ->
NewChord chord
( Nothing, _ ) ->
DoNothing
)
(Random.List.choose model.whitelistedChords)
)
Play ->
( { model | isPaused = False }
, Cmd.none
)
Pause ->
( { model | isPaused = True }
, Cmd.none
)
IncreaseTempo ->
( { model | tempo = model.tempo + tempoStep }
, Cmd.none
)
DecreaseTempo ->
( { model | tempo = model.tempo - tempoStep }
, Cmd.none
)
ToggleChordType chordType ->
let
chordTypes =
if List.member chordType model.whitelistedChordTypes then
List.filter ((/=) chordType) model.whitelistedChordTypes
else
chordType :: model.whitelistedChordTypes
in
( { model
| whitelistedChordTypes = chordTypes
, whitelistedChords =
Theory.allChords
{ start = model.firstNote
, end = model.lastNote
, inversions = model.whitelistedInversions
, chordTypes = chordTypes
, pitchClasses = model.whitelistedPitchClasses
}
}
, Cmd.none
)
ToggleInversion inversion ->
let
inversions =
if List.member inversion model.whitelistedInversions then
List.filter ((/=) inversion) model.whitelistedInversions
else
inversion :: model.whitelistedInversions
in
( { model
| whitelistedInversions = inversions
, whitelistedChords =
Theory.allChords
{ start = model.firstNote
, end = model.lastNote
, inversions = inversions
, chordTypes = model.whitelistedChordTypes
, pitchClasses = model.whitelistedPitchClasses
}
}
, Cmd.none
)
TogglePitchClass pitchClass ->
let
pitchClasses =
if List.member pitchClass model.whitelistedPitchClasses then
List.filter ((/=) pitchClass) model.whitelistedPitchClasses
else
pitchClass :: model.whitelistedPitchClasses
in
( { model
| whitelistedPitchClasses = pitchClasses
, whitelistedChords =
Theory.allChords
{ start = model.firstNote
, end = model.lastNote
, inversions = model.whitelistedInversions
, chordTypes = model.whitelistedChordTypes
, pitchClasses = pitchClasses
}
}
, Cmd.none
)
ToggleKey key ->
let
keys =
if List.member key model.whitelistedKeys then
List.filter ((/=) key) model.whitelistedKeys
else
key :: model.whitelistedKeys
in
( { model
| whitelistedKeys = keys
, whitelistedChords =
keys |> List.concatMap Theory.chordsForKey
}
, Cmd.none
)
SetTempo tempo ->
( { model
| tempo =
case String.toInt tempo of
Just x ->
x
Nothing ->
model.tempo
}
, Cmd.none
)
playPause : Model -> Html Msg
playPause { isPaused } =
if isPaused then
button [ onClick Play ] [ text "Play" ]
else
button [ onClick Pause ] [ text "Pause" ]
chordTypeCheckboxes : List Theory.ChordType -> Html Msg
chordTypeCheckboxes chordTypes =
ul []
(Theory.allChordTypes
|> List.map
(\chordType ->
li []
[ label [] [ text (Theory.chordTypeName chordType) ]
, input
[ type_ "checkbox"
, onClick (ToggleChordType chordType)
, checked (List.member chordType chordTypes)
]
[]
]
)
)
inversionCheckboxes : List Theory.ChordInversion -> Html Msg
inversionCheckboxes inversions =
ul []
(Theory.allInversions
|> List.map
(\inversion ->
li []
[ label [] [ text (Theory.inversionName inversion) ]
, input
[ type_ "checkbox"
, onClick (ToggleInversion inversion)
, checked (List.member inversion inversions)
]
[]
]
)
)
selectKey :
Model
->
{ relativeMajor : Theory.Key
, relativeMinor : Theory.Key
}
-> Html Msg
selectKey model { relativeMajor, relativeMinor } =
let
active key =
List.member key model.whitelistedKeys
buttonLabel major minor =
Theory.viewKey major ++ ", " ++ Theory.viewKey minor
in
div [ class "flex pt-0" ]
[ UI.textToggleButton
{ label = buttonLabel relativeMajor relativeMinor
, handleClick = ToggleKey relativeMinor
, classes = [ "flex-1" ]
, toggled = active relativeMinor
}
]
keyCheckboxes : Model -> Html Msg
keyCheckboxes model =
let
majorKey pitchClass =
{ pitchClass = pitchClass, mode = Theory.MajorMode }
minorKey pitchClass =
{ pitchClass = pitchClass, mode = Theory.MinorMode }
circleOfFifths =
[ ( Theory.C, Theory.A )
, ( Theory.G, Theory.E )
, ( Theory.D, Theory.B )
, ( Theory.A, Theory.F_sharp )
, ( Theory.E, Theory.C_sharp )
, ( Theory.B, Theory.G_sharp )
, ( Theory.F_sharp, Theory.D_sharp )
, ( Theory.C_sharp, Theory.A_sharp )
, ( Theory.G_sharp, Theory.F )
, ( Theory.D_sharp, Theory.C )
, ( Theory.A_sharp, Theory.G )
, ( Theory.F, Theory.D )
]
in
div []
[ h2 [ class "text-gray-500 text-center pt-10 text-5xl" ] [ text "Select keys" ]
, ul []
(circleOfFifths
|> List.map
(\( major, minor ) ->
selectKey model
{ relativeMajor = majorKey major
, relativeMinor = minorKey minor
}
)
)
]
practiceModeButtons : Model -> Html Msg
practiceModeButtons model =
div [ class "text-center" ]
[ h2 [ class "py-10 text-5xl" ] [ text "Practice Mode" ]
, div [ class "flex pb-6" ]
[ UI.simpleButton
{ label = "Key"
, classes = [ "flex-1", "rounded-r-none" ]
, handleClick = SetPracticeMode KeyMode
, color =
if model.practiceMode == KeyMode then
UI.Primary
else
UI.Secondary
}
, UI.simpleButton
{ label = "Fine Tune"
, handleClick = SetPracticeMode FineTuneMode
, classes = [ "flex-1", "rounded-l-none" ]
, color =
if model.practiceMode == FineTuneMode then
UI.Primary
else
UI.Secondary
}
]
]
openPreferences : Html Msg
openPreferences =
button
[ class "w-48 h-48 absolute left-0 top-0 z-20"
, onClick (SetView Preferences)
]
[ Icon.cog ]
closePreferences : Html Msg
closePreferences =
button
[ class "w-48 h-48 absolute right-0 top-0 z-10"
, onClick (SetView Practice)
]
[ Icon.close ]
preferences : Model -> Html Msg
preferences model =
div [ class "pt-10 pb-20 px-10" ]
[ closePreferences
, Tempo.render
{ tempo = model.tempo
, handleInput = SetTempo
}
, case model.practiceMode of
KeyMode ->
keyCheckboxes model
FineTuneMode ->
div []
[ inversionCheckboxes model.whitelistedInversions
, chordTypeCheckboxes model.whitelistedChordTypes
]
]
practice : Model -> Html Msg
practice model =
let
classes =
[ "bg-gray-600"
, "h-screen"
, "w-full"
, "absolute"
, "z-10"
, "text-6xl"
]
( handleClick, extraClasses, buttonText ) =
if model.isPaused then
( Play, [ "opacity-50" ], "Press to practice" )
else
( Pause, [ "opacity-0" ], "" )
in
div []
[ button
[ [ classes, extraClasses ] |> List.concat |> UI.tw |> class
, onClick handleClick
]
[ text buttonText
]
, openPreferences
, Piano.render
{ highlight = model.selectedChord |> Maybe.andThen Theory.notesForChord |> Maybe.withDefault []
, start = model.firstNote
, end = model.lastNote
}
]
view : Model -> Html Msg
view model =
case model.view of
Preferences ->
preferences model
Practice ->
practice model
{-| For now, I'm just dumping things onto the page to sketch ideas.
-}
main =
Browser.element
{ init = \() -> ( init, Cmd.none )
, subscriptions = subscriptions
, update = update
, view = view
}

View file

@ -0,0 +1,47 @@
module Misc exposing (..)
import Array exposing (Array)
comesAfter : a -> List a -> Maybe a
comesAfter x xs =
case xs of
[] ->
Nothing
_ :: [] ->
Nothing
y :: z :: rest ->
if y == x then
Just z
else
comesAfter x (z :: rest)
comesBefore : a -> List a -> Maybe a
comesBefore x xs =
case xs of
[] ->
Nothing
_ :: [] ->
Nothing
y :: z :: rest ->
if z == x then
Just y
else
comesBefore x (z :: rest)
find : (a -> Bool) -> List a -> Maybe a
find pred xs =
case xs |> List.filter pred of
[] ->
Nothing
x :: _ ->
Just x

View file

@ -0,0 +1,238 @@
module Piano exposing (render)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import List.Extra
import Theory
{-| On mobile phones, the keyboard displays vertically.
-}
type Direction
= Horizontal
| Vertical
type alias KeyMarkup a =
{ offset : Int
, isHighlit : Bool
, note : Theory.Note
, direction : Direction
}
-> Html a
type alias Props =
{ highlight : List Theory.Note
, start : Theory.Note
, end : Theory.Note
}
{-| Convert an integer into its pixel representation for CSS.
-}
pixelate : Int -> String
pixelate x =
String.fromInt x ++ "px"
{-| Pixel width of the white keys.
-}
naturalWidth : Direction -> Int
naturalWidth direction =
case direction of
Vertical ->
-- Right now, I'm designing this specifically for my Google Pixel 4
-- phone, which has a screen width of 1080px.
1080
Horizontal ->
45
{-| Pixel height of the white keys.
-}
naturalHeight : Direction -> Int
naturalHeight direction =
case direction of
Vertical ->
-- Right now, I'm designing this specifically for my Google Pixel 4
-- phone, which has a screen height of 2280px. 2280 / 21
-- (i.e. no. natural keys) ~= 108
108
Horizontal ->
250
{-| Pixel width of the black keys.
-}
accidentalWidth : Direction -> Int
accidentalWidth direction =
case direction of
Vertical ->
round (toFloat (naturalWidth direction) * 0.6)
Horizontal ->
round (toFloat (naturalWidth direction) * 0.4)
{-| Pixel height of the black keys.
-}
accidentalHeight : Direction -> Int
accidentalHeight direction =
case direction of
Vertical ->
round (toFloat (naturalHeight direction) * 0.63)
Horizontal ->
round (toFloat (naturalHeight direction) * 0.63)
{-| Return the markup for either a white or a black key.
-}
pianoKey : KeyMarkup a
pianoKey { offset, isHighlit, note, direction } =
let
sharedClasses =
[ "box-border" ]
{ keyWidth, keyHeight, keyColor, offsetEdge, extraClasses } =
case ( Theory.keyClass note, direction ) of
( Theory.Natural, Vertical ) ->
{ keyWidth = naturalWidth Vertical
, keyHeight = naturalHeight Vertical
, keyColor = "white"
, offsetEdge = "top"
, extraClasses = []
}
( Theory.Natural, Horizontal ) ->
{ keyWidth = naturalWidth Horizontal
, keyHeight = naturalHeight Horizontal
, keyColor = "white"
, offsetEdge = "left"
, extraClasses = []
}
( Theory.Accidental, Vertical ) ->
{ keyWidth = accidentalWidth Vertical
, keyHeight = accidentalHeight Vertical
, keyColor = "black"
, offsetEdge = "top"
, extraClasses = [ "z-10" ]
}
( Theory.Accidental, Horizontal ) ->
{ keyWidth = accidentalWidth Horizontal
, keyHeight = accidentalHeight Horizontal
, keyColor = "black"
, offsetEdge = "left"
, extraClasses = [ "z-10" ]
}
in
div
[ style "background-color"
(if isHighlit then
"red"
else
keyColor
)
, style "border-top" "1px solid black"
, style "border-bottom" "1px solid black"
, style "border-left" "1px solid black"
, style "border-right" "1px solid black"
, style "width" (pixelate keyWidth)
, style "height" (pixelate keyHeight)
, style "position" "absolute"
, style offsetEdge (String.fromInt offset ++ "px")
, class <| String.join " " (List.concat [ sharedClasses, extraClasses ])
]
[]
{-| A section of the piano consisting of all twelve notes.
-}
keys : Direction -> Theory.Note -> Theory.Note -> List Theory.Note -> List (Html a)
keys direction start end highlight =
let
isHighlit note =
List.member note highlight
spacing prevOffset prev curr =
case ( Theory.keyClass prev, Theory.keyClass curr, direction ) of
-- Horizontal
( Theory.Natural, Theory.Accidental, Horizontal ) ->
prevOffset + naturalWidth direction - round (toFloat (accidentalWidth direction) / 2)
( Theory.Accidental, Theory.Natural, Horizontal ) ->
prevOffset + round (toFloat (accidentalWidth direction) / 2)
( Theory.Natural, Theory.Natural, Horizontal ) ->
prevOffset + naturalWidth direction
-- Vertical
( Theory.Natural, Theory.Accidental, Vertical ) ->
prevOffset + naturalHeight direction - round (toFloat (accidentalHeight direction) / 2)
( Theory.Accidental, Theory.Natural, Vertical ) ->
prevOffset + round (toFloat (accidentalHeight direction) / 2)
( Theory.Natural, Theory.Natural, Vertical ) ->
prevOffset + naturalHeight direction
-- This pattern should never hit.
_ ->
prevOffset
( _, _, notes ) =
Theory.notesFromRange start end
|> List.foldl
(\curr ( prevOffset, prev, result ) ->
case ( prevOffset, prev ) of
( Nothing, Nothing ) ->
( Just 0
, Just curr
, pianoKey
{ offset = 0
, isHighlit = List.member curr highlight
, note = curr
, direction = direction
}
:: result
)
( Just po, Just p ) ->
let
offset =
spacing po p curr
in
( Just offset
, Just curr
, pianoKey
{ offset = offset
, isHighlit = List.member curr highlight
, note = curr
, direction = direction
}
:: result
)
-- This pattern should never hit.
_ ->
( Nothing, Nothing, [] )
)
( Nothing, Nothing, [] )
in
List.reverse notes
{-| Return the HTML that renders a piano representation.
-}
render : Props -> Html a
render { highlight, start, end } =
div [ style "display" "flex" ]
(keys Vertical start end highlight |> List.reverse |> List.repeat 1 |> List.concat)

View file

@ -0,0 +1,24 @@
module Tempo exposing (render)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import UI
type alias Props msg =
{ tempo : Int
, handleInput : String -> msg
}
render : Props msg -> Html msg
render { tempo, handleInput } =
div [ class "text-center" ]
[ p [ class "text-5xl py-10" ] [ text (String.fromInt tempo ++ " BPM") ]
, UI.textField
{ placeholderText = "Set tempo..."
, handleInput = handleInput
, classes = []
}
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
module UI exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
type Color
= Primary
| Secondary
bgForColor : Color -> String
bgForColor color =
case color of
Primary ->
"bg-gray-600"
Secondary ->
"bg-gray-300"
textForColor : Color -> String
textForColor color =
case color of
Primary ->
"text-white"
Secondary ->
"text-black"
tw : List String -> String
tw styles =
String.join " " styles
simpleButton :
{ label : String
, handleClick : msg
, color : Color
, classes : List String
}
-> Html msg
simpleButton { label, handleClick, color, classes } =
let
buttonClasses =
[ bgForColor color
, textForColor color
, "py-10"
, "px-20"
, "text-5xl"
, "rounded-lg"
]
in
button
[ class (tw <| List.concat [ buttonClasses, classes ])
, onClick handleClick
]
[ text label ]
textToggleButton :
{ label : String
, handleClick : msg
, classes : List String
, toggled : Bool
}
-> Html msg
textToggleButton { label, toggled, handleClick, classes } =
let
( textColor, textTreatment ) =
if toggled then
( "text-red-600", "underline" )
else
( "text-black", "no-underline" )
buttonClasses =
[ textColor
, textTreatment
, "py-8"
, "px-10"
, "text-5xl"
]
in
button
[ class (tw <| List.concat [ buttonClasses, classes ])
, onClick handleClick
]
[ text label ]
textField :
{ placeholderText : String
, handleInput : String -> msg
, classes : List String
}
-> Html msg
textField { placeholderText, handleInput, classes } =
let
inputClasses =
[ "text-5xl"
, "w-full"
, "py-10"
, "px-16"
, "border"
, "rounded-lg"
]
in
input
[ class (tw <| List.concat [ inputClasses, classes ])
, onInput handleInput
, placeholder placeholderText
]
[]