├── 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 | 2 | 3 | 4 | 5 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 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 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 18 | 29 | 32 | 68 | 77 | 105 | 114 | 122 | 126 | 131 | 134 | 141 | 146 | 152 | 160 | 167 | 174 | 175 | 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 | --------------------------------------------------------------------------------