├── resources
├── schema.edn
└── public
│ ├── favicon.ico
│ ├── BravuraText.woff2
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── mstile-150x150.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── brain.svg
│ └── safari-pinned-tab.svg
├── dev
├── user.clj
└── virtuoso
│ ├── dev.clj
│ ├── dev.cljs
│ └── export.clj
├── figwheel-main.edn
├── bb.edn
├── tests.edn
├── deps.local.sample.edn
├── bin
└── launchpad
├── tf
├── distribution
│ ├── outputs.tf
│ ├── rewrite-lambda.js
│ ├── variables.tf
│ ├── headers-lambda.js
│ └── main.tf
├── bucket
│ ├── variables.tf
│ ├── outputs.tf
│ └── main.tf
├── config.tf
└── main.tf
├── package.json
├── src
├── virtuoso
│ ├── ui
│ │ ├── core.cljs
│ │ ├── db.cljc
│ │ ├── actions.cljc
│ │ └── main.cljs
│ ├── elements
│ │ ├── musical_notation_selection.cljc
│ │ ├── typography.cljc
│ │ ├── badge.cljc
│ │ ├── button_panel.cljc
│ │ ├── colored_boxes.cljc
│ │ ├── button.cljc
│ │ ├── layout.cljc
│ │ ├── page.cljc
│ │ ├── modal.cljc
│ │ ├── icon_button.cljc
│ │ ├── form.cljc
│ │ ├── musical_notation.cljc
│ │ ├── bar.cljc
│ │ └── brain.cljc
│ ├── pages
│ │ ├── not_found.clj
│ │ ├── frontpage.clj
│ │ ├── interleaved_clickup.cljc
│ │ └── metronome.cljc
│ ├── core.clj
│ ├── interleaved_clickup.cljc
│ └── metronome.cljc
└── main.css
├── .dir-locals.el
├── prod.cljs.edn
├── .gitignore
├── portfolio
└── virtuoso
│ ├── elements
│ ├── brain_scenes.cljs
│ ├── layout_scenes.cljs
│ ├── typography_scenes.cljs
│ ├── button_scenes.cljs
│ ├── metronome_scenes.cljs
│ ├── musical_notation_selection_scenes.cljs
│ ├── colored_boxes_scenes.cljs
│ ├── button_panel_scenes.cljs
│ ├── form_scenes.cljs
│ ├── musical_notation_scenes.cljs
│ ├── icon_button_scenes.cljs
│ └── bar_scenes.cljs
│ └── scenes.cljs
├── portfolio-prod.cljs.edn
├── dev-resources
└── public
│ ├── index.html
│ └── canvas.html
├── dev.cljs.edn
├── deploy.sh
├── content
└── pages.edn
├── Makefile
├── deps.edn
├── tailwind.config.js
├── test
└── virtuoso
│ ├── pages
│ └── interleaved_clickup_test.clj
│ ├── test_helper.clj
│ ├── elements
│ ├── form_test.clj
│ └── musical_notation_test.cljc
│ ├── interleaved_clickup_test.clj
│ └── metronome_test.clj
└── README.md
/resources/schema.edn:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/dev/user.clj:
--------------------------------------------------------------------------------
1 | (ns user
2 | (:require [virtuoso.dev]))
3 |
--------------------------------------------------------------------------------
/figwheel-main.edn:
--------------------------------------------------------------------------------
1 | {:ring-server-options {:port 4847}
2 | :open-url false}
3 |
--------------------------------------------------------------------------------
/bb.edn:
--------------------------------------------------------------------------------
1 | {:deps {com.lambdaisland/launchpad {:mvn/version "0.31.142-alpha"}}}
2 |
--------------------------------------------------------------------------------
/tests.edn:
--------------------------------------------------------------------------------
1 | #kaocha/v1
2 | {:plugins [:noyoda.plugin/swap-actual-and-expected]}
3 |
--------------------------------------------------------------------------------
/deps.local.sample.edn:
--------------------------------------------------------------------------------
1 | {:launchpad/aliases [:dev]
2 | :launchpad/options {:emacs true}}
3 |
--------------------------------------------------------------------------------
/resources/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/favicon.ico
--------------------------------------------------------------------------------
/bin/launchpad:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bb
2 |
3 | (require '[lambdaisland.launchpad :as launchpad])
4 |
5 | (launchpad/main {})
6 |
--------------------------------------------------------------------------------
/resources/public/BravuraText.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/BravuraText.woff2
--------------------------------------------------------------------------------
/resources/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/favicon-16x16.png
--------------------------------------------------------------------------------
/resources/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/favicon-32x32.png
--------------------------------------------------------------------------------
/resources/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/resources/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/mstile-150x150.png
--------------------------------------------------------------------------------
/tf/distribution/outputs.tf:
--------------------------------------------------------------------------------
1 | output "distribution_arn" {
2 | value = "${aws_cloudfront_distribution.s3_distribution.arn}"
3 | }
4 |
--------------------------------------------------------------------------------
/resources/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/resources/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/virtuoso/HEAD/resources/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@tailwindcss/typography": "^0.5.10",
4 | "daisyui": "^4.6.0",
5 | "tailwindcss": "^3.4.0"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/virtuoso/ui/core.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.ui.core
2 | (:require [virtuoso.ui.main :as virtuoso]))
3 |
4 | (defonce ^:export kicking-out-the-jams (virtuoso/boot))
5 |
--------------------------------------------------------------------------------
/.dir-locals.el:
--------------------------------------------------------------------------------
1 | ((nil
2 | (cider-clojure-cli-aliases . "-A:dev")
3 | (cider-preferred-build-tool . clojure-cli)
4 | (cider-default-cljs-repl . figwheel-main)
5 | (cider-figwheel-main-default-options . ":dev")))
6 |
--------------------------------------------------------------------------------
/prod.cljs.edn:
--------------------------------------------------------------------------------
1 | {:main virtuoso.ui.core
2 | :optimizations :advanced
3 | :pretty-print false
4 | :source-map "target/public/js/compiled/app.js.map"
5 | :asset-path "/js/compiled/out"
6 | :output-to "target/public/js/compiled/app.js"
7 | :output-dir "target/public/js/compiled/out"}
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.lsp/
2 | /.cpcache/
3 | /.nrepl-port
4 | /node_modules/
5 | /.clj-kondo/
6 | /resources/public/tailwind.css
7 | /resources/fontawesome-icons/
8 | dev-resources/public/js
9 | /target/
10 | /tf/.terraform.lock.hcl
11 | /tf/.terraform/
12 | /tf/distribution/.zip/
13 | /deps.local.edn
14 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/brain_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.brain-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.brain :as brain]))
4 |
5 | (defscene brain
6 | (brain/brain))
7 |
8 | (defscene brain-only
9 | (brain/brain {:text? false}))
10 |
--------------------------------------------------------------------------------
/tf/distribution/rewrite-lambda.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | exports.handler = (event, context, callback) => {
4 | const request = event.Records[0].cf.request;
5 |
6 | if (request.uri && /\/$/.test(request.uri)) {
7 | request.uri = request.uri + "index.html";
8 | }
9 |
10 | callback(null, request);
11 | };
12 |
--------------------------------------------------------------------------------
/portfolio-prod.cljs.edn:
--------------------------------------------------------------------------------
1 | {:main virtuoso.scenes
2 | :optimizations :advanced
3 | :pretty-print true
4 | :source-map "target/public/js/compiled/app-portfolio.js.map"
5 | :asset-path "/js/compiled/out"
6 | :output-to "target/public/js/compiled/app-portfolio.js"
7 | :output-dir "target/public/js/compiled/out"
8 | :infer-externs true}
9 |
--------------------------------------------------------------------------------
/dev-resources/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Portfolio
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/dev-resources/public/canvas.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Portfolio
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/dev.cljs.edn:
--------------------------------------------------------------------------------
1 | ^{:watch-dirs ["src" "dev" "portfolio"]
2 | :css-dirs ["resources/public"]
3 | :extra-main-files {:portfolio {:main virtuoso.scenes}}}
4 | {:main virtuoso.dev
5 | :optimizations :none
6 | :pretty-print true
7 | :source-map true
8 | :asset-path "/js/compiled/dev"
9 | :output-to "dev-resources/public/js/compiled/app.js"
10 | :output-dir "dev-resources/public/js/compiled/dev"}
11 |
--------------------------------------------------------------------------------
/tf/bucket/variables.tf:
--------------------------------------------------------------------------------
1 | variable "bucket_name" {
2 | description = "Bucket to store files in"
3 | type = string
4 | }
5 |
6 | variable "app_name" {
7 | description = "Descriptive name"
8 | type = string
9 | }
10 |
11 | variable "domain_name" {
12 | description = "Domain name"
13 | type = string
14 | }
15 |
16 | variable "hosted_zone" {
17 | description = "Hosted zone"
18 | type = string
19 | }
20 |
--------------------------------------------------------------------------------
/src/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | @media (min-width: 800px) {
7 | html {
8 | font-size: 20px;
9 | }
10 | }
11 |
12 | @font-face {
13 | font-family: 'Bravura';
14 | src: url('/BravuraText.woff2') format('woff2');
15 | font-weight: normal;
16 | font-style: normal;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/dev/virtuoso/dev.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.dev
2 | (:require [powerpack.dev :as dev]
3 | [virtuoso.core :as virtuoso]
4 | [virtuoso.export :as export]))
5 |
6 | (defmethod dev/configure! :default []
7 | (virtuoso/create-app :dev))
8 |
9 | (comment
10 | (set! *print-namespace-maps* false)
11 | (export/export)
12 | )
13 |
14 | (comment ;; s-:
15 | (dev/start)
16 | (dev/stop)
17 | (dev/reset)
18 | )
19 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/layout_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.layout-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.layout :as layout]
4 | [virtuoso.elements.typography :as t]))
5 |
6 | (defscene box
7 | (layout/box {}
8 | (t/h2 "A box")
9 | (t/p "This here is a real nice box")))
10 |
11 | (defscene header
12 | (layout/header {:title "Interleaved Clicking Up"}))
13 |
--------------------------------------------------------------------------------
/tf/bucket/outputs.tf:
--------------------------------------------------------------------------------
1 | output "bucket_regional_domain_name" {
2 | value = "${aws_s3_bucket.bucket.bucket_regional_domain_name}"
3 | }
4 |
5 | output "cloudfront_access_identity_path" {
6 | value = "${aws_cloudfront_origin_access_identity.identity.cloudfront_access_identity_path}"
7 | }
8 |
9 | output "certificate_arn" {
10 | value = "${aws_acm_certificate.cert.arn}"
11 | }
12 |
13 | output "s3_bucket_arn" {
14 | value = "${aws_s3_bucket.bucket.arn}"
15 | }
16 |
--------------------------------------------------------------------------------
/tf/config.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | aws = {
4 | source = "hashicorp/aws"
5 | version = "~> 3.0"
6 | }
7 | }
8 |
9 | backend "s3" {
10 | bucket = "terraform-state-cjohansen"
11 | key = "virtuoso.tools/terraform.tfstate"
12 | region = "eu-west-1"
13 | }
14 | }
15 |
16 | provider "aws" {
17 | region = "eu-west-1"
18 | }
19 |
20 | provider "aws" {
21 | alias = "us-east-1"
22 | region = "us-east-1"
23 | }
24 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/musical_notation_selection.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.musical-notation-selection
2 | (:require [virtuoso.elements.musical-notation :as mn]))
3 |
4 | (defn musical-notation-selection [{:keys [items]}]
5 | [:ul.menu.menu-horizontal.rounded-box.bg-base-100.gap-2
6 | (for [{:keys [notation active? actions]} items]
7 | [:li
8 | [:a.text-2xl
9 | {:on {:click actions}
10 | :class (when active? [:active])}
11 | (mn/render notation)]])])
12 |
--------------------------------------------------------------------------------
/src/virtuoso/pages/not_found.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.pages.not-found
2 | (:require [virtuoso.elements.brain :as brain]
3 | [virtuoso.elements.layout :as layout]))
4 |
5 | (defn render-page [_ctx _page]
6 | (layout/layout
7 | (layout/flex-container
8 | [:main.grow.flex.flex-col.justify-center
9 | (brain/brain)
10 | [:p.my-6.opacity-80
11 | "Sorry, that page doesn't exist."]]
12 | [:footer
13 | [:p.my-4.opacity-80
14 | "Made by " [:a.link {:href "https://cjohansen.no"} "Christian Johansen"]]])))
15 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/typography.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.typography)
2 |
3 | (defn h1
4 | ([text] (h1 nil text))
5 | ([props text]
6 | [:h1.text-2xl.md:text-3xl
7 | props
8 | text]))
9 |
10 | (defn h2
11 | ([text] (h2 nil text))
12 | ([_props text]
13 | [:h2.mb-4
14 | text]))
15 |
16 | (defn p
17 | ([text] (p nil text))
18 | ([props & texts]
19 | [:p.opacity-80.my-4.text-sm.last:mb-0 props texts]))
20 |
21 | (defn preformatted [text]
22 | [:pre.whitespace-pre.font-mono.my-8.text-xs.md:text-sm
23 | text])
24 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/badge.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.badge)
2 |
3 | (defn badge [{:keys [text]}]
4 | [:div.flex.justify-center.text-8xl text])
5 |
6 | (def themes
7 | {:neutral "border-neutral"
8 | :success "border-success"})
9 |
10 | (defn round-badge [{:keys [text label theme]}]
11 | [:div.flex.justify-center.relative
12 | [:div.rounded-full.border-4.w-44.h-44.flex.items-center.justify-center.flex-col
13 | {:class (themes (or theme :neutral))}
14 | [:div.text-7xl.relative.-top-2
15 | text]
16 | [:div.opacity-40.absolute.bottom-8 label]]])
17 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/button_panel.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.button-panel
2 | (:require [virtuoso.elements.icon-button :as icon-button]))
3 |
4 | (defn button-panel [{:keys [buttons]}]
5 | [:nav
6 | [:div.flex.items-center.gap-4.justify-center
7 | (map #(icon-button/icon-button
8 | (cond-> %
9 | (and (not= :large (:size %)) (not (:icon-size %)))
10 | (assoc :icon-size :small))) buttons)]
11 | (when-let [kbds (seq (keep :kbd buttons))]
12 | [:div.flex.items-center.gap-8.justify-center.mt-4.max-md:hidden
13 | (for [k kbds]
14 | [:kbd.kbd.kbd-sm k])])])
15 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/colored_boxes.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.colored-boxes)
2 |
3 | (def colors
4 | ["bg-error" "bg-warning" "bg-success" "bg-info"
5 | "bg-accent" "bg-secondary" "bg-primary"])
6 |
7 | (defn colored-boxes [{:keys [boxes footer]}]
8 | [:div.flex.flex-col.justify-center
9 | [:div.flex.gap-2.justify-center.mb-8.flex-wrap
10 | (let [n (count colors)]
11 | (for [{:keys [text color-idx]} boxes]
12 | [:div.text-primary-content.rounded.py-2.px-4.font-bold
13 | {:class (get colors (mod color-idx n))}
14 | text]))]
15 | [:p.text-center
16 | (:text footer)]])
17 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/typography_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.typography-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.typography :as typo]))
4 |
5 | (defscene h1
6 | (typo/h1 "A top level heading"))
7 |
8 | (defscene h2
9 | (typo/h2 "A second level heading"))
10 |
11 | (defscene p
12 | (typo/p "A paragraph of text"))
13 |
14 | (defscene typography
15 | [:div
16 | (typo/h1 "Some example text")
17 | (typo/p "This is a demonstration of the typography with multiple elements.")
18 | (typo/h2 "Important!")
19 | (typo/p "This is a further demonstration of the typography with multiple elements.")])
20 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/button.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.button
2 | (:require [phosphor.icons :as icons]))
3 |
4 | (defn button [{:keys [text actions spinner? left-icon right-icon subtle?] :as btn}]
5 | [:button.btn.max-sm:btn-block
6 | (cond-> (dissoc btn :text :spinner? :left-icon :right-icon :actions :subtle?)
7 | actions (assoc-in [:on :click] actions)
8 | subtle? (assoc :class "btn-neutral")
9 | (not subtle?) (assoc :class "btn-primary"))
10 | (when spinner?
11 | [:span.loading.loading-spinner])
12 | (when left-icon
13 | (icons/render left-icon {:class ["h-6" "w-6"]}))
14 | text
15 | (when right-icon
16 | (icons/render right-icon {:class ["h-6" "w-6"]}))])
17 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/button_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.button-scenes
2 | (:require [phosphor.icons :as icons]
3 | [portfolio.replicant :refer [defscene]]
4 | [virtuoso.elements.button :as button]))
5 |
6 | (defscene button
7 | (button/button {:text "Click it"}))
8 |
9 | (defscene button-spinner
10 | (button/button {:text "Click it"
11 | :spinner? true}))
12 |
13 | (defscene button-left-icon
14 | (button/button {:text "Click it"
15 | :left-icon (icons/icon :phosphor.regular/metronome)}))
16 |
17 | (defscene button-right-icon
18 | (button/button {:text "Click it"
19 | :right-icon (icons/icon :phosphor.regular/metronome)}))
20 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -xe
4 |
5 | dist="$(dirname $0)/target/site"
6 |
7 | if [ ! -d "$dist" ]; then
8 | echo "$dist does not exist, run `make $dist` first"
9 | exit 1
10 | fi
11 |
12 | bucket="s3://virtuoso.tools/"
13 |
14 | if [ -z "$distribution_id" ]; then
15 | distribution_id="E2TV9YDUC066"
16 | fi
17 |
18 | cd $dist
19 |
20 | # Sync over bundles, cacheable for a year
21 | aws s3 sync . $bucket --cache-control max-age=31536000,public,immutable --exclude "*" --metadata-directive REPLACE --include "bundles/*"
22 |
23 | # Sync pages, do not cache
24 | aws s3 sync . $bucket --cache-control "no-cache,must-revalidate" --exclude "bundles/*"
25 |
26 | # Delete older bundles etc
27 | aws s3 sync . $bucket --delete
28 |
--------------------------------------------------------------------------------
/content/pages.edn:
--------------------------------------------------------------------------------
1 | [{:page/uri "/"
2 | :page/kind :page.kind/frontpage
3 | :open-graph/description "Tools for practicing musicians."
4 | :open-graph/image "/brain.svg"}
5 | {:page/uri "/interleaved-clickup/"
6 | :page/title "Interleaved clicking up metronome"
7 | :page/kind :page.kind/interleaved-clickup
8 | :open-graph/description "A specialized metronome to help you solidify and speed up music you're practicing."
9 | :open-graph/image "/brain.svg"}
10 | {:page/uri "/metronome/"
11 | :page/title "Metronome"
12 | :page/kind :page.kind/metronome
13 | :open-graph/description "A highly customizable metronome with tempo and time signature changes and more."
14 | :open-graph/image "/brain.svg"}
15 | {:page/uri "/404/"
16 | :page/kind :page.kind/not-found}]
17 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/metronome_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.metronome-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.page :as page]
4 | [virtuoso.pages.metronome :as metronome]))
5 |
6 | (defscene paused
7 | (->> {:activity/paused? true
8 | :music/tempo 60
9 | :music/bars [{:music/time-signature [4 4]}]}
10 | (metronome/prepare-metronome nil)
11 | page/page))
12 |
13 | (defscene higher-tempo
14 | (->> {:activity/paused? true
15 | :music/tempo 195
16 | :music/bars [{:music/time-signature [4 4]}]}
17 | (metronome/prepare-metronome nil)
18 | page/page))
19 |
20 | (defscene playing
21 | (->> {:music/tempo 195
22 | :music/bars [{:music/time-signature [4 4]}]}
23 | (metronome/prepare-metronome nil)
24 | page/page))
25 |
--------------------------------------------------------------------------------
/tf/main.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | app_name = "virtuoso_tools"
3 | domain_name = "virtuoso.tools"
4 | hosted_zone = "virtuoso.tools."
5 | }
6 |
7 | module "site_bucket" {
8 | source = "./bucket"
9 | bucket_name = "virtuoso.tools"
10 | app_name = "${local.app_name}"
11 | domain_name = "${local.domain_name}"
12 | hosted_zone = "${local.hosted_zone}"
13 | providers = {
14 | aws = aws
15 | aws.us-east-1 = aws.us-east-1
16 | }
17 | }
18 |
19 | module "distribution" {
20 | source = "./distribution"
21 | app_name = "${local.app_name}"
22 | domain_name = "${local.domain_name}"
23 | hosted_zone = "${local.hosted_zone}"
24 | immutable_path = "/bundles/*"
25 | bucket_regional_domain_name = "${module.site_bucket.bucket_regional_domain_name}"
26 | cloudfront_access_identity_path = "${module.site_bucket.cloudfront_access_identity_path}"
27 | certificate_arn = "${module.site_bucket.certificate_arn}"
28 | providers = {
29 | aws = aws
30 | aws.us-east-1 = aws.us-east-1
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/virtuoso/ui/db.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.ui.db
2 | (:require [datascript.core :as d]
3 | [virtuoso.elements.modal :as modal]
4 | [virtuoso.pages.interleaved-clickup :as icu]
5 | [virtuoso.pages.metronome :as metronome]
6 | [virtuoso.ui.actions :as actions]))
7 |
8 | (defn connect []
9 | (d/create-conn
10 | (merge
11 | {:activity/paused? {} ;; boolean
12 | :ordered/idx {} ;; number, ordered collections
13 | :view/tool {:db/type :db.type/ref}
14 | }
15 | metronome/schema
16 | modal/schema
17 | icu/schema)))
18 |
19 | (defn get-eid [e]
20 | (or (:db/id e) e))
21 |
22 | (defmethod actions/perform-action :action/db.add [_ _ [e a v]]
23 | [{:kind ::transact
24 | :args [[:db/add (get-eid e) a v]]}])
25 |
26 | (defmethod actions/perform-action :action/db.retract [_ _ [e a v]]
27 | [{:kind ::transact
28 | :args [[:db/retract (get-eid e) a v]]}])
29 |
30 | (defmethod actions/perform-action :action/transact [_ _ [tx-data]]
31 | [{:kind ::transact
32 | :args tx-data}])
33 |
--------------------------------------------------------------------------------
/tf/distribution/variables.tf:
--------------------------------------------------------------------------------
1 | variable "app_name" {
2 | description = "Application identifier - no spaces"
3 | type = string
4 | }
5 |
6 | variable "domain_name" {
7 | description = "Domain name to serve the site over"
8 | type = string
9 | }
10 |
11 | variable "hosted_zone" {
12 | description = "Hosted zone the domain name belongs to"
13 | type = string
14 | }
15 |
16 | variable "immutable_path" {
17 | description = "A path pattern that can be cached for a long time"
18 | type = string
19 | }
20 |
21 | variable "bucket_regional_domain_name" {
22 | description = "aws_s3_bucket.your_bucket.bucket_regional_domain_name of bucket to serve"
23 | type = string
24 | }
25 |
26 | variable "cloudfront_access_identity_path" {
27 | description = "aws_cloudfront_origin_access_identity.identity.cloudfront_access_identity_path of access identity created for bucket"
28 | type = string
29 | }
30 |
31 | variable "certificate_arn" {
32 | description = "aws_acm_certificate.cert.arn of certificate to use with distribution"
33 | type = string
34 | }
35 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/musical_notation_selection_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.musical-notation-selection-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.musical-notation-selection :as selection]))
4 |
5 | (defscene quarter-note-beat-selection
6 | (selection/musical-notation-selection
7 | {:items [{:notation [:note/quarter]
8 | :active? true}
9 | {:notation [[:notation/beam :note/eighth :note/eighth]]
10 | :actions []}
11 | {:notation [[:notation/beam [:notation/dot :note/eighth] :note/sixteenth]]
12 | :actions []}
13 | {:notation [[:notation/beam :note/eighth :note/eighth :note/eighth]]
14 | :actions []}
15 | {:notation [[:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth :note/sixteenth]]
16 | :actions []}
17 | {:notation [[:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth
18 | :note/sixteenth :note/sixteenth :note/sixteenth]]
19 | :actions []}]}))
20 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/layout.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.layout
2 | (:require [virtuoso.elements.brain :as brain]
3 | [virtuoso.elements.typography :as t]))
4 |
5 | (defn layout [& content]
6 | [:html.bg-base-200 {:data-theme "dracula"}
7 | [:head
8 | [:link {:rel "mask-icon" :href "/safari-pinned-tab.svg" :color "#ff79c6"}]
9 | [:meta {:name "theme-color" :content "#232530"}]]
10 | [:body
11 | content]])
12 |
13 | (defn flex-container [& content]
14 | [:div.container.max-w-2xl.mx-auto.px-4.flex.flex-col.min-h-screen.justify-between
15 | content])
16 |
17 | (def container-classes
18 | "container max-w-2xl mx-auto px-2 md:px-4")
19 |
20 | (def box-classes ["bg-base-100" "rounded-box" "border-base-300" "md:border"])
21 |
22 | (defn ^{:indent 1} box [attrs & content]
23 | [(if (:href attrs)
24 | :a.block
25 | :div)
26 | (update attrs :class concat ["p-4" "md:p-6"] box-classes)
27 | content])
28 |
29 | (defn header [{:keys [title]}]
30 | [:header.m-4.flex.justify-between.items-center.gap-4
31 | [:a.block.right.w-12
32 | {:href "/"}
33 | (brain/brain {:text? false})]
34 | (t/h1 {:class "max-w-2xl grow"} title)
35 | [:span " "]])
36 |
--------------------------------------------------------------------------------
/dev/virtuoso/dev.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.dev
2 | (:require [clojure.string :as str]
3 | [gadget.inspector :as inspector]
4 | [virtuoso.ui.main :as virtuoso]))
5 |
6 | (def labels
7 | {::virtuoso/ui-layer "Page data"
8 | ::virtuoso/modal-layer "Modal data"})
9 |
10 | (set! virtuoso/*on-render* #(inspector/inspect (labels %1) %2))
11 |
12 | (defn print-error [e]
13 | (js/console.error (.-message e))
14 | (prn (ex-data e))
15 | (println (.-stack e))
16 | (when-let [cause (.-cause e)]
17 | (println "Caused by:")
18 | (print-error cause)))
19 |
20 | (defonce ^:export kicking-out-the-jams
21 | (do
22 | (set! *print-namespace-maps* false)
23 | (js/window.addEventListener
24 | "error"
25 | (fn [e]
26 | (.preventDefault e)
27 | (let [file (or (some-> (.-filename e)
28 | (str/split #"js/compiled")
29 | second)
30 | (.-filename e))]
31 | (js/console.error "Uncaught exception in " (str file ":" (.-lineno e) ":" (.-colno e))))
32 | (print-error (.-error e))))
33 | (virtuoso/boot)))
34 |
35 | (comment
36 | (set! *print-namespace-maps* false)
37 | )
38 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | resources/fontawesome-icons:
2 | clojure -Sdeps "{:deps {no.cjohansen/fontawesome-clj {:mvn/version \"2023.10.26\"} \
3 | clj-http/clj-http {:mvn/version \"3.12.3\"} \
4 | hickory/hickory {:mvn/version \"0.7.1\"}}}" \
5 | -M -m fontawesome.import :download resources 6.4.2
6 |
7 | node_modules:
8 | npm install
9 |
10 | tailwind: resources/fontawesome-icons node_modules
11 | npx tailwindcss -i ./src/main.css -o ./resources/public/tailwind.css --watch
12 |
13 | target/public/js/compiled/app.js: resources/fontawesome-icons
14 | clojure -M:build -m figwheel.main -bo prod
15 |
16 | target/public/js/compiled/app-portfolio.js: resources/fontawesome-icons
17 | clojure -M:dev:build -m figwheel.main -bo portfolio-prod
18 |
19 | target/site: target/public/js/compiled/app.js target/public/js/compiled/app-portfolio.js
20 | clojure -X:dev:build
21 |
22 | launch:
23 | bin/launchpad
24 |
25 | clean:
26 | rm -fr target resources/public/js dev-resources/public/js
27 |
28 | deploy: clean target/site
29 | ./deploy.sh
30 |
31 | test:
32 | LOG_LEVEL=warn clojure -M:dev:test -m kaocha.runner
33 |
34 | autotest:
35 | LOG_LEVEL=warn clojure -M:dev:test -m kaocha.runner --watch
36 |
37 | .PHONY: tailwind clean deploy test autotest
38 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/colored_boxes_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.colored-boxes-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.colored-boxes :as colored-boxes]))
4 |
5 | (defscene single-box
6 | (colored-boxes/colored-boxes
7 | {:boxes [{:text "Bar 1"
8 | :color-idx 0}]
9 | :footer {:text "Look at the box"}}))
10 |
11 | (defscene a-few-boxes
12 | (colored-boxes/colored-boxes
13 | {:boxes [{:text "Bar 1"
14 | :color-idx 0}
15 | {:text "Bar 2"
16 | :color-idx 1}]
17 | :footer {:text "Two boxes"}}))
18 |
19 | (defscene lots-of-boxes
20 | (colored-boxes/colored-boxes
21 | {:boxes [{:text "Bar 1"
22 | :color-idx 0}
23 | {:text "Bar 2"
24 | :color-idx 1}
25 | {:text "Bar 3"
26 | :color-idx 2}
27 | {:text "Bar 4"
28 | :color-idx 3}
29 | {:text "Bar 5"
30 | :color-idx 4}
31 | {:text "Bar 6"
32 | :color-idx 5}
33 | {:text "Bar 7"
34 | :color-idx 6}
35 | {:text "Bar 8"
36 | :color-idx 7}]
37 | :footer {:text "Wow, that's a lot of boxes"}}))
38 |
--------------------------------------------------------------------------------
/src/virtuoso/pages/frontpage.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.pages.frontpage
2 | (:require [phosphor.icons :as icons]
3 | [virtuoso.elements.brain :as brain]
4 | [virtuoso.elements.layout :as layout]))
5 |
6 | (defn box-title [icon text]
7 | [:h2.mb-2.flex.gap-4.items-center
8 | (icons/render icon {:size "2rem"})
9 | [:strong.font-bold text]])
10 |
11 | (defn render-page [_ctx _page]
12 | (layout/layout
13 | (layout/flex-container
14 | [:main.grow.flex.flex-col.justify-center
15 | (brain/brain)
16 | [:p.my-6.opacity-80
17 | "Tools for musicians to practice more efficiently."]
18 | (layout/box {:href "/interleaved-clickup/"}
19 | (box-title :phosphor.regular/speedometer "Interleaved Clicking Up")
20 | [:p.opacity-80 "Solidify a piece of music and bring it up to speed using
21 | random practice while clicking up with the metronome."])
22 | (layout/box {:href "/metronome/"
23 | :class ["mt-4"]}
24 | (box-title :phosphor.regular/metronome "Metronome")
25 | [:p.opacity-80 "A metronome that supports randomly dropping
26 | beats, accenting beats, changing time signatures and tempos and
27 | more."])]
28 | [:footer
29 | [:p.my-4.opacity-80
30 | "Made by " [:a.link {:href "https://cjohansen.no"} "Christian Johansen"]]])))
31 |
--------------------------------------------------------------------------------
/src/virtuoso/ui/actions.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.ui.actions
2 | (:require [clojure.walk :as walk]))
3 |
4 | (defmulti execute-side-effect! (fn [_conn {:keys [kind]}] kind))
5 |
6 | (defmulti perform-action (fn [_db action _args] action))
7 |
8 | (defmethod perform-action :action/start-metronome [_ _ args]
9 | (let [[options tempo] args]
10 | [{:kind :virtuoso/start-metronome
11 | :args [options tempo]}]))
12 |
13 | (defmethod perform-action :action/stop-metronome [_ _ _]
14 | [{:kind :virtuoso/stop-metronome}])
15 |
16 | (defn parse-number [s]
17 | (when (not-empty s)
18 | (parse-long s)))
19 |
20 | (defn interpolate-event-data [event data]
21 | (walk/postwalk
22 | (fn [x]
23 | (cond
24 | (= :event/key x) (.-key event)
25 | (= :event/target-value x) (some-> event .-target .-value)
26 | (= :event/target-value-num x) (or (some-> event .-target .-value .trim parse-number) 0)
27 | (= :event/target-value-kw x) (some-> event .-target .-value .trim keyword)
28 | :else x))
29 | data))
30 |
31 | (defn perform-actions [db actions]
32 | (mapcat
33 | (fn [[action & args]]
34 | (apply println "[virtuoso.ui.action]" action args)
35 | (perform-action db action args))
36 | (remove nil? actions)))
37 |
38 | (defn execute! [conn effects]
39 | (doseq [effect effects]
40 | (execute-side-effect! conn effect)))
41 |
--------------------------------------------------------------------------------
/deps.edn:
--------------------------------------------------------------------------------
1 | {:paths ["src" "resources"]
2 | :deps {org.clojure/clojure {:mvn/version "1.11.1"}
3 | org.clojure/clojurescript {:mvn/version "1.11.60"}
4 | datascript/datascript {:mvn/version "1.7.2"}
5 | no.cjohansen/phosphor-clj {:mvn/version "2024.07.31"}
6 | no.cjohansen/powerpack {:git/url "https://github.com/cjohansen/powerpack"
7 | :sha "34b53859d878aabc95a0bb86e143fa8a439f7e0d"}
8 | no.cjohansen/replicant {:git/url "https://github.com/cjohansen/replicant"
9 | :sha "39e64d17dd3a7279e40321531105f1a2cb7634ee"}}
10 | :aliases
11 | {:dev {:extra-paths ["dev" "test" "dev-resources" "portfolio"]
12 | :extra-deps {cider/piggieback {:mvn/version "0.5.3"}
13 | com.bhauman/figwheel-main {:mvn/version "0.2.18"}
14 | cjohansen/gadget-inspector {:mvn/version "0.2023.04.12"}
15 | no.cjohansen/portfolio {:mvn/version "2024.03.18"}
16 | kaocha-noyoda/kaocha-noyoda {:mvn/version "2019-06-03"}
17 | lambdaisland/kaocha {:mvn/version "1.87.1366"}}}
18 | :test {:exec-fn kaocha.runner/exec-fn
19 | :exec-args {}}
20 | :build {:extra-paths ["dev" "target"]
21 | :extra-deps {com.bhauman/figwheel-main {:mvn/version "0.2.18"}}
22 | :exec-fn virtuoso.export/export}}}
23 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.clj", "./src/**/*.cljc", "./src/**/*.cljs", "./portfolio/**/*.cljs"],
4 | theme: {
5 | extend: {
6 | typography: theme => ({
7 | DEFAULT: {
8 | css: {
9 | a: {
10 | color: theme('colors.blue.600')
11 | },
12 | 'a:hover': {
13 | color: theme('colors.blue.500')
14 | }
15 | }
16 | },
17 | invert: {}
18 | })
19 | }
20 | },
21 | plugins: [
22 | require('@tailwindcss/typography'),
23 | require("daisyui")
24 | ],
25 | daisyui: {
26 | themes: [
27 | "light",
28 | "dark",
29 | "cupcake",
30 | "bumblebee",
31 | "emerald",
32 | "corporate",
33 | "synthwave",
34 | "retro",
35 | "cyberpunk",
36 | "valentine",
37 | "halloween",
38 | "garden",
39 | "forest",
40 | "aqua",
41 | "lofi",
42 | "pastel",
43 | "fantasy",
44 | "wireframe",
45 | "black",
46 | "luxury",
47 | "dracula",
48 | "cmyk",
49 | "autumn",
50 | "business",
51 | "acid",
52 | "lemonade",
53 | "night",
54 | "coffee",
55 | "winter",
56 | "dim",
57 | "nord",
58 | "sunset",
59 | // "winter"
60 | ]
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/page.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.page
2 | (:require [virtuoso.elements.badge :as badge]
3 | [virtuoso.elements.bar :as bar]
4 | [virtuoso.elements.button :as button]
5 | [virtuoso.elements.button-panel :as panel]
6 | [virtuoso.elements.colored-boxes :as colored-boxes]
7 | [virtuoso.elements.form :as form]
8 | [virtuoso.elements.musical-notation-selection :as mns]
9 | [virtuoso.elements.typography :as t]))
10 |
11 | (defn footer [{:keys [heading text button]}]
12 | [:div.justify-center.px-4.md:px-0
13 | (some-> heading t/h2)
14 | (some-> text t/p)
15 | (some-> button button/button)])
16 |
17 | (defn page [{:keys [sections spacing]}]
18 | [:div.flex.flex-col
19 | {:class (if (= :wide spacing)
20 | ["gap-16" "pt-8"]
21 | ["gap-8"])}
22 | (for [section sections]
23 | (case (:kind section)
24 | :element.kind/bars (bar/bars section)
25 | :element.kind/boxed-form (form/boxed-form section)
26 | :element.kind/button-panel (panel/button-panel section)
27 | :element.kind/colored-boxes (colored-boxes/colored-boxes section)
28 | :element.kind/footer (footer section)
29 | :element.kind/musical-notation-selection (mns/musical-notation-selection section)
30 | :element.kind/round-badge (badge/round-badge section)
31 | (prn "Unknown section kind" section)))])
32 |
--------------------------------------------------------------------------------
/tf/distribution/headers-lambda.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | exports.handler = (event, context, callback) => {
4 | const response = event.Records[0].cf.response;
5 |
6 | response.headers = Object.assign(response.headers, {
7 | "Content-Security-Policy": [{
8 | key: "Content-Security-Policy",
9 | value: (
10 | "default-src 'self'; " +
11 | "connect-src 'self'; " +
12 | "font-src 'self'; img-src 'self' data: 'self'; " +
13 | "script-src 'self' 'unsafe-inline'; " +
14 | "style-src 'self' 'unsafe-inline'; " +
15 | "frame-src 'self';" +
16 | "worker-src blob: ;" +
17 | "child-src blob: ;" +
18 | "img-src data: blob: ;"
19 | )
20 | }],
21 | "Strict-Transport-Security": [{
22 | key: "Strict-Transport-Security",
23 | value: "max-age=31536000"
24 | }],
25 | "X-Frame-Options": [{
26 | key: "X-Frame-Options",
27 | value: "sameorigin"
28 | }],
29 | "X-Content-Type-Options": [{
30 | key: "X-Content-Type-Options",
31 | value: "nosniff"
32 | }],
33 | "Referrer-Policy": [{
34 | key: "Referrer-Policy",
35 | value: "strict-origin"
36 | }],
37 | "Permissions-Policy": [{
38 | key: "Permissions-Policy",
39 | value: "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; fullscreen 'none'; payment 'none'"
40 | }]
41 | });
42 |
43 | callback(null, response);
44 | };
45 |
--------------------------------------------------------------------------------
/test/virtuoso/pages/interleaved_clickup_test.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.pages.interleaved-clickup-test
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [datascript.core :as d]
4 | [virtuoso.interleaved-clickup :as icu]
5 | [virtuoso.pages.interleaved-clickup :as sut]
6 | [virtuoso.test-helper :as helper]))
7 |
8 | (deftest prepare-settings-test
9 | (testing "Shows setting when not started"
10 | (is (= (-> (helper/with-db [db sut/get-boot-actions]
11 | (sut/prepare-ui-data db))
12 | :sections
13 | first
14 | :kind)
15 | :element.kind/boxed-form)))
16 |
17 | (testing "Starts exercise with the specified tempo"
18 | (is (= (->> (helper/with-conn [conn]
19 | (helper/execute-actions conn (sut/get-boot-actions (d/db conn)))
20 | (helper/execute-actions conn
21 | [[:action/db.add (sut/get-activity (d/db conn)) ::icu/tempo-start 75]])
22 | (sut/prepare-ui-data (d/db conn)))
23 | :sections
24 | first
25 | :button
26 | :actions
27 | (filter (comp #{:action/start-metronome} first))
28 | first
29 | (helper/strip-keys-by-ns #{"db" "virtuoso.interleaved-clickup"}))
30 | [:action/start-metronome
31 | {:music/bars [{:metronome/accentuate-beats #{1}
32 | :metronome/drop-pct 0
33 | :metronome/click-beats #{1 4 3 2}
34 | :music/time-signature [4 4]}]
35 | :music/tempo 75}]))))
36 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.scenes
2 | (:require [portfolio.ui :as ui]
3 | [replicant.dom :as replicant]
4 | [virtuoso.elements.bar-scenes]
5 | [virtuoso.elements.brain-scenes]
6 | [virtuoso.elements.button-panel-scenes]
7 | [virtuoso.elements.button-scenes]
8 | [virtuoso.elements.colored-boxes-scenes]
9 | [virtuoso.elements.form-scenes]
10 | [virtuoso.elements.icon-button-scenes]
11 | [virtuoso.elements.layout-scenes]
12 | [virtuoso.elements.metronome-scenes]
13 | [virtuoso.elements.musical-notation-scenes]
14 | [virtuoso.elements.musical-notation-selection-scenes]
15 | [virtuoso.elements.typography-scenes]))
16 |
17 | :virtuoso.elements.bar-scenes/keep
18 | :virtuoso.elements.brain-scenes/keep
19 | :virtuoso.elements.button-panel-scenes/keep
20 | :virtuoso.elements.button-scenes/keep
21 | :virtuoso.elements.colored-boxes-scenes/keep
22 | :virtuoso.elements.form-scenes/keep
23 | :virtuoso.elements.icon-button-scenes/keep
24 | :virtuoso.elements.layout-scenes/keep
25 | :virtuoso.elements.metronome-scenes/keep
26 | :virtuoso.elements.musical-notation-scenes/keep
27 | :virtuoso.elements.musical-notation-selection-scenes/keep
28 | :virtuoso.elements.typography-scenes/keep
29 |
30 | (replicant/set-dispatch! #(prn %3))
31 |
32 | (defonce app
33 | (ui/start!
34 | {:config
35 | {:css-paths ["/tailwind.css"]
36 | :canvas-path "canvas.html"
37 | :background/options
38 | [{:id :dracula
39 | :title "Dracula"
40 | :value {:background/background-color "#212227"
41 | :background/document-class "dracula"}}]}}))
42 |
--------------------------------------------------------------------------------
/src/virtuoso/core.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.core
2 | (:require [virtuoso.pages.frontpage :as frontpage]
3 | [virtuoso.pages.interleaved-clickup :as icu-page]
4 | [virtuoso.pages.metronome :as metronome-page]
5 | [virtuoso.pages.not-found :as not-found]))
6 |
7 | (defn render-page [context page]
8 | (if-let [f (case (:page/kind page)
9 | :page.kind/frontpage frontpage/render-page
10 | :page.kind/interleaved-clickup icu-page/render-page
11 | :page.kind/metronome metronome-page/render-page
12 | :page.kind/not-found not-found/render-page
13 | nil)]
14 | (f context page)
15 | [:h1 "Page not found 🤷♂️"]))
16 |
17 | (defn create-app [env]
18 | (cond-> {:site/title "Virtuoso"
19 | :powerpack/render-page #'render-page
20 | :powerpack/port 4848
21 | :powerpack/log-level :debug
22 | :powerpack/content-file-suffixes ["md" "edn"]
23 |
24 | :powerpack/dev-assets-root-path "public"
25 |
26 | :optimus/bundles {"/styles.css"
27 | {:public-dir "public"
28 | :paths ["/tailwind.css"]}
29 |
30 | "/app.js"
31 | {:public-dir "public"
32 | :paths ["/js/compiled/app.js"]}}
33 |
34 | :optimus/assets [{:public-dir "public"
35 | :paths [#"\.png$"
36 | #"\.svg$"
37 | #"\.ico$"]}]
38 |
39 | :powerpack/build-dir "target/site"}
40 |
41 | (= :build env)
42 | (assoc :site/base-url "https://virtuoso.tools")
43 |
44 | (= :dev env) ;; serve figwheel compiled js
45 | (assoc :powerpack/dev-assets-root-path "public")))
46 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/modal.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.modal
2 | (:require [datascript.core :as d]
3 | [replicant.core :as replicant]
4 | [virtuoso.elements.page :as page]
5 | [virtuoso.ui.actions :as actions]))
6 |
7 | (def schema
8 | {:modal/layer-idx {:db/unique :db.unique/identity} ;; number
9 | :modal/kind {} ;; keyword
10 | :modal/params {}
11 | })
12 |
13 | (defn get-current-modal [db]
14 | (some->> (d/q '[:find ?e ?idx
15 | :where
16 | [?e :modal/layer-idx ?idx]]
17 | db)
18 | (sort-by second)
19 | ffirst
20 | (d/entity db)))
21 |
22 | (defn get-open-modal-actions [db kind & [params]]
23 | (let [modal (get-current-modal db)]
24 | [[:action/transact
25 | [(cond-> {:modal/layer-idx (or (some-> modal :modal/layer-idx inc) 0)
26 | :modal/kind kind}
27 | params (assoc :modal/params params))]]]))
28 |
29 | (defmethod actions/perform-action :action/clear-modal [db _ _]
30 | (when-let [modal (get-current-modal db)]
31 | [{:kind :virtuoso.ui.db/transact
32 | :args [[:db/retractEntity (:db/id modal)]]}]))
33 |
34 | (defn open-modal [{:replicant/keys [node]}]
35 | (.showModal node)
36 | (->> (fn [e]
37 | (replicant/*dispatch* e [[:action/clear-modal]]))
38 | (.addEventListener node "close")))
39 |
40 | (defn render [props]
41 | (when props
42 | [:dialog#modal.modal.modal-bottom.sm:modal-middle
43 | {:replicant/on-mount #'open-modal}
44 | [:div.modal-box
45 | {:class (:modal/class props)}
46 | (when-let [title (:title props)]
47 | [:h1.text-lg.mb-2 title])
48 | (page/page props)
49 | [:form.modal-action {:method "dialog"}
50 | [:button.btn "Close"]]]
51 | [:form.modal-backdrop {:method "dialog"}
52 | [:button "Close"]]]))
53 |
--------------------------------------------------------------------------------
/test/virtuoso/test_helper.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.test-helper
2 | (:require [clojure.walk :as walk]
3 | [datascript.core :as d]
4 | [virtuoso.ui.actions :as actions]
5 | [virtuoso.ui.db :as db]))
6 |
7 | (defmethod actions/execute-side-effect! ::db/transact [conn {:keys [args]}]
8 | (try
9 | (d/transact conn args)
10 | (catch Exception e
11 | (throw (ex-info "Failed to transact data" {:tx-data args} e)))))
12 |
13 | (defmethod actions/execute-side-effect! :virtuoso/start-metronome [conn _args]
14 | ;; No-op for testing
15 | )
16 |
17 | (defn ^{:style/indent 1} execute-actions [conn actions]
18 | (some->> actions
19 | (actions/perform-actions @conn)
20 | (actions/execute! conn)))
21 |
22 | (defmacro with-conn
23 | {:clj-kondo/lint-as 'clojure.core/fn}
24 | [[binding] & body]
25 | `(let [~binding (db/connect)]
26 | ~@body))
27 |
28 | (defmacro with-db
29 | {:clj-kondo/lint-as 'clojure.core/let}
30 | [[binding get-boot-actions] & body]
31 | `(let [conn# (db/connect)]
32 | (when (ifn? ~get-boot-actions)
33 | (execute-actions conn# (~get-boot-actions (d/db conn#))))
34 | (let [~binding (d/db conn#)]
35 | ~@body)))
36 |
37 | (defn e->map [x]
38 | (cond
39 | (:db/id x) (update-vals (into {:db/id (:db/id x)} x) e->map)
40 | (map? x) (update-vals x e->map)
41 | (set? x) (set (map e->map x))
42 | (vector? x) (mapv e->map x)
43 | (coll? x) (map e->map x)
44 | :else x))
45 |
46 | (defn strip-keys [data ks]
47 | (walk/postwalk
48 | (fn [x]
49 | (cond-> x
50 | (map? x) (select-keys (remove ks (keys x)))))
51 | (e->map data)))
52 |
53 | (defn strip-keys-by-ns [nss data]
54 | (walk/postwalk
55 | (fn [x]
56 | (cond-> x
57 | (map? x) (select-keys (remove (comp nss namespace) (keys x)))))
58 | (e->map data)))
59 |
60 | (defn simplify-db-actions [data]
61 | (walk/postwalk
62 | (fn [x]
63 | (cond-> x
64 | (and (vector? x)
65 | (#{:action/db.add :action/db.retract} (first x))
66 | (map? (second x)))
67 | (update-in [1] #(select-keys % [:db/id]))))
68 | (e->map data)))
69 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/icon_button.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.icon-button
2 | (:require [phosphor.icons :as icons]))
3 |
4 | (def button-icon-size
5 | {:small {:tiny ["h-2" "w-2"]
6 | :small ["h-3" "w-3"]
7 | :medium ["h-4" "w-4"]
8 | :large ["h-6" "w-6"]}
9 | :medium {:tiny ["h-2.5" "w-2.5"]
10 | :small ["h-4" "w-4"]
11 | :medium ["h-6" "w-6"]
12 | :large ["h-8" "w-8"]
13 | :xlarge ["h-10" "w-10"]}
14 | :large {:tiny ["h-3" "w-3"]
15 | :small ["h-5" "w-5"]
16 | :medium ["h-8" "w-8"]
17 | :large ["h-12" "w-12"]}})
18 |
19 | (def theme-class
20 | {:info "btn-info"
21 | :warn "btn-error"
22 | :success "btn-success"})
23 |
24 | (defn icon-button [{:keys [text size theme icon icon-after-label icon-size actions disabled?]}]
25 | (let [size (if (button-icon-size size) size :medium)
26 | theme (if (theme-class theme) theme :info)
27 | icon-el (icons/render icon
28 | {:class (or (get-in button-icon-size [size icon-size])
29 | (get-in button-icon-size [size :medium]))})]
30 | [:div.btn.btn-circle
31 | {:title text
32 | :class [(when (= :large size) :btn-lg)
33 | (when (= :small size) :btn-sm)
34 | (when (= :xsmall size) :btn-xs)
35 | (when disabled? :btn-disabled)
36 | (theme-class theme)]
37 | :on {:click actions}}
38 | (if icon-after-label
39 | [:div.flex.items-center.text-lg {:class "gap-0.5"}
40 | icon-el
41 | icon-after-label]
42 | icon-el)]))
43 |
44 | (def bare-theme-class
45 | {:info "text-info"
46 | :warn "text-error"
47 | :success "text-success"
48 | :neutral "text-neutral-content"})
49 |
50 | (defn bare-icon-button [{:keys [text size theme icon actions]}]
51 | (let [theme (if (bare-theme-class theme) theme :neutral)]
52 | [:div.cursor-pointer
53 | {:title text
54 | :class (bare-theme-class theme)
55 | :on {:click actions}}
56 | (icons/render icon
57 | {:class (or (get-in button-icon-size [:medium size])
58 | (get-in button-icon-size [:medium :medium]))})]))
59 |
--------------------------------------------------------------------------------
/dev/virtuoso/export.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.export
2 | (:require [clojure.java.io :as io]
3 | [clojure.string :as str]
4 | [optimus.assets :as assets]
5 | [optimus.export :as optimus-export]
6 | [optimus.optimizations :as optimizations]
7 | [powerpack.export :as export]
8 | [virtuoso.core :as virtuoso]))
9 |
10 | (def optimize #(optimizations/all % {}))
11 |
12 | (defn export-portfolio []
13 | (let [js-path "/js/compiled/app-portfolio.js"
14 | assets (->> [["portfolio.js" [js-path]]
15 | ["portfolio/styles/portfolio.css"]
16 | ["portfolio/prism.js"]
17 | ["tailwind.css"]]
18 | (mapcat (fn [[bundle paths]]
19 | (assets/load-bundle "public" bundle (or paths [(str "/" bundle)]))))
20 | optimize)
21 | asset-map (into {} (map (juxt :original-path identity) assets))
22 | js-bundle (asset-map "/bundles/portfolio.js")
23 | portfolio-css (asset-map "/bundles/portfolio/styles/portfolio.css")
24 | prism-js (asset-map "/bundles/portfolio/prism.js")
25 | app-css (asset-map "/bundles/tailwind.css")]
26 | (-> [(assoc portfolio-css :path "/portfolio/styles/portfolio.css")
27 | (assoc prism-js :path "/portfolio/prism.js")
28 | (assoc app-css :path "/tailwind.css")
29 | js-bundle]
30 | (concat (->> assets
31 | (remove :bundled)
32 | (remove :outdated)
33 | (remove (comp #{(:path js-bundle)
34 | (:path portfolio-css)
35 | (:path prism-js)
36 | (:path app-css)} :path))))
37 | (optimus-export/save-assets "target/site"))
38 | (spit "target/site/portfolio/index.html"
39 | (str/replace (slurp (io/resource "public/index.html"))
40 | (re-pattern js-path) (:path js-bundle)))
41 | (spit "target/site/portfolio/canvas.html" (slurp (io/resource "public/canvas.html")))))
42 |
43 | (defn ^:export export [& _args]
44 | (set! *print-namespace-maps* false)
45 | (export/export! (virtuoso/create-app :prod))
46 | (export-portfolio))
47 |
--------------------------------------------------------------------------------
/src/virtuoso/interleaved_clickup.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.interleaved-clickup)
2 |
3 | (defn get-tempo-step [options]
4 | (or (::tempo-step options) 5))
5 |
6 | (defn get-tempo-start [options]
7 | (or (::tempo-start options) 60))
8 |
9 | (defn get-tempo [options]
10 | (or (::tempo-current options) (get-tempo-start options)))
11 |
12 | (defn increase-tempo [options]
13 | (+ (get-tempo options) (get-tempo-step options)))
14 |
15 | (defn decrease-tempo [options]
16 | (let [tempo (get-tempo options)]
17 | (when (< (get-tempo-start options) tempo)
18 | (- tempo (get-tempo-step options)))))
19 |
20 | (defn get-phrase-size [options]
21 | (or (::phrase-size options) 4))
22 |
23 | (defn get-phrases-tabs [options]
24 | (->> (::tabs options)
25 | (partition-all (get-phrase-size options))
26 | vec))
27 |
28 | (defn get-n-phrases [options]
29 | (or (::phrase-count options)
30 | (count (get-phrases-tabs options))))
31 |
32 | (defn get-next-phrase [options]
33 | (let [curr (::phrase-current options)]
34 | (cond
35 | (nil? curr)
36 | 0
37 |
38 | (= curr (dec (get-n-phrases options)))
39 | nil
40 |
41 | :else
42 | (inc curr))))
43 |
44 | (defn get-prev-phrase [options]
45 | (let [curr (::phrase-current options)]
46 | (when-not (or (nil? curr) (= 0 curr))
47 | (dec curr))))
48 |
49 | (defn get-phrase-indices [options]
50 | (let [tempo-step (get-tempo-step options)
51 | tempo-diff (- (get-tempo options) (get-tempo-start options))]
52 | (if (= 1 (mod (/ tempo-diff tempo-step) 2))
53 | [(::phrase-current options)]
54 | (let [n (inc (::phrase-current options))
55 | rest (if (= 0 (::phrase-current options))
56 | 0
57 | (mod (/ tempo-diff (* 2 tempo-step)) (::phrase-current options)))
58 | start (if (= 0 rest) 0 (- n (inc rest)))]
59 | (range start n)))))
60 |
61 | (defn get-phrases [options]
62 | (cond
63 | (and (::phrase-max options)
64 | (not= 0 (::phrase-max options))
65 | (<= (::phrase-max options)
66 | (::phrase-current options)))
67 | (let [offset (- (::phrase-current options)
68 | (dec (::phrase-max options)))]
69 | (map #(+ offset %)
70 | (-> options
71 | (update ::phrase-current - offset)
72 | get-phrase-indices)))
73 |
74 | :else
75 | (get-phrase-indices options)))
76 |
77 | (defn select-phrases [options phrases]
78 | (cond
79 | (= :start/end (::start-at options))
80 | (let [n (get-n-phrases options)]
81 | (reverse (map #(- n % 1) phrases)))
82 |
83 | :else
84 | phrases))
85 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/button_panel_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.button-panel-scenes
2 | (:require [phosphor.icons :as icons]
3 | [portfolio.replicant :refer [defscene]]
4 | [virtuoso.elements.button-panel :as panel]))
5 |
6 | (defscene button-panel
7 | (panel/button-panel
8 | {:buttons [{:text "Lower BPM"
9 | :icon (icons/icon :phosphor.bold/minus)
10 | :kbd "-"}
11 | {:text "Previous phrase"
12 | :icon (icons/icon :phosphor.fill/skip-back)
13 | :kbd "p"}
14 | {:text "Play"
15 | :icon (icons/icon :phosphor.fill/play)
16 | :size :large
17 | :kbd "space"}
18 | {:text "Next phrase"
19 | :icon (icons/icon :phosphor.fill/skip-forward)
20 | :kbd "n"}
21 | {:text "Bump BPM"
22 | :icon (icons/icon :phosphor.bold/plus)
23 | :kbd "+"}]}))
24 |
25 | (defscene button-panel-pause-button
26 | (panel/button-panel
27 | {:buttons [{:text "Lower BPM"
28 | :icon (icons/icon :phosphor.bold/minus)
29 | :disabled? true
30 | :kbd "-"}
31 | {:text "Previous phrase"
32 | :icon (icons/icon :phosphor.fill/skip-back)
33 | :disabled? true
34 | :kbd "p"}
35 | {:text "Pause"
36 | :icon (icons/icon :phosphor.fill/pause)
37 | :size :large
38 | :kbd "space"}
39 | {:text "Next phrase"
40 | :icon (icons/icon :phosphor.fill/skip-forward)
41 | :kbd "n"}
42 | {:text "Bump BPM"
43 | :icon (icons/icon :phosphor.bold/plus)
44 | :kbd "+"}]}))
45 |
46 | (defscene button-panel-disabled-buttons
47 | (panel/button-panel
48 | {:buttons [{:text "Lower BPM"
49 | :icon (icons/icon :phosphor.bold/minus)
50 | :disabled? true
51 | :kbd "-"}
52 | {:text "Previous phrase"
53 | :icon (icons/icon :phosphor.fill/skip-back)
54 | :disabled? true
55 | :kbd "p"}
56 | {:text "Play"
57 | :icon (icons/icon :phosphor.fill/play)
58 | :size :large
59 | :kbd "space"}
60 | {:text "Next phrase"
61 | :disabled? true
62 | :icon (icons/icon :phosphor.fill/skip-forward)
63 | :kbd "n"}
64 | {:text "Bump BPM"
65 | :disabled? true
66 | :icon (icons/icon :phosphor.bold/plus)
67 | :kbd "+"}]}))
68 |
--------------------------------------------------------------------------------
/tf/bucket/main.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | aws = {
4 | source = "hashicorp/aws"
5 | configuration_aliases = [
6 | aws.us-east-1,
7 | ]
8 | }
9 | }
10 | }
11 |
12 | resource "aws_s3_bucket" "bucket" {
13 | bucket = "${var.bucket_name}"
14 | }
15 |
16 | resource "aws_s3_bucket_ownership_controls" "ownership" {
17 | bucket = aws_s3_bucket.bucket.id
18 | rule {
19 | object_ownership = "BucketOwnerPreferred"
20 | }
21 | }
22 |
23 | resource "aws_s3_bucket_acl" "acl" {
24 | depends_on = [aws_s3_bucket_ownership_controls.ownership]
25 |
26 | bucket = aws_s3_bucket.bucket.id
27 | acl = "private"
28 | }
29 |
30 | resource "aws_s3_bucket_website_configuration" "website" {
31 | bucket = aws_s3_bucket.bucket.id
32 |
33 | index_document {
34 | suffix = "index.html"
35 | }
36 |
37 | error_document {
38 | key = "404/index.html"
39 | }
40 | }
41 |
42 | resource "aws_cloudfront_origin_access_identity" "identity" {
43 | comment = "Origin access identity for ${var.app_name}"
44 | }
45 |
46 | data "aws_iam_policy_document" "s3_policy" {
47 | statement {
48 | actions = ["s3:GetObject"]
49 | resources = ["${aws_s3_bucket.bucket.arn}/*"]
50 |
51 | principals {
52 | type = "AWS"
53 | identifiers = ["${aws_cloudfront_origin_access_identity.identity.iam_arn}"]
54 | }
55 | }
56 |
57 | statement {
58 | actions = ["s3:ListBucket"]
59 | resources = ["${aws_s3_bucket.bucket.arn}"]
60 |
61 | principals {
62 | type = "AWS"
63 | identifiers = ["${aws_cloudfront_origin_access_identity.identity.iam_arn}"]
64 | }
65 | }
66 | }
67 |
68 | resource "aws_s3_bucket_policy" "bucket_policy" {
69 | bucket = "${aws_s3_bucket.bucket.id}"
70 | policy = "${data.aws_iam_policy_document.s3_policy.json}"
71 | }
72 |
73 | resource "aws_acm_certificate" "cert" {
74 | provider = aws.us-east-1
75 | domain_name = "${var.domain_name}"
76 | validation_method = "DNS"
77 |
78 | lifecycle {
79 | create_before_destroy = true
80 | }
81 | }
82 |
83 | data "aws_route53_zone" "zone" {
84 | name = "${var.hosted_zone}"
85 | }
86 |
87 | resource "aws_route53_record" "cert_validation" {
88 | name = element(aws_acm_certificate.cert.domain_validation_options[*].resource_record_name, 0)
89 | type = element(aws_acm_certificate.cert.domain_validation_options[*].resource_record_type, 0)
90 | zone_id = "${data.aws_route53_zone.zone.id}"
91 | records = [element(aws_acm_certificate.cert.domain_validation_options[*].resource_record_value, 0)]
92 | ttl = 60
93 | }
94 |
95 | resource "aws_acm_certificate_validation" "cert" {
96 | provider = aws.us-east-1
97 | certificate_arn = "${aws_acm_certificate.cert.arn}"
98 | validation_record_fqdns = ["${aws_route53_record.cert_validation.fqdn}"]
99 | }
100 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/form_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.form-scenes
2 | (:require [phosphor.icons :as icons]
3 | [portfolio.replicant :refer [defscene]]
4 | [virtuoso.elements.form :as form]))
5 |
6 | (defscene form
7 | (form/boxed-form
8 | {:boxes
9 | [{:title "Exercise details"
10 | :fields
11 | [{:controls
12 | [{:label "Length"
13 | :inputs [{:input/kind :input.kind/number
14 | :value 4}
15 | {:input/kind :input.kind/select
16 | :options [{:value "beat" :text "Beats"}
17 | {:value "bar" :selected? true :text "Bars"}
18 | {:value "line" :text "Lines"}
19 | {:value "phrase" :text "Phrases"}]}]}
20 | {:label "Time signature"
21 | :inputs [{:input/kind :input.kind/number
22 | :value 4}
23 | {:input/kind :input.kind/select
24 | :options [{:value "4" :selected? true :text "4"}
25 | {:value "8" :text "8"}
26 | {:value "16" :text "16"}
27 | {:value "32" :text "32"}]}]}]}]}
28 |
29 | {:title "Session settings"
30 | :fields
31 | [{:controls
32 | [{:label "Start at"
33 | :inputs [{:input/kind :input.kind/select
34 | :options [{:value "forward" :selected? true :text "the top"}
35 | {:value "backward" :text "the end"}]}]}
36 | {:label "Max phrase length"
37 | :inputs [{:input/kind :input.kind/number
38 | :value 0}]}]}]}
39 |
40 | {:title "Metronome settings"
41 | :fields
42 | [{:controls
43 | [{:label "Start tempo"
44 | :inputs [{:input/kind :input.kind/number
45 | :value 60}]}
46 | {:label "BPM step"
47 | :inputs [{:input/kind :input.kind/number
48 | :value 5}]}
49 | {:label "Drop beats (%)"
50 | :inputs [{:input/kind :input.kind/number
51 | :value 0}]}]}
52 | {:controls
53 | [{:label "Tick beats"
54 | :inputs [{:input/kind :input.kind/pill-select
55 | :options [{:text "1" :selected? true}
56 | {:text "2" :selected? true}
57 | {:text "3" :selected? true}
58 | {:text "4" :selected? true}]}]}]}
59 |
60 | {:controls
61 | [{:label "Accentuate beats"
62 | :inputs [{:input/kind :input.kind/pill-select
63 | :options [{:text "1" :selected? true}
64 | {:text "2"}
65 | {:text "3"}
66 | {:text "4"}]}]}]}]}]
67 | :button {:text "Let's go!"
68 | :right-icon (icons/icon :phosphor.regular/metronome)}}))
69 |
--------------------------------------------------------------------------------
/test/virtuoso/elements/form_test.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.form-test
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [virtuoso.elements.form :as sut]))
4 |
5 | (deftest prepare-select-test
6 | (testing "Expects event value as number when current is number"
7 | (is (= (sut/prepare-select {:val 42} :val [[42 "42"]])
8 | {:input/kind :input.kind/select
9 | :on {:input [[:action/db.add {:val 42} :val :event/target-value-num]]}
10 | :options [{:value "42"
11 | :text "42"
12 | :selected? true}]})))
13 |
14 | (testing "Expects event value as keyword when current is keyword"
15 | (is (= (sut/prepare-select {} :val [[:some/kw "Some kw"]])
16 | {:input/kind :input.kind/select
17 | :on {:input [[:action/db.add {} :val :event/target-value-kw]]}
18 | :options [{:value "some/kw"
19 | :text "Some kw"
20 | :selected? true}]})))
21 |
22 | (testing "Expects raw event value when option is string"
23 | (is (= (sut/prepare-select {} :val [["1" "One"]
24 | ["2" "Two"]] "2")
25 | {:input/kind :input.kind/select
26 | :on {:input [[:action/db.add {} :val :event/target-value]]}
27 | :options [{:value "1"
28 | :text "One"}
29 | {:value "2"
30 | :text "Two"
31 | :selected? true}]}))))
32 |
33 | (deftest prepare-multi-select-test
34 | (testing "Prepares some pills"
35 | (is (= (sut/prepare-multi-select {} :val [:one :two])
36 | {:input/kind :input.kind/pill-select
37 | :options [{:text ":one" :on {:click [[:action/db.add {} :val :one]]}}
38 | {:text ":two" :on {:click [[:action/db.add {} :val :two]]}}]})))
39 |
40 | (testing "Selected value is marked as selected"
41 | (is (= (-> (sut/prepare-multi-select {:val #{:two}} :val [:one :two])
42 | :options
43 | second
44 | (select-keys [:text :selected?]))
45 | {:text ":two"
46 | :selected? true})))
47 |
48 | (testing "Clicking selected value removes it"
49 | (is (= (-> (sut/prepare-multi-select {:val #{:two}} :val [:one :two])
50 | :options
51 | second
52 | :on
53 | :click)
54 | [[:action/db.retract {:val #{:two}} :val :two]]))))
55 |
56 | (deftest prepare-number-input-test
57 | (testing "Prepares input"
58 | (is (= (sut/prepare-number-input {} :num)
59 | {:input/kind :input.kind/number
60 | :on {:input [[:action/db.add {} :num :event/target-value-num]]}
61 | :value nil})))
62 |
63 | (testing "Prepares input with current value"
64 | (is (= (sut/prepare-number-input {:num 42} :num)
65 | {:input/kind :input.kind/number
66 | :on {:input [[:action/db.add {:num 42} :num :event/target-value-num]]}
67 | :value 42}))))
68 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/musical_notation_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.musical-notation-scenes
2 | (:require [portfolio.replicant :refer [defscene]]
3 | [virtuoso.elements.musical-notation :as mn]))
4 |
5 | (defscene long-notes
6 | (mn/render [:note/whole
7 | :note/dotted-whole
8 | :note/half
9 | :note/dotted-half
10 | :note/quarter
11 | :note/dotted-quarter]))
12 |
13 | (defscene eighth-note
14 | [:div.text-6xl
15 | [:div.flex.gap-4
16 | (mn/render [:note/eighth])
17 | (mn/render [[:notation/dot :note/eighth]])
18 | (mn/render [[:notation/beam :note/eighth :note/eighth]])]
19 | [:div.flex.gap-4
20 | (mn/render [[:notation/beam [:notation/dot :note/eighth] :note/eighth]])
21 | (mn/render [[:notation/beam :note/eighth [:notation/dot :note/eighth]]])
22 | (mn/render [[:notation/beam [:notation/dot :note/eighth] [:notation/dot :note/eighth]]])]
23 | [:div.flex.gap-4
24 | (mn/render [[:notation/beam :note/eighth :note/eighth :note/eighth]])
25 | (mn/render [[:notation/beam [:notation/dot :note/eighth] :note/eighth :note/eighth]])
26 | (mn/render [[:notation/beam :note/eighth [:notation/dot :note/eighth] :note/eighth]])
27 | (mn/render [[:notation/beam :note/eighth :note/eighth [:notation/dot :note/eighth]]])
28 | (mn/render [[:notation/beam [:notation/dot :note/eighth] [:notation/dot :note/eighth] [:notation/dot :note/eighth]]])]])
29 |
30 | (defscene eighth-note
31 | [:div.text-6xl
32 | [:div.flex.gap-4
33 | (mn/render [:note/eighth])
34 | (mn/render [[:notation/dot :note/eighth]])
35 | (mn/render [[:notation/beam :note/eighth :note/eighth]])]
36 | [:div.flex.gap-4
37 | (mn/render [[:notation/beam [:notation/dot :note/eighth] :note/eighth]])
38 | (mn/render [[:notation/beam :note/eighth [:notation/dot :note/eighth]]])
39 | (mn/render [[:notation/beam [:notation/dot :note/eighth] [:notation/dot :note/eighth]]])]
40 | [:div.flex.gap-4
41 | (mn/render [[:notation/beam :note/eighth :note/eighth :note/eighth]])
42 | (mn/render [[:notation/beam [:notation/dot :note/eighth] :note/eighth :note/eighth]])
43 | (mn/render [[:notation/beam :note/eighth [:notation/dot :note/eighth] :note/eighth]])
44 | (mn/render [[:notation/beam :note/eighth :note/eighth [:notation/dot :note/eighth]]])
45 | (mn/render [[:notation/beam [:notation/dot :note/eighth] [:notation/dot :note/eighth] [:notation/dot :note/eighth]]])]])
46 |
47 | (defscene sixteenth-note
48 | [:div.text-6xl
49 | [:div.flex.gap-4
50 | (mn/render [:note/sixteenth])
51 | (mn/render [[:notation/dot :note/sixteenth]])
52 | (mn/render [[:notation/beam :note/sixteenth :note/sixteenth]])
53 | (mn/render [[:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth :note/sixteenth]])]
54 | [:div.flex.gap-4
55 | (mn/render [[:notation/beam :note/eighth :note/sixteenth :note/sixteenth]])
56 | (mn/render [[:notation/beam :note/eighth [:notation/dot :note/sixteenth]]])
57 | (mn/render [[:notation/beam [:notation/dot :note/eighth] :note/sixteenth]])]])
58 |
59 | (defscene note-positioning
60 | [:div
61 | [:div.relative.text-6xl
62 | [:div.absolute.bottom-1.left-0.right-0.border-b-2]
63 | [:div.flex.gap-4
64 | (mn/render [:note/whole])
65 | (mn/render [:note/half])
66 | (mn/render [:note/quarter])
67 | (mn/render [:note/eighth])
68 | (mn/render [:note/sixteenth])
69 | (mn/render [[:notation/dot :note/eighth]])
70 | (mn/render [[:notation/beam :note/eighth :note/eighth]])
71 | (mn/render [[:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth :note/sixteenth]])]]
72 | [:div.relative.text-3xl
73 | [:div.absolute.bottom-1.left-0.right-0.border-b-2]
74 | (mn/render [:note/whole :note/half :note/quarter :note/eighth [:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth :note/sixteenth]])]])
75 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/icon_button_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.icon-button-scenes
2 | (:require [phosphor.icons :as icons]
3 | [portfolio.replicant :refer [defscene]]
4 | [virtuoso.elements.icon-button :as icon-button]))
5 |
6 | (defscene default-button
7 | (icon-button/icon-button
8 | {:text "Click"
9 | :icon (icons/icon :phosphor.regular/music-notes-plus)}))
10 |
11 | (defscene small-buttons
12 | [:div.flex.gap-8
13 | (icon-button/icon-button
14 | {:text "Click"
15 | :icon (icons/icon :phosphor.regular/music-notes-plus)
16 | :icon-size :small
17 | :size :small})
18 | (icon-button/icon-button
19 | {:text "Click"
20 | :icon (icons/icon :phosphor.regular/music-notes-plus)
21 | :icon-size :medium
22 | :size :small})
23 | (icon-button/icon-button
24 | {:text "Click"
25 | :icon (icons/icon :phosphor.regular/music-notes-plus)
26 | :icon-size :large
27 | :size :small})])
28 |
29 | (defscene medium-buttons
30 | [:div.flex.gap-8
31 | (icon-button/icon-button
32 | {:text "Click"
33 | :icon (icons/icon :phosphor.regular/play)
34 | :icon-size :small
35 | :size :medium})
36 | (icon-button/icon-button
37 | {:text "Click"
38 | :icon (icons/icon :phosphor.regular/play)
39 | :icon-size :medium
40 | :size :medium})
41 | (icon-button/icon-button
42 | {:text "Click"
43 | :icon (icons/icon :phosphor.regular/play)
44 | :icon-size :large
45 | :size :medium})])
46 |
47 | (defscene large-buttons
48 | [:div.flex.gap-8
49 | (icon-button/icon-button
50 | {:text "Click"
51 | :icon (icons/icon :phosphor.regular/play)
52 | :icon-size :small
53 | :size :large})
54 | (icon-button/icon-button
55 | {:text "Click"
56 | :icon (icons/icon :phosphor.regular/play)
57 | :icon-size :medium
58 | :size :large})
59 | (icon-button/icon-button
60 | {:text "Click"
61 | :icon (icons/icon :phosphor.regular/play)
62 | :icon-size :large
63 | :size :large})])
64 |
65 | (defscene themes
66 | [:div.flex.gap-8
67 | (icon-button/icon-button
68 | {:text "Click"
69 | :icon (icons/icon :phosphor.regular/music-notes-simple)
70 | :theme :info})
71 | (icon-button/icon-button
72 | {:text "Click"
73 | :icon (icons/icon :phosphor.regular/music-notes-minus)
74 | :theme :warn})
75 | (icon-button/icon-button
76 | {:text "Click"
77 | :icon (icons/icon :phosphor.regular/music-notes-plus)
78 | :theme :success})])
79 |
80 | (defscene with-text
81 | (icon-button/icon-button
82 | {:text "Click"
83 | :icon (icons/icon :phosphor.bold/plus)
84 | :icon-size :small
85 | :icon-after-label "5"
86 | :theme :warn}))
87 |
88 | (defscene bare-buttons
89 | [:div
90 | [:div.flex.gap-8.items-center
91 | (icon-button/bare-icon-button
92 | {:text "Click"
93 | :icon (icons/icon :phosphor.regular/music-notes-plus)
94 | :size :small
95 | :theme :neutral})
96 | (icon-button/bare-icon-button
97 | {:text "Click"
98 | :icon (icons/icon :phosphor.regular/music-notes-plus)
99 | :size :medium
100 | :theme :neutral})
101 | (icon-button/bare-icon-button
102 | {:text "Click"
103 | :icon (icons/icon :phosphor.regular/music-notes-plus)
104 | :size :large
105 | :theme :neutral})
106 | (icon-button/bare-icon-button
107 | {:text "Click"
108 | :icon (icons/icon :phosphor.regular/music-notes-plus)
109 | :size :xlarge
110 | :theme :neutral})
111 | (icon-button/bare-icon-button
112 | {:text "Click"
113 | :icon (icons/icon :phosphor.regular/music-notes-plus)
114 | :theme :info})
115 | (icon-button/bare-icon-button
116 | {:text "Click"
117 | :icon (icons/icon :phosphor.regular/music-notes-plus)
118 | :theme :warn})
119 | (icon-button/bare-icon-button
120 | {:text "Click"
121 | :icon (icons/icon :phosphor.regular/music-notes-plus)
122 | :theme :success})]])
123 |
--------------------------------------------------------------------------------
/resources/public/brain.svg:
--------------------------------------------------------------------------------
1 |
44 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/form.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.form
2 | (:require [virtuoso.elements.layout :as layout]
3 | [virtuoso.elements.button :as button]))
4 |
5 | (defn prepare-select [e a options & [default]]
6 | (let [current (or (get e a) default (ffirst options))]
7 | {:input/kind :input.kind/select
8 | :on {:input [[:action/db.add e a
9 | (cond
10 | (keyword? current)
11 | :event/target-value-kw
12 |
13 | (number? current)
14 | :event/target-value-num
15 |
16 | :else
17 | :event/target-value)]]}
18 | :options (for [[v t] options]
19 | (cond-> {:value (cond
20 | (keyword? v)
21 | (str (when-let [ns (namespace v)]
22 | (str ns "/"))
23 | (name v))
24 |
25 | :else (str v))
26 | :text t}
27 | (= v current) (assoc :selected? true)))}))
28 |
29 | (defn prepare-multi-select [e a options & [default]]
30 | (let [current (set (or (get e a) default))]
31 | {:input/kind :input.kind/pill-select
32 | :options
33 | (for [v options]
34 | (cond-> {:text (str v)
35 | :on {:click (if (current v)
36 | [[:action/db.retract e a v]]
37 | [[:action/db.add e a v]])}}
38 | (current v) (assoc :selected? true)))}))
39 |
40 | (defn prepare-number-input [e a & [default]]
41 | {:input/kind :input.kind/number
42 | :on {:input [[:action/db.add e a :event/target-value-num]]}
43 | :value (or (get e a) default)})
44 |
45 | ;; Rendering
46 |
47 | (defn box [attrs & content]
48 | [(if (:href attrs)
49 | :a.block
50 | :div)
51 | (update attrs :class concat ["p-3" "md:p-5"] layout/box-classes)
52 | content])
53 |
54 | (defn h2
55 | ([text] (h2 nil text))
56 | ([_props text]
57 | [:h2.text-sm.mb-2.ml-1.font-bold
58 | text]))
59 |
60 | (defn fieldset [& fields]
61 | [:fieldset.flex.gap-4 fields])
62 |
63 | (defn control [{:keys [label size]} & fields]
64 | [:label.form-control
65 | {:class (if (= :md size) "w-24" "w-40")}
66 | [:div.label
67 | [:span.label-text label]]
68 | [:div.flex.gap-2
69 | fields]])
70 |
71 | (defn number-input [option]
72 | [:input.input.input-bordered.w-12.input-sm
73 | (merge option {:type "text"})])
74 |
75 | (defn select [params]
76 | [:select.select.select-bordered.w-26.select-sm
77 | (dissoc params :options)
78 | (for [option (:options params)]
79 | [:option (cond-> (dissoc option :selected? :text)
80 | (:selected? option) (assoc :selected "selected"))
81 | (:text option)])])
82 |
83 | (defn pill-select [{:keys [options]}]
84 | [:div.flex.gap-2
85 | (for [option options]
86 | [:button.badge.badge-neutral
87 | (merge
88 | (dissoc option :text :selected?)
89 | {:class (when-not (:selected? option)
90 | "badge-outline")})
91 | (:text option)])])
92 |
93 | (defn render-input [input]
94 | (case (:input/kind input)
95 | :input.kind/number (number-input input)
96 | :input.kind/select (select input)
97 | :input.kind/pill-select (pill-select input)))
98 |
99 | (defn form-box [form]
100 | (box
101 | {}
102 | (some-> form :title h2)
103 | (let [rows (for [{:keys [controls size]} (:fields form)]
104 | (let [fields (for [{:keys [label inputs]} controls]
105 | (control {:label label
106 | :size size}
107 | (map render-input inputs)))]
108 | (if (= 1 (count fields))
109 | fields
110 | (fieldset fields))))]
111 | (if (= 1 (count rows))
112 | rows
113 | [:div.flex.flex-col.gap-4 rows]))))
114 |
115 | (defn boxed-form [{:keys [boxes button]}]
116 | [:div.flex.flex-col.gap-4
117 | (map form-box boxes)
118 | (when button
119 | [:div.my-4
120 | (button/button button)])])
121 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/musical_notation.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.musical-notation
2 | "Express musical notation using the Bravura SMuFL
3 | font (https://w3c.github.io/smufl/latest/index.html).
4 |
5 | Turns out expressing musical notation with data is really quite difficult.
6 | This namespace merely provides a sort-of solution for a very narrow use case,
7 | and I'm not even very happy with how that is solved 😅 Enjoy!")
8 |
9 | (defn ustr [code-point]
10 | #?(:cljs (js/String.fromCodePoint code-point)
11 | :clj (String. (Character/toChars code-point))))
12 |
13 | (def augmentation-dot (ustr 0x1D16D))
14 |
15 | (def sixteenth-note (ustr 0x1D161))
16 | (def eighth-note (ustr 0x1D160))
17 | (def quarter-note (ustr 0x1D15F))
18 | (def half-note (ustr 0x1D15E))
19 | (def whole-note (ustr 0x1D157))
20 |
21 | (def note-long-stem (ustr 0xE1F1))
22 | (def eighth-beam-long-stem (ustr 0xE1F8))
23 | (def frac-eighth-long-stem (ustr 0xE1F3))
24 | (def sixteenth-beam-long-stem (ustr 0xE1FA))
25 | (def frac-sixteenth-long-stem (ustr 0xE1F5))
26 |
27 | (def skewed? #{note-long-stem
28 | eighth-beam-long-stem
29 | frac-eighth-long-stem
30 | sixteenth-beam-long-stem
31 | frac-sixteenth-long-stem})
32 |
33 | (defn position [note]
34 | (if (skewed? note)
35 | [:span.relative {:style {:bottom "-0.4em"}} note]
36 | note))
37 |
38 | (defn dot [note]
39 | [:span.relative
40 | note
41 | [:span.pl-1.absolute
42 | (cond-> {}
43 | (not (#{frac-eighth-long-stem
44 | frac-sixteenth-long-stem} note))
45 | (assoc :class ["absolute"])
46 |
47 | (#{eighth-beam-long-stem
48 | sixteenth-beam-long-stem} note)
49 | (assoc :style {:left "0"}))
50 | augmentation-dot]])
51 |
52 | (def note->hiccup
53 | {:note/sixteenth sixteenth-note
54 | :note/dotted-sixteenth (dot sixteenth-note)
55 | :note/eighth eighth-note
56 | :note/dotted-eighth (dot eighth-note)
57 | :note/quarter quarter-note
58 | :note/dotted-quarter (dot quarter-note)
59 | :note/half half-note
60 | :note/dotted-half (dot half-note)
61 | :note/whole whole-note
62 | :note/dotted-whole (dot whole-note)
63 |
64 | :beamed/note-stem note-long-stem
65 | :beamed/eighth-beam-long-stem eighth-beam-long-stem
66 | :beamed/sixteenth-beam-long-stem sixteenth-beam-long-stem
67 | :beamed/frac-eighth-long-stem frac-eighth-long-stem
68 | :beamed/frac-sixteenth-long-stem frac-sixteenth-long-stem})
69 |
70 | (def beam-symbol
71 | {:note/eighth :beamed/eighth-beam-long-stem
72 | :note/sixteenth :beamed/sixteenth-beam-long-stem})
73 |
74 | (def beamed-symbol
75 | {:note/eighth :beamed/frac-eighth-long-stem
76 | :note/sixteenth :beamed/frac-sixteenth-long-stem
77 | :beamed/note-stem :beamed/note-stem})
78 |
79 | (defn beam-note [note]
80 | (if (vector? note)
81 | (update note 1 beamed-symbol)
82 | (beamed-symbol note)))
83 |
84 | (defn beam [notes]
85 | (loop [prev :beamed/note-stem
86 | notes (seq notes)
87 | res []]
88 | (if notes
89 | (let [[candidate & more] notes
90 | [wrapped? note] (if (vector? candidate)
91 | [true (second candidate)]
92 | [false candidate])]
93 | (recur
94 | note
95 | more
96 | (cond-> res
97 | :always (conj (cond->> (beam-note (if more prev note))
98 | wrapped? (assoc candidate 1)))
99 | more (conj (beam-symbol note)))))
100 | res)))
101 |
102 | (defn render-beamed [note]
103 | (if (vector? note)
104 | (dot (position (note->hiccup (second note))))
105 | (position (note->hiccup note))))
106 |
107 | (defn ^{:indent 1} render
108 | ([notes]
109 | (render nil notes))
110 | ([attrs notes]
111 | (into [:div.flex.gap-4.items-center
112 | (merge {:class (concat ["font-['Bravura']"] (:class attrs))}
113 | (dissoc attrs :class))]
114 | (for [note notes]
115 | (cond
116 | (vector? note)
117 | (let [[notation & ns] note]
118 | (case notation
119 | :notation/beam [:span (map render-beamed (beam ns))]
120 | :notation/dot (dot (position (note->hiccup (first ns))))))
121 |
122 | :else
123 | [:span (position (note->hiccup note))])))))
124 |
--------------------------------------------------------------------------------
/test/virtuoso/elements/musical_notation_test.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.musical-notation-test
2 | (:require [virtuoso.elements.musical-notation :as sut]
3 | [clojure.test :refer [deftest is testing]]))
4 |
5 | (deftest beam-test
6 | (testing "Beams eighth notes"
7 | (is (= (sut/beam [:note/eighth :note/eighth])
8 | [:beamed/note-stem
9 | :beamed/eighth-beam-long-stem
10 | :beamed/frac-eighth-long-stem])))
11 |
12 | (testing "Beams dotted eighth note with eight note"
13 | (is (= (sut/beam [[:notation/dot :note/eighth] :note/eighth])
14 | [[:notation/dot :beamed/note-stem]
15 | :beamed/eighth-beam-long-stem
16 | :beamed/frac-eighth-long-stem])))
17 |
18 | (testing "Beams eighth note with dotted eight note"
19 | (is (= (sut/beam [:note/eighth [:notation/dot :note/eighth]])
20 | [:beamed/note-stem
21 | :beamed/eighth-beam-long-stem
22 | [:notation/dot :beamed/frac-eighth-long-stem]])))
23 |
24 | (testing "Beams dotted eighth notes"
25 | (is (= (sut/beam [[:notation/dot :note/eighth] [:notation/dot :note/eighth]])
26 | [[:notation/dot :beamed/note-stem]
27 | :beamed/eighth-beam-long-stem
28 | [:notation/dot :beamed/frac-eighth-long-stem]])))
29 |
30 | (testing "Beams eighth note triples"
31 | (is (= (sut/beam [:note/eighth :note/eighth :note/eighth])
32 | [:beamed/note-stem
33 | :beamed/eighth-beam-long-stem
34 | :beamed/frac-eighth-long-stem
35 | :beamed/eighth-beam-long-stem
36 | :beamed/frac-eighth-long-stem])))
37 |
38 | (testing "Beams eighth note triples, dotting the first"
39 | (is (= (sut/beam [[:notation/dot :note/eighth] :note/eighth :note/eighth])
40 | [[:notation/dot :beamed/note-stem]
41 | :beamed/eighth-beam-long-stem
42 | :beamed/frac-eighth-long-stem
43 | :beamed/eighth-beam-long-stem
44 | :beamed/frac-eighth-long-stem])))
45 |
46 | (testing "Beams eighth note triples, dotting the second"
47 | (is (= (sut/beam [:note/eighth [:notation/dot :note/eighth] :note/eighth])
48 | [:beamed/note-stem
49 | :beamed/eighth-beam-long-stem
50 | [:notation/dot :beamed/frac-eighth-long-stem]
51 | :beamed/eighth-beam-long-stem
52 | :beamed/frac-eighth-long-stem])))
53 |
54 | (testing "Beams eighth note triples, dotting the third"
55 | (is (= (sut/beam [:note/eighth :note/eighth [:notation/dot :note/eighth]])
56 | [:beamed/note-stem
57 | :beamed/eighth-beam-long-stem
58 | :beamed/frac-eighth-long-stem
59 | :beamed/eighth-beam-long-stem
60 | [:notation/dot :beamed/frac-eighth-long-stem]])))
61 |
62 | (testing "Beams eighth note triples, dotting the two first"
63 | (is (= (sut/beam [[:notation/dot :note/eighth] [:notation/dot :note/eighth] :note/eighth])
64 | [[:notation/dot :beamed/note-stem]
65 | :beamed/eighth-beam-long-stem
66 | [:notation/dot :beamed/frac-eighth-long-stem]
67 | :beamed/eighth-beam-long-stem
68 | :beamed/frac-eighth-long-stem])))
69 |
70 | (testing "Beams eighth note triples, dotting the two last"
71 | (is (= (sut/beam [:note/eighth [:notation/dot :note/eighth] [:notation/dot :note/eighth]])
72 | [:beamed/note-stem
73 | :beamed/eighth-beam-long-stem
74 | [:notation/dot :beamed/frac-eighth-long-stem]
75 | :beamed/eighth-beam-long-stem
76 | [:notation/dot :beamed/frac-eighth-long-stem]])))
77 |
78 | (testing "Beams eighth note triples, dotting 1 and 3"
79 | (is (= (sut/beam [[:notation/dot :note/eighth] :note/eighth [:notation/dot :note/eighth]])
80 | [[:notation/dot :beamed/note-stem]
81 | :beamed/eighth-beam-long-stem
82 | :beamed/frac-eighth-long-stem
83 | :beamed/eighth-beam-long-stem
84 | [:notation/dot :beamed/frac-eighth-long-stem]])))
85 |
86 | (testing "Beams eighth note triples, dotting all of them"
87 | (is (= (sut/beam [[:notation/dot :note/eighth] [:notation/dot :note/eighth] [:notation/dot :note/eighth]])
88 | [[:notation/dot :beamed/note-stem]
89 | :beamed/eighth-beam-long-stem
90 | [:notation/dot :beamed/frac-eighth-long-stem]
91 | :beamed/eighth-beam-long-stem
92 | [:notation/dot :beamed/frac-eighth-long-stem]])))
93 |
94 | (testing "Beams an eighth note with two sixteenths"
95 | (is (= (sut/beam [:note/eighth :note/sixteenth :note/sixteenth])
96 | [:beamed/note-stem
97 | :beamed/eighth-beam-long-stem
98 | :beamed/frac-eighth-long-stem
99 | :beamed/sixteenth-beam-long-stem
100 | :beamed/frac-sixteenth-long-stem])))
101 |
102 | (testing "Beams an eighth note with a dotted sixteenth"
103 | (is (= (sut/beam [:note/eighth [:notation/dot :note/sixteenth]])
104 | [:beamed/note-stem
105 | :beamed/eighth-beam-long-stem
106 | [:notation/dot :beamed/frac-sixteenth-long-stem]]))))
107 |
--------------------------------------------------------------------------------
/tf/distribution/main.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | aws = {
4 | source = "hashicorp/aws"
5 | configuration_aliases = [
6 | aws.us-east-1,
7 | ]
8 | }
9 | }
10 | }
11 |
12 | data "aws_iam_policy_document" "lambda" {
13 | statement {
14 | actions = ["sts:AssumeRole"]
15 |
16 | principals {
17 | type = "Service"
18 | identifiers = [
19 | "lambda.amazonaws.com",
20 | "edgelambda.amazonaws.com"
21 | ]
22 | }
23 | }
24 | }
25 |
26 | resource "aws_iam_role" "lambda_role" {
27 | name_prefix = "${var.domain_name}"
28 | assume_role_policy = "${data.aws_iam_policy_document.lambda.json}"
29 | }
30 |
31 | resource "aws_iam_role_policy_attachment" "lambda_exec" {
32 | role = "${aws_iam_role.lambda_role.name}"
33 | policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
34 | }
35 |
36 | data "archive_file" "headers" {
37 | type = "zip"
38 | output_path = "${path.module}/.zip/headers.zip"
39 |
40 | source {
41 | filename = "lambda.js"
42 | content = file("${path.module}/headers-lambda.js")
43 | }
44 | }
45 |
46 | resource "aws_lambda_function" "headers" {
47 | provider = aws.us-east-1
48 | function_name = "${var.app_name}-headers"
49 | filename = data.archive_file.headers.output_path
50 | source_code_hash = data.archive_file.headers.output_base64sha256
51 | role = aws_iam_role.lambda_role.arn
52 | runtime = "nodejs18.x"
53 | handler = "lambda.handler"
54 | memory_size = 128
55 | timeout = 3
56 | publish = true
57 | }
58 |
59 | data "archive_file" "url_rewrite" {
60 | type = "zip"
61 | output_path = "${path.module}/.zip/rewrite.zip"
62 |
63 | source {
64 | filename = "lambda.js"
65 | content = file("${path.module}/rewrite-lambda.js")
66 | }
67 | }
68 |
69 | resource "aws_lambda_function" "url_rewrite" {
70 | provider = aws.us-east-1
71 | function_name = "${var.app_name}-url-rewrite"
72 | filename = data.archive_file.url_rewrite.output_path
73 | source_code_hash = data.archive_file.url_rewrite.output_base64sha256
74 | role = aws_iam_role.lambda_role.arn
75 | runtime = "nodejs18.x"
76 | handler = "lambda.handler"
77 | memory_size = 128
78 | timeout = 3
79 | publish = true
80 | }
81 |
82 | locals {
83 | s3_origin_id = "StaticFilesS3BucketOrigin"
84 | }
85 |
86 | resource "aws_cloudfront_distribution" "s3_distribution" {
87 | origin {
88 | domain_name = "${var.bucket_regional_domain_name}"
89 | origin_id = "${local.s3_origin_id}"
90 |
91 | s3_origin_config {
92 | origin_access_identity = "${var.cloudfront_access_identity_path}"
93 | }
94 | }
95 |
96 | enabled = true
97 | is_ipv6_enabled = true
98 | comment = "${var.app_name} distribution"
99 | # default_root_object = "index.html"
100 | aliases = ["${var.domain_name}"]
101 |
102 | default_cache_behavior {
103 | allowed_methods = ["GET", "HEAD", "OPTIONS"]
104 | cached_methods = ["GET", "HEAD"]
105 | target_origin_id = "${local.s3_origin_id}"
106 |
107 | forwarded_values {
108 | query_string = false
109 |
110 | cookies {
111 | forward = "none"
112 | }
113 | }
114 |
115 | min_ttl = 0
116 | default_ttl = 3600
117 | max_ttl = 86400
118 | compress = true
119 | viewer_protocol_policy = "redirect-to-https"
120 |
121 | lambda_function_association {
122 | event_type = "viewer-request"
123 | lambda_arn = "${aws_lambda_function.url_rewrite.qualified_arn}"
124 | include_body = false
125 | }
126 |
127 | lambda_function_association {
128 | event_type = "viewer-response"
129 | lambda_arn = "${aws_lambda_function.headers.qualified_arn}"
130 | include_body = false
131 | }
132 | }
133 |
134 | # Cache immutable paths for a long time
135 | ordered_cache_behavior {
136 | path_pattern = "${var.immutable_path}"
137 | allowed_methods = ["GET", "HEAD", "OPTIONS"]
138 | cached_methods = ["GET", "HEAD", "OPTIONS"]
139 | target_origin_id = "${local.s3_origin_id}"
140 | min_ttl = 0
141 | default_ttl = 86400
142 | max_ttl = 31536000
143 | compress = true
144 | viewer_protocol_policy = "redirect-to-https"
145 |
146 | forwarded_values {
147 | query_string = false
148 | headers = ["Origin"]
149 | cookies {
150 | forward = "none"
151 | }
152 | }
153 | }
154 |
155 | price_class = "PriceClass_100"
156 |
157 | restrictions {
158 | geo_restriction {
159 | restriction_type = "none"
160 | }
161 | }
162 |
163 | viewer_certificate {
164 | acm_certificate_arn = "${var.certificate_arn}"
165 | minimum_protocol_version = "TLSv1.2_2021"
166 | ssl_support_method = "sni-only"
167 | }
168 |
169 | custom_error_response {
170 | error_code = "404"
171 | response_code = "404"
172 | response_page_path = "/404/index.html"
173 | }
174 | }
175 |
176 | data "aws_route53_zone" "zone" {
177 | name = "${var.hosted_zone}"
178 | }
179 |
180 | resource "aws_route53_record" "record" {
181 | name = "${var.domain_name}"
182 | zone_id = "${data.aws_route53_zone.zone.zone_id}"
183 | type = "A"
184 |
185 | alias {
186 | name = "${aws_cloudfront_distribution.s3_distribution.domain_name}"
187 | zone_id = "${aws_cloudfront_distribution.s3_distribution.hosted_zone_id}"
188 | evaluate_target_health = true
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/src/virtuoso/ui/main.cljs:
--------------------------------------------------------------------------------
1 | (ns ^:figwheel-hooks virtuoso.ui.main
2 | (:require [clojure.walk :as walk]
3 | [datascript.core :as d]
4 | [replicant.dom :as replicant]
5 | [virtuoso.elements.modal :as modal]
6 | [virtuoso.elements.page :as page]
7 | [virtuoso.metronome :as metronome]
8 | [virtuoso.pages.interleaved-clickup :as icu-page]
9 | [virtuoso.pages.metronome :as metronome-page]
10 | [virtuoso.ui.actions :as actions]
11 | [virtuoso.ui.db :as db]))
12 |
13 | (defonce conn (db/connect))
14 | (defonce metronome (metronome/create-metronome))
15 | (def ^:dynamic *on-render* nil)
16 |
17 | (def features
18 | {"interleaved-clickup"
19 | {:feature/prepare icu-page/prepare-ui-data
20 | :feature/render page/page
21 | :feature/get-boot-actions icu-page/get-boot-actions
22 | :feature/get-keypress-actions icu-page/get-keypress-actions}
23 |
24 | "metronome"
25 | {:feature/prepare metronome-page/prepare-ui-data
26 | :feature/prepare-modal metronome-page/prepare-modal-data
27 | :feature/render page/page
28 | :feature/get-boot-actions metronome-page/get-boot-actions
29 | :feature/get-keypress-actions metronome-page/get-keypress-actions}})
30 |
31 | (defn execute-actions [conn actions]
32 | (some->> actions
33 | (actions/perform-actions @conn)
34 | (actions/execute! conn)))
35 |
36 | (defmethod actions/execute-side-effect! :virtuoso/start-metronome [_ {:keys [args]}]
37 | (let [[activity & [{:keys [on-click]}]] args
38 | drop-pct (:metronome/drop-pct activity)
39 | tempo (:music/tempo activity)]
40 | (metronome/start
41 | metronome
42 | (cond->> (:music/bars activity)
43 | drop-pct (metronome/set-default :metronome/drop-pct drop-pct)
44 | :always metronome/click-beats
45 | :always metronome/accentuate-beats
46 | tempo (metronome/set-tempo tempo))
47 | {:on-click (when on-click
48 | (fn [click]
49 | (execute-actions
50 | conn
51 | (walk/postwalk
52 | #(if (and (vector? %) (= :metronome/click (first %)))
53 | (get click (second %))
54 | %)
55 | on-click))))})))
56 |
57 | (defmethod actions/execute-side-effect! :virtuoso/stop-metronome [_ _]
58 | (metronome/stop metronome))
59 |
60 | (defmethod actions/execute-side-effect! ::db/transact [conn {:keys [args]}]
61 | (try
62 | (d/transact conn args)
63 | (catch :default e
64 | (throw (ex-info "Failed to transact data" {:tx-data args} e)))))
65 |
66 | (defn prepare-and-render-ui [el db k prepare render & data]
67 | (let [page-data (apply prepare db data)]
68 | (when (ifn? *on-render*)
69 | (*on-render* k page-data))
70 | (->> page-data
71 | render
72 | (replicant/render el))))
73 |
74 | (defn prepare-and-render [el db {:feature/keys [prepare render prepare-modal render-modal]}]
75 | (let [ui-el (.-firstChild el)
76 | modal-el (.-nextSibling ui-el)
77 | render-page (or render page/page)
78 | render-modal (or render-modal modal/render)]
79 | (if-let [modal (when prepare-modal (modal/get-current-modal db))]
80 | (do
81 | (when-not (.-firstChild ui-el)
82 | ;; Render UI at least once, so it displays under the modal
83 | (prepare-and-render-ui ui-el db ::ui-layer prepare render-page))
84 | (prepare-and-render-ui modal-el db ::modal-layer prepare-modal render-modal modal))
85 | (do
86 | (when (.-firstChild modal-el)
87 | ;; Unmount existing modal
88 | (prepare-and-render-ui modal-el db ::modal-layer (constantly nil) render-modal nil))
89 | (prepare-and-render-ui ui-el db ::ui-layer prepare render-page)))))
90 |
91 | (defn render [db roots]
92 | (doseq [{:keys [el id]} roots]
93 | (prepare-and-render el db (get features id))))
94 |
95 | (defn add-class [el class]
96 | (.add (.-classList el) class))
97 |
98 | (defn boot-roots [conn roots]
99 | (doseq [{:keys [el id]} roots]
100 | (.appendChild el (doto (js/document.createElement "div")
101 | (add-class "ui-layer")))
102 | (.appendChild el (doto (js/document.createElement "div")
103 | (add-class "modal-layer")))
104 | (when-let [get-boot-actions (get-in features [id :feature/get-boot-actions])]
105 | (execute-actions conn (get-boot-actions @conn)))))
106 |
107 | (defn get-roots []
108 | (for [el (seq (js/document.querySelectorAll "[data-replicant-view]"))]
109 | {:el el
110 | :id (.getAttribute el "data-replicant-view")}))
111 |
112 | (defn ^{:after-load true :export true} main []
113 | (d/transact conn [{:db/ident :virtuoso/app
114 | :app/reloaded-at (.getTime (js/Date.))}]))
115 |
116 | (defn process-event [conn event data]
117 | (execute-actions conn (actions/interpolate-event-data (:replicant/js-event event) data)))
118 |
119 | (defn get-keypress-actions [roots db data]
120 | (loop [xs (seq roots)]
121 | (when xs
122 | (let [f (get-in features [(:id (first xs)) :feature/get-keypress-actions])]
123 | (if (ifn? f)
124 | (f db data)
125 | (recur (next xs)))))))
126 |
127 | (defn boot []
128 | (replicant/set-dispatch! #(process-event conn %1 %2))
129 | (let [roots (get-roots)]
130 | (boot-roots conn roots)
131 | (add-watch conn ::render (fn [_ _ _ db] (render db roots)))
132 | (js/document.body.addEventListener
133 | "keydown"
134 | (fn [e]
135 | (when (= js/document.body (.-target e))
136 | (when-let [actions (get-keypress-actions roots @conn {:key (.-key e)})]
137 | (.preventDefault e)
138 | (.stopPropagation e)
139 | (execute-actions conn actions))))))
140 | (d/transact conn [{:db/ident :virtuoso/app
141 | :app/booted-at (.getTime (js/Date.))}]))
142 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/bar.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.bar
2 | (:require [phosphor.icons :as icons]
3 | [virtuoso.elements.form :as form]
4 | [virtuoso.elements.icon-button :as icon-button]
5 | [virtuoso.elements.musical-notation :as mn]))
6 |
7 | (def sizes
8 | {:medium 2.5
9 | :large 8})
10 |
11 | (def bar-line-thin
12 | {:medium "w-px"
13 | :large "w-0.5"})
14 |
15 | (def bar-line-thick
16 | {:medium "w-0.5"
17 | :large "w-1"})
18 |
19 | (def rep-dot
20 | {:medium ["w-1" "h-1"]
21 | :large ["w-2" "h-2"]})
22 |
23 | (def rep-text-size
24 | {:medium "text-sm"
25 | :large "text-lg"})
26 |
27 | (def tempo-label-size
28 | {:medium "text-xs"
29 | :large "text-sm"})
30 |
31 | (def text-class
32 | {:medium "text-lg"
33 | :large "text-6xl"})
34 |
35 | (def icon-size
36 | {:medium "1rem"
37 | :large "2rem"})
38 |
39 | (def dot-padding
40 | {:medium "pt-2"
41 | :large "pt-4"})
42 |
43 | (def dot-size
44 | {:medium ["w-3" "h-3"]
45 | :large ["w-5" "h-5"]})
46 |
47 | (def label-width
48 | {:medium "min-w-4"
49 | :large "min-w-20"})
50 |
51 | (def note-size
52 | {:medium "text-4xl"
53 | :large "text-8xl"})
54 |
55 | (def note-padding
56 | {:medium "px-2"
57 | :large "px-8"})
58 |
59 | (defn icon-button [{:keys [icon actions]} {:keys [size]}]
60 | (when icon
61 | [:button {:on {:click actions}
62 | :class (when-not actions
63 | "text-neutral")
64 | :disabled (not actions)}
65 | (icons/render icon {:size (icon-size size)})]))
66 |
67 | (defn render-time-signature-buttons [xs k {:keys [height size]}]
68 | (when (some k xs)
69 | [:div.text-center.relative.leading-none.flex.flex-col.justify-around.pr-2
70 | {:style {:height height}}
71 | (for [x xs]
72 | (let [button (k x)]
73 | [:div {:style {:min-height (icon-size size)}}
74 | (icon-button button {:size size})]))]))
75 |
76 | (defn bar [{:keys [beats rhythm subdivision tempo reps dots size buttons actions] :as bar}]
77 | (let [size (if (sizes size) size :medium)
78 | rem-size (sizes size)
79 | height (str rem-size "rem")]
80 | [:div (when-let [k (:replicant/key bar)]
81 | {:replicant/key k})
82 | [:div.relative.pl-4.pr-4.min-w-12.border-l-2.border-r-2.border-neutral.flex
83 | {:style {:height height}}
84 | (for [i (range 5)]
85 | [:div.bg-neutral.h-px.absolute.left-0.right-0 {:style {:top (str (* i (/ rem-size 4)) "rem")}}])
86 | ;; Time signature
87 | (render-time-signature-buttons [beats subdivision] :left-button {:height height :size size})
88 | [:div.text-center.relative.leading-none.flex.flex-col.justify-around.pr-2
89 | (cond-> {:style {:height height}
90 | :class [(label-width size) (text-class size)]}
91 | actions (assoc :on {:click actions})
92 | actions (update :class conj "cursor-pointer"))
93 | [:div (:val beats)]
94 | [:div (:val subdivision)]]
95 | (render-time-signature-buttons [beats subdivision] :right-button {:height height :size size})
96 | ;; Rhythm
97 | (when (:pattern rhythm)
98 | (let [actions (or actions (:actions rhythm))]
99 | (mn/render (cond-> {:class [(note-size size) (note-padding size) "relative"]}
100 | actions (assoc :on {:click actions})
101 | actions (update :class conj "cursor-pointer"))
102 | (:pattern rhythm))))
103 | ;; Tempo
104 | (when tempo
105 | (let [subtle? (= :subtle (:style tempo))]
106 | [:div.pl-2.relative.flex.flex-col.text-center.justify-center.text-neutral-content
107 | (cond-> {}
108 | (and actions (not (:actions tempo)))
109 | (merge {:on {:click actions}
110 | :class "cursor-pointer"}))
111 | (if (:actions tempo)
112 | (form/number-input
113 | {:on {:input (:actions tempo)}
114 | :value (:val tempo)
115 | :class (cond-> ["mb-1" (rep-text-size size) "w-14"]
116 | subtle? (conj "text-neutral"))})
117 | [:div {:class (rep-text-size size)} (:val tempo)])
118 | [:div {:class (cond-> [(tempo-label-size size)]
119 | subtle? (conj "text-neutral"))}
120 | (:unit tempo)]]))
121 | ;; Reps
122 | (when reps
123 | [:div.relative.flex.ml-4
124 | [:div.flex.flex-col.justify-center.mr-1
125 | [:div.rounded-full.bg-neutral-content.mb-1
126 | {:class (rep-dot size)}]
127 | [:div.rounded-full.bg-neutral-content
128 | {:class (rep-dot size)}]]
129 | [:div.bg-neutral-content.mr-1 {:class (bar-line-thin size)}]
130 | [:div.bg-neutral-content {:class (bar-line-thick size)}]])
131 | (when reps
132 | [:div.pl-2.relative.flex.flex-col.justify-around.items-start
133 | (icon-button (:button-above reps) {:size size})
134 | [:span {:class [(label-width size) (rep-text-size size)]}
135 | (:val reps) " " (:unit reps)]
136 | (icon-button (:button-below reps) {:size size})])
137 | ;; Buttons
138 | (when (seq buttons)
139 | [:div.relative.flex.ml-4.gap-4.items-center
140 | (for [button buttons]
141 | (icon-button/bare-icon-button button))])]
142 | ;; Dots
143 | (when dots
144 | [:div.flex.justify-between.gap-1
145 | {:class (dot-padding size)}
146 | (for [dot dots]
147 | [:div.rounded-full.transition.duration-300.border
148 | {:on {:click (:actions dot)}
149 | :class (concat
150 | (dot-size size)
151 | (when (:actions dot)
152 | ["cursor-pointer"])
153 | (cond
154 | (:current? dot)
155 | ["bg-success" "border-success"]
156 |
157 | (:disabled? dot)
158 | ["border-neutral"]
159 |
160 | (:highlight? dot)
161 | ["bg-neutral" "border-info"]
162 |
163 | :else
164 | ["bg-neutral" "border-neutral"]))}])])]))
165 |
166 | (defn bars [{:keys [bars buttons]}]
167 | [:div.flex.gap-4.justify-center
168 | (map bar bars)
169 | (let [size (:size (first bars))
170 | size (if (sizes size) size :medium)]
171 | (for [button buttons]
172 | [:div.flex.items-center {:style {:height (str (sizes size) "rem")}}
173 | (icon-button/bare-icon-button
174 | (-> button
175 | (assoc :size :small)
176 | (update :theme #(or % :success))))]))])
177 |
--------------------------------------------------------------------------------
/portfolio/virtuoso/elements/bar_scenes.cljs:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.bar-scenes
2 | (:require [phosphor.icons :as icons]
3 | [portfolio.replicant :refer [defscene]]
4 | [virtuoso.elements.bar :as bar]))
5 |
6 | (defscene bar
7 | (bar/bar
8 | {:beats {:val 4}
9 | :subdivision {:val 4}
10 | :rhythm {:pattern [:note/quarter]}
11 | :dots [{:highlight? true}
12 | {}
13 | {}
14 | {}]}))
15 |
16 | (defscene repeated-bar
17 | (bar/bar
18 | {:beats {:val 4}
19 | :subdivision {:val 4}
20 | :rhythm {:pattern [[:notation/beam :note/eighth :note/eighth]]}
21 | :reps {:val 2 :unit "times"}
22 | :dots [{} {} {} {}]}))
23 |
24 | (defscene odd-time-signature
25 | (bar/bar
26 | {:beats {:val 15}
27 | :subdivision {:val 8}
28 | :rhythm {:pattern [[:notation/beam :note/sixteenth :note/sixteenth :note/sixteenth :note/sixteenth]]}
29 | :dots (repeat 15 {})}))
30 |
31 | (defscene with-tempo
32 | (bar/bar
33 | {:beats {:val 7}
34 | :subdivision {:val 8}
35 | :rhythm {:pattern [:note/half [:notation/beam :note/eighth :note/eighth :note/sixteenth :note/sixteenth]]}
36 | :actions []
37 | :tempo {:val 60 :unit "BPM"}
38 | :dots [{}
39 | {:disabled? true}
40 | {}
41 | {:disabled? true}
42 | {:disabled? true}
43 | {}]}))
44 |
45 | (defscene being-clicked
46 | :params (atom {:beats {:val 7}
47 | :subdivision {:val 8}
48 | :tempo {:val 60 :unit "BPM"}
49 | :current 2
50 | :dots [{}
51 | {:disabled? true}
52 | {}
53 | {:disabled? true}
54 | {:disabled? true}
55 | {}
56 | {}]})
57 | :on-mount (fn [params]
58 | (swap! params assoc ::timer
59 | (js/setInterval
60 | (fn []
61 | (swap! params update :current #(inc (mod % 7)))) 500)))
62 | :on-unmount (fn [params]
63 | (js/clearInterval (::timer @params)))
64 | [params]
65 | (let [{:keys [current] :as bar} @params]
66 | (bar/bar
67 | (assoc bar :dots (map-indexed
68 | (fn [idx dot]
69 | (cond-> dot
70 | (= (inc idx) current)
71 | (assoc :current? true)))
72 | (:dots bar))))))
73 |
74 | (defscene bar-with-buttons
75 | (bar/bar
76 | {:beats {:val 4
77 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)
78 | :actions []}
79 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
80 | :actions []}}
81 | :subdivision {:val 4
82 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)}
83 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
84 | :actions []}}
85 | :buttons [{:text "Remove bar"
86 | :icon (icons/icon :phosphor.regular/minus-circle)
87 | :theme :warn
88 | :actions []}]
89 | :dots [{:highlight? true} {} {} {}]}))
90 |
91 | (defscene editable-big-bar
92 | "When the bar is editable we want labels to always occupy the same amount of
93 | space, to avoid things moving around when using the plus and minus buttons
94 | etc."
95 | [:div.flex.flex-col.gap-8
96 | (bar/bar
97 | {:beats {:val 4
98 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)
99 | :actions []}
100 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
101 | :actions []}}
102 | :subdivision {:val 4
103 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)}
104 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
105 | :actions []}}
106 | :rhythm {:pattern [:note/quarter]}
107 | :reps {:val 1
108 | :unit "time"
109 | :button-above {:icon (icons/icon :phosphor.regular/minus-circle)}
110 | :button-below {:icon (icons/icon :phosphor.regular/plus-circle)
111 | :actions []}}
112 | :tempo {:val 70 :unit "BPM" :actions []}
113 | :dots [{:highlight? true} {} {} {}]
114 | :size :large})
115 | (bar/bar
116 | {:beats {:val 9
117 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)
118 | :actions []}
119 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
120 | :actions []}}
121 | :subdivision {:val 16
122 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)}
123 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
124 | :actions []}}
125 | :rhythm {:pattern [:note/quarter]}
126 | :reps {:val 12
127 | :unit "times"
128 | :button-above {:icon (icons/icon :phosphor.regular/minus-circle)}
129 | :button-below {:icon (icons/icon :phosphor.regular/plus-circle)
130 | :actions []}}
131 | :tempo {:val 190 :unit "BPM" :actions []}
132 | :dots [{:highlight? true} {} {} {}]
133 | :size :large})])
134 |
135 | (defscene editable-bar-without-tempo
136 | (bar/bar
137 | {:beats {:val 4
138 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)
139 | :actions []}
140 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
141 | :actions []}}
142 | :subdivision {:val 4
143 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)}
144 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
145 | :actions []}}
146 | :reps {:val 2
147 | :unit "times"
148 | :button-above {:icon (icons/icon :phosphor.regular/minus-circle)
149 | :actions []}
150 | :button-below {:icon (icons/icon :phosphor.regular/plus-circle)
151 | :actions []}}
152 | :dots [{:highlight? true} {} {} {}]
153 | :size :large}))
154 |
155 | (defscene editable-bar-with-default-tempo
156 | (bar/bar
157 | {:beats {:val 4
158 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)
159 | :actions []}
160 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
161 | :actions []}}
162 | :subdivision {:val 4
163 | :left-button {:icon (icons/icon :phosphor.regular/minus-circle)}
164 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
165 | :actions []}}
166 | :reps {:val 2
167 | :unit "times"
168 | :button-above {:icon (icons/icon :phosphor.regular/minus-circle)
169 | :actions []}
170 | :button-below {:icon (icons/icon :phosphor.regular/plus-circle)
171 | :actions []}}
172 | :tempo {:val 70 :unit "BPM" :actions [] :style :subtle}
173 | :dots [{:highlight? true} {} {} {}]
174 | :size :large}))
175 |
176 | (defscene multiple-bars
177 | (bar/bars
178 | {:bars [{:beats {:val 4}
179 | :subdivision {:val 4}
180 | :tempo {:val 60 :unit "BPM"}
181 | :reps {:val 2 :unit "times"}
182 | :dots [{:highlight? true} {} {} {}]}
183 | {:beats {:val 12}
184 | :subdivision {:val 8}
185 | :tempo {:val 70 :unit "BPM"}
186 | :dots [{:highlight? true} {} {}
187 | {:disabled? true} {} {} {} {} {}
188 | {:disabled? true} {} {}]
189 | :buttons [{:text "Remove bar"
190 | :icon (icons/icon :phosphor.regular/minus-circle)
191 | :theme :warn
192 | :actions []}]}]
193 | :buttons [{:text "Add bar"
194 | :icon (icons/icon :phosphor.regular/music-notes-plus)
195 | :icon-size :large
196 | :actions []}]}))
197 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Virtuoso tools
2 |
3 | [virtuoso.tools](https://virtuoso.tools) is a collection of tools to help
4 | musicians practice more efficiently.
5 |
6 | ## Technical overview
7 |
8 | Virtuoso is a static web site with a JavaScript frontend. Specifically, the
9 | static web pages are built with Clojure using
10 | [Powerpack](https://github.com/cjohansen/powerpack), and the frontend is written
11 | in ClojureScript, using [Datascript](https://github.com/tonsky/datascript) to
12 | store state and [Replicant](https://github.com/cjohansen/replicant) to render
13 | the UI. The frontend is [state-less and
14 | data-driven](https://vimeo.com/861600197) - every function, except those in
15 | [`virtuoso.ui.main`](./src/virtuoso/ui/main.cljs), is pure.
16 |
17 | ### `virtuoso.core`
18 |
19 | This namespace defines the Powerpack app and is used to boot the "backend",
20 | which only consists of static pages. There really isn't much to this part. Pages
21 | can boot a dynamic frontend by setting the `data-replicant-view` attribute on an
22 | element to a string identifier.
23 |
24 | ### `virtuoso.ui.main`
25 |
26 | This namespace contains the machinery of the frontend. At the top you will find
27 | a definition of all the "features" implemented, each using the same identifier
28 | from the above `data-replicant-view` attribute as an id. Each feature defines a
29 | few functions:
30 |
31 | - `:feature/prepare` - When the app state changes, this function is passed the
32 | Datascript database to prepare UI data.
33 | - `:feature/render` - This function will be called with what's returned by
34 | `:feature/prepare`.
35 | - `:feature/get-boot-actions` - An optional function that can return actions to
36 | execute when the feature is first loaded. Actions are described below.
37 | - `:feature/prepare-modal` - Like `:feature/prepare`, but specifically for modal
38 | data.
39 | - `:feature/render-modal` - Like `:feature/render`, but specifically for
40 | rendering modals.
41 | - `:feature/get-keypress-actions` - A function that is called with the database
42 | and a map of `{:key}`, and can return actions to perform for key-presses. This
43 | is how the app implements keyboard shortcuts.
44 |
45 | ## The render loop
46 |
47 | All the app state lives in a Datascript database. Whenever it changes, a render
48 | is triggered. Each change causes all mounted features to render (though in
49 | practice, each page only has a single feature at this point).
50 |
51 | Features are rendered in two distinct layers: the app layer, and the modal
52 | layer. The idea behind this is that there isn't much point in rendering the
53 | underlying page when focus is on a modal.
54 |
55 | Every change causes a feature's `:feature/prepare` function to be called with
56 | the current Datascript database. The resulting data structure is then passed to
57 | `:feature/render`, which is expected to return hiccup. The resulting hiccup is
58 | rendered to the DOM with Replicant.
59 |
60 | When there is a modal (as determined by
61 | `virtuoso.elements.modal/get-current-modal`, and the current feature has a
62 | `:feature/prepare-modal` function, the rendering focus switches to the modal. If
63 | the underlying app has been rendered at least once, it will not be rendered
64 | again until the modal goes away. If the app hasn't yet been rendered, it is
65 | rendered just once before being paused. The modal will be rendered with
66 | `:feature/prepare-modal` and `:feature/render-modal` (or
67 | `virtuoso.elements.modal/render` by default) for every change for as long as
68 | there is a current modal.
69 |
70 | The database and the rendering loop is initiated by `virtuoso.ui.main/boot`.
71 |
72 | ## Actions
73 |
74 | The frontend uses keyword dispatch of actions (e.g. event handlers, key presses,
75 | boot actions, etc). An action is a tuple like so:
76 |
77 | ```clj
78 | [action-kind & args]
79 | ```
80 |
81 | E.g.:
82 |
83 | ```clj
84 | [:action/transact [{:music/tempo 60}]]
85 | ```
86 |
87 | An action is dispatched with `virtuoso.ui.actions/perform-action`. This is a
88 | multi-method that dispatches on the action kind keyword, and is expected to
89 | return a sequence of effects. In other words, actions are pure functions. This
90 | makes it convenient to implement page/feature-specific actions without having to
91 | write side-effecting functions. Side-effects are handled by "effects", and there
92 | are a limited few effects ever necessary to implement.
93 |
94 | Actions may use a placeholder for event-time data, with these keywords:
95 |
96 | - `:event/key` the key pressed (in key events)
97 | - `:event/target-value` - the value of the event target element (e.g. to get the
98 | value of an edited input field)
99 | - `:event/target-value-num` - the event target value as a number
100 | - `:event/target-value-kw` - the event target value as a keyword
101 |
102 | These can be used like so:
103 |
104 | ```clj
105 | [:action/transact [{:music/tempo :event/target-value-num}]]
106 | ```
107 |
108 | Which will store an entity with `:music/tempo` set to the value of the input
109 | field it triggers on.
110 |
111 | ### A note on multi-methods
112 |
113 | The action system uses multi-methods to add new actions and to implement
114 | side-effects. Normally I'm skeptical of using multi-methods in application code,
115 | so allow me to explain this choice.
116 |
117 | I wanted all the effects to be implemented in `virtuoso.ui.main`, to keep all
118 | the side-effecting code in one place. I also wanted to be able to work with
119 | actions in tests, etc, without loading the main namespace. To satisfy these
120 | requirements, there is a `virtuoso.ui.actions` namespace that performs actions
121 | and executes effects, but the side-effect implementations themselves are defined
122 | in main for the app.
123 |
124 | I wanted individual features to be able to register custom actions. This is the
125 | main reason I separated actions and effects in the first place, so that you can
126 | add custom actions with pure functions (by returning a mix of the existing
127 | effects). I wanted feature-specific actions to be co-located with the feature
128 | they're used by. In other words there was a need for some sort registration
129 | mechanism, and I opted for a multi-method instead of building a home-grown
130 | solution.
131 |
132 | ## Rendering and "components"
133 |
134 | All the rendering code is completely generic and oriented towards the user
135 | interface as its domain. There are no components, because the rendering code
136 | literally does nothing but convert UI data to hiccup. I call them
137 | [elements](./src/virtuoso/elements), but really they're just regular pure
138 | functions.
139 |
140 | The element portfolio is available in production on
141 | [virtuoso.tools/portfolio/](https://virtuoso.tools/portfolio/).
142 |
143 | Some elements can seem to be tangled with the app's domain, but that is not the
144 | case. Yes, there is a [bar element](./src/virtuoso/elementsbar.cljc), but that
145 | is simply because a bar of music looks like nothing else -- it looks like a bar
146 | of music. There is no domain specifics inside this function. For instance, the
147 | dots below the bar is not assumed to be related to the bar's time signature.
148 | Instead, both of these visual elements are described individually. It is up to
149 | the user to decide how these should be related. This keeps domain logic out of
150 | the rendering logic, and it makes the visual components more widely reusable.
151 |
152 | ## Working with the code
153 |
154 | Copy the sample launchpad config:
155 |
156 | ```sh
157 | cp deps.local.sample.edn deps.local.edn
158 | ```
159 |
160 | Edit `deps.local.edn` to your heart's content. The default assumes Emacs. Launch
161 | a REPL with launchpad:
162 |
163 | ```sh
164 | make launch
165 | ```
166 |
167 | Then run `cider-connect-sibling-cljs` in Emacs to add a ClojureScript sibling
168 | REPL.
169 |
170 | The UI is built with [Tailwind](https://tailwindcss.com/) and
171 | [DaisyUI](https://daisyui.com/). Start the build process with:
172 |
173 | ```sh
174 | make tailwind
175 | ```
176 |
177 | Now start the Powerpack app by evaluating `(dev/start)` from
178 | [dev/virtuoso/dev.clj](./dev/virtuoso/dev.clj), and you should be off to the
179 | races.
180 |
181 | - The app runs on [http://localhost:4848/](http://localhost:4848/).
182 | - Portfolio displays UI elements on [http://localhost:4847/](http://localhost:4847/).
183 |
184 | ## Tests
185 |
186 | Since [pages](./src/virtuoso/pages/metronome.cljc) are implemented entirely with
187 | pure functions in CLJC files, they are trivial to test.
188 | [Tests](./test/virtuoso/pages/metronome_test.clj) are written with REPL
189 | ergonomics in mind (e.g. it should always be easy to evaluate the test AND the
190 | expression being tested separately).
191 |
192 | Run all the tests with
193 |
194 | ```sh
195 | make test
196 | ```
197 |
--------------------------------------------------------------------------------
/src/virtuoso/elements/brain.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.elements.brain
2 | (:require [clojure.string :as str]))
3 |
4 | (def themes
5 | {:default {:text ["oklch(var(--bc))"]
6 | :gradient ["oklch(var(--p)"
7 | "oklch(var(--p)"
8 | "oklch(var(--a))"]}})
9 |
10 | (defn brain [& [{:keys [theme id] :as opt}]]
11 | (let [theme (or theme (:default themes))
12 | text (cycle (:text theme))
13 | id (or id (-> (str/join "" (:gradient theme))
14 | (str/replace #"[^a-zA-Z0-9]" "")))
15 | gradient-id (str "gradient-" id)
16 | include-text? (get opt :text? true)]
17 | [:svg {:xmlns "http://www.w3.org/2000/svg"
18 | :viewBox (if include-text?
19 | "60 30 840 500"
20 | "284 30 472 470")
21 | :class (:class opt)}
22 | [:g {}
23 | (when include-text?
24 | [:g {}
25 | [:path {:fill (nth text 0) :d "M181,415.9l-46,106h-22l-46-106h19.6l37.5,86.2l37.3-86.2H181z"}]
26 | [:path {:fill (nth text 1) :d "M195.7,521.9v-106h17.9v106H195.7z"}]
27 | [:path {:fill (nth text 2) :d "M322.4,521.9h-21.2l-20.1-31.3h-21.8v31.3h-5.4h-12.6v-106h46.5c5.3,0,10.3,1,14.9,2.9
28 | c4.6,2,8.7,4.6,12.1,8c3.5,3.4,6.2,7.3,8.2,11.9c2,4.6,3,9.4,3,14.5c0,4-0.6,7.9-1.9,11.6c-1.3,3.7-3,7.1-5.2,10.1
29 | c-2.2,3-4.9,5.7-8,8c-3.1,2.3-6.5,4.1-10.2,5.4L322.4,521.9z M287.8,472.7c2.8,0,5.5-0.5,7.9-1.5c2.4-1,4.6-2.4,6.4-4.2
30 | c1.8-1.7,3.3-3.8,4.4-6.2c1.1-2.4,1.6-4.9,1.6-7.5c0-2.7-0.5-5.2-1.6-7.6c-1.1-2.3-2.6-4.4-4.4-6.1c-1.8-1.7-4-3.1-6.4-4.2
31 | c-2.4-1-5.1-1.5-7.9-1.5h-28.5v38.8H287.8z"}]
32 | [:path {:fill (nth text 3) :d "M420.4,433.9h-34.1v88h-17.9v-88h-34.1v-17.9h86.1V433.9z"}]
33 | [:path {:fill (nth text 4) :d "M482.5,523.7c-6.4,0-12.5-1.2-18.1-3.6c-5.7-2.4-10.6-5.6-14.8-9.7c-4.2-4.1-7.6-8.9-10-14.4
34 | c-2.4-5.5-3.7-11.4-3.7-17.6v-62.4h17.9v62.4c0,3.8,0.8,7.4,2.3,10.7c1.5,3.3,3.6,6.2,6.2,8.7c2.6,2.5,5.6,4.5,9.1,5.9
35 | c3.5,1.4,7.2,2.1,11.1,2.1c3.9,0,7.6-0.7,11.1-2.1s6.5-3.4,9.1-5.9c2.6-2.5,4.7-5.4,6.2-8.7c1.5-3.3,2.3-6.9,2.3-10.7v-62.4h17.9
36 | v62.4c0,6.2-1.2,12.1-3.7,17.6c-2.4,5.5-5.8,10.4-10,14.4c-4.2,4.1-9.2,7.3-14.8,9.7C494.9,522.5,488.9,523.7,482.5,523.7z"}]
37 | [:path {:fill (nth text 5) :d "M605.7,523.7c-7.7,0-15-1.4-21.8-4.3s-12.8-6.8-17.9-11.7c-5.1-4.9-9.1-10.8-12.1-17.4
38 | c-2.9-6.7-4.4-13.8-4.4-21.3c0-7.5,1.5-14.6,4.4-21.3c2.9-6.7,7-12.5,12.1-17.4c5.1-4.9,11.1-8.9,17.9-11.7s14.1-4.3,21.8-4.3
39 | c7.8,0,15.2,1.4,22,4.3c6.8,2.9,12.8,6.8,17.9,11.7c5.1,4.9,9.1,10.8,12.1,17.4c2.9,6.7,4.4,13.8,4.4,21.3
40 | c0,7.5-1.5,14.6-4.4,21.3c-2.9,6.7-7,12.5-12.1,17.4c-5.1,4.9-11.1,8.9-17.9,11.7C620.9,522.3,613.6,523.7,605.7,523.7z
41 | M605.7,432.1c-5.2,0-10.2,1-14.8,2.9c-4.7,1.9-8.7,4.5-12.2,7.9c-3.5,3.4-6.2,7.3-8.2,11.7c-2,4.5-3,9.2-3,14.3s1,9.9,3,14.3
42 | c2,4.5,4.8,8.4,8.2,11.7c3.5,3.4,7.6,6,12.2,7.9c4.7,1.9,9.6,2.9,14.8,2.9c5.3,0,10.3-0.9,15-2.9c4.7-1.9,8.7-4.5,12.2-7.9
43 | c3.5-3.4,6.2-7.3,8.2-11.7c2-4.5,3-9.2,3-14.3s-1-9.9-3-14.3c-2-4.5-4.8-8.4-8.2-11.7c-3.5-3.4-7.6-6-12.2-7.9
44 | C616.1,433,611.1,432.1,605.7,432.1z"}]
45 | [:path {:fill (nth text 6) :d "M686.1,521.9V504h43.5c1.5,0,3.1-0.2,4.6-0.7c1.6-0.4,3-1.1,4.2-2c1.2-0.9,2.3-2.1,3-3.5
46 | c0.8-1.4,1.1-3.1,1.1-5.1c0-1.7-0.4-3.3-1.2-4.6c-0.8-1.3-2.1-2.5-3.9-3.6c-1.8-1.1-4.2-2.1-7.1-3.2c-2.9-1-6.6-2.1-10.9-3.3
47 | c-3.9-1.1-8-2.4-12.4-3.8c-4.3-1.5-8.3-3.4-12-5.8c-3.6-2.4-6.7-5.5-9-9.2c-2.4-3.7-3.6-8.4-3.6-14.1c0-4.1,0.8-8,2.4-11.6
48 | c1.6-3.6,3.7-6.7,6.5-9.3c2.8-2.6,6-4.6,9.8-6.1c3.8-1.5,7.9-2.2,12.3-2.2H757v17.9h-43.5c-1.6,0-3.2,0.2-4.7,0.6
49 | c-1.5,0.4-2.9,1.1-4.2,2s-2.3,2.1-3,3.5c-0.8,1.4-1.1,3.1-1.1,5.1c0,2,0.4,3.6,1.2,5c0.8,1.4,2.1,2.6,4,3.8
50 | c1.8,1.2,4.3,2.3,7.3,3.3c3,1,6.8,2.2,11.2,3.5c3.8,1.1,7.9,2.3,12.1,3.8c4.3,1.4,8.2,3.3,11.8,5.6c3.6,2.3,6.5,5.3,8.9,9
51 | c2.3,3.6,3.5,8.2,3.5,13.6c0,4.1-0.8,8-2.4,11.6c-1.6,3.6-3.8,6.7-6.5,9.3c-2.8,2.6-6.1,4.6-9.9,6.1c-3.8,1.5-7.9,2.2-12.2,2.2
52 | H686.1z"}]
53 | [:path {:fill (nth text 7) :d "M837.2,523.7c-7.7,0-15-1.4-21.8-4.3s-12.8-6.8-17.9-11.7c-5.1-4.9-9.1-10.8-12.1-17.4
54 | c-2.9-6.7-4.4-13.8-4.4-21.3c0-7.5,1.5-14.6,4.4-21.3c2.9-6.7,7-12.5,12.1-17.4c5.1-4.9,11.1-8.9,17.9-11.7s14.1-4.3,21.8-4.3
55 | c7.8,0,15.2,1.4,22,4.3c6.8,2.9,12.8,6.8,17.9,11.7c5.1,4.9,9.1,10.8,12.1,17.4c2.9,6.7,4.4,13.8,4.4,21.3
56 | c0,7.5-1.5,14.6-4.4,21.3c-2.9,6.7-7,12.5-12.1,17.4c-5.1,4.9-11.1,8.9-17.9,11.7C852.4,522.3,845.1,523.7,837.2,523.7z
57 | M837.2,432.1c-5.2,0-10.2,1-14.8,2.9c-4.7,1.9-8.7,4.5-12.2,7.9c-3.5,3.4-6.2,7.3-8.2,11.7c-2,4.5-3,9.2-3,14.3s1,9.9,3,14.3
58 | c2,4.5,4.8,8.4,8.2,11.7c3.5,3.4,7.6,6,12.2,7.9c4.7,1.9,9.6,2.9,14.8,2.9c5.3,0,10.3-0.9,15-2.9c4.7-1.9,8.7-4.5,12.2-7.9
59 | c3.5-3.4,6.2-7.3,8.2-11.7c2-4.5,3-9.2,3-14.3s-1-9.9-3-14.3c-2-4.5-4.8-8.4-8.2-11.7c-3.5-3.4-7.6-6-12.2-7.9
60 | C847.6,433,842.6,432.1,837.2,432.1z"}]])
61 | [:g {}
62 | [:clipPath {:id (str id "-cp")}
63 | [:path {:d "M536.9,297.1c3.2,4.7,5.1,10.5,5.1,16.6c0,16.3-13.2,29.5-29.5,29.5c-16.3,0-29.5-13.2-29.5-29.5
64 | c0-16.3,13.2-29.5,29.5-29.5c4.7,0,9.2,1.1,13.2,3.1l137.1-157.2c-2.1-3.6-3.4-7.7-3.4-12.2c0-13.2,10.7-36.8,23.8-36.8
65 | c4.7,0,10.7,2.6,16.6,6.5l11.3-13c2.7-3.1,7.4-3.4,10.5-0.7c3.1,2.7,3.4,7.4,0.7,10.5l-11.2,12.8c5.2,5.7,8.9,11.9,8.9,17.1
66 | c0,13.2-23.6,27.4-36.8,27.4c-3.3,0-6.4-0.7-9.3-1.9L536.9,297.1z M351.3,198.6c0-18.1-14.7-32.7-32.7-32.7s-32.7,14.7-32.7,32.7
67 | c0,18.1,14.7,32.7,32.7,32.7S351.3,216.7,351.3,198.6z M364.8,77.7c-16.3,0-29.5,13.2-29.5,29.5c0,16.3,13.2,29.5,29.5,29.5
68 | c16.3,0,29.5-13.2,29.5-29.5C394.4,90.9,381.2,77.7,364.8,77.7z M428.3,88c9.4,0,17.1-7.7,17.1-17.1c0-9.4-7.7-17.1-17.1-17.1
69 | s-17.1,7.7-17.1,17.1C411.2,80.3,418.9,88,428.3,88z M504.8,82c12.8,0,23.2-10.4,23.2-23.2s-10.4-23.2-23.2-23.2
70 | s-23.2,10.4-23.2,23.2S492,82,504.8,82z M618.6,107.2c19.4,0,35.1-15.7,35.1-35.1S638,36.9,618.6,36.9
71 | c-19.4,0-35.1,15.7-35.1,35.1S599.2,107.2,618.6,107.2z M704.2,228.8c0,12.1,9.8,21.9,21.9,21.9s21.9-9.8,21.9-21.9
72 | c0-12.1-9.8-21.9-21.9-21.9S704.2,216.7,704.2,228.8z M712.1,278.8c-22.4,0-40.6,18.2-40.6,40.6c0,22.4,18.2,40.6,40.6,40.6
73 | c22.4,0,40.6-18.2,40.6-40.6C752.7,296.9,734.5,278.8,712.1,278.8z M694,189.8c0-9.5-7.7-17.1-17.1-17.1
74 | c-9.5,0-17.1,7.7-17.1,17.1c0,9.5,7.7,17.1,17.1,17.1C686.4,206.9,694,199.2,694,189.8z M629.3,241.3
75 | c-13.2,0-23.9,10.7-23.9,23.9c0,13.2,10.7,23.9,23.9,23.9c13.2,0,23.9-10.7,23.9-23.9C653.2,252,642.5,241.3,629.3,241.3z
76 | M476.7,168c17.3,0,31.3-14,31.3-31.3c0-17.3-14-31.3-31.3-31.3c-17.3,0-31.3,14-31.3,31.3C445.4,154,459.4,168,476.7,168z
77 | M434.4,198.6c0-12.8-10.4-23.1-23.1-23.1c-12.8,0-23.1,10.4-23.1,23.1c0,12.8,10.4,23.1,23.1,23.1
78 | C424,221.8,434.4,211.4,434.4,198.6z M428.3,268.7c-16.4,0-29.6,13.3-29.6,29.6c0,16.4,13.3,29.6,29.6,29.6s29.6-13.3,29.6-29.6
79 | C457.9,282,444.7,268.7,428.3,268.7z M358.3,245.7c-12.7,0-23,10.3-23,23c0,12.7,10.3,23,23,23s23-10.3,23-23
80 | C381.3,256,371,245.7,358.3,245.7z M605.4,445.6c-13.2,0-23.9,10.7-23.9,23.9c0,13.2,10.7,23.9,23.9,23.9s23.9-10.7,23.9-23.9
81 | C629.3,456.3,618.6,445.6,605.4,445.6z M602.5,313.8c-22.5,0-40.8,18.3-40.8,40.8s18.3,40.8,40.8,40.8
82 | c22.5,0,40.8-18.3,40.8-40.8S625.1,313.8,602.5,313.8z M535.1,226.7c3.9-13.8,7.3-27.8,11.1-41.6c4.9-17.9,9.3-35.8,14-53.8
83 | c0.3-1.3,0.8-2.5,1.4-3.7c1.3-2.4,3.5-3.4,6.2-2.7c2.9,0.7,4.7,2.8,6.2,5.1c1.8,2.8,3.4,5.7,5,8.6c8.1,14.2,9,29,4,44.4
84 | c-0.7,2.1-0.7,4.2,0.1,6.2c0.9,2.1,2.7,2.9,4.8,2.2c1.3-0.4,2.4-1.2,3.2-2.3c3.6-5.2,6.5-10.7,8-16.9c2.2-10.3,1.7-20.3-2.2-30.2
85 | c-3.8-9.7-8.8-18.7-13.7-27.8c-4.3-8-8.2-16.2-9.7-25.3c-0.4-2.4-1.2-4.8-2-7.1c-1.3-3.8-6.2-5.2-9.2-2.7
86 | c-1.4,1.2-1.8,2.9-2.3,4.6c-4.9,18-9.2,36.2-14.3,54.1c-4.5,16.1-8.7,32.4-13.1,48.6c-1.4,5.2-2.9,10.4-4.4,15.7
87 | c-0.4,1.4-1.1,2.8-1.9,4c-2.3,3.6-4.2,4.1-7.8,1.8c-4.2-2.7-8.6-4.8-13.4-6.1c-12-3.1-23.8-1.8-35.1,3.2
88 | c-7.1,3.1-12.7,8.3-16.5,15.2c-5.6,10.3-3.8,21.3,5.2,28.8c7.1,5.9,15.5,9.1,24.8,9.6C520.9,263.1,534,230.4,535.1,226.7z"}]]
89 | [:linearGradient {:id gradient-id
90 | :gradientUnits "userSpaceOnUse"
91 | :x1 "324.8879"
92 | :y1 "21.2758"
93 | :x2 "718.0211"
94 | :y2 "479.1865"}
95 | (let [n (count (:gradient theme))
96 | m (dec n)]
97 | (for [i (range n)]
98 | [:stop {:offset (if (= i m)
99 | 1
100 | (str (/ i m))) :stop-color (nth (:gradient theme) i)}]))]
101 | [:path {:clip-path (str "url(#" id "-cp)")
102 | :fill (str "url(#" gradient-id ")")
103 | :d "M771.6,378.9c0,58.5-50,106-111.6,163.2H387.7
104 | c-61.6-57.2-111.6-104.6-111.6-163.2V120.2c0-58.5,50-106,111.6-106H660c61.6,0,111.6,47.4,111.6,106V378.9z"}]]]]))
105 |
--------------------------------------------------------------------------------
/src/virtuoso/metronome.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.metronome)
2 |
3 | (defn- create-sine-wave
4 | "Creates a sine wave oscillator with a gain and connects it to the audio
5 | context. Returns a map of `{:oscillator :gain}`"
6 | [audio-ctx frequency]
7 | (let [oscillator (.createOscillator audio-ctx)
8 | gain (.createGain audio-ctx)]
9 | (set! (.-type oscillator) "sine")
10 | (set! (.. oscillator -frequency -value) frequency)
11 | (set! (.. gain -gain -value) 0)
12 | (.connect oscillator gain)
13 | (.connect gain (.-destination audio-ctx))
14 | (.start oscillator 0)
15 | {:oscillator oscillator
16 | :gain gain}))
17 |
18 | (defn ^:export create-metronome
19 | "Creates a metronome with an audio contex. Optionally pass in a pre-existing
20 | audio context.
21 |
22 | The optional map argument can be used to configure the metronome:
23 |
24 | - `:tick-frequency` the frequency for the tick. Defaults to 1000.
25 | - `:accent-frequency` the frequency of accented ticks. Defaults to 1250.
26 | - `:count-in-frequency` the frequency of the count-in. No default."
27 | [& {:keys [audio-ctx tick-frequency accent-frequency count-in-frequency]}]
28 | (atom {:audio-ctx (or audio-ctx #?(:cljs (js/AudioContext.)))
29 | :tick-frequency (or tick-frequency 1000)
30 | :accent-frequency (or accent-frequency 1250)
31 | :count-in-frequency count-in-frequency}))
32 |
33 | (defn ^:export configure!
34 | "Re-configure the metronome, see `create-metronome` for options."
35 | [metronome {:keys [tick-frequency accent-frequency count-in-frequency] :as opt}]
36 | (swap! metronome
37 | (fn [m]
38 | (cond-> m
39 | :always (update :tick-frequency #(or tick-frequency %))
40 | (contains? opt :accent-frequency) (assoc :accent-frequency accent-frequency)
41 | (contains? opt :count-in-frequency) (assoc :count-in-frequency count-in-frequency)))))
42 |
43 | (defn- unmount [audio-ctx {:keys [oscillator gain]}]
44 | (some-> gain (.disconnect (.-destination audio-ctx)))
45 | (when oscillator
46 | (.stop oscillator)
47 | (.disconnect oscillator gain)))
48 |
49 | (defn generate-bar-clicks
50 | "Generates clicks for a single bar. A bar is a map of:
51 |
52 | - `:music/time-signature` - a tuple of [beats subdivision], e.g. `[4 4]`.
53 | - `:music/tempo` - the bar tempo, as a number of beats per minute.
54 | - `:bar/rhythm` - a list of note durations that make up the bar rhythm
55 | - `:bar/reps` - optional number of times to repeat the bar.
56 | - `:click?` - a function that receives a click and decides if it should, well,
57 | click.
58 | - `:accentuate?` - a function that receives a click and decides if it should
59 | be accentuated.`
60 |
61 | A click, as passed to `:metronome/click?` and `:metronome/accentuate?` is a
62 | map of:
63 |
64 | - `:bar/n` - the total bar number, 1 indexed.
65 | - `:bar/beat` - the beat number within the bar, 1 indexed.
66 | - `:beat/n` - the total beat number, 1 indexed.
67 | - `:rhythm/n` - the current position in the rhythm pattern
68 |
69 | Returns a sequence of clicks. In addition to the above keys, it includes:
70 |
71 | - `:metronome/click-at` a timestamp (e.g. ms since epoch) of when to trigger
72 | this click.
73 | - `:metronome/accentuate?` a boolean indicating if the click should be
74 | accentuated."
75 | [bar {:keys [first-beat first-bar start-time idx]}]
76 | (let [[beats subdivision] (or (:music/time-signature bar) [4 4])
77 | accentuate? (or (:accentuate? bar) (constantly false))
78 | click? (or (:click? bar) (constantly true))
79 | quarter-note-duration (/ (* 60 1000) (or (:music/tempo bar) 120))
80 | beat-duration (* quarter-note-duration (/ 4 subdivision))
81 | bar-duration (* quarter-note-duration
82 | beats
83 | (/ 4 subdivision)) ;; A beat is always a quarter note
84 | duration (* bar-duration (or (:bar/reps bar) 1))
85 | rhythm (or (:bar/rhythm bar) [(/ 1 subdivision)])
86 | rhythm-pattern (cycle rhythm)
87 | rhythm-pattern-len (count rhythm)]
88 | (loop [now 0
89 | bar-now 0
90 | n 0
91 | specs rhythm-pattern
92 | clicks []]
93 | (if (< now duration)
94 | (if (< bar-now bar-duration)
95 | ;; (/ 1 4) represents a quarter note, so multiply by 4
96 | (let [dur (* quarter-note-duration (first specs) 4)
97 | bar-n (int (/ now bar-duration))
98 | beat-n (int (/ now beat-duration))
99 | click {:bar/idx idx
100 | :bar/n (+ first-bar bar-n)
101 | :bar/beat (inc (mod beat-n beats))
102 | :beat/n (+ first-beat beat-n)
103 | :rhythm/n (inc (mod n rhythm-pattern-len))}]
104 | (recur
105 | (+ now dur)
106 | (+ bar-now dur)
107 | (inc n)
108 | (next specs)
109 | (cond-> clicks
110 | (click? click)
111 | (conj (cond-> click
112 | (accentuate? click) (assoc :metronome/accentuate? true)
113 | :then (assoc :metronome/click-at (+ start-time now)))))))
114 | (recur (+ (- now bar-now) bar-duration) 0 n rhythm-pattern clicks))
115 | {:duration duration
116 | :beats (* beats (or (:bar/reps bar) 1))
117 | :clicks clicks}))))
118 |
119 | (defn generate-clicks
120 | "Generate clicks for the metronome from the sequence of `bars`."
121 | [bars & [{:keys [now first-bar first-beat]}]]
122 | (loop [bars (seq bars)
123 | res nil
124 | bar-n (or first-bar 1)
125 | beat-offset (or first-beat 1)
126 | start-time (or now 0)
127 | idx 1]
128 | (if (nil? bars)
129 | {:clicks res
130 | :bar-count (dec bar-n)
131 | :beat-count (dec beat-offset)
132 | :time start-time
133 | :duration (- start-time (or now 0))}
134 | (let [bar (update (first bars) :music/time-signature #(or % [4 4]))
135 | {:keys [beats duration clicks]}
136 | (generate-bar-clicks bar {:first-beat beat-offset
137 | :start-time start-time
138 | :first-bar bar-n
139 | :idx idx})]
140 | (recur (next bars)
141 | (concat res clicks)
142 | (+ bar-n (:bar/reps bar 1))
143 | (+ beat-offset beats)
144 | (+ start-time duration)
145 | (inc idx))))))
146 |
147 | (defn- set-timeout [f ms]
148 | #?(:cljs (js/setTimeout f ms)
149 | :clj [f ms] ;; silence clj-kondo
150 | ))
151 |
152 | (defn- clear-timeout [id]
153 | #?(:cljs (js/clearTimeout id)
154 | :clj id ;; silence clj-kondo
155 | ))
156 |
157 | (defn schedule-ticks [metronome bars opt]
158 | (let [{:keys [accent tick]} @metronome
159 | {:keys [clicks bar-count beat-count time duration]} (generate-clicks bars opt)
160 | now (* 1000 (.-currentTime (:audio-ctx @metronome)))
161 | callbacks (transient [])]
162 | (doseq [{:metronome/keys [click-at accentuate?] :as click} clicks]
163 | (when-let [on-click (:on-click opt)]
164 | (conj! callbacks (set-timeout #(on-click click) (- click-at now))))
165 | (let [{:keys [gain]} (if accentuate? accent tick)
166 | click-at (/ click-at 1000)]
167 | (.cancelScheduledValues (.-gain gain) click-at)
168 | (.setValueAtTime (.-gain gain) 0 click-at)
169 | (.linearRampToValueAtTime (.-gain gain) 1 (+ click-at 0.001))
170 | (.linearRampToValueAtTime (.-gain gain) 0 (+ click-at 0.001 0.01))))
171 | (swap! metronome assoc
172 | :tick-schedule
173 | (set-timeout
174 | (fn []
175 | (when (:running? @metronome)
176 | (->> (merge opt {:now time
177 | :first-bar (inc bar-count)
178 | :first-beat (inc beat-count)})
179 | (schedule-ticks metronome bars))))
180 | (* 0.9 duration))
181 |
182 | :callbacks (persistent! callbacks))))
183 |
184 | (defn set-tempo [tempo bars]
185 | (let [start-tempo (or (:music/tempo (first bars)) tempo)
186 | scale (/ tempo start-tempo)]
187 | (loop [bars (seq bars)
188 | res []
189 | current-tempo start-tempo]
190 | (if (nil? bars)
191 | res
192 | (let [bar (first bars)
193 | bar-tempo (or (:music/tempo bar) current-tempo)]
194 | (recur
195 | (next bars)
196 | (conj res (assoc bar :music/tempo (* scale bar-tempo)))
197 | bar-tempo))))))
198 |
199 | (defn set-default [k v bars]
200 | (for [bar bars]
201 | (update (into {} bar) k #(or % v))))
202 |
203 | (defn accentuate-beats
204 | "Converts `:metronome/accentuate-beats` on bars to an `:accentuate?` function."
205 | [bars]
206 | (for [bar bars]
207 | (let [beats (:metronome/accentuate-beats bar)]
208 | (cond-> (into {} bar)
209 | beats (dissoc :metronome/accentuate-beats)
210 | beats (assoc :accentuate? (comp (set beats) :bar/beat))))))
211 |
212 | (defn click-beats
213 | "Prepares a `:click?` function on bars that have either
214 | `:metronome/click-beats` (a set of beat numbers to click), or
215 | `:metronome/drop-pct` (a percentage of beats to randomly drop)."
216 | [bars]
217 | (for [bar bars]
218 | (let [click? (or (when-let [pct (:metronome/drop-pct bar)]
219 | (when (< 0 pct)
220 | (if-let [click-beat? (some-> bar :metronome/click-beats set)]
221 | (fn [click]
222 | (let [i (rand-int 100)]
223 | (and (< pct i) (click-beat? (:bar/beat click)))))
224 | (fn [_]
225 | (let [i (rand-int 100)]
226 | (< pct i))))))
227 | (some-> (:metronome/click-beats bar) set (comp :bar/beat)))]
228 | (cond-> (into {} bar)
229 | click? (dissoc :metronome/click-beats :metronome/drop-pct)
230 | click? (assoc :click? click?)))))
231 |
232 | (defn stop [metronome]
233 | (let [{:keys [running? callbacks]} @metronome]
234 | (when running?
235 | (let [{:keys [tick accent count-in audio-ctx]} @metronome]
236 | (unmount audio-ctx tick)
237 | (when-not (= tick accent)
238 | (unmount audio-ctx accent))
239 | (unmount audio-ctx count-in))
240 | (when-let [t (:tick-schedule @metronome)]
241 | (clear-timeout t))
242 | (swap! metronome dissoc :running? :tick :accent :count-in :tick-schedule))
243 | (doseq [id callbacks]
244 | (clear-timeout id)))
245 | nil)
246 |
247 | (defn start [metronome bars & [{:keys [on-click]}]]
248 | (stop metronome)
249 | (let [{:keys [tick-frequency accent-frequency count-in-frequency audio-ctx]} @metronome
250 | tick (create-sine-wave audio-ctx (or tick-frequency 1000))]
251 | (swap!
252 | metronome
253 | (fn [m]
254 | (-> m
255 | (assoc :running? true)
256 | (assoc :count-in (when count-in-frequency
257 | (create-sine-wave audio-ctx count-in-frequency)))
258 | (assoc :tick tick)
259 | (assoc :accent (or (when accent-frequency
260 | (create-sine-wave audio-ctx accent-frequency))
261 | tick)))))
262 | (schedule-ticks
263 | metronome
264 | (if (map? bars) [bars] bars)
265 | {;; Offset slightly to avoid the very fist click occasionally being cut short
266 | :now (+ 5 (* 1000 (.-currentTime (:audio-ctx @metronome))))
267 | :first-bar 1
268 | :first-beat 1
269 | :on-click on-click})
270 | nil))
271 |
--------------------------------------------------------------------------------
/resources/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
176 |
--------------------------------------------------------------------------------
/src/virtuoso/pages/interleaved_clickup.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.pages.interleaved-clickup
2 | (:require [clojure.string :as str]
3 | [datascript.core :as d]
4 | [phosphor.icons :as icons]
5 | [virtuoso.elements.form :as form]
6 | [virtuoso.elements.layout :as layout]
7 | [virtuoso.elements.typography :as t]
8 | [virtuoso.interleaved-clickup :as icu]))
9 |
10 | (defn render-page [_ctx _page]
11 | (layout/layout
12 | [:div.flex.flex-col.min-h-screen.justify-between
13 | (layout/header {:title "Interleaved Clicking Up"})
14 | [:main.grow.flex.flex-col.gap-4.justify-center
15 | {:class layout/container-classes
16 | :data-replicant-view "interleaved-clickup"}]
17 | [:footer.my-4 {:class layout/container-classes}
18 | [:div.px-4.md:px-0.mt-4
19 | (t/h2 "What's this?")
20 | (t/p
21 | "Interleaved clicking up helps you solidify and bring a piece of music up
22 | to speed by breaking it into chunks and clicking them up in a rotating
23 | pattern. You start with just a single chunk and click it up. You then add
24 | another chunk, and will be asked to play sections of varying lengths
25 | every time you increase the speed.")
26 | (t/p {}
27 | "This exercise was designed by "
28 | [:a.link {:href "https://www.mollygebrian.com/"} "Molly Gebrian"] ". She
29 | has a very good explanation and a demonstration of the process "
30 | [:a.link {:href "https://www.youtube.com/watch?v=it89AswI2dw"} "on YouTube"]
31 | ".")]]]))
32 |
33 | (def schema
34 | {::icu/tempo-start {} ;; number
35 | ::icu/tempo-step {} ;; number
36 | ::icu/tempo-current {} ;; number
37 | ::icu/phrase-max {} ;; number
38 | ::icu/phrase-count {} ;; number
39 | ::icu/phrase-size {} ;; number
40 | ::icu/phrase-kind {} ;; keyword
41 | ::icu/start-at {} ;; keyword
42 | })
43 |
44 | (def phrase-label
45 | {:phrase.kind/beat "Beat"
46 | :phrase.kind/bar "Bar"
47 | :phrase.kind/line "Line"
48 | :phrase.kind/phrase "Phrase"})
49 |
50 | (def phrase-kinds
51 | [[:phrase.kind/beat "Beats"]
52 | [:phrase.kind/bar "Bars"]
53 | [:phrase.kind/line "Lines"]
54 | [:phrase.kind/phrase "Phrases"]])
55 |
56 | (def denominators
57 | [2 3 4 8 16 32])
58 |
59 | (def starts
60 | [[:start/beginning "the top"]
61 | [:start/end "the bottom"]])
62 |
63 | (defn started? [activity]
64 | (::icu/tempo-current activity))
65 |
66 | (defn decrease-tempo [activity]
67 | (when-not (:activity/paused? activity)
68 | (when-let [tempo (icu/decrease-tempo activity)]
69 | [[:action/db.add activity ::icu/tempo-current tempo]
70 | [:action/start-metronome {:music/bars [activity]
71 | :music/tempo tempo}]])))
72 |
73 | (defn increase-tempo [activity]
74 | (when-not (:activity/paused? activity)
75 | (let [tempo (icu/increase-tempo activity)]
76 | [[:action/db.add activity ::icu/tempo-current tempo]
77 | [:action/start-metronome {:music/bars [activity]
78 | :music/tempo tempo}]])))
79 |
80 | (defn change-phrase [activity next-phrase]
81 | (when-not (:activity/paused? activity)
82 | (when next-phrase
83 | (let [tempo (icu/get-tempo-start activity)]
84 | [[:action/transact
85 | [[:db/add (:db/id activity) ::icu/tempo-current tempo]
86 | [:db/add (:db/id activity) ::icu/phrase-current next-phrase]]]
87 | [:action/start-metronome {:music/bars [activity]
88 | :music/tempo tempo}]]))))
89 |
90 | (defn forward-phrase [activity]
91 | (when-not (:activity/paused? activity)
92 | (->> (icu/get-next-phrase activity)
93 | (change-phrase activity))))
94 |
95 | (defn backward-phrase [activity]
96 | (when-not (:activity/paused? activity)
97 | (->> (icu/get-prev-phrase activity)
98 | (change-phrase activity))))
99 |
100 | (defn stop [activity]
101 | [[:action/transact
102 | [[:db/retract (:db/id activity) ::icu/tempo-current]
103 | [:db/retract (:db/id activity) ::icu/phrase-current]
104 | [:db/retract (:db/id activity) :activity/paused?]]]
105 | [:action/stop-metronome]])
106 |
107 | (defn pause [activity]
108 | [[:action/db.add activity :activity/paused? true]
109 | [:action/stop-metronome]])
110 |
111 | (defn play [activity]
112 | (when (:activity/paused? activity)
113 | [[:action/db.add activity :activity/paused? false]
114 | [:action/start-metronome {:music/bars [activity]
115 | :music/tempo (icu/get-tempo activity)}]]))
116 |
117 | (defn get-activity [db]
118 | (:view/tool (d/entity db :virtuoso/current-view)))
119 |
120 | (defn get-keypress-actions [db data]
121 | (let [activity (get-activity db)]
122 | (when (started? activity)
123 | (case (:key data)
124 | "+" (increase-tempo activity)
125 | "ArrowUp" (increase-tempo activity)
126 | "-" (decrease-tempo activity)
127 | "ArrowDown" (decrease-tempo activity)
128 | " " (if (:activity/paused? activity)
129 | (play activity)
130 | (pause activity))
131 | "n" (forward-phrase activity)
132 | "ArrowRight" (forward-phrase activity)
133 | "p" (backward-phrase activity)
134 | "ArrowLeft" (backward-phrase activity)
135 | nil))))
136 |
137 | (defn prepare-icu [activity]
138 | (let [label (phrase-label (::icu/phrase-kind activity))]
139 | {:spacing :wide
140 | :sections
141 | [{:kind :element.kind/colored-boxes
142 | :footer {:text (str (::icu/tempo-current activity) " BPM")}
143 | :bpm (::icu/tempo-current activity)
144 | :boxes (->> (icu/get-phrases activity)
145 | (icu/select-phrases activity)
146 | (map (fn [idx]
147 | {:text (str label " " (inc idx))
148 | :color-idx idx})))}
149 | {:kind :element.kind/button-panel
150 | :buttons (for [button [{:text "Lower BPM"
151 | :icon (icons/icon :phosphor.bold/minus)
152 | :actions (decrease-tempo activity)
153 | :kbd "↓"}
154 | {:text "Previous phrase"
155 | :icon (icons/icon :phosphor.fill/skip-back)
156 | :actions (backward-phrase activity)
157 | :kbd "←"}
158 | (if (:activity/paused? activity)
159 | {:text "Play"
160 | :icon (icons/icon :phosphor.fill/play)
161 | :actions (play activity)
162 | :size :large
163 | :kbd "space"}
164 | {:text "Pause"
165 | :icon (icons/icon :phosphor.fill/pause)
166 | :actions (pause activity)
167 | :size :large
168 | :kbd "space"})
169 | {:text "Next phrase"
170 | :icon (icons/icon :phosphor.fill/skip-forward)
171 | :actions (forward-phrase activity)
172 | :kbd "→"}
173 | {:text "Bump BPM"
174 | :icon (icons/icon :phosphor.bold/plus)
175 | :actions (increase-tempo activity)
176 | :kbd "↑"}]]
177 | (cond-> button
178 | (nil? (:actions button)) (assoc :disabled? true)))}
179 | {:kind :element.kind/footer
180 | :button {:text "Start over"
181 | :subtle? true
182 | :actions (stop activity)}}
183 | {:kind :element.kind/footer
184 | :heading "How to use"
185 | :text
186 | (list
187 | (str "Play the indicated " (str/lower-case label) " once,
188 | then click the + button or the ")
189 | [:kbd.kbd.kbd-sm "↑"] " key (or " [:kbd.kbd.kbd-sm "+"] ") "
190 |
191 | " on your keyboard to bump
192 | the tempo. Repeat until you are at the goal tempo, or you can no longer
193 | keep up. Click the skip button or the " [:kbd.kbd.kbd-sm "→"]
194 | " key (or " [:kbd.kbd.kbd-sm "n"] ") on your keyboard to
195 | add a " (str/lower-case label) ", then repeat the process.")}]}))
196 |
197 | #_(defn prepare-time-signature [activity]
198 | (let [[numerator denominator] (:music/time-signature activity)]
199 | {:label "Time signature"
200 | :inputs
201 | [{:input/kind :input.kind/number
202 | :on {:input [[:action/db.add activity :music/time-signature [:event/target-value-num denominator]]]}
203 | :value numerator}
204 | {:input/kind :input.kind/select
205 | :on {:input [[:action/db.add activity :music/time-signature [numerator :event/target-value-num]]]}
206 | :options (for [i denominators]
207 | (cond-> {:value (str i) :text (str i)}
208 | (= i denominator) (assoc :selected? true)))}]}))
209 |
210 | (defn prepare-settings [activity]
211 | {:sections
212 | [{:kind :element.kind/boxed-form
213 | :button {:text "Start"
214 | :right-icon (icons/icon :phosphor.regular/metronome)
215 | :actions (let [tempo (::icu/tempo-start activity)]
216 | [[:action/transact
217 | [{:db/id (:db/id activity)
218 | ::icu/tempo-current tempo
219 | ::icu/phrase-current (icu/get-next-phrase activity)}]]
220 | [:action/start-metronome {:music/bars [activity]
221 | :music/tempo tempo}]])}
222 | :boxes
223 | [{:title "Exercise details"
224 | :fields
225 | [{:controls
226 | [{:label "Length"
227 | :inputs
228 | [(form/prepare-number-input activity ::icu/phrase-count)
229 | (form/prepare-select activity ::icu/phrase-kind phrase-kinds)]}
230 | #_(prepare-time-signature activity)]}]}
231 |
232 | {:title "Session settings"
233 | :fields [{:controls
234 | [{:label "Start at"
235 | :inputs [(form/prepare-select activity ::icu/start-at starts)]}
236 | {:label "Max phrase length"
237 | :inputs [(form/prepare-number-input activity ::icu/phrase-max)]}]}]}
238 |
239 | (let [beats (range 1 (inc (first (:music/time-signature activity))))]
240 | {:title "Metronome settings"
241 | :fields
242 | [{:size :md
243 | :controls
244 | [{:label "Start tempo"
245 | :inputs [(form/prepare-number-input activity ::icu/tempo-start)]}
246 | {:label "BPM step"
247 | :inputs [(form/prepare-number-input activity ::icu/tempo-step)]}
248 | {:label "Drop beats (%)"
249 | :inputs [(form/prepare-number-input activity :metronome/drop-pct)]}]}
250 | {:controls
251 | [{:label "Click beats"
252 | :inputs [(form/prepare-multi-select activity :metronome/click-beats beats)]}]}
253 | {:controls
254 | [{:label "Accentuate beats"
255 | :inputs [(form/prepare-multi-select activity :metronome/accentuate-beats beats)]}]}]})]}]})
256 |
257 | (defn prepare-ui-data [db]
258 | (let [activity (get-activity db)]
259 | (if (started? activity)
260 | (prepare-icu activity)
261 | (prepare-settings activity))))
262 |
263 | (defn get-settings [{::icu/keys [phrase-max phrase-count phrase-kind
264 | start-at tempo-start tempo-step]
265 | :metronome/keys [click-beats accentuate-beats drop-pct]
266 | :as settings}]
267 | (let [beats (or (get (:music/time-signature settings) 0) 4)]
268 | {::icu/phrase-count (or phrase-count 4)
269 | ::icu/phrase-kind (or phrase-kind :phrase.kind/bar)
270 | ::icu/phrase-max (or phrase-max 0)
271 | ::icu/start-at (or start-at :start/beginning)
272 | ::icu/tempo-start (or tempo-start 60)
273 | ::icu/tempo-step (or tempo-step 5)
274 | :music/time-signature [beats (or (get (:music/time-signature settings) 1) 4)]
275 | :metronome/drop-pct (or drop-pct 0)
276 | :metronome/click-beats (or click-beats (set (range 1 (inc beats))))
277 | :metronome/accentuate-beats (or accentuate-beats settings #{1})}))
278 |
279 | (defn get-boot-actions [db]
280 | [[:action/transact
281 | [{:db/ident :virtuoso/current-view
282 | :action/keypress-handler ::tool
283 | :view/tool (into {:db/ident ::tool}
284 | (get-settings (d/entity db ::tool)))}]]])
285 |
--------------------------------------------------------------------------------
/test/virtuoso/interleaved_clickup_test.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.interleaved-clickup-test
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [virtuoso.interleaved-clickup :as sut]))
4 |
5 | (def tabs
6 | [[{:fret 14 :string :B}]
7 | [{:fret 12 :string :B}]
8 | [{:fret 14 :string :B}]
9 |
10 | [{:fret 12 :string :B}]
11 | [{:fret 10 :string :B}]
12 | [{:fret 12 :string :B}]
13 |
14 | [{:fret 10 :string :B}]
15 | [{:fret 8 :string :B}]
16 | [{:fret 10 :string :B}]
17 |
18 | [{:fret 9 :string :G}]
19 | [{:fret 8 :string :B}]
20 | [{:fret 9 :string :G}]
21 |
22 | [{:fret 9 :string :G}]
23 | [{:fret 7 :string :G}]])
24 |
25 | (deftest get-next-phrase-test
26 | (testing "With tabs: Starts at phrase 0"
27 | (is (= (sut/get-next-phrase
28 | {::sut/phrase-size 3
29 | ::sut/tabs tabs})
30 | 0)))
31 |
32 | (testing "With tabs: Picks the next phrase"
33 | (is (= (sut/get-next-phrase
34 | {::sut/phrase-current 2
35 | ::sut/phrase-size 3
36 | ::sut/tabs tabs})
37 | 3)))
38 |
39 | (testing "With tabs: Can't pick phrases beyond the last one"
40 | (is (nil? (sut/get-next-phrase
41 | {::sut/phrase-current 4
42 | ::sut/phrase-size 3
43 | ::sut/tabs tabs}))))
44 |
45 | (testing "With phrase count: Starts at phrase 0"
46 | (is (= (sut/get-next-phrase
47 | {::sut/phrase-count 4}) 0)))
48 |
49 | (testing "With phrase count: Picks the next phrase"
50 | (is (= (sut/get-next-phrase
51 | {::sut/phrase-current 2
52 | ::sut/phrase-count 4}) 3)))
53 |
54 | (testing "With phrase count: Can't pick phrases beyond the last one"
55 | (is (nil? (sut/get-next-phrase
56 | {::sut/phrase-current 9
57 | ::sut/phrase-count 10})))))
58 |
59 | (deftest get-tempo-test
60 | (testing "Defaults to 60bpm"
61 | (is (= (sut/get-tempo {}) 60)))
62 |
63 | (testing "Gets the default tempo"
64 | (is (= (sut/get-tempo {::sut/tempo-start 54}) 54)))
65 |
66 | (testing "Gets current tempo"
67 | (is (= (sut/get-tempo {::sut/tempo-current 65
68 | ::sut/tempo-start 54}) 65)))
69 |
70 | (testing "Gets next tempo from defaults"
71 | (is (= (sut/increase-tempo {}) 65)))
72 |
73 | (testing "Gets next tempo from custom start"
74 | (is (= (sut/increase-tempo {::sut/tempo-start 55}) 60)))
75 |
76 | (testing "Gets next tempo from current tempo"
77 | (is (= (sut/increase-tempo {::sut/tempo-current 75}) 80)))
78 |
79 | (testing "Gets next tempo from current tempo with custom step"
80 | (is (= (sut/increase-tempo {::sut/tempo-current 74 ::sut/tempo-step 2}) 76))))
81 |
82 | (deftest get-phrases-test
83 | (testing "Forward: Starts with the first phrase"
84 | (is (= (sut/get-phrases
85 | {::sut/start-at :start/beginning
86 | ::sut/phrase-current 0})
87 | [0])))
88 |
89 | (testing "Forward: Treats phrase-max 0 as not set"
90 | (is (= (sut/get-phrases
91 | {::sut/start-at :start/beginning
92 | ::sut/phrase-current 0
93 | ::sut/phrase-max 0})
94 | [0])))
95 |
96 | (testing "Forward: Continues with the first phrase"
97 | (is (= (sut/get-phrases
98 | {::sut/start-at :start/beginning
99 | ::sut/phrase-current 0
100 | ::sut/tempo-current 65})
101 | [0]))
102 |
103 | (is (= (sut/get-phrases
104 | {::sut/start-at :start/beginning
105 | ::sut/phrase-current 0
106 | ::sut/tempo-current 115})
107 | [0])))
108 |
109 | (testing "Forward 2 phrases"
110 | (is (= (sut/get-phrases
111 | {::sut/start-at :start/beginning
112 | ::sut/phrase-current 1
113 | ::sut/tempo-current 60})
114 | [0 1]))
115 |
116 | (is (= (sut/get-phrases
117 | {::sut/start-at :start/beginning
118 | ::sut/phrase-current 1
119 | ::sut/tempo-current 65})
120 | [1]))
121 |
122 | (is (= (sut/get-phrases
123 | {::sut/start-at :start/beginning
124 | ::sut/phrase-current 1
125 | ::sut/tempo-current 70})
126 | [0 1]))
127 |
128 | (is (= (sut/get-phrases
129 | {::sut/start-at :start/beginning
130 | ::sut/phrase-current 1
131 | ::sut/tempo-current 75})
132 | [1])))
133 |
134 | (testing "Forward 3 phrases"
135 | (is (= (sut/get-phrases
136 | {::sut/start-at :start/beginning
137 | ::sut/phrase-current 2
138 | ::sut/tempo-current 60})
139 | [0 1 2]))
140 |
141 | (is (= (sut/get-phrases
142 | {::sut/start-at :start/beginning
143 | ::sut/phrase-current 2
144 | ::sut/tempo-current 65})
145 | [2]))
146 |
147 | (is (= (sut/get-phrases
148 | {::sut/start-at :start/beginning
149 | ::sut/phrase-current 2
150 | ::sut/tempo-current 70})
151 | [1 2]))
152 |
153 | (is (= (sut/get-phrases
154 | {::sut/start-at :start/beginning
155 | ::sut/phrase-current 2
156 | ::sut/tempo-current 75})
157 | [2]))
158 |
159 | (is (= (sut/get-phrases
160 | {::sut/start-at :start/beginning
161 | ::sut/phrase-current 2
162 | ::sut/tempo-current 80})
163 | [0 1 2]))
164 |
165 | (is (= (sut/get-phrases
166 | {::sut/start-at :start/beginning
167 | ::sut/phrase-current 2
168 | ::sut/tempo-current 85})
169 | [2])))
170 |
171 | (testing "Forward 4 phrases"
172 | (is (= (sut/get-phrases
173 | {::sut/start-at :start/beginning
174 | ::sut/phrase-current 3
175 | ::sut/tempo-current 60})
176 | [0 1 2 3]))
177 |
178 | (is (= (sut/get-phrases
179 | {::sut/start-at :start/beginning
180 | ::sut/phrase-current 3
181 | ::sut/tempo-current 65})
182 | [3]))
183 |
184 | (is (= (sut/get-phrases
185 | {::sut/start-at :start/beginning
186 | ::sut/phrase-current 3
187 | ::sut/tempo-current 70})
188 | [2 3]))
189 |
190 | (is (= (sut/get-phrases
191 | {::sut/start-at :start/beginning
192 | ::sut/phrase-current 3
193 | ::sut/tempo-current 75})
194 | [3]))
195 |
196 | (is (= (sut/get-phrases
197 | {::sut/start-at :start/beginning
198 | ::sut/phrase-current 3
199 | ::sut/tempo-current 80})
200 | [1 2 3]))
201 |
202 | (is (= (sut/get-phrases
203 | {::sut/start-at :start/beginning
204 | ::sut/phrase-current 3
205 | ::sut/tempo-current 85})
206 | [3]))
207 |
208 | (is (= (sut/get-phrases
209 | {::sut/start-at :start/beginning
210 | ::sut/phrase-current 3
211 | ::sut/tempo-current 90})
212 | [0 1 2 3])))
213 |
214 | (testing "Forward 5 phrases"
215 | (is (= (sut/get-phrases
216 | {::sut/start-at :start/beginning
217 | ::sut/phrase-current 4
218 | ::sut/tempo-current 60})
219 | [0 1 2 3 4]))
220 |
221 | (is (= (sut/get-phrases
222 | {::sut/start-at :start/beginning
223 | ::sut/phrase-current 4
224 | ::sut/tempo-current 65})
225 | [4]))
226 |
227 | (is (= (sut/get-phrases
228 | {::sut/start-at :start/beginning
229 | ::sut/phrase-current 4
230 | ::sut/tempo-current 70})
231 | [3 4]))
232 |
233 | (is (= (sut/get-phrases
234 | {::sut/start-at :start/beginning
235 | ::sut/phrase-current 4
236 | ::sut/tempo-current 75})
237 | [4]))
238 |
239 | (is (= (sut/get-phrases
240 | {::sut/start-at :start/beginning
241 | ::sut/phrase-current 4
242 | ::sut/tempo-current 80})
243 | [2 3 4]))
244 |
245 | (is (= (sut/get-phrases
246 | {::sut/start-at :start/beginning
247 | ::sut/phrase-current 4
248 | ::sut/tempo-current 85})
249 | [4]))
250 |
251 | (is (= (sut/get-phrases
252 | {::sut/start-at :start/beginning
253 | ::sut/phrase-current 4
254 | ::sut/tempo-current 90})
255 | [1 2 3 4]))
256 |
257 | (is (= (sut/get-phrases
258 | {::sut/start-at :start/beginning
259 | ::sut/phrase-current 4
260 | ::sut/tempo-current 95})
261 | [4]))
262 |
263 | (is (= (sut/get-phrases
264 | {::sut/start-at :start/beginning
265 | ::sut/phrase-current 4
266 | ::sut/tempo-current 100})
267 | [0 1 2 3 4])))
268 |
269 | (testing "Forward 4 phrases with sliding window"
270 | (is (= (sut/get-phrases
271 | {::sut/start-at :start/beginning
272 | ::sut/phrase-current 2
273 | ::sut/phrase-max 3
274 | ::sut/tempo-current 60})
275 | [0 1 2]))
276 |
277 | (is (= (sut/get-phrases
278 | {::sut/start-at :start/beginning
279 | ::sut/phrase-current 3
280 | ::sut/phrase-max 3
281 | ::sut/tempo-current 60})
282 | [1 2 3]))
283 |
284 | (is (= (sut/get-phrases
285 | {::sut/start-at :start/beginning
286 | ::sut/phrase-current 3
287 | ::sut/phrase-max 3
288 | ::sut/tempo-current 65})
289 | [3]))
290 |
291 | (is (= (sut/get-phrases
292 | {::sut/start-at :start/beginning
293 | ::sut/phrase-current 3
294 | ::sut/phrase-max 3
295 | ::sut/tempo-current 70})
296 | [2 3]))
297 |
298 | (is (= (sut/get-phrases
299 | {::sut/start-at :start/beginning
300 | ::sut/phrase-current 3
301 | ::sut/phrase-max 3
302 | ::sut/tempo-current 75})
303 | [3]))
304 |
305 | (is (= (sut/get-phrases
306 | {::sut/start-at :start/beginning
307 | ::sut/phrase-current 3
308 | ::sut/phrase-max 3
309 | ::sut/tempo-current 80})
310 | [1 2 3]))
311 |
312 | (is (= (sut/get-phrases
313 | {::sut/start-at :start/beginning
314 | ::sut/phrase-current 15
315 | ::sut/phrase-max 3
316 | ::sut/tempo-current 60})
317 | [13 14 15]))))
318 |
319 | (deftest select-phrases-test
320 | (testing "Tabs, forward: Selects given phrases"
321 | (is (= (sut/select-phrases {::sut/tabs tabs} [0]) [0]))
322 | (is (= (sut/select-phrases {::sut/tabs tabs} [0 1 2]) [0 1 2])))
323 |
324 | (testing "Tabs, backward: Selects given from the end"
325 | (is (= (sut/select-phrases
326 | {::sut/start-at :start/end
327 | ::sut/phrase-size 3
328 | ::sut/tabs tabs} [0]) [4]))
329 | (is (= (sut/select-phrases
330 | {::sut/start-at :start/end
331 | ::sut/phrase-size 3
332 | ::sut/tabs tabs} [0 1 2]) [2 3 4])))
333 |
334 | (testing "Phrase count, forward: Selects given phrases"
335 | (is (= (sut/select-phrases {::sut/phrase-count 4} [0]) [0]))
336 | (is (= (sut/select-phrases {::sut/phrase-count 4} [0 1 2]) [0 1 2])))
337 |
338 | (testing "Phrase count, backward: Selects given phrases"
339 | (is (= (sut/select-phrases {::sut/start-at :start/end ::sut/phrase-count 10} [0]) [9]))
340 | (is (= (sut/select-phrases {::sut/start-at :start/end ::sut/phrase-count 10} [0 1 2]) [7 8 9]))))
341 |
342 | (deftest backwards-interleaving-test
343 | (testing "Progresses correctly through sequence from the end"
344 | (is (= (->> {::sut/phrase-current 2
345 | ::sut/tempo-current 60}
346 | sut/get-phrases
347 | (sut/select-phrases
348 | {::sut/tabs tabs
349 | ::sut/start-at :start/end}))
350 | [1 2 3]))
351 |
352 | (is (= (->> {::sut/phrase-current 2
353 | ::sut/tempo-current 65}
354 | sut/get-phrases
355 | (sut/select-phrases
356 | {::sut/tabs tabs
357 | ::sut/start-at :start/end}))
358 | [1]))
359 |
360 | (is (= (->> {::sut/phrase-current 2
361 | ::sut/tempo-current 70}
362 | sut/get-phrases
363 | (sut/select-phrases
364 | {::sut/tabs tabs
365 | ::sut/start-at :start/end}))
366 | [1 2]))
367 |
368 | (is (= (->> {::sut/phrase-current 2
369 | ::sut/tempo-current 75}
370 | sut/get-phrases
371 | (sut/select-phrases
372 | {::sut/tabs tabs
373 | ::sut/start-at :start/end}))
374 | [1]))
375 |
376 | (is (= (->> {::sut/phrase-current 2
377 | ::sut/tempo-current 80}
378 | sut/get-phrases
379 | (sut/select-phrases
380 | {::sut/tabs tabs
381 | ::sut/start-at :start/end}))
382 | [1 2 3]))))
383 |
--------------------------------------------------------------------------------
/test/virtuoso/metronome_test.clj:
--------------------------------------------------------------------------------
1 | (ns virtuoso.metronome-test
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [virtuoso.metronome :as sut]))
4 |
5 | (defn simplify-click [click]
6 | (select-keys click [:beat/n :metronome/click-at :metronome/accentuate?]))
7 |
8 | (deftest generate-clicks-test
9 | (testing "Generates clicks for 4/4 quarter notes @60BPM"
10 | (is (= (-> [{:music/time-signature [4 4]
11 | :music/tempo 60
12 | :bar/rhythm [(/ 1 4)]}]
13 | sut/generate-clicks
14 | :clicks)
15 | [{:bar/idx 1 :bar/n 1 :bar/beat 1 :beat/n 1 :rhythm/n 1 :metronome/click-at 0000}
16 | {:bar/idx 1 :bar/n 1 :bar/beat 2 :beat/n 2 :rhythm/n 1 :metronome/click-at 1000}
17 | {:bar/idx 1 :bar/n 1 :bar/beat 3 :beat/n 3 :rhythm/n 1 :metronome/click-at 2000}
18 | {:bar/idx 1 :bar/n 1 :bar/beat 4 :beat/n 4 :rhythm/n 1 :metronome/click-at 3000}])))
19 |
20 | (testing "Generates clicks for 4/4 eight notes @60BPM"
21 | (is (= (-> [{:music/time-signature [4 4]
22 | :music/tempo 60
23 | :bar/rhythm [(/ 1 8) (/ 1 8)]}]
24 | sut/generate-clicks
25 | :clicks)
26 | [{:bar/idx 1 :bar/n 1 :bar/beat 1 :beat/n 1 :rhythm/n 1 :metronome/click-at 0}
27 | {:bar/idx 1 :bar/n 1 :bar/beat 1 :beat/n 1 :rhythm/n 2 :metronome/click-at 500}
28 | {:bar/idx 1 :bar/n 1 :bar/beat 2 :beat/n 2 :rhythm/n 1 :metronome/click-at 1000}
29 | {:bar/idx 1 :bar/n 1 :bar/beat 2 :beat/n 2 :rhythm/n 2 :metronome/click-at 1500}
30 | {:bar/idx 1 :bar/n 1 :bar/beat 3 :beat/n 3 :rhythm/n 1 :metronome/click-at 2000}
31 | {:bar/idx 1 :bar/n 1 :bar/beat 3 :beat/n 3 :rhythm/n 2 :metronome/click-at 2500}
32 | {:bar/idx 1 :bar/n 1 :bar/beat 4 :beat/n 4 :rhythm/n 1 :metronome/click-at 3000}
33 | {:bar/idx 1 :bar/n 1 :bar/beat 4 :beat/n 4 :rhythm/n 2 :metronome/click-at 3500}])))
34 |
35 | (testing "Generates quarter note clicks in a 9/16 bar"
36 | (is (= (->> [{:music/time-signature [9 16]
37 | :music/tempo 60
38 | :bar/rhythm [(/ 1 4)]}]
39 | sut/generate-clicks
40 | :clicks
41 | (map simplify-click))
42 | [{:beat/n 1 :metronome/click-at 0}
43 | {:beat/n 5 :metronome/click-at 1000N}
44 | {:beat/n 9 :metronome/click-at 2000N}])))
45 |
46 | (testing "Clicks the beginning of the bar even if it means cutting the rhythm short"
47 | ;; Quarter notes don't add up in a 9/16 bar.
48 | ;; When this happens, the metronome should reset at the beginning of the bar.
49 | (is (= (->> [{:music/time-signature [9 16]
50 | :music/tempo 60
51 | :bar/reps 2
52 | :bar/rhythm [(/ 1 4)]}]
53 | sut/generate-clicks
54 | :clicks
55 | (map simplify-click))
56 | [{:beat/n 1 :metronome/click-at 0}
57 | {:beat/n 5 :metronome/click-at 1000N}
58 | {:beat/n 9 :metronome/click-at 2000N}
59 | {:beat/n 10 :metronome/click-at 2250}
60 | {:beat/n 14 :metronome/click-at 3250N}
61 | {:beat/n 18 :metronome/click-at 4250N}])))
62 |
63 | (testing "Generates clicks for 3/4 @60BPM"
64 | (is (= (->> [{:music/time-signature [3 4]
65 | :music/tempo 60}]
66 | sut/generate-clicks
67 | :clicks
68 | (map simplify-click))
69 | [{:beat/n 1 :metronome/click-at 0000}
70 | {:beat/n 2 :metronome/click-at 1000}
71 | {:beat/n 3 :metronome/click-at 2000}])))
72 |
73 | (testing "Generates triplet clicks in 2/4 @60BPM"
74 | (is (= (->> [{:music/time-signature [2 4]
75 | :music/tempo 60
76 | :bar/rhythm [(* (/ 1 8) (/ 2 3))
77 | (* (/ 1 8) (/ 2 3))
78 | (* (/ 1 8) (/ 2 3))]}]
79 | sut/generate-clicks
80 | :clicks
81 | (map simplify-click))
82 | [{:beat/n 1 :metronome/click-at 0}
83 | {:beat/n 1 :metronome/click-at 1000/3}
84 | {:beat/n 1 :metronome/click-at 2000/3}
85 | {:beat/n 2 :metronome/click-at 1000N}
86 | {:beat/n 2 :metronome/click-at 4000/3}
87 | {:beat/n 2 :metronome/click-at 5000/3}])))
88 |
89 | (testing "Generates clicks against a quarter note beat"
90 | (is (= (->> [{:music/time-signature [6 8]
91 | :music/tempo 60}]
92 | sut/generate-clicks
93 | :clicks
94 | (map simplify-click))
95 | [{:beat/n 1 :metronome/click-at 0}
96 | {:beat/n 2 :metronome/click-at 500}
97 | {:beat/n 3 :metronome/click-at 1000}
98 | {:beat/n 4 :metronome/click-at 1500}
99 | {:beat/n 5 :metronome/click-at 2000}
100 | {:beat/n 6 :metronome/click-at 2500}])))
101 |
102 | (testing "Repeats a bar"
103 | (is (= (->> [{:music/time-signature [2 4]
104 | :music/tempo 60
105 | :bar/reps 2}]
106 | sut/generate-clicks
107 | :clicks
108 | (map simplify-click))
109 | [{:beat/n 1 :metronome/click-at 0000}
110 | {:beat/n 2 :metronome/click-at 1000}
111 | {:beat/n 3 :metronome/click-at 2000}
112 | {:beat/n 4 :metronome/click-at 3000}])))
113 |
114 | (testing "Accentuates the first beat of the bar"
115 | (is (= (->> [{:music/time-signature [4 4]
116 | :music/tempo 60
117 | :bar/reps 2
118 | :accentuate? (comp #{1} :bar/beat)}]
119 | sut/generate-clicks
120 | :clicks
121 | (filter :metronome/accentuate?)
122 | (map simplify-click))
123 | [{:beat/n 1 :metronome/click-at 0, :metronome/accentuate? true}
124 | {:beat/n 5 :metronome/click-at 4000, :metronome/accentuate? true}])))
125 |
126 | (testing "Accentuates the first beat of the bar with data"
127 | (is (= (->> [{:music/time-signature [4 4]
128 | :music/tempo 60
129 | :bar/reps 2
130 | :metronome/accentuate-beats #{1}}]
131 | sut/accentuate-beats
132 | sut/generate-clicks
133 | :clicks
134 | (filter :metronome/accentuate?)
135 | (map simplify-click))
136 | [{:beat/n 1 :metronome/click-at 0, :metronome/accentuate? true}
137 | {:beat/n 5 :metronome/click-at 4000, :metronome/accentuate? true}])))
138 |
139 | (testing "Drops some clicks"
140 | (is (= (->> [{:music/time-signature [4 4]
141 | :music/tempo 60
142 | :bar/reps 2
143 | :click? (comp #{1 4} :bar/beat)}]
144 | sut/generate-clicks
145 | :clicks
146 | (map simplify-click))
147 | [{:beat/n 1 :metronome/click-at 0}
148 | {:beat/n 4 :metronome/click-at 3000}
149 | {:beat/n 5 :metronome/click-at 4000}
150 | {:beat/n 8 :metronome/click-at 7000}])))
151 |
152 | (testing "Drops some clicks with data"
153 | (is (= (->> [{:music/time-signature [4 4]
154 | :music/tempo 60
155 | :bar/reps 2
156 | :metronome/click-beats #{1 4}}]
157 | sut/click-beats
158 | sut/generate-clicks
159 | :clicks
160 | (map simplify-click))
161 | [{:beat/n 1 :metronome/click-at 0}
162 | {:beat/n 4 :metronome/click-at 3000}
163 | {:beat/n 5 :metronome/click-at 4000}
164 | {:beat/n 8 :metronome/click-at 7000}])))
165 |
166 | (testing "Clicks only on specified beats while dropping random beats"
167 | (is (= (->> [{:music/time-signature [4 4]
168 | :music/tempo 60
169 | :bar/reps 10
170 | :metronome/click-beats #{1 4}
171 | :metronome/drop-pct 50}]
172 | sut/click-beats
173 | sut/generate-clicks
174 | :clicks
175 | (map :bar/beat)
176 | set)
177 | #{1 4})))
178 |
179 | (testing "Drops random beats while clicking specific beats"
180 | (is (< (->> [{:music/time-signature [4 4]
181 | :music/tempo 60
182 | :bar/reps 10
183 | :metronome/click-beats #{1 4}
184 | :metronome/drop-pct 75}]
185 | sut/click-beats
186 | sut/generate-clicks
187 | :clicks
188 | (map :bar/beat)
189 | count)
190 | 20)))
191 |
192 | (testing "Drops some clicks randomly with data"
193 | (is (< (->> [{:music/time-signature [4 4]
194 | :music/tempo 60
195 | :bar/reps 2
196 | :metronome/drop-pct 75}]
197 | sut/click-beats
198 | sut/generate-clicks
199 | :clicks
200 | count)
201 | 8)))
202 |
203 | (testing "Uses individual bar tempo"
204 | (is (= (->> [{:music/time-signature [4 4]
205 | :music/tempo 60}
206 | {:music/time-signature [4 4]
207 | :music/tempo 120}]
208 | sut/generate-clicks
209 | :clicks
210 | (map simplify-click))
211 | [{:beat/n 1 :metronome/click-at 0}
212 | {:beat/n 2 :metronome/click-at 1000}
213 | {:beat/n 3 :metronome/click-at 2000}
214 | {:beat/n 4 :metronome/click-at 3000}
215 | {:beat/n 5 :metronome/click-at 4000}
216 | {:beat/n 6 :metronome/click-at 4500}
217 | {:beat/n 7 :metronome/click-at 5000}
218 | {:beat/n 8 :metronome/click-at 5500}])))
219 |
220 | (testing "Calculates beat numbers in eigths"
221 | (is (= (->> [{:music/time-signature [6 8]
222 | :music/tempo 90
223 | :bar/reps 2
224 | :accentuate? (comp #{1} :bar/beat)}]
225 | sut/generate-clicks
226 | :clicks
227 | (map #(select-keys % [:bar/n :bar/beat :beat/n])))
228 | [{:bar/n 1 :bar/beat 1 :beat/n 1}
229 | {:bar/n 1 :bar/beat 2 :beat/n 2}
230 | {:bar/n 1 :bar/beat 3 :beat/n 3}
231 | {:bar/n 1 :bar/beat 4 :beat/n 4}
232 | {:bar/n 1 :bar/beat 5 :beat/n 5}
233 | {:bar/n 1 :bar/beat 6 :beat/n 6}
234 |
235 | {:bar/n 2 :bar/beat 1 :beat/n 7}
236 | {:bar/n 2 :bar/beat 2 :beat/n 8}
237 | {:bar/n 2 :bar/beat 3 :beat/n 9}
238 | {:bar/n 2 :bar/beat 4 :beat/n 10}
239 | {:bar/n 2 :bar/beat 5 :beat/n 11}
240 | {:bar/n 2 :bar/beat 6 :beat/n 12}])))
241 |
242 | (testing "Generates longer sequence of clicks"
243 | (is (= (->> [{:music/time-signature [4 4]
244 | :music/tempo 120
245 | :bar/reps 2
246 | :accentuate? (comp #{1} :bar/beat)}
247 | {:music/time-signature [6 8]
248 | :music/tempo 90
249 | :bar/reps 2
250 | :accentuate? (comp #{1} :bar/beat)}]
251 | sut/generate-clicks)
252 | {:clicks [{:rhythm/n 1, :bar/idx 1, :bar/n 1, :bar/beat 1, :beat/n 1, :metronome/click-at 0, :metronome/accentuate? true}
253 | {:rhythm/n 1, :bar/idx 1, :bar/n 1, :bar/beat 2, :beat/n 2, :metronome/click-at 500}
254 | {:rhythm/n 1, :bar/idx 1, :bar/n 1, :bar/beat 3, :beat/n 3, :metronome/click-at 1000}
255 | {:rhythm/n 1, :bar/idx 1, :bar/n 1, :bar/beat 4, :beat/n 4, :metronome/click-at 1500}
256 | {:rhythm/n 1, :bar/idx 1, :bar/n 2, :bar/beat 1, :beat/n 5, :metronome/click-at 2000, :metronome/accentuate? true}
257 | {:rhythm/n 1, :bar/idx 1, :bar/n 2, :bar/beat 2, :beat/n 6, :metronome/click-at 2500}
258 | {:rhythm/n 1, :bar/idx 1, :bar/n 2, :bar/beat 3, :beat/n 7, :metronome/click-at 3000}
259 | {:rhythm/n 1, :bar/idx 1, :bar/n 2, :bar/beat 4, :beat/n 8, :metronome/click-at 3500}
260 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 1, :beat/n 9, :metronome/click-at 4000N, :metronome/accentuate? true}
261 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 2, :beat/n 10, :metronome/click-at 13000/3}
262 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 3, :beat/n 11, :metronome/click-at 14000/3}
263 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 4, :beat/n 12, :metronome/click-at 5000N}
264 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 5, :beat/n 13, :metronome/click-at 16000/3}
265 | {:rhythm/n 1, :bar/idx 2, :bar/n 3, :bar/beat 6, :beat/n 14, :metronome/click-at 17000/3}
266 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 1, :beat/n 15, :metronome/click-at 6000N, :metronome/accentuate? true}
267 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 2, :beat/n 16, :metronome/click-at 19000/3}
268 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 3, :beat/n 17, :metronome/click-at 20000/3}
269 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 4, :beat/n 18, :metronome/click-at 7000N}
270 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 5, :beat/n 19, :metronome/click-at 22000/3}
271 | {:rhythm/n 1, :bar/idx 2, :bar/n 4, :bar/beat 6, :beat/n 20, :metronome/click-at 23000/3}]
272 | :bar-count 4
273 | :beat-count 20
274 | :time 8000N
275 | :duration 8000N}))))
276 |
277 | (deftest set-tempo-test
278 | (testing "Changes tempo of bar"
279 | (is (= (sut/set-tempo 40 [{:music/tempo 60
280 | :music/time-signature [4 4]}])
281 | [{:music/tempo 40
282 | :music/time-signature [4 4]}])))
283 |
284 | (testing "Uses exact specified tempo"
285 | (is (= (sut/set-tempo 50 [{:music/tempo 60}])
286 | [{:music/tempo 50}])))
287 |
288 | (testing "Scales tempo linearly across bars"
289 | (is (= (sut/set-tempo 50 [{:music/tempo 60}
290 | {:music/tempo 120}
291 | {:music/tempo 90}])
292 | [{:music/tempo 50}
293 | {:music/tempo 100}
294 | {:music/tempo 75}])))
295 |
296 | (testing "Sets tempo when not specified"
297 | (is (= (sut/set-tempo 60 [{:music/time-signature [4 4]}
298 | {:music/time-signature [6 8]}])
299 | [{:music/time-signature [4 4] :music/tempo 60}
300 | {:music/time-signature [6 8] :music/tempo 60}])))
301 |
302 | (testing "Defaults same tempo across all bars"
303 | (is (= (sut/set-tempo 80 [{:music/tempo 120
304 | :music/time-signature [4 4]}
305 | {:music/time-signature [6 8]}])
306 | [{:music/time-signature [4 4] :music/tempo 80}
307 | {:music/time-signature [6 8] :music/tempo 80}]))))
308 |
309 | (deftest set-default-test
310 | (testing "Applies metronome-wide settings on bars"
311 | (is (= (sut/set-default :metronome/drop-pct 0.3 [{:music/time-signature [4 4]}
312 | {:music/time-signature [6 8]}])
313 | [{:music/time-signature [4 4]
314 | :metronome/drop-pct 0.3}
315 | {:music/time-signature [6 8]
316 | :metronome/drop-pct 0.3}])))
317 |
318 | (testing "Keeps bar-specific overrides"
319 | (is (= (sut/set-default :metronome/drop-pct 0.3 [{:music/time-signature [4 4]}
320 | {:music/time-signature [6 8]
321 | :metronome/drop-pct 0.25}])
322 | [{:music/time-signature [4 4]
323 | :metronome/drop-pct 0.3}
324 | {:music/time-signature [6 8]
325 | :metronome/drop-pct 0.25}]))))
326 |
--------------------------------------------------------------------------------
/src/virtuoso/pages/metronome.cljc:
--------------------------------------------------------------------------------
1 | (ns virtuoso.pages.metronome
2 | (:require [datascript.core :as d]
3 | [phosphor.icons :as icons]
4 | [virtuoso.elements.layout :as layout]
5 | [virtuoso.elements.modal :as modal]
6 | [virtuoso.elements.typography :as t]
7 | [virtuoso.metronome :as metronome]))
8 |
9 | (defn render-page [_ctx _page]
10 | (layout/layout
11 | [:div.flex.flex-col.min-h-screen.justify-between
12 | (layout/header {:title "Metronome"})
13 | [:main.grow.flex.flex-col.gap-4.justify-center
14 | {:class layout/container-classes
15 | :data-replicant-view "metronome"}]
16 | [:footer.my-4 {:class layout/container-classes}
17 | [:div.px-4.md:px-0.mt-4
18 | (t/h2 "Help")
19 | (t/p
20 | "To change time signature, click the bar indicating the time signature,
21 | and make your changes. To accentuate certain beats, click the
22 | corresponding dot under the bar. Clicking it again will disable clicking
23 | at this beat.")
24 | (t/p
25 | "The metronome supports both tempo and time signature changes. To use
26 | them, click the notes with a plus next to the bar to add another bar.
27 | Bars can have varying tempos, time signatures, and may repeat. The tempo
28 | will be recalculated appropriately when stepping the metronome tempo up
29 | and down.")]]]))
30 |
31 | (def schema
32 | {:music/tempo {} ;; number, bpm
33 | :music/time-signature {} ;; tuple of numbers [4 4]
34 |
35 | ;; set of numbers #{1}
36 | :metronome/accentuate-beats {:db/cardinality :db.cardinality/many}
37 | ;; set of numbers #{1 2 3 4}
38 | :metronome/click-beats {:db/cardinality :db.cardinality/many}
39 | ;; number, percentage of beats to randomly drop
40 | :metronome/drop-pct {}
41 | ;; bars
42 | :music/bars {:db/cardinality :db.cardinality/many
43 | :db/type :db.type/ref}})
44 |
45 | (defn get-step-size [activity]
46 | (or (:metronome/tempo-step-size activity) 5))
47 |
48 | (defn start-metronome [activity & [tempo]]
49 | [:action/start-metronome
50 | (update (into {} activity) :music/tempo #(or tempo %))
51 | {:on-click [[:action/transact
52 | [{:db/id (:db/id activity)
53 | :metronome/current-bar [:metronome/click :bar/idx]
54 | :metronome/current-beat [:metronome/click :bar/beat]}]]]}])
55 |
56 | (defn stop-metronome [activity]
57 | (when-not (:activity/paused? activity)
58 | [[:action/db.add activity :activity/paused? true]
59 | [:action/stop-metronome]]))
60 |
61 | (defn adjust-tempo [activity tempo-change]
62 | (let [target-tempo (+ (:music/tempo activity) tempo-change)]
63 | (cond-> [[:action/db.add activity :music/tempo target-tempo]]
64 | (not (:activity/paused? activity))
65 | (conj (start-metronome activity target-tempo)))))
66 |
67 | (defn get-activity [db]
68 | (:view/tool (d/entity db :virtuoso/current-view)))
69 |
70 | (defn alias-ks [m alias->k]
71 | (loop [m m
72 | alias->k (seq alias->k)]
73 | (if (nil? alias->k)
74 | m
75 | (let [[alias k] (first alias->k)]
76 | (recur (assoc m alias (get m k)) (next alias->k))))))
77 |
78 | (defn get-button-actions [activity]
79 | (let [step-size (get-step-size activity)]
80 | (alias-ks
81 | {"p" (adjust-tempo activity (- step-size))
82 | "-" (adjust-tempo activity (- 1))
83 | "space" (if (:activity/paused? activity)
84 | [[:action/db.retract activity :activity/paused?]
85 | (start-metronome activity)]
86 | (stop-metronome activity))
87 | "+" (adjust-tempo activity 1)
88 | "n" (adjust-tempo activity step-size)}
89 | {" " "space"
90 | "ArrowUp" "+"
91 | "ArrowDown" "-"
92 | "ArrowRight" "n"
93 | "ArrowLeft" "p"})))
94 |
95 | (defn get-keypress-actions [db data]
96 | (-> (get-activity db)
97 | get-button-actions
98 | (get (:key data))))
99 |
100 | (defn prepare-button-panel [activity]
101 | (let [actions (get-button-actions activity)
102 | step-size (get-step-size activity)]
103 | {:kind :element.kind/button-panel
104 | :buttons
105 | (for [button [{:text (str "Lower tempo by " step-size " bpm")
106 | :icon (icons/icon :phosphor.bold/minus)
107 | :icon-size :tiny
108 | :icon-after-label (str step-size)
109 | :kbd "p"}
110 | {:text "Lower tempo"
111 | :icon (icons/icon :phosphor.bold/minus)
112 | :kbd "-"}
113 | (if (:activity/paused? activity)
114 | {:text "Play"
115 | :icon (icons/icon :phosphor.fill/play)
116 | :size :large
117 | :kbd "space"}
118 | {:text "Pause"
119 | :icon (icons/icon :phosphor.fill/pause)
120 | :size :large
121 | :kbd "space"})
122 | {:text "Bump tempo"
123 | :icon (icons/icon :phosphor.bold/plus)
124 | :kbd "+"}
125 | {:text (str "Bump tempo by " step-size " bpm")
126 | :icon (icons/icon :phosphor.bold/plus)
127 | :icon-size :tiny
128 | :icon-after-label (str step-size)
129 | :kbd "n"}]]
130 | (assoc button :actions (get actions (:kbd button))))}))
131 |
132 | (defn prepare-badge [activity]
133 | {:kind :element.kind/round-badge
134 | :text (str (:music/tempo activity))
135 | :label "BPM"
136 | :theme (if (:activity/paused? activity) :neutral :success)})
137 |
138 | (defn prepare-dots [activity bar & [{:keys [active?]}]]
139 | (let [beat-xs (range 1 (inc (first (:music/time-signature bar))))
140 | click-beat? (set (or (:metronome/click-beats bar) beat-xs))
141 | base-actions (stop-metronome activity)]
142 | (for [[n beat] (map-indexed vector beat-xs)]
143 | (cond
144 | (not (click-beat? beat))
145 | (cond-> {:disabled? true
146 | :actions (conj base-actions [:action/db.add (:db/id bar) :metronome/click-beats beat])}
147 | (and active? (= (:metronome/current-beat activity) (inc n)))
148 | (assoc :current? true))
149 |
150 | (contains? (:metronome/accentuate-beats bar) beat)
151 | {:highlight? true
152 | :actions (into base-actions
153 | [[:action/db.retract (:db/id bar) :metronome/accentuate-beats beat]
154 | (if (:metronome/click-beats bar)
155 | [:action/db.retract (:db/id bar) :metronome/click-beats beat]
156 | [:action/transact [{:db/id (:db/id bar) :metronome/click-beats (set (remove #{beat} beat-xs))}]])])}
157 |
158 | :else
159 | (cond-> {:actions (conj base-actions [:action/db.add (:db/id bar) :metronome/accentuate-beats beat])}
160 | (and active? (= (:metronome/current-beat activity) (inc n)))
161 | (assoc :current? true))))))
162 |
163 | (def whole-note (/ 1 1))
164 | (def half-note (/ 1 2))
165 | (def quarter-note (/ 1 4))
166 | (def eighth-note (/ 1 8))
167 | (def eighth-note-triplet (* (/ 2 3) (/ 1 8)))
168 | (def dotted-eighth-note (* (/ 3 2) (/ 1 8)))
169 | (def sixteenth-note (/ 1 16))
170 | (def sixteenth-note-triplet (* (/ 2 3) (/ 1 16)))
171 | (def dotted-sixteenth-note (* (/ 3 2) (/ 1 16)))
172 |
173 | (def note-val->sym
174 | {whole-note :note/whole
175 | half-note :note/half
176 | quarter-note :note/quarter
177 | eighth-note :note/eighth
178 | eighth-note-triplet :note/eighth
179 | sixteenth-note :note/sixteenth
180 | sixteenth-note-triplet :note/sixteenth})
181 |
182 | (defn symbolize-note-val [nv]
183 | (or (note-val->sym nv)
184 | (some->> (* nv (/ 2 3))
185 | note-val->sym
186 | (conj [:notation/dot]))))
187 |
188 | (defn collect-for [nvs duration]
189 | (loop [elapsed 0
190 | nvs (seq nvs)
191 | res []]
192 | (if (nil? nvs)
193 | [nil (if (= 1 (count res))
194 | (first res)
195 | res)]
196 | (let [nv (first nvs)
197 | new-elapsed (+ elapsed nv)]
198 | (if (<= new-elapsed duration)
199 | (recur new-elapsed (next nvs) (conj res nv))
200 | [nvs (if (= 1 (count res))
201 | (first res)
202 | res)])))))
203 |
204 | (defn symbolize-rhythm [rhythm]
205 | (loop [nvs (seq rhythm)
206 | res []]
207 | (if nvs
208 | (let [nv (first nvs)
209 | [next-nvs group] (if (#{eighth-note
210 | dotted-eighth-note
211 | eighth-note-triplet
212 | sixteenth-note
213 | dotted-sixteenth-note
214 | sixteenth-note-triplet} nv)
215 | (collect-for nvs quarter-note)
216 | [(next nvs) nv])]
217 | (->> (if (coll? group)
218 | (->> group
219 | (map symbolize-note-val)
220 | (into [:notation/beam]))
221 | (symbolize-note-val group))
222 | (conj res)
223 | (recur next-nvs)))
224 | res)))
225 |
226 | (defn prepare-bar [db activity bar paced-bar bar-n]
227 | (let [[beats subdivision] (:music/time-signature bar)]
228 | (cond-> {:replicant/key [:bar (:db/id bar)]
229 | :beats {:val beats}
230 | :subdivision {:val subdivision}
231 | :dots (prepare-dots activity bar {:active? (and (not (:activity/paused? activity))
232 | (= (:metronome/current-bar activity) bar-n))})}
233 | (:bar/rhythm bar)
234 | (assoc :rhythm {:pattern (symbolize-rhythm (:bar/rhythm bar))})
235 |
236 | db
237 | (assoc :actions (concat (modal/get-open-modal-actions db ::edit-bar-modal {:idx (:ordered/idx bar)})
238 | (stop-metronome activity)))
239 |
240 | (< 1 (or (:metronome/reps bar) 1))
241 | (assoc :reps {:val (:metronome/reps bar)
242 | :unit "times"})
243 |
244 | (< 1 (count (:music/bars activity)))
245 | (assoc :buttons [{:text "Remove bar"
246 | :icon (icons/icon :phosphor.regular/minus-circle)
247 | :theme :warn
248 | :actions (conj (stop-metronome activity)
249 | [:action/transact [[:db/retractEntity (:db/id bar)]]])}])
250 |
251 | (:music/tempo bar)
252 | (assoc :tempo {:val (Math/round (float (:music/tempo paced-bar)))
253 | :unit "BPM"}))))
254 |
255 | (defn prepare-bars [db activity]
256 | (let [paced-bars (metronome/set-tempo (:music/tempo activity) (map #(into {} %) (:music/bars activity)))]
257 | {:kind :element.kind/bars
258 | :bars (map #(prepare-bar db activity %1 %2 (inc %3)) (:music/bars activity) paced-bars (range))
259 | :buttons [{:text "Add bar"
260 | :icon (icons/icon :phosphor.regular/music-notes-plus)
261 | :icon-size :large
262 | :actions (let [idx (inc (apply max 0 (keep :ordered/idx (:music/bars activity))))]
263 | (cond-> []
264 | (not (:activity/paused? activity))
265 | (into (stop-metronome activity))
266 |
267 | :then
268 | (conj [:action/transact
269 | [{:db/id (:db/id activity)
270 | :music/bars
271 | [{:ordered/idx idx
272 | :bar/rhythm [(/ 1 4)]
273 | :music/time-signature [4 4]}]}]])
274 |
275 | db
276 | (into (modal/get-open-modal-actions db ::edit-new-bar-modal {:idx idx}))
277 |
278 | db
279 | (into (stop-metronome activity))))}]}))
280 |
281 | (defn prepare-form [activity]
282 | {:kind :element.kind/boxed-form
283 | :boxes
284 | (let [stop-actions (stop-metronome activity)]
285 | [{:title "Settings"
286 | :fields
287 | [{:controls
288 | [{:label "Drop % of beats"
289 | :inputs
290 | [{:input/kind :input.kind/number
291 | :on
292 | {:input
293 | (cond-> [[:action/db.add activity :metronome/drop-pct :event/target-value-num]]
294 | stop-actions (into stop-actions))}
295 | :value (:metronome/drop-pct activity)}]}
296 | {:label "Skip interval"
297 | :inputs
298 | [{:input/kind :input.kind/number
299 | :on
300 | {:input
301 | [[:action/db.add
302 | activity
303 | :metronome/tempo-step-size
304 | :event/target-value-num]]}
305 | :value (get-step-size activity)}]}]}]}])})
306 |
307 | (defn prepare-metronome [db activity]
308 | {:sections
309 | [(prepare-badge activity)
310 | (prepare-bars db activity)
311 | (prepare-button-panel activity)
312 | (prepare-form activity)]})
313 |
314 | (defn prepare-ui-data [db]
315 | (prepare-metronome db (get-activity db)))
316 |
317 | (def subdivisions
318 | [4 8 16 32 64])
319 |
320 | (defn get-prev [xs x]
321 | (loop [xs (seq xs)
322 | val nil]
323 | (when xs
324 | (let [candidate (first xs)]
325 | (if (= candidate x)
326 | val
327 | (recur (next xs) candidate))))))
328 |
329 | (defn get-next [xs x]
330 | (loop [xs (seq xs)]
331 | (when xs
332 | (let [rest (next xs)]
333 | (if (= (first xs) x)
334 | (first rest)
335 | (recur rest))))))
336 |
337 | (defn prepare-reps-edit [bar]
338 | (let [reps (or (:metronome/reps bar) 1)]
339 | {:val reps
340 | :unit (if (= 1 reps) "time" "times")
341 | :button-above (cond-> {:icon (icons/icon :phosphor.regular/minus-circle)}
342 | (< 1 reps) (assoc :actions [[:action/db.add bar :metronome/reps (dec reps)]]))
343 | :button-below {:icon (icons/icon :phosphor.regular/plus-circle)
344 | :actions [[:action/db.add bar :metronome/reps (inc reps)]]}}))
345 |
346 | (defn prepare-tempo-edit [activity bar]
347 | (cond-> {:val (or (:music/tempo bar) (:music/tempo activity))
348 | :unit "BPM"
349 | :actions (concat (for [bar (->> (:music/bars activity)
350 | (remove :music/tempo)
351 | (remove (comp #{(:db/id bar)} :db/id)))]
352 | [:action/db.add bar :music/tempo (:music/tempo activity)])
353 | [[:action/db.add bar :music/tempo :event/target-value-num]])}
354 | (not (:music/tempo bar)) (assoc :subtle? true)))
355 |
356 | (def subdivision-rhythms
357 | {4 [[quarter-note]
358 | [eighth-note eighth-note]
359 | [dotted-eighth-note sixteenth-note]
360 | (repeat 3 eighth-note-triplet)
361 | (repeat 4 sixteenth-note)
362 | (repeat 6 sixteenth-note-triplet)]
363 | 8 [[quarter-note]
364 | [eighth-note]
365 | [sixteenth-note sixteenth-note]
366 | (repeat 3 sixteenth-note-triplet)]
367 | :default [[quarter-note]
368 | [eighth-note]
369 | [sixteenth-note]]})
370 |
371 | (defn prepare-bar-edit-modal [activity bar]
372 | (let [[beats subdivision] (:music/time-signature bar)
373 | multi-bar? (< 1 (count (:music/bars activity)))]
374 | {:title "Configure bar"
375 | :classes ["max-w-64"]
376 | :sections
377 | [{:kind :element.kind/bars
378 | :bars
379 | [(cond-> {:beats {:val beats
380 | :left-button (cond-> {:icon (icons/icon :phosphor.regular/minus-circle)}
381 | (< 1 beats)
382 | (assoc :actions [[:action/db.add bar :music/time-signature [(dec beats) subdivision]]]))
383 | :right-button {:icon (icons/icon :phosphor.regular/plus-circle)
384 | :actions [[:action/db.add bar :music/time-signature [(inc beats) subdivision]]]}}
385 | :subdivision {:val subdivision
386 | :left-button
387 | (let [prev-s (get-prev subdivisions subdivision)]
388 | (cond-> {:icon (icons/icon :phosphor.regular/minus-circle)}
389 | prev-s
390 | (assoc :actions [[:action/db.add bar :music/time-signature [beats prev-s]]])))
391 |
392 | :right-button
393 | (let [next-s (get-next subdivisions subdivision)]
394 | (cond-> {:icon (icons/icon :phosphor.regular/plus-circle)}
395 | next-s
396 | (assoc :actions [[:action/db.add bar :music/time-signature [beats next-s]]])))}
397 | :dots (prepare-dots activity bar)
398 | :size :large}
399 | multi-bar? (assoc :reps (prepare-reps-edit bar))
400 | multi-bar? (assoc :tempo (prepare-tempo-edit activity bar)))]}
401 | {:kind :element.kind/musical-notation-selection
402 | :items (for [rhythm (or (subdivision-rhythms subdivision)
403 | (subdivision-rhythms :default))]
404 | (let [active? (= rhythm (:bar/rhythm bar))]
405 | (cond-> {:notation (symbolize-rhythm rhythm)}
406 | active? (assoc :active? true)
407 | (not active?) (assoc :actions [[:action/db.add bar :bar/rhythm rhythm]]))))}]}))
408 |
409 | (defn prepare-new-bar-modal [activity modal]
410 | (let [n (.indexOf (map :ordered/idx (:music/bars activity))
411 | (-> modal :modal/params :idx))
412 | [template bar] (drop (dec n) (:music/bars activity))]
413 | (->> (merge
414 | (select-keys template [:music/time-signature :bar/rhythm])
415 | bar
416 | {:db/id (:db/id bar)})
417 | (prepare-bar-edit-modal activity))))
418 |
419 | (defn prepare-existing-bar-edit-modal [activity modal]
420 | (->> (:music/bars activity)
421 | (filter (comp #{(-> modal :modal/params :idx)} :ordered/idx))
422 | first
423 | (prepare-bar-edit-modal activity)))
424 |
425 | (defn prepare-modal-data [db modal]
426 | (case (:modal/kind modal)
427 | ::edit-new-bar-modal (prepare-new-bar-modal (get-activity db) modal)
428 | ::edit-bar-modal (prepare-existing-bar-edit-modal (get-activity db) modal)))
429 |
430 | (defn get-settings [settings]
431 | {:activity/paused? true
432 | :music/tempo (or (:music/tempo settings) 60)
433 | :metronome/drop-pct (or (:metronome/drop-pct settings) 0)
434 | :metronome/tempo-step-size (or (:metronome/tempo-step-size settings) 5)
435 | :music/bars (or (:music/bars settings)
436 | [{:ordered/idx 0
437 | :music/time-signature [4 4]
438 | :bar/rhythm [(/ 1 4)]}])})
439 |
440 | (defn get-boot-actions [db]
441 | [[:action/transact
442 | [{:db/ident :virtuoso/current-view
443 | :action/keypress-handler ::tool
444 | :view/tool (into {:db/ident ::tool}
445 | (get-settings (d/entity db ::tool)))}]]])
446 |
--------------------------------------------------------------------------------